Repository: trinadhthatakula/Thor Branch: master Commit: 463cdc746d41 Files: 285 Total size: 933.3 KB Directory structure: gitextract_upudqvso/ ├── .github/ │ ├── FUNDING.yml │ ├── dependabot.yml │ └── workflows/ │ ├── copilot-setup-steps.yml │ ├── dev-check.yml │ ├── manual-build.yml │ ├── production-deploy.yml │ ├── release-manager.yml │ └── telegram-release.yml ├── .gitignore ├── .idea/ │ ├── .gitignore │ ├── AndroidProjectSystem.xml │ ├── codeStyles/ │ │ ├── Project.xml │ │ └── codeStyleConfig.xml │ ├── compiler.xml │ ├── copilot.data.migration.agent.xml │ ├── copilot.data.migration.ask.xml │ ├── copilot.data.migration.ask2agent.xml │ ├── copilot.data.migration.edit.xml │ ├── deviceManager.xml │ ├── dictionaries/ │ │ └── project.xml │ ├── google-java-format.xml │ ├── gradle.xml │ ├── inspectionProfiles/ │ │ └── Project_Default.xml │ ├── kotlinc.xml │ ├── markdown.xml │ ├── migrations.xml │ ├── misc.xml │ ├── runConfigurations/ │ │ └── Generate_Baseline_Profile_for_app.xml │ ├── runConfigurations.xml │ └── vcs.xml ├── LICENSE ├── PROJECT_CONTEXT.md ├── README.md ├── app/ │ ├── .gitignore │ ├── baselineprofile/ │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ └── src/ │ │ └── main/ │ │ ├── AndroidManifest.xml │ │ └── java/ │ │ └── com/ │ │ └── valhalla/ │ │ └── thor/ │ │ └── baselineprofile/ │ │ ├── BaselineProfileGenerator.kt │ │ └── StartupBenchmarks.kt │ ├── build.gradle.kts │ ├── proguard-rules-foss.pro │ ├── proguard-rules.pro │ ├── schemas/ │ │ └── com.valhalla.thor.data.source.local.room.AppDatabase/ │ │ ├── 1.json │ │ └── 2.json │ └── src/ │ ├── androidTest/ │ │ └── java/ │ │ └── com/ │ │ └── valhalla/ │ │ └── thor/ │ │ └── ExampleInstrumentedTest.kt │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── assets/ │ │ │ └── adi-registration.properties │ │ ├── java/ │ │ │ ├── android/ │ │ │ │ └── content/ │ │ │ │ └── pm/ │ │ │ │ ├── IPackageInstaller.java │ │ │ │ └── IPackageManager.java │ │ │ └── com/ │ │ │ └── valhalla/ │ │ │ └── thor/ │ │ │ ├── HomeActivity.kt │ │ │ ├── ThorApplication.kt │ │ │ ├── core/ │ │ │ │ └── ThorShellConfig.kt │ │ │ ├── data/ │ │ │ │ ├── Constants.kt │ │ │ │ ├── gateway/ │ │ │ │ │ ├── DhizukuSystemGateway.kt │ │ │ │ │ ├── RootSystemGateway.kt │ │ │ │ │ └── ShizukuSystemGateway.kt │ │ │ │ ├── receivers/ │ │ │ │ │ └── InstallReceiver.kt │ │ │ │ ├── repository/ │ │ │ │ │ ├── AppAnalyzerImpl.kt │ │ │ │ │ ├── AppRepositoryImpl.kt │ │ │ │ │ ├── InstallerRepositoryImpl.kt │ │ │ │ │ ├── PreferenceRepositoryImpl.kt │ │ │ │ │ └── SystemRepositoryImpl.kt │ │ │ │ ├── security/ │ │ │ │ │ └── BiometricHelper.kt │ │ │ │ ├── source/ │ │ │ │ │ └── local/ │ │ │ │ │ ├── ShellDataSource.kt │ │ │ │ │ ├── dhizuku/ │ │ │ │ │ │ ├── Dhizuku.kt │ │ │ │ │ │ └── DhizukuReflector.kt │ │ │ │ │ ├── room/ │ │ │ │ │ │ ├── AppDao.kt │ │ │ │ │ │ ├── AppDatabase.kt │ │ │ │ │ │ ├── AppEntity.kt │ │ │ │ │ │ └── AppTypeConverters.kt │ │ │ │ │ ├── root/ │ │ │ │ │ │ └── RootMain.kt │ │ │ │ │ └── shizuku/ │ │ │ │ │ ├── PackageManagerExt.kt │ │ │ │ │ ├── Packages.kt │ │ │ │ │ ├── Shizuku.kt │ │ │ │ │ ├── ShizukuPackageInstallerUtils.kt │ │ │ │ │ ├── ShizukuReflector.kt │ │ │ │ │ └── Targets.kt │ │ │ │ └── util/ │ │ │ │ ├── ApksMetadataGenerator.kt │ │ │ │ └── PackageVerifier.kt │ │ │ ├── di/ │ │ │ │ └── Modules.kt │ │ │ ├── domain/ │ │ │ │ ├── InstallState.kt │ │ │ │ ├── InstallerEventBus.kt │ │ │ │ ├── gateway/ │ │ │ │ │ └── SystemGateway.kt │ │ │ │ ├── model/ │ │ │ │ │ ├── ApkDetails.kt │ │ │ │ │ ├── AppClickAction.kt │ │ │ │ │ ├── AppInfo.kt │ │ │ │ │ ├── AppInstallable.kt │ │ │ │ │ ├── AppListType.kt │ │ │ │ │ ├── AppMetadata.kt │ │ │ │ │ ├── FilterType.kt │ │ │ │ │ ├── HistoryRecord.kt │ │ │ │ │ ├── MultiAppAction.kt │ │ │ │ │ ├── PrivilegeMode.kt │ │ │ │ │ ├── SortBy.kt │ │ │ │ │ ├── ThemeMode.kt │ │ │ │ │ └── UserPreferences.kt │ │ │ │ ├── repository/ │ │ │ │ │ ├── AppAnalyzer.kt │ │ │ │ │ ├── AppRepository.kt │ │ │ │ │ ├── InstallerRepository.kt │ │ │ │ │ ├── PreferenceRepository.kt │ │ │ │ │ └── SystemRepository.kt │ │ │ │ └── usecase/ │ │ │ │ ├── GetAppDetailsUseCase.kt │ │ │ │ ├── GetInstalledAppsUseCase.kt │ │ │ │ ├── ManageAppUseCase.kt │ │ │ │ └── ShareAppUseCase.kt │ │ │ ├── presentation/ │ │ │ │ ├── appList/ │ │ │ │ │ ├── AppListScreen.kt │ │ │ │ │ └── AppListViewModel.kt │ │ │ │ ├── common/ │ │ │ │ │ ├── ShizukuPermissionHandler.kt │ │ │ │ │ └── components/ │ │ │ │ │ └── ConnectedButtonGroup.kt │ │ │ │ ├── freezer/ │ │ │ │ │ ├── FreezerScreen.kt │ │ │ │ │ └── FreezerViewModel.kt │ │ │ │ ├── home/ │ │ │ │ │ ├── AppDestinations.kt │ │ │ │ │ ├── HomeScreen.kt │ │ │ │ │ ├── HomeViewModel.kt │ │ │ │ │ └── components/ │ │ │ │ │ ├── AnimatedCounter.kt │ │ │ │ │ ├── AppDistributionChart.kt │ │ │ │ │ ├── DashboardHeader.kt │ │ │ │ │ ├── SocialLinksRow.kt │ │ │ │ │ └── SummaryStatRow.kt │ │ │ │ ├── installer/ │ │ │ │ │ ├── InstallerViewModel.kt │ │ │ │ │ ├── PortableInstaller.kt │ │ │ │ │ └── PortableInstallerActivity.kt │ │ │ │ ├── main/ │ │ │ │ │ ├── MainScreen.kt │ │ │ │ │ ├── MainViewModel.kt │ │ │ │ │ └── ThorNavigationBar.kt │ │ │ │ ├── security/ │ │ │ │ │ ├── AuthState.kt │ │ │ │ │ ├── BiometricPromptHandler.kt │ │ │ │ │ ├── BiometricScreen.kt │ │ │ │ │ └── SecurityViewModel.kt │ │ │ │ ├── settings/ │ │ │ │ │ ├── SettingsScreen.kt │ │ │ │ │ └── SettingsViewModel.kt │ │ │ │ ├── theme/ │ │ │ │ │ ├── Color.kt │ │ │ │ │ ├── Motion.kt │ │ │ │ │ ├── Theme.kt │ │ │ │ │ └── Type.kt │ │ │ │ ├── utils/ │ │ │ │ │ ├── AppIconLoader.kt │ │ │ │ │ ├── CacheScanner.kt │ │ │ │ │ └── UiUtils.kt │ │ │ │ └── widgets/ │ │ │ │ ├── AffirmationDialog.kt │ │ │ │ ├── AnimateLottieRaw.kt │ │ │ │ ├── AppInfoDialog.kt │ │ │ │ ├── AppList.kt │ │ │ │ ├── MultiSelectToolBox.kt │ │ │ │ ├── TermLogger.kt │ │ │ │ └── TypeWriterText.kt │ │ │ └── util/ │ │ │ ├── LocaleManager.kt │ │ │ └── Logger.kt │ │ └── res/ │ │ ├── drawable/ │ │ │ ├── android.xml │ │ │ ├── apk_install.xml │ │ │ ├── apps.xml │ │ │ ├── arrow_downward.xml │ │ │ ├── arrow_drop_down.xml │ │ │ ├── arrow_upward.xml │ │ │ ├── bolt.xml │ │ │ ├── brand_github.xml │ │ │ ├── brand_patreon.xml │ │ │ ├── brand_telegram.xml │ │ │ ├── cat.xml │ │ │ ├── check_circle.xml │ │ │ ├── clear_all.xml │ │ │ ├── danger.xml │ │ │ ├── delete.xml │ │ │ ├── delete_forever.xml │ │ │ ├── dhizuku.xml │ │ │ ├── exit_to_app.xml │ │ │ ├── filter_list.xml │ │ │ ├── force_close.xml │ │ │ ├── freeze_off.xml │ │ │ ├── frozen.xml │ │ │ ├── grid_view.xml │ │ │ ├── home.xml │ │ │ ├── home_outline.xml │ │ │ ├── ic_launcher_background.xml │ │ │ ├── ic_launcher_foreground.xml │ │ │ ├── ios_share.xml │ │ │ ├── key.xml │ │ │ ├── key_outline.xml │ │ │ ├── list.xml │ │ │ ├── magisk_icon.xml │ │ │ ├── open_in.xml │ │ │ ├── open_in_new.xml │ │ │ ├── privacy_tip.xml │ │ │ ├── round_close.xml │ │ │ ├── round_key.xml │ │ │ ├── round_search.xml │ │ │ ├── settings.xml │ │ │ ├── settings_backup_restore.xml │ │ │ ├── settings_outline.xml │ │ │ ├── share.xml │ │ │ ├── shield.xml │ │ │ ├── shield_bad.xml │ │ │ ├── shield_countdown.xml │ │ │ ├── shield_encrypted.xml │ │ │ ├── shield_maybe.xml │ │ │ ├── shield_search.xml │ │ │ ├── shield_verified.xml │ │ │ ├── shield_with_heart.xml │ │ │ ├── shizuku.xml │ │ │ ├── shizuku_outline_icon.xml │ │ │ ├── snowflake.xml │ │ │ ├── sort.xml │ │ │ ├── sort_by_alpha.xml │ │ │ ├── storage.xml │ │ │ ├── theme_panel.xml │ │ │ ├── thor_animated.xml │ │ │ ├── thor_drawn_foreground.xml │ │ │ ├── thor_icon_foreground.xml │ │ │ ├── thor_mono.xml │ │ │ ├── unfreeze.xml │ │ │ ├── view_stream.xml │ │ │ └── warning.xml │ │ ├── mipmap-anydpi-v26/ │ │ │ ├── thor_drawn.xml │ │ │ └── thor_drawn_round.xml │ │ ├── raw/ │ │ │ └── rearrange.json │ │ ├── raw-night/ │ │ │ └── rearrange.json │ │ ├── values/ │ │ │ ├── colors.xml │ │ │ ├── font_certs.xml │ │ │ ├── non-translatable.xml │ │ │ ├── strings.xml │ │ │ ├── themes.xml │ │ │ ├── thor_drawn_background.xml │ │ │ └── thor_icon_background.xml │ │ ├── values-ar/ │ │ │ └── strings.xml │ │ ├── values-es/ │ │ │ └── strings.xml │ │ ├── values-fr/ │ │ │ └── strings.xml │ │ ├── values-v31/ │ │ │ └── themes.xml │ │ ├── values-zh-rCN/ │ │ │ └── strings.xml │ │ └── xml/ │ │ ├── backup_rules.xml │ │ ├── data_extraction_rules.xml │ │ ├── locales_config.xml │ │ └── provider_paths.xml │ └── test/ │ └── java/ │ └── com/ │ └── valhalla/ │ └── thor/ │ └── ExampleUnitTest.kt ├── build.gradle.kts ├── bypass/ │ ├── .gitignore │ ├── README.md │ ├── build.gradle.kts │ ├── consumer-rules.pro │ ├── proguard-rules.pro │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── java/ │ └── com/ │ └── valhalla/ │ └── bypass/ │ └── Bypass.kt ├── fastlane/ │ ├── Appfile │ ├── Fastfile │ └── metadata/ │ └── android/ │ └── en-US/ │ ├── changelogs/ │ │ └── 1600.txt │ ├── full_description.txt │ ├── short_description.txt │ └── title.txt ├── gradle/ │ ├── gradle-daemon-jvm.properties │ ├── libs.versions.toml │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── settings.gradle.kts ├── suCore/ │ ├── .gitignore │ ├── README.md │ ├── build.gradle.kts │ ├── proguard-rules.pro │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── aidl/ │ │ └── com/ │ │ └── valhalla/ │ │ └── superuser/ │ │ └── ipc/ │ │ └── IIPC.aidl │ └── java/ │ └── com/ │ └── valhalla/ │ └── superuser/ │ ├── CallbackList.kt │ ├── NoShellException.kt │ ├── Shell.kt │ ├── ShellUtils.kt │ ├── internal/ │ │ ├── BuilderImpl.kt │ │ ├── CoroutineStreamGobbler.kt │ │ ├── JobTask.kt │ │ ├── MainShell.kt │ │ ├── PendingJob.kt │ │ ├── ResultFuture.kt │ │ ├── ResultHolder.kt │ │ ├── ResultImpl.kt │ │ ├── ShellImpl.kt │ │ ├── ShellInputSource.kt │ │ ├── ShellJob.kt │ │ ├── StreamGobbler.kt │ │ ├── UiThreadHandler.kt │ │ └── Utils.kt │ ├── ktx/ │ │ ├── ShellExtensions.kt │ │ └── ShellRepository.kt │ └── utils/ │ ├── Logger.kt │ └── ShellUtils.kt └── vm-runtime/ ├── README.md ├── build.gradle.kts └── src/ └── main/ └── java/ └── dalvik/ └── system/ └── VMRuntime.java ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ github: trinadhthatakula patreon: trinadh ko_fi: trinadh buy_me_a_coffee: trinadh custom: [ "https://www.paypal.me/trinadhthatakula" ] ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "gradle" directory: "/" schedule: interval: "daily" target-branch: "dev" groups: maven: patterns: - "*" ================================================ FILE: .github/workflows/copilot-setup-steps.yml ================================================ name: "Copilot Setup Steps" on: workflow_dispatch: push: branches: [ "master" ] paths: - .github/workflows/copilot-setup-steps.yml jobs: copilot-setup-steps: runs-on: ubuntu-latest permissions: contents: read steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up JDK 21 uses: actions/setup-java@v4 with: distribution: 'zulu' java-version: '21' cache: 'gradle' # Optional: Verify installation and warm up the Gradle daemon - name: Verify Java Version run: java -version && ./gradlew --version ================================================ FILE: .github/workflows/dev-check.yml ================================================ name: Dev Build & Test on: push: branches: [ "master" ] workflow_dispatch: jobs: build-release-check: runs-on: ubuntu-latest permissions: contents: write # Required for creating GitHub Releases steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup JDK 21 uses: actions/setup-java@v4 with: distribution: 'zulu' java-version: '21' cache: 'gradle' # Added Ruby for Fastlane in Dev - name: Setup Ruby uses: ruby/setup-ruby@v1 with: ruby-version: '3.0' - name: Install Fastlane run: gem install fastlane # --- 1. SECRETS INJECTION --- - name: Decode Keystore env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} run: echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > app/release.jks # Added Play Store JSON for Dev (needed for Internal Track upload) - name: Decode Google Play Service Account env: PLAY_STORE_JSON_KEY: ${{ secrets.PLAY_STORE_JSON_KEY }} run: echo "$PLAY_STORE_JSON_KEY" > app/google-play-api.json - name: Grant execute permission for gradlew run: chmod +x gradlew # --- 2. BUILD & DEPLOY (FASTLANE) --- # Replaced manual Gradle command with Fastlane lane - name: Run Fastlane (Distribute Dev) env: JSON_KEY_FILE: ${{ github.workspace }}/app/google-play-api.json SUPPLY_JSON_KEY: ${{ github.workspace }}/app/google-play-api.json KEY_ALIAS: ${{ secrets.KEY_ALIAS }} KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} KEYSTORE_FILE_PATH: ${{ github.workspace }}/app/release.jks run: fastlane android distribute_dev # --- 3. TELEGRAM --- - name: Send APK to Telegram if: success() env: TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} run: | # Fastlane 'copyStoreReleaseApk' task puts it here APK_PATH=$(find app/build/distribution/store -name "*.apk" | head -n 1) if [ ! -f "$APK_PATH" ]; then echo "Error: APK not found in app/build/distribution/store/" exit 1 fi echo "Sending $APK_PATH to Telegram..." CAPTION="🚀 *Thor (Valhalla) Dev Build* branch: ${{ github.ref_name }} author: ${{ github.actor }} track: Internal Testing status: Uploaded to Play Store & GitHub" curl -s -F "chat_id=$TELEGRAM_CHAT_ID" \ -F "document=@$APK_PATH" \ -F "caption=$CAPTION" \ -F "parse_mode=Markdown" \ "https://api.telegram.org/bot$TELEGRAM_TOKEN/sendDocument" > /dev/null # --- 4. GITHUB RELEASE (PRE-RELEASE) --- - name: Read Version Info id: get_version run: | VERSION_NAME=$(cat version_name.txt) echo "version_name=$VERSION_NAME" >> "$GITHUB_OUTPUT" - name: Create GitHub Pre-Release uses: softprops/action-gh-release@v2 with: # Unique tag for dev builds: v1.0.0-dev-45 tag_name: v${{ steps.get_version.outputs.version_name }}-dev-${{ github.run_number }} name: Dev Build v${{ steps.get_version.outputs.version_name }} (${{ github.run_number }}) files: | app/build/distribution/foss/foss-release.apk app/build/distribution/store/store-release.apk draft: false prerelease: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # --- 5. CLEANUP --- - name: Cleanup sensitive files if: always() run: | rm -f app/release.jks rm -f app/google-play-api.json ================================================ FILE: .github/workflows/manual-build.yml ================================================ name: Manual Build (No Upload) on: workflow_dispatch: jobs: build-only: runs-on: ubuntu-latest permissions: contents: read steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup JDK 21 uses: actions/setup-java@v4 with: distribution: 'zulu' java-version: '21' cache: 'gradle' - name: Setup Ruby uses: ruby/setup-ruby@v1 with: ruby-version: '3.0' - name: Install Fastlane run: gem install fastlane # --- SECRET DECODING (Required for Play Store Version Check & Signing) --- - name: Decode Keystore env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} run: echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > app/release.jks - name: Decode Google Play Service Account env: PLAY_STORE_JSON_KEY: ${{ secrets.PLAY_STORE_JSON_KEY }} run: echo "$PLAY_STORE_JSON_KEY" > app/google-play-api.json - name: Grant execute permission for gradlew run: chmod +x gradlew # --- FASTLANE --- - name: Run Fastlane (Build Candidates) env: JSON_KEY_FILE: ${{ github.workspace }}/app/google-play-api.json SUPPLY_JSON_KEY: ${{ github.workspace }}/app/google-play-api.json KEY_ALIAS: ${{ secrets.KEY_ALIAS }} KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} KEYSTORE_FILE_PATH: ${{ github.workspace }}/app/release.jks run: fastlane android build_release_candidates # --- ARTIFACT UPLOAD --- - name: Read Version Info id: version_info run: | echo "VERSION_NAME=$(cat version_name.txt)" >> $GITHUB_ENV echo "VERSION_CODE=$(cat version_code.txt)" >> $GITHUB_ENV - name: Upload APKs to GitHub Actions uses: actions/upload-artifact@v4 with: name: Thor-v${{ env.VERSION_NAME }}-Build-${{ env.VERSION_CODE }} path: | app/build/distribution/foss/foss-release.apk app/build/distribution/store/store-release.apk # --- SECURITY CLEANUP --- - name: Cleanup sensitive files if: always() run: | rm -f app/release.jks rm -f app/google-play-api.json ================================================ FILE: .github/workflows/production-deploy.yml ================================================ name: 2. Production Build & Distribute on: push: branches: [ "production" ] workflow_dispatch: jobs: release-distribute: runs-on: ubuntu-latest permissions: contents: write steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup JDK 21 uses: actions/setup-java@v4 with: distribution: 'zulu' java-version: '21' cache: 'gradle' - name: Setup Ruby uses: ruby/setup-ruby@v1 with: ruby-version: '3.0' - name: Install Fastlane run: gem install fastlane # --- SECRET DECODING --- - name: Decode Keystore env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} run: echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > app/release.jks - name: Decode Google Play Service Account env: PLAY_STORE_JSON_KEY: ${{ secrets.PLAY_STORE_JSON_KEY }} run: echo "$PLAY_STORE_JSON_KEY" > app/google-play-api.json - name: Grant execute permission for gradlew run: chmod +x gradlew # --- FASTLANE (CLOSED TESTING) --- - name: Run Fastlane (Distribute Prod) env: JSON_KEY_FILE: ${{ github.workspace }}/app/google-play-api.json SUPPLY_JSON_KEY: ${{ github.workspace }}/app/google-play-api.json KEY_ALIAS: ${{ secrets.KEY_ALIAS }} KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} KEYSTORE_FILE_PATH: ${{ github.workspace }}/app/release.jks # Updated to use the production lane (Closed Track) run: fastlane android distribute_production # --- SYNC & RELEASE --- - name: Read Version Name id: get_app_version run: | VERSION_NAME=$(cat version_name.txt) echo "version_name=$VERSION_NAME" >> "$GITHUB_OUTPUT" - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: tag_name: v${{ steps.get_app_version.outputs.version_name }} name: Release v${{ steps.get_app_version.outputs.version_name }} files: | app/build/distribution/foss/foss-release.apk app/build/distribution/store/store-release.apk app/build/outputs/bundle/storeRelease/*.aab draft: false prerelease: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # --- SECURITY CLEANUP --- - name: Cleanup sensitive files if: always() run: | rm -f app/release.jks rm -f app/google-play-api.json ================================================ FILE: .github/workflows/release-manager.yml ================================================ name: 1. Release Manager (Bump Version) on: workflow_dispatch: inputs: bump_type: description: 'Version Bump Type (affects Version Name)' required: true default: 'patch' type: choice options: - patch - minor - major - none (code only) jobs: bump-and-push: runs-on: ubuntu-latest permissions: contents: write steps: - name: Checkout code uses: actions/checkout@v4 with: # Use a PAT if you want this push to trigger the 'production-deploy' workflow automatically. # If you use the default token, you must trigger production-deploy manually. token: ${{ secrets.PAT_TOKEN || secrets.GITHUB_TOKEN }} ref: production - name: Setup Java (for property parsing if needed, or simple bash) uses: actions/setup-java@v4 with: distribution: 'zulu' java-version: '21' - name: Bump Version in gradle.properties run: | PROPS_FILE="gradle.properties" # 1. Read Current Values CURRENT_CODE=$(grep "versionCode" $PROPS_FILE | cut -d'=' -f2) CURRENT_NAME=$(grep "versionName" $PROPS_FILE | cut -d'=' -f2) echo "Current Version: $CURRENT_NAME ($CURRENT_CODE)" # 2. Increment Version Code (Always +1 for release) NEW_CODE=$((CURRENT_CODE + 1)) # 3. Increment Version Name # Split version name X.Y.Z IFS='.' read -r -a parts <<< "$CURRENT_NAME" major=${parts[0]} minor=${parts[1]} patch=${parts[2]} case "${{ inputs.bump_type }}" in major) major=$((major + 1)); minor=0; patch=0 ;; minor) minor=$((minor + 1)); patch=0 ;; patch) patch=$((patch + 1)) ;; *) echo "Skipping name bump";; esac NEW_NAME="$major.$minor.$patch" echo "New Version: $NEW_NAME ($NEW_CODE)" # 4. Write back to file (Linux sed) sed -i "s/versionCode=.*/versionCode=$NEW_CODE/" $PROPS_FILE sed -i "s/versionName=.*/versionName=$NEW_NAME/" $PROPS_FILE # 5. Output for next steps echo "NEW_NAME=$NEW_NAME" >> $GITHUB_ENV echo "NEW_CODE=$NEW_CODE" >> $GITHUB_ENV - name: Commit and Push run: | git config --global user.name "GitHub Actions CI" git config --global user.email "actions@github.com" git add gradle.properties git commit -m "chore(release): bump version to v${{ env.NEW_NAME }} ($NEW_CODE) [skip ci]" # Tagging is optional here, usually better done after successful deploy # git tag -a "v${{ env.NEW_NAME }}" -m "Release v${{ env.NEW_NAME }}" git push origin production ================================================ FILE: .github/workflows/telegram-release.yml ================================================ name: Telegram Release on: workflow_dispatch: inputs: version_code: description: 'Specific Version Code (Leave empty to use latest Store version)' required: false type: string increment: description: 'Increment Version? (Only used if Version Code is empty)' required: true type: boolean default: false jobs: build-and-announce: runs-on: ubuntu-latest permissions: contents: read # Required to check for releases steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup JDK 21 uses: actions/setup-java@v4 with: distribution: 'zulu' java-version: '21' cache: 'gradle' - name: Setup Ruby uses: ruby/setup-ruby@v1 with: ruby-version: '3.0' - name: Install Fastlane run: gem install fastlane # --- SECRET DECODING --- - name: Decode Keystore env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} run: echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > app/release.jks - name: Decode Google Play Service Account env: PLAY_STORE_JSON_KEY: ${{ secrets.PLAY_STORE_JSON_KEY }} run: echo "$PLAY_STORE_JSON_KEY" > app/google-play-api.json - name: Grant execute permission for gradlew run: chmod +x gradlew # --- BUILD ARTIFACTS --- # Updated to pass inputs to Fastlane - name: Run Fastlane (Build Candidates) env: JSON_KEY_FILE: ${{ github.workspace }}/app/google-play-api.json SUPPLY_JSON_KEY: ${{ github.workspace }}/app/google-play-api.json KEY_ALIAS: ${{ secrets.KEY_ALIAS }} KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} KEYSTORE_FILE_PATH: ${{ github.workspace }}/app/release.jks # Map inputs to Env Vars (cleaner than passing directly in run command string) INPUT_VERSION_CODE: ${{ inputs.version_code }} INPUT_INCREMENT: ${{ inputs.increment }} run: | # Pass arguments to the lane using key:value syntax # If INPUT_VERSION_CODE is empty, Fastlane receives nil/empty string and logic handles it fastlane android build_release_candidates \ version_code:"$INPUT_VERSION_CODE" \ increment:"$INPUT_INCREMENT" # --- PREPARE ANNOUNCEMENT --- - name: Read Version Info id: version_info run: | echo "VERSION_NAME=$(cat version_name.txt)" >> $GITHUB_ENV echo "VERSION_CODE=$(cat version_code.txt)" >> $GITHUB_ENV - name: Check for GitHub Release id: check_release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | TAG="v${{ env.VERSION_NAME }}" echo "Checking for release tag: $TAG" # Check if release exists using GitHub CLI if gh release view "$TAG" > /dev/null 2>&1; then echo "Release found!" echo "release_url=https://github.com/${{ github.repository }}/releases/tag/$TAG" >> $GITHUB_OUTPUT echo "has_release=true" >> $GITHUB_OUTPUT else echo "No release found for $TAG" echo "has_release=false" >> $GITHUB_OUTPUT fi - name: Publish to Telegram Channel env: TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_RELEASE_CHAT_ID }} RELEASE_URL: ${{ steps.check_release.outputs.release_url }} HAS_RELEASE: ${{ steps.check_release.outputs.has_release }} run: | STORE_APK="app/build/distribution/store/store-release.apk" FOSS_APK="app/build/distribution/foss/foss-release.apk" # 1. Construct the Caption CAPTION="⚡️ *Thor v${{ env.VERSION_NAME }} Released!* Build: ${{ env.VERSION_CODE }} Branch: ${{ github.ref_name }}" if [ "$HAS_RELEASE" = "true" ]; then CAPTION="$CAPTION 🔗 [View on GitHub]($RELEASE_URL)" fi # 2. Send Store APK echo "Sending Store APK..." curl -s -F "chat_id=$TELEGRAM_CHAT_ID" \ -F "document=@$STORE_APK" \ -F "caption=$CAPTION" \ -F "parse_mode=Markdown" \ "https://api.telegram.org/bot$TELEGRAM_TOKEN/sendDocument" > /dev/null # 3. Send FOSS APK (Reply to previous? No, just send as second message for now) echo "Sending FOSS APK..." curl -s -F "chat_id=$TELEGRAM_CHAT_ID" \ -F "document=@$FOSS_APK" \ -F "caption=🔓 *FOSS Version (No Google Services)*" \ -F "parse_mode=Markdown" \ "https://api.telegram.org/bot$TELEGRAM_TOKEN/sendDocument" > /dev/null # --- SECURITY CLEANUP --- - name: Cleanup sensitive files if: always() run: | rm -f app/release.jks rm -f app/google-play-api.json ================================================ FILE: .gitignore ================================================ *.iml .gradle /local.properties /.idea/caches /.idea/libraries /.idea/modules.xml /.idea/workspace.xml /.idea/navEditor.xml /.idea/assetWizardSettings.xml /.idea/deploymentTargetDropDown.xml /.idea/deploymentTargetSelector.xml .DS_Store /build /captures .externalNativeBuild .cxx local.properties /.idea/studiobot.xml /.idea/appInsightsSettings.xml /.agents /*/build /graphify-out ================================================ FILE: .idea/.gitignore ================================================ # Default ignored files /shelf/ /workspace.xml ================================================ FILE: .idea/AndroidProjectSystem.xml ================================================ ================================================ FILE: .idea/codeStyles/Project.xml ================================================ ================================================ FILE: .idea/codeStyles/codeStyleConfig.xml ================================================ ================================================ FILE: .idea/compiler.xml ================================================ ================================================ FILE: .idea/copilot.data.migration.agent.xml ================================================ ================================================ FILE: .idea/copilot.data.migration.ask.xml ================================================ ================================================ FILE: .idea/copilot.data.migration.ask2agent.xml ================================================ ================================================ FILE: .idea/copilot.data.migration.edit.xml ================================================ ================================================ FILE: .idea/deviceManager.xml ================================================ ================================================ FILE: .idea/dictionaries/project.xml ================================================ Appfile apkm chown dcim fastlane fira hmmss libsu readlines shellout shizuku xapk zulu ================================================ FILE: .idea/google-java-format.xml ================================================ ================================================ FILE: .idea/gradle.xml ================================================ ================================================ FILE: .idea/inspectionProfiles/Project_Default.xml ================================================ ================================================ FILE: .idea/kotlinc.xml ================================================ ================================================ FILE: .idea/markdown.xml ================================================ ================================================ FILE: .idea/migrations.xml ================================================ ================================================ FILE: .idea/misc.xml ================================================ ================================================ FILE: .idea/runConfigurations/Generate_Baseline_Profile_for_app.xml ================================================ ================================================ FILE: .idea/runConfigurations.xml ================================================ ================================================ FILE: .idea/vcs.xml ================================================ ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: PROJECT_CONTEXT.md ================================================ # Thor App Manager - Project Context Thor is a modern, lightweight, and privacy-focused Android App Manager. It is designed to be 100% offline, free, and open-source (FOSS), providing advanced app management capabilities through Shizuku, Dhizuku, and Root access. ## 🏗 Architecture The project follows **Clean Architecture** principles combined with **MVVM (Model-View-ViewModel)** for the presentation layer. ### Modules: - **`app/`**: The core application module. - **Presentation**: Built with **Jetpack Compose**. ViewModels manage state using `StateFlow` and Koin for dependency injection. - **Domain**: Pure Kotlin layer containing business logic, Use Cases, and repository interfaces. Platform-agnostic where possible. - **Data**: Implementation of repositories, interacting with Android's `PackageManager`, `Shizuku`/`Dhizuku` APIs, and `DataStore` for persistence. - **DI**: Dependency Injection using **Koin**, organized into `commonModule`, `installerModule`, `preferenceModule`, `coreModule`, and `roomModule`. - **`suCore/`**: A specialized module for root shell management. It's a Kotlin-refactored version of the `libsu` core module by `topjohnwu`, optimized for modern Kotlin idioms and memory safety. - **`bypass/`**: A core utility module for bypassing Android's hidden API restrictions using `VMRuntime` exemptions and enhanced reflection. - **`vm-runtime/`**: Compile-only **Java** stubs required for the `bypass` module to interface with internal Android classes like `VMRuntime`. Intentionally a pure Java library (not Kotlin) to ensure correct class shadowing behaviour at compile time. ## 🛠 Tech Stack - **Language**: Kotlin (all modules except `vm-runtime`, which is pure Java for stub compatibility) - **UI Framework**: Jetpack Compose with `MaterialExpressiveTheme` + `MotionScheme.expressive()`. Static "Asgardian" color scheme by default; optional Material You dynamic color on Android 12+. Navigation uses a custom `ThorNavigationBar` with spring animations + `HorizontalPager` for swipe-between-screens. - **Dependency Injection**: Koin - **Asynchronous Programming**: Kotlin Coroutines & Flow - **Image Loading**: Coil 3 - **Animation**: Lottie + Compose `AnimatedVisibility`/`AnimatedContent` - **Persistence**: - **Jetpack Room**: High-performance caching of `AppInfo` metadata, invalidated via `lastUpdateTime`. - **Jetpack DataStore**: User preferences including theme, AMOLED mode, biometric lock, and preferred privilege mode. - **Security**: Android Biometrics via `BiometricPrompt` API directly (no `androidx.biometric` dependency). `HomeActivity` extends `ComponentActivity`. - **Elevated Privileges**: - **Root (su)**: Via `suCore` module (Kotlin-refactored fork of `libsu`). - **Shizuku**: Shell-command-first (`am`, `pm`, `appops`) with reflection fallback via `:bypass`. - **Dhizuku**: Device Owner API with reflection fallback via `:bypass`. - **Work Mode (`PrivilegeMode`)**: User-selectable privilege engine (ROOT / SHIZUKU / DHIZUKU) with automatic fallback strategy (Root → Shizuku → Dhizuku). - **Internal Bypass (`:bypass`)**: Custom Kotlin implementation using `VMRuntime` exemptions and reflection, backed by Java stubs in `:vm-runtime`. - **Build System**: Gradle Kotlin DSL with Version Catalog (`libs.versions.toml`). Exact tool and library versions defined in `libs.versions.toml`; do not hardcode them in docs. - **Distribution**: Two product flavors: `store` (Play Store compliant) and `foss` (fully libre/open). ## ✨ Key Features - **App Management**: Install, uninstall, freeze (disable/enable), suspend/unsuspend, and background-restrict apps. Tracks `isSuspended` and `isDebuggable` flags directly on `AppInfo`. - **Work Mode**: User-selectable privilege engine (`PrivilegeMode`: ROOT, SHIZUKU, DHIZUKU) stored in `UserPreferences`. Falls back automatically if the preferred mode is unavailable. - **Batch Operations**: Batch freeze/unfreeze, reinstall, uninstall, kill, suspend/unsuspend, and clear data — all logged in real time through the terminal logger dialog. - **App Suspension**: Uses `IPackageManager` reflection to suspend apps, showing a custom "Thor" -branded system dialog. Supports Android 10 through 13+ with version-specific fallbacks. - **Background Restriction**: Restricts an app's background activity via `setAppRestricted`. - **Fix Store (Reinstall with Google)**: Reassigns installer to Play Store. Available in all privilege modes (Root, Shizuku, Dhizuku). - **Clear Data / Clear Cache**: Available in all privilege modes; `clearAppData` uses `pm clear` with multi-user support. - **Advanced Insights**: Installer source (resolved from package labels, not hardcoded), split APK indicators, version codes, SDK targets, `isSuspended`, `isDebuggable`. - **System App Support**: Uninstall or freeze system apps (requires any privilege mode). - **Security**: Biometric/device-credential lock for app access. Per-session authentication state. - **App Metadata Caching**: Room DB cache for `AppInfo`, invalidated via `lastUpdateTime`. - **Preferences** (`UserPreferences`): theme, AMOLED, dynamic color, biometric lock, sort/filter state, privilege mode, and language — all persisted via DataStore. - **Customization**: Dark/Light/System + AMOLED themes. "Asgardian" static color scheme is the default; Material You dynamic color opt-in. Preferred privilege mode persisted across sessions. - **Search**: Live search by app name or package name in App List and Freezer screens. - **Multi-language**: Supports English, Spanish, French, Arabic, and Chinese. Runtime locale switching via `LocaleManager` (`util/LocaleManager.kt`); language preference stored in `UserPreferences.language` (null = system default). - **Privacy**: Fully offline, no ads, no trackers, FOSS (GPL-3.0). ## ⚠️ Limitations - **Privilege Dependency**: Advanced features (freeze, suspend, system app removal) require at least one of Root, Shizuku, or Dhizuku. Work Mode selection with automatic fallback mitigates partial availability. - **Suspension Compatibility**: `setAppSuspended` uses reflection against internal APIs; behaviour may vary across Android 10–14+ due to API signature changes. - **Offline Only**: No cloud backup or remote synchronization (by design, for privacy). - **Android Constraints**: Subject to evolving Android security restrictions (hidden API policy, target SDK requirements). - **Feature Gap**: App data backup is not yet implemented. ## 🚀 Opportunities - **Data Backup**: Implementing local app data backup and restoration. - **Package Editing**: Direct editing of `packages.xml` for advanced users. - **Batch Install**: Installing multiple APKs in one operation. - **Automation**: Scheduled freezing/unfreezing or automated cleanup tasks. - **Installer Integration**: Expanding support for third-party installers (e.g., F-Droid, Aurora Store). ## 🛡 Threats - **Play Store Policies**: As an "App Manager" with elevated privileges, it faces strict scrutiny from Google Play. - **Android OS Changes**: Future Android updates might further restrict Shizuku or root-level access methods. - **Competition**: Several established open-source app managers exist; maintaining a niche in " lightweight & offline" is key. ================================================ FILE: README.md ================================================

Thor Logo

Thor App Manager

Get it on Google Play   Get it on IzzyOnDroid   Download on Indus Appstore

Telegram Channel

--- * Kotlin + Material 3 Design * Jetpack Compose * Room DB App Caching * Custom Hidden API Bypass * PlayStore Download Size (around 2.0 MB) * Smallest APK size (less than 4 MB) * FOSS - GPL-3.0 * Fully Offline * No Ads/Trackers ## Working Features - High-performance app list loading with Room DB metadata caching - Fingerprint Lock - Themes (dark, light, system) + AMOLED + Asgardian static theme - App Installer (install with root, shizuku or normal) - Root Support - Shizuku Support - Dhizuku Support - Fully reproducible, copyleft libre software (GPLv3.0) - Material 3 with optional dynamic colors (Material You) - Work Mode selection — manually choose between Root, Shizuku, or Dhizuku as the active privilege engine - Displays App List while sorting them based on Installation source - Search in App List and Freezer - Multi-language support (English, Spanish, French, Arabic, Chinese) with in-app language switcher - Launch App Activities - Install/Uninstall/Freeze/Unfreeze Apk files - Suspend/Unsuspend apps (shows custom Thor-branded system dialog) - Background Restriction (restrict app background activity) - Reinstall APKs/Fix Store installer record (available in all privilege modes) - Share App Apk file - Batch Reinstall/Uninstall/Freeze/Unfreeze/Kill/Suspend/Clear Data - Split App Indicator - AppState Indicator (frozen / suspended / hidden) - Uninstall System Apps - Freeze/UnFreeze System apps - Sorting & filters - Clear Data/Cache (available in all privilege modes) ## Upcoming Features - BackUp App Data - Editing Packages.xml - Batch Install - Many more ## 💖 Support Development Thor is a labor of love, built to be **100% offline, ad-free, and tracker-free**. If this tool has made your Android management easier, consider supporting its continued development. Your contributions help keep the project alive and free for everyone. | Platform | Link | |---------------------|-------------------------------------------------------------| | **Patreon** | [Support on Patreon](https://www.patreon.com/trinadh) | | **Buy Me a Coffee** | [Buy me a coffee](https://www.buymeacoffee.com/trinadh) | | **PayPal** | [Donate via PayPal](https://www.paypal.me/trinadhthatakula) | ## Credits - Portions of this app use code from [`libsu`](https://github.com/topjohnwu/libsu) by [topjohnwu](https://github.com/topjohnwu/), adapted and integrated as the [ `suCore`](https://github.com/trinadhthatakula/Thor/tree/master/suCore) module. - Replaced [`AndroidHiddenApiBypass`](https://github.com/LSPosed/AndroidHiddenApiBypass) with an internal Kotlin implementation in the [ `bypass`](https://github.com/trinadhthatakula/Thor/tree/master/bypass) module, backed by Java stubs in the [`vm-runtime`](https://github.com/trinadhthatakula/Thor/tree/master/vm-runtime) module for maximum compatibility when shadowing system classes. ### Modifications to libsu - Fully converted the original Java-based `libsu` code to Kotlin for `suCore` - Refer SuCore [README](https://github.com/trinadhthatakula/Thor/blob/master/suCore/README.md) for more details ## License This project is licensed under the GNU General Public License v3.0 (GPL-3.0). - `libsu` is licensed under the Apache License 2.0. All modifications and usage in this project comply with the Apache-2.0 requirements. - This project as a whole is distributed under the GNU General Public License v3.0 (GPL-3.0). - See the [LICENSE](LICENSE) file for full license text. ================================================ FILE: app/.gitignore ================================================ /build ================================================ FILE: app/baselineprofile/.gitignore ================================================ /build ================================================ FILE: app/baselineprofile/build.gradle.kts ================================================ plugins { alias(libs.plugins.android.test) alias(libs.plugins.baselineprofile) } android { namespace = "com.valhalla.thor.baselineprofile" compileSdk { version = release(36) } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } defaultConfig { minSdk = 28 targetSdk = 36 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } targetProjectPath = ":app" flavorDimensions += listOf("distribution") productFlavors { create("store") { dimension = "distribution" } create("foss") { dimension = "distribution" } } } // This is the configuration block for the Baseline Profile plugin. // You can specify to run the generators on a managed devices or connected devices. baselineProfile { useConnectedDevices = true } dependencies { implementation(libs.androidx.junit) implementation(libs.androidx.espresso.core) implementation(libs.androidx.uiautomator) implementation(libs.androidx.benchmark.macro.junit4) } androidComponents { onVariants { v -> val artifactsLoader = v.artifacts.getBuiltArtifactsLoader() v.instrumentationRunnerArguments.put( "targetAppId", v.testedApks.map { artifactsLoader.load(it)?.applicationId } ) } } ================================================ FILE: app/baselineprofile/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/baselineprofile/src/main/java/com/valhalla/thor/baselineprofile/BaselineProfileGenerator.kt ================================================ package com.valhalla.thor.baselineprofile import androidx.benchmark.macro.junit4.BaselineProfileRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest import androidx.test.platform.app.InstrumentationRegistry import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith /** * This test class generates a basic startup baseline profile for the target package. * * We recommend you start with this but add important user flows to the profile to improve their performance. * Refer to the [baseline profile documentation](https://d.android.com/topic/performance/baselineprofiles) * for more information. * * You can run the generator with the "Generate Baseline Profile" run configuration in Android Studio or * the equivalent `generateBaselineProfile` gradle task: * ``` * ./gradlew :app:generateReleaseBaselineProfile * ``` * The run configuration runs the Gradle task and applies filtering to run only the generators. * * Check [documentation](https://d.android.com/topic/performance/benchmarking/macrobenchmark-instrumentation-args) * for more information about available instrumentation arguments. * * After you run the generator, you can verify the improvements running the [StartupBenchmarks] benchmark. * * When using this class to generate a baseline profile, only API 33+ or rooted API 28+ are supported. * * The minimum required version of androidx.benchmark to generate a baseline profile is 1.2.0. **/ @RunWith(AndroidJUnit4::class) @LargeTest class BaselineProfileGenerator { @get:Rule val rule = BaselineProfileRule() @Test fun generate() { // The application id for the running build variant is read from the instrumentation arguments. rule.collect( packageName = InstrumentationRegistry.getArguments().getString("targetAppId") ?: throw Exception("targetAppId not passed as instrumentation runner arg"), // See: https://d.android.com/topic/performance/baselineprofiles/dex-layout-optimizations includeInStartupProfile = true ) { // This block defines the app's critical user journey. Here we are interested in // optimizing for app startup. But you can also navigate and scroll through your most important UI. // Start default activity for your app pressHome() startActivityAndWait() // TODO Write more interactions to optimize advanced journeys of your app. // For example: // 1. Wait until the content is asynchronously loaded // 2. Scroll the feed content // 3. Navigate to detail screen // Check UiAutomator documentation for more information how to interact with the app. // https://d.android.com/training/testing/other-components/ui-automator } } } ================================================ FILE: app/baselineprofile/src/main/java/com/valhalla/thor/baselineprofile/StartupBenchmarks.kt ================================================ package com.valhalla.thor.baselineprofile import androidx.benchmark.macro.BaselineProfileMode import androidx.benchmark.macro.CompilationMode import androidx.benchmark.macro.StartupMode import androidx.benchmark.macro.StartupTimingMetric import androidx.benchmark.macro.junit4.MacrobenchmarkRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest import androidx.test.platform.app.InstrumentationRegistry import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith /** * This test class benchmarks the speed of app startup. * Run this benchmark to verify how effective a Baseline Profile is. * It does this by comparing [CompilationMode.None], which represents the app with no Baseline * Profiles optimizations, and [CompilationMode.Partial], which uses Baseline Profiles. * * Run this benchmark to see startup measurements and captured system traces for verifying * the effectiveness of your Baseline Profiles. You can run it directly from Android * Studio as an instrumentation test, or run all benchmarks for a variant, for example benchmarkRelease, * with this Gradle task: * ``` * ./gradlew :app:baselineprofile:connectedBenchmarkReleaseAndroidTest * ``` * * You should run the benchmarks on a physical device, not an Android emulator, because the * emulator doesn't represent real world performance and shares system resources with its host. * * For more information, see the [Macrobenchmark documentation](https://d.android.com/macrobenchmark#create-macrobenchmark) * and the [instrumentation arguments documentation](https://d.android.com/topic/performance/benchmarking/macrobenchmark-instrumentation-args). **/ @RunWith(AndroidJUnit4::class) @LargeTest class StartupBenchmarks { @get:Rule val rule = MacrobenchmarkRule() @Test fun startupCompilationNone() = benchmark(CompilationMode.None()) @Test fun startupCompilationBaselineProfiles() = benchmark(CompilationMode.Partial(BaselineProfileMode.Require)) private fun benchmark(compilationMode: CompilationMode) { // The application id for the running build variant is read from the instrumentation arguments. rule.measureRepeated( packageName = InstrumentationRegistry.getArguments().getString("targetAppId") ?: throw Exception("targetAppId not passed as instrumentation runner arg"), metrics = listOf(StartupTimingMetric()), compilationMode = compilationMode, startupMode = StartupMode.COLD, iterations = 10, setupBlock = { pressHome() }, measureBlock = { startActivityAndWait() // TODO Add interactions to wait for when your app is fully drawn. // The app is fully drawn when Activity.reportFullyDrawn is called. // For Jetpack Compose, you can use ReportDrawn, ReportDrawnWhen and ReportDrawnAfter // from the AndroidX Activity library. // Check the UiAutomator documentation for more information on how to // interact with the app. // https://d.android.com/training/testing/other-components/ui-automator } ) } } ================================================ FILE: app/build.gradle.kts ================================================ import com.android.build.api.artifact.SingleArtifact import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.io.FileInputStream import java.util.Properties plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlinSerialization) alias(libs.plugins.room) alias(libs.plugins.ksp) } kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) freeCompilerArgs.add("-Xexplicit-backing-fields") optIn.add("kotlin.RequiresOptIn") optIn.add("kotlin.time.ExperimentalTime") optIn.add("org.koin.core.annotation.KoinExperimentalAPI") optIn.add("androidx.compose.material3.ExperimentalMaterial3ExpressiveApi") optIn.add("androidx.compose.material3.ExperimentalMaterial3Api") } } room { schemaDirectory("$projectDir/schemas") } val keystorePropertiesFile: File = rootProject.file("jks.properties") val keystoreProperties = Properties() if (keystorePropertiesFile.exists()) { keystoreProperties.load(FileInputStream(keystorePropertiesFile)) } // --- VERSIONING HELPERS (Private & Modernized) --- // 1. Resolve Code: Checks property 'versionCode' first, falls back to 'initialVersionCode' private fun resolveVersionCode(): Int { val initial = providers.gradleProperty("initialVersionCode") .orNull ?.toIntOrNull() ?: throw GradleException("Required 'initialVersionCode' missing in gradle.properties") val override = providers.gradleProperty("versionCode") .orNull ?.toIntOrNull() return override ?: initial } // 2. Calculate Name: The math logic (1712 -> 1.71.2) private fun calculateVersionName(code: Int): String { val major = code / 1000 val minor = (code % 1000) / 10 val patch = code % 10 return "$major.$minor.$patch" } // 3. Resolve Name: Checks property 'versionName' first, falls back to math private fun resolveVersionName(code: Int): String { return providers.gradleProperty("versionName").orNull ?: calculateVersionName(code) } android { namespace = "com.valhalla.thor" compileSdk = 37 defaultConfig { applicationId = "com.valhalla.thor" minSdk = 28 targetSdk = 37 // Calculate versions using the private helpers val code = resolveVersionCode() versionCode = code versionName = resolveVersionName(code) vectorDrawables.useSupportLibrary = true testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" ndk { debugSymbolLevel = "SYMBOL_TABLE" } } signingConfigs { create("release") { if (keystorePropertiesFile.exists()) { keyAlias = keystoreProperties["keyAlias"] as String keyPassword = keystoreProperties["keyPassword"] as String storeFile = file(keystoreProperties["storeFile"] as String) storePassword = keystoreProperties["storePassword"] as String } else if (System.getenv("KEY_ALIAS") != null) { // CI/CD Build (GitHub Actions) keyAlias = System.getenv("KEY_ALIAS") keyPassword = System.getenv("KEY_PASSWORD") storePassword = System.getenv("KEYSTORE_PASSWORD") storeFile = file(System.getenv("KEYSTORE_FILE_PATH") ?: "release.jks") } else { logger.warn("⚠️ keystore.properties not found or environment variables not set. Release build will not be signed properly.") } } } dependenciesInfo { includeInApk = false includeInBundle = true } buildTypes { release { isMinifyEnabled = true isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) signingConfig = signingConfigs.getByName("release") } debug { isMinifyEnabled = false applicationIdSuffix = ".debug" } } flavorDimensions += "distribution" productFlavors { create("store") { dimension = "distribution" } create("foss") { dimension = "distribution" versionNameSuffix = "-foss" proguardFile("proguard-rules-foss.pro") } } compileOptions { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } buildFeatures { buildConfig = true compose = true aidl = true } packaging { resources { excludes += "/specs/**" excludes += "**/*.dll" excludes += "**/*.dylib" excludes += "**/x64/**" excludes += "**/x86_64/*.dll" excludes += "**/META-INF/*.{kotlin_module,dot}" excludes += "META-INF/services/javax.annotation.processing.Processor" excludes += "META-INF/DEPENDENCIES" excludes += "META-INF/LICENSE*" excludes += "META-INF/NOTICE*" } } } androidComponents { // 1. Existing FOSS Copy Task onVariants(selector().withFlavor("distribution", "foss")) { variant -> if (variant.buildType == "release") { val apkDir = variant.artifacts.get(SingleArtifact.APK) tasks.register("copyFossReleaseApk") { dependsOn("assembleFossRelease") from(apkDir) { include("*.apk") } into(layout.buildDirectory.dir("distribution/foss")) rename(".*\\.apk", "foss-release.apk") } } } // 2. Store Copy Task onVariants(selector().withFlavor("distribution", "store")) { variant -> if (variant.buildType == "release") { val apkDir = variant.artifacts.get(SingleArtifact.APK) tasks.register("copyStoreReleaseApk") { dependsOn("assembleStoreRelease") from(apkDir) { include("*.apk") } into(layout.buildDirectory.dir("distribution/store")) rename(".*\\.apk", "store-release.apk") } } } } dependencies { implementation(project(":suCore")) implementation(project(":bypass")) implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.splashscreen) implementation(libs.androidx.core.ktx) implementation(libs.androidx.activity.compose) implementation(libs.androidx.biometric) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.ui) implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.ui.test.junit4) debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.accompanist.drawablepainter) implementation(libs.kotlinx.serialization.json) implementation(libs.lottie.compose) implementation(libs.shizuku.api) implementation(libs.shizuku.provider) implementation(libs.dhizuku.api) implementation(libs.bundles.coil) implementation(libs.bundles.koin) implementation(libs.room.runtime) implementation(libs.room.ktx) ksp(libs.room.compiler) } // These rely on the private functions above, which is allowed in the same file scope val currentVersionCode = resolveVersionCode() val currentVersionName = resolveVersionName(currentVersionCode) tasks.register("printVersionName") { val vName = currentVersionName doLast { println(vName) } } ================================================ FILE: app/proguard-rules-foss.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile # Ignore missing service definitions that are not relevant for Android runtime -dontwarn javax.annotation.processing.Processor -dontwarn javax.annotation.Nullable -dontobfuscate -keepattributes SourceFile,LineNumberTable #-keep class com.valhalla.thor.** { *; } ================================================ FILE: app/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile # Ignore missing service definitions that are not relevant for Android runtime -dontwarn javax.annotation.processing.Processor -dontwarn javax.annotation.Nullable -dontwarn dalvik.system.VMRuntime ================================================ FILE: app/schemas/com.valhalla.thor.data.source.local.room.AppDatabase/1.json ================================================ { "formatVersion": 1, "database": { "version": 1, "identityHash": "0250ce576c64723c12faf13e9d8ccb18", "entities": [ { "tableName": "apps", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `appName` TEXT, `versionName` TEXT, `versionCode` INTEGER NOT NULL, `minSdk` INTEGER NOT NULL, `targetSdk` INTEGER NOT NULL, `isSystem` INTEGER NOT NULL, `installerPackageName` TEXT, `publicSourceDir` TEXT, `splitPublicSourceDirs` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `dataDir` TEXT, `nativeLibraryDir` TEXT, `deviceProtectedDataDir` TEXT, `sharedLibraryFiles` TEXT, `obbFilePath` TEXT, `sourceDir` TEXT, `sharedDataDir` TEXT NOT NULL, `lastUpdateTime` INTEGER NOT NULL, `firstInstallTime` INTEGER NOT NULL, `isDebuggable` INTEGER NOT NULL, PRIMARY KEY(`packageName`))", "fields": [ { "fieldPath": "packageName", "columnName": "packageName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "appName", "columnName": "appName", "affinity": "TEXT" }, { "fieldPath": "versionName", "columnName": "versionName", "affinity": "TEXT" }, { "fieldPath": "versionCode", "columnName": "versionCode", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "minSdk", "columnName": "minSdk", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "targetSdk", "columnName": "targetSdk", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isSystem", "columnName": "isSystem", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "installerPackageName", "columnName": "installerPackageName", "affinity": "TEXT" }, { "fieldPath": "publicSourceDir", "columnName": "publicSourceDir", "affinity": "TEXT" }, { "fieldPath": "splitPublicSourceDirs", "columnName": "splitPublicSourceDirs", "affinity": "TEXT", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "dataDir", "columnName": "dataDir", "affinity": "TEXT" }, { "fieldPath": "nativeLibraryDir", "columnName": "nativeLibraryDir", "affinity": "TEXT" }, { "fieldPath": "deviceProtectedDataDir", "columnName": "deviceProtectedDataDir", "affinity": "TEXT" }, { "fieldPath": "sharedLibraryFiles", "columnName": "sharedLibraryFiles", "affinity": "TEXT" }, { "fieldPath": "obbFilePath", "columnName": "obbFilePath", "affinity": "TEXT" }, { "fieldPath": "sourceDir", "columnName": "sourceDir", "affinity": "TEXT" }, { "fieldPath": "sharedDataDir", "columnName": "sharedDataDir", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "firstInstallTime", "columnName": "firstInstallTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isDebuggable", "columnName": "isDebuggable", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "packageName" ] } } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0250ce576c64723c12faf13e9d8ccb18')" ] } } ================================================ FILE: app/schemas/com.valhalla.thor.data.source.local.room.AppDatabase/2.json ================================================ { "formatVersion": 1, "database": { "version": 2, "identityHash": "37ee5d25d4bab9e2695c36e7dc21a849", "entities": [ { "tableName": "apps", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `appName` TEXT, `versionName` TEXT, `versionCode` INTEGER NOT NULL, `minSdk` INTEGER NOT NULL, `targetSdk` INTEGER NOT NULL, `isSystem` INTEGER NOT NULL, `installerPackageName` TEXT, `publicSourceDir` TEXT, `splitPublicSourceDirs` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `dataDir` TEXT, `nativeLibraryDir` TEXT, `deviceProtectedDataDir` TEXT, `sharedLibraryFiles` TEXT, `obbFilePath` TEXT, `sourceDir` TEXT, `sharedDataDir` TEXT NOT NULL, `lastUpdateTime` INTEGER NOT NULL, `firstInstallTime` INTEGER NOT NULL, `isDebuggable` INTEGER NOT NULL, `isSuspended` INTEGER NOT NULL, PRIMARY KEY(`packageName`))", "fields": [ { "fieldPath": "packageName", "columnName": "packageName", "affinity": "TEXT", "notNull": true }, { "fieldPath": "appName", "columnName": "appName", "affinity": "TEXT" }, { "fieldPath": "versionName", "columnName": "versionName", "affinity": "TEXT" }, { "fieldPath": "versionCode", "columnName": "versionCode", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "minSdk", "columnName": "minSdk", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "targetSdk", "columnName": "targetSdk", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isSystem", "columnName": "isSystem", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "installerPackageName", "columnName": "installerPackageName", "affinity": "TEXT" }, { "fieldPath": "publicSourceDir", "columnName": "publicSourceDir", "affinity": "TEXT" }, { "fieldPath": "splitPublicSourceDirs", "columnName": "splitPublicSourceDirs", "affinity": "TEXT", "notNull": true }, { "fieldPath": "enabled", "columnName": "enabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "dataDir", "columnName": "dataDir", "affinity": "TEXT" }, { "fieldPath": "nativeLibraryDir", "columnName": "nativeLibraryDir", "affinity": "TEXT" }, { "fieldPath": "deviceProtectedDataDir", "columnName": "deviceProtectedDataDir", "affinity": "TEXT" }, { "fieldPath": "sharedLibraryFiles", "columnName": "sharedLibraryFiles", "affinity": "TEXT" }, { "fieldPath": "obbFilePath", "columnName": "obbFilePath", "affinity": "TEXT" }, { "fieldPath": "sourceDir", "columnName": "sourceDir", "affinity": "TEXT" }, { "fieldPath": "sharedDataDir", "columnName": "sharedDataDir", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "firstInstallTime", "columnName": "firstInstallTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isDebuggable", "columnName": "isDebuggable", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isSuspended", "columnName": "isSuspended", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "packageName" ] } } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '37ee5d25d4bab9e2695c36e7dc21a849')" ] } } ================================================ FILE: app/src/androidTest/java/com/valhalla/thor/ExampleInstrumentedTest.kt ================================================ package com.valhalla.thor import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith /** * Instrumented test, which will execute on an Android device. * * See [testing documentation](http://d.android.com/tools/testing). */ @RunWith(AndroidJUnit4::class) class ExampleInstrumentedTest { @Test fun useAppContext() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext assertEquals("com.valhalla.thor", appContext.packageName) } } ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/assets/adi-registration.properties ================================================ CIQKVO6RQ32OMAAAAAAAAAAAAA ================================================ FILE: app/src/main/java/android/content/pm/IPackageInstaller.java ================================================ package android.content.pm; import android.os.Binder; import android.os.IBinder; import android.os.IInterface; /** * Taken from Shizuku API Demo * * @author RikkaW */ public interface IPackageInstaller extends IInterface { abstract class Stub extends Binder implements IPackageInstaller { public static IPackageInstaller asInterface(IBinder binder) { throw new UnsupportedOperationException(); } } } ================================================ FILE: app/src/main/java/android/content/pm/IPackageManager.java ================================================ package android.content.pm; import android.os.Binder; import android.os.IBinder; import android.os.IInterface; import android.os.RemoteException; /** * Taken from Shizuku API Demo * * @author RikkaW */ public interface IPackageManager extends IInterface { IPackageInstaller getPackageInstaller() throws RemoteException; abstract class Stub extends Binder implements IPackageManager { public static IPackageManager asInterface(IBinder obj) { throw new UnsupportedOperationException(); } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/HomeActivity.kt ================================================ package com.valhalla.thor import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.getValue import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import com.valhalla.thor.domain.model.ThemeMode import com.valhalla.thor.domain.repository.SystemRepository import com.valhalla.thor.presentation.common.ShizukuPermissionHandler import com.valhalla.thor.presentation.home.HomeViewModel import com.valhalla.thor.presentation.main.MainScreen import com.valhalla.thor.presentation.security.AuthState import com.valhalla.thor.presentation.security.BiometricScreen import com.valhalla.thor.presentation.security.SecurityViewModel import com.valhalla.thor.presentation.settings.SettingsViewModel import com.valhalla.thor.presentation.theme.ThorTheme import com.valhalla.thor.util.Logger import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel class HomeActivity : ComponentActivity() { private val systemRepository: SystemRepository by inject() private val homeViewModel: HomeViewModel by viewModel() private val securityViewModel: SecurityViewModel by viewModel() private val settingsViewModel: SettingsViewModel by viewModel() private val requestCode = 1001 private var hasRequestedShizuku = false private val shizukuHandler = ShizukuPermissionHandler( onPermissionGranted = { Logger.d("HomeActivity", "Shizuku Ready") homeViewModel.loadDashboardData() }, onPermissionDenied = { Logger.d("HomeActivity", "Shizuku Denied") }, onBinderDead = { Logger.w("HomeActivity", "Shizuku Binder Died") } ) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) installSplashScreen() enableEdgeToEdge() shizukuHandler.register() setContent { val prefs by settingsViewModel.preferences.collectAsStateWithLifecycle() val systemDark = isSystemInDarkTheme() val darkTheme = when (prefs.themeMode) { ThemeMode.LIGHT -> false ThemeMode.DARK -> true ThemeMode.SYSTEM -> systemDark } ThorTheme( darkTheme = darkTheme, dynamicColor = prefs.useDynamicColor, amoledMode = prefs.useAmoled, ) { val authState by securityViewModel.authState.collectAsStateWithLifecycle() when (authState) { AuthState.NotRequired, AuthState.Unlocked -> { MainScreen( homeViewModel = homeViewModel, onExit = { finish() } ) } AuthState.Locked, is AuthState.Error -> { BiometricScreen( isError = authState is AuthState.Error, errorMessage = (authState as? AuthState.Error)?.message ?: "", onAuthenticated = { securityViewModel.onAuthenticated() }, onError = { message -> Logger.e("HomeActivity", "Biometric error: $message") securityViewModel.onAuthError(message) }, onRetry = { securityViewModel.onRetry() }, onExit = { finish() } ) } } } } } override fun onResume() { super.onResume() lifecycleScope.launch { if (!systemRepository.isRootAvailable() && !hasRequestedShizuku) { hasRequestedShizuku = true shizukuHandler.checkAndRequestPermission(requestCode) } } } override fun onDestroy() { shizukuHandler.unregister() super.onDestroy() } } ================================================ FILE: app/src/main/java/com/valhalla/thor/ThorApplication.kt ================================================ package com.valhalla.thor import android.app.Application import com.rosan.dhizuku.api.Dhizuku import com.valhalla.bypass.Bypass import com.valhalla.thor.core.ThorShellConfig import com.valhalla.thor.di.commonModule import com.valhalla.thor.di.coreModule import com.valhalla.thor.di.installerModule import com.valhalla.thor.di.preferenceModule import com.valhalla.thor.di.presentationModule import com.valhalla.thor.di.roomModule import com.valhalla.thor.domain.repository.PreferenceRepository import com.valhalla.thor.util.LocaleManager import com.valhalla.thor.util.Logger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.android.ext.android.inject import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger import org.koin.androix.startup.KoinStartup import org.koin.dsl.koinConfiguration class ThorApplication : Application(), KoinStartup { private val preferenceRepository: PreferenceRepository by inject() private val localeManager: LocaleManager by inject() override fun onKoinStartup() = koinConfiguration { androidContext(this@ThorApplication) androidLogger(Logger.koinLogLevel) modules( coreModule, installerModule, preferenceModule, commonModule, presentationModule, roomModule ) } override fun onCreate() { super.onCreate() // Initialize Bypass with custom logging Bypass.setLogger { message, throwable -> Logger.e("Bypass", message, throwable) } Bypass.prepareThor() ThorShellConfig.init() try { Dhizuku.init(this) } catch (e: Exception) { Logger.e("ThorApp", "Dhizuku init failed", e) } // Apply saved language on startup MainScope().launch { val prefs = preferenceRepository.userPreferences.first() withContext(Dispatchers.Main) { localeManager.applyLocale(prefs.language) } } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/core/ThorShellConfig.kt ================================================ package com.valhalla.thor.core import com.valhalla.superuser.Shell import com.valhalla.thor.BuildConfig import com.valhalla.thor.core.ThorShellConfig.init /** * Centralized configuration for the Root Shell. * Call [init] in your Application.onCreate(). */ object ThorShellConfig { fun init() { // Set logging based on build type Shell.enableVerboseLogging = BuildConfig.DEBUG // Configure the default builder. // FLAG_MOUNT_MASTER: Essential for global namespace operations (mounting, etc.) Shell.setDefaultBuilder( Shell.Builder.create() .setFlags(Shell.FLAG_MOUNT_MASTER) // If you have specific initializers, add them here // .setInitializers(MyInitializer::class.java) ) } } ================================================ FILE: app/src/main/java/com/valhalla/thor/data/Constants.kt ================================================ package com.valhalla.thor.data const val ACTION_INSTALL_STATUS = "com.valhalla.thor.INSTALL_STATUS" ================================================ FILE: app/src/main/java/com/valhalla/thor/data/gateway/DhizukuSystemGateway.kt ================================================ package com.valhalla.thor.data.gateway import com.valhalla.thor.data.source.local.dhizuku.DhizukuHelper import com.valhalla.thor.data.source.local.dhizuku.DhizukuReflector import com.valhalla.thor.domain.gateway.SystemGateway class DhizukuSystemGateway( private val reflector: DhizukuReflector ) : SystemGateway { override suspend fun isRootAvailable() = false override fun isShizukuAvailable(): Boolean = false override fun isDhizukuAvailable(): Boolean { return DhizukuHelper.isDhizukuAvailable() } override suspend fun forceStopApp(packageName: String): Result { return if (reflector.forceStop(packageName)) Result.success(Unit) else Result.failure(Exception("Dhizuku: Force stop failed. Shell command and reflection both denied.")) } override suspend fun clearCache(packageName: String): Result { return if (reflector.clearCache(packageName)) Result.success(Unit) else Result.failure(Exception("Dhizuku: Clear cache failed. System reflection and shell rm -rf both failed.")) } override suspend fun clearAppData(packageName: String): Result { return if (reflector.clearData(packageName)) Result.success(Unit) else Result.failure(Exception("Dhizuku: Clear data failed. Shell pm clear and reflection both failed.")) } override suspend fun setAppDisabled(packageName: String, isDisabled: Boolean): Result { return if (reflector.setAppEnabled(packageName, !isDisabled)) Result.success(Unit) else Result.failure(Exception("Dhizuku: Set enabled state failed. Shell and reflection both failed.")) } override suspend fun rebootDevice(reason: String): Result { return Result.failure(Exception("Dhizuku: Reboot not supported directly. Use Root mode instead.")) } override suspend fun uninstallApp(packageName: String): Result { return if (reflector.uninstallApp(packageName)) { Result.success(Unit) } else { Result.failure(Exception("Dhizuku: Uninstall failed.")) } } override suspend fun installApp(apkPath: String, canDowngrade: Boolean): Result { val result = DhizukuHelper.execute( "pm install -r -g${if (canDowngrade) " -d" else ""} ${ com.valhalla.superuser.ShellUtils.escapedString(apkPath) }" ) return if (result.first == 0) { Result.success(Unit) } else { Result.failure(Exception("Dhizuku: Install failed: ${result.second}")) } } override suspend fun getAppCacheSize(packageName: String): Long { return 0L } override suspend fun reinstallAppWithGoogle(packageName: String): Result { if (packageName == com.valhalla.thor.BuildConfig.APPLICATION_ID) return Result.failure(Exception("Cannot reinstall Thor")) return try { // 1. Get the APK path(s) val pathResult = DhizukuHelper.execute("pm path $packageName") val paths = pathResult.second?.lines() ?.filter { it.isNotBlank() } ?.map { it.removePrefix("package:").trim() } ?: emptyList() if (paths.isEmpty()) { return Result.failure(Exception("Dhizuku: Could not find APK path for $packageName")) } val combinedPath = paths.joinToString(" ") { "\"$it\"" } // 2. Get Current User ID val userResult = DhizukuHelper.execute("am get-current-user") val currentUser = userResult.second?.trim() ?: return Result.failure(Exception("Dhizuku: Could not determine current user")) // 3. Execute the reinstallation command val command = "pm install -r -d -i \"com.android.vending\" --user $currentUser --install-reason 0 $combinedPath" val result = DhizukuHelper.execute(command) if (result.first == 0) Result.success(Unit) else Result.failure(Exception("Dhizuku: Reinstall failed: ${result.second}")) } catch (e: Exception) { Result.failure(e) } } override suspend fun setAppSuspended(packageName: String, isSuspended: Boolean): Result { return if (reflector.setAppSuspended(packageName, isSuspended)) Result.success(Unit) else Result.failure(Exception("Dhizuku: Set suspended state failed.")) } override suspend fun setAppRestricted( packageName: String, isRestricted: Boolean ): Result { return if (reflector.setAppRestricted(packageName, isRestricted)) Result.success(Unit) else Result.failure(Exception("Dhizuku: Set restricted state failed.")) } } ================================================ FILE: app/src/main/java/com/valhalla/thor/data/gateway/RootSystemGateway.kt ================================================ package com.valhalla.thor.data.gateway import android.content.Context import com.valhalla.superuser.ktx.ShellRepository import com.valhalla.thor.BuildConfig import com.valhalla.thor.domain.gateway.SystemGateway import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext /** * Modern implementation of SystemGateway using the reactive ShellRepository. * No more static blocking calls. */ class RootSystemGateway( private val context: Context, private val shellRepository: ShellRepository ) : SystemGateway { // A root check is strictly asynchronous. Blocking the thread for this is unacceptable. override suspend fun isRootAvailable(): Boolean { return shellRepository.isRootGranted() } override fun isShizukuAvailable(): Boolean = false override fun isDhizukuAvailable(): Boolean = false override suspend fun forceStopApp(packageName: String): Result { return runCommand("am force-stop $packageName") } override suspend fun clearCache(packageName: String): Result { val command = "rm -rf /data/data/$packageName/cache /sdcard/Android/data/$packageName/cache" return runCommand(command) } override suspend fun clearAppData(packageName: String): Result { return runCommand("pm clear $packageName") } override suspend fun setAppDisabled(packageName: String, isDisabled: Boolean): Result { val state = if (isDisabled) "disable" else "enable" return runCommand("pm $state $packageName") } override suspend fun setAppSuspended(packageName: String, isSuspended: Boolean): Result { // Try one-shot root task first to show proper branding if (isSuspended && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { val taskResult = runRootTask("suspend", packageName, isSuspended.toString()) if (taskResult.isSuccess) return Result.success(Unit) } val state = if (isSuspended) "suspend" else "unsuspend" return runCommand("pm $state $packageName") } override suspend fun setAppRestricted( packageName: String, isRestricted: Boolean ): Result { val state = if (isRestricted) "ignore" else "allow" return runCommand("appops set $packageName RUN_ANY_IN_BACKGROUND $state") } override suspend fun rebootDevice(reason: String): Result { // executeResult returns success if ANY of the commands succeed in the chain logic return runCommand("svc power reboot $reason || reboot $reason") } override suspend fun uninstallApp(packageName: String): Result { return runCommand("pm uninstall --user 0 $packageName") } override suspend fun installApp(apkPath: String, canDowngrade: Boolean): Result { val command = "pm install -r -g${if (canDowngrade) " -d" else ""} ${ com.valhalla.superuser.ShellUtils.escapedString(apkPath) }" return runCommand(command) } override suspend fun getAppCacheSize(packageName: String): Long { return try { val result = shellRepository.runCommand("du -s /data/data/$packageName/cache") val outputLine = result.getOrNull()?.firstOrNull() ?: return 0L // Output format is usually "12345 /path/to/file" // We parse this in Kotlin, not using brittle 'awk' or 'cut' val sizeInBlocks = outputLine.substringBefore('\t').substringBefore(' ').toLongOrNull() ?: 0L // du usually returns 1k blocks sizeInBlocks * 1024 } catch (_: Exception) { 0L } } /** * Modernized Reinstall Logic. * Replaces the 'sed' and 'tr' pipes with proper Kotlin string manipulation. */ override suspend fun reinstallAppWithGoogle(packageName: String): Result { if (packageName == BuildConfig.APPLICATION_ID) return Result.failure(Exception("Cannot reinstall Thor")) return withContext(Dispatchers.IO) { try { // 1. Get the APK path(s) val paths = getAppPaths(packageName) if (paths.isEmpty()) { return@withContext Result.failure(Exception("Could not find APK path for $packageName")) } val combinedPath = paths.joinToString(" ") { "\"$it\"" } // 2. Get Current User ID val userResult = shellRepository.runCommand("am get-current-user") val currentUser = userResult.getOrNull()?.firstOrNull()?.trim() ?: return@withContext Result.failure(Exception("Could not determine current user")) // 3. Execute the reinstallation command val command = "pm install -r -d -i \"com.android.vending\" --user $currentUser --install-reason 0 $combinedPath" runCommand(command) } catch (e: Exception) { Result.failure(e) } } } /** * Copies a file using Root privileges. */ suspend fun copyFile(source: String, destination: String) { val command = "cp \"$source\" \"$destination\"" val result = runCommand(command) if (result.isFailure) { throw Exception("Root copy failed: $command") } } /** * Retrieves all APK paths (Base + Splits) for a package. */ suspend fun getAppPaths(packageName: String): List { val result = shellRepository.runCommand("pm path \"$packageName\"") val lines = result.getOrNull() ?: emptyList() return lines .filter { it.isNotBlank() } .map { it.removePrefix("package:").trim() } } /** * Executes a Root command in a separate process using app_process. */ private suspend fun runRootTask(action: String, vararg args: String): Result { val apkPath = context.packageCodePath val className = "com.valhalla.thor.data.source.local.root.RootMain" val cmd = "export CLASSPATH=$apkPath && app_process /system/bin $className $action ${args.joinToString(" ")}" return runCommand(cmd) } /** * Helper to bridge ShellRepository's Result> to Result */ private suspend fun runCommand(cmd: String): Result { val result = shellRepository.runCommand(cmd) return if (result.isSuccess) { Result.success(Unit) } else { // Forward the exception from the repository or create a new one Result.failure(result.exceptionOrNull() ?: Exception("Shell command failed: $cmd")) } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/data/gateway/ShizukuSystemGateway.kt ================================================ package com.valhalla.thor.data.gateway import android.content.pm.PackageManager import com.valhalla.thor.BuildConfig import com.valhalla.thor.data.source.local.shizuku.ShizukuReflector import com.valhalla.thor.domain.gateway.SystemGateway import rikka.shizuku.Shizuku import com.valhalla.thor.data.source.local.shizuku.Shizuku as ShizukuHelper class ShizukuSystemGateway( private val reflector: ShizukuReflector ) : SystemGateway { override suspend fun isRootAvailable() = false override fun isShizukuAvailable(): Boolean { return try { Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED && Shizuku.pingBinder() } catch (_: Exception) { false } } override fun isDhizukuAvailable(): Boolean = false override suspend fun forceStopApp(packageName: String): Result { return runAction { reflector.forceStop(packageName) } } override suspend fun clearCache(packageName: String): Result { return runAction { reflector.clearCache(packageName) } } override suspend fun clearAppData(packageName: String): Result { return runAction { reflector.clearData(packageName) } } override suspend fun setAppDisabled(packageName: String, isDisabled: Boolean): Result { return runAction { reflector.setAppEnabled(packageName, !isDisabled) } } override suspend fun setAppSuspended(packageName: String, isSuspended: Boolean): Result { return runAction { reflector.setAppSuspended(packageName, isSuspended) } } override suspend fun setAppRestricted( packageName: String, isRestricted: Boolean ): Result { return runAction { reflector.setAppRestricted(packageName, isRestricted) } } override suspend fun rebootDevice(reason: String): Result { return Result.failure(Exception("Reboot requires Root. Shizuku cannot perform this action.")) } override suspend fun uninstallApp(packageName: String): Result { return if (reflector.uninstallApp(packageName)) { Result.success(Unit) } else { Result.failure(Exception("Uninstall failed")) } } override suspend fun installApp(apkPath: String, canDowngrade: Boolean): Result { return if (reflector.installPackage(apkPath, canDowngrade)) { Result.success(Unit) } else { Result.failure(Exception("Shizuku install failed. Ensure the file path is readable by Shell/ADB.")) } } override suspend fun getAppCacheSize(packageName: String): Long { return 0L // Requires specialized logic } override suspend fun reinstallAppWithGoogle(packageName: String): Result { if (packageName == BuildConfig.APPLICATION_ID) return Result.failure(Exception("Cannot reinstall Thor")) return try { // 1. Get the APK path(s) val pathResult = ShizukuHelper.execute("pm path $packageName") val paths = pathResult.second?.lines() ?.filter { it.isNotBlank() } ?.map { it.removePrefix("package:").trim() } ?: emptyList() if (paths.isEmpty()) { return Result.failure(Exception("Could not find APK path for $packageName")) } val combinedPath = paths.joinToString(" ") { "\"$it\"" } // 2. Get Current User ID val userResult = ShizukuHelper.execute("am get-current-user") val currentUser = userResult.second?.trim() ?: return Result.failure(Exception("Could not determine current user")) // 3. Execute the reinstallation command val command = "pm install -r -d -i \"com.android.vending\" --user $currentUser --install-reason 0 $combinedPath" val result = ShizukuHelper.execute(command) if (result.first == 0) Result.success(Unit) else Result.failure(Exception("Shizuku reinstall failed: ${result.second}")) } catch (e: Exception) { Result.failure(e) } } /** * Standardizes error handling for reflection and shell actions. */ private inline fun runAction(action: () -> Boolean): Result { if (!isShizukuAvailable()) { return Result.failure(Exception("Shizuku is not available or permission denied.")) } return try { if (action()) Result.success(Unit) else Result.failure(Exception("Action failed. This may happen if reflection is blocked or shell lacks permissions.")) } catch (e: Exception) { if (BuildConfig.DEBUG) e.printStackTrace() Result.failure(e) } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/data/receivers/InstallReceiver.kt ================================================ package com.valhalla.thor.data.receivers import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.pm.PackageInstaller import android.os.Build import com.valhalla.thor.data.ACTION_INSTALL_STATUS import com.valhalla.thor.domain.InstallState import com.valhalla.thor.domain.InstallerEventBus import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject /** * Receives the async installation status result from the Android System. * This receiver is not exported for security; it is triggered via a targeted PendingIntent. */ class InstallReceiver : BroadcastReceiver(), KoinComponent { private val eventBus: InstallerEventBus by inject() override fun onReceive(context: Context, intent: Intent) { if (intent.action != ACTION_INSTALL_STATUS) return val pendingResult = goAsync() val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1) CoroutineScope(Dispatchers.IO).launch { try { when (status) { PackageInstaller.STATUS_SUCCESS -> { eventBus.emit(InstallState.Success) } PackageInstaller.STATUS_PENDING_USER_ACTION -> { val confirmIntent: Intent? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { intent.getParcelableExtra( Intent.EXTRA_INTENT, Intent::class.java ) } else { @Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_INTENT) } if (confirmIntent != null) { eventBus.emit(InstallState.UserConfirmationRequired(confirmIntent)) } } else -> { val msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) ?: "Unknown Error" eventBus.emit(InstallState.Error("Install Failed ($status): $msg")) } } } finally { pendingResult.finish() } } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/data/repository/AppAnalyzerImpl.kt ================================================ package com.valhalla.thor.data.repository import android.content.Context import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.net.Uri import android.os.Build import androidx.core.graphics.createBitmap import com.valhalla.thor.domain.model.AppMetadata import com.valhalla.thor.domain.repository.AppAnalyzer import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File import java.io.FileOutputStream import java.util.zip.ZipInputStream class AppAnalyzerImpl(private val context: Context) : AppAnalyzer { override suspend fun analyze(uri: Uri): Result = withContext(Dispatchers.IO) { val tempFile = File(context.cacheDir, "analysis_${System.currentTimeMillis()}.apk") try { val contentResolver = context.contentResolver // Phase 1: Try to extract a nested APK (for XAPK/APKS) var isNestedBundle = false try { contentResolver.openInputStream(uri)?.use { inputStream -> ZipInputStream(inputStream).use { zipStream -> var entry = zipStream.nextEntry while (entry != null) { val name = entry.name if (name.endsWith(".apk", ignoreCase = true)) { FileOutputStream(tempFile).use { fos -> zipStream.copyTo(fos) } isNestedBundle = true break } zipStream.closeEntry() entry = zipStream.nextEntry } } } } catch (_: Exception) { isNestedBundle = false } // Phase 2: Fallback (Standard APK) if (!isNestedBundle) { contentResolver.openInputStream(uri)?.use { input -> FileOutputStream(tempFile).use { output -> input.copyTo(output) } } } // Phase 3: Parsing val pm = context.packageManager val flags = PackageManager.GET_META_DATA or PackageManager.GET_PERMISSIONS val archiveInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { pm.getPackageArchiveInfo( tempFile.absolutePath, PackageManager.PackageInfoFlags.of(flags.toLong()) ) } else { @Suppress("DEPRECATION") pm.getPackageArchiveInfo(tempFile.absolutePath, flags) } if (archiveInfo == null) { return@withContext Result.failure(Exception("Failed to parse APK manifest. The file might be corrupted or encrypted.")) } // Necessary to load resources properly from an external file archiveInfo.applicationInfo?.sourceDir = tempFile.absolutePath archiveInfo.applicationInfo?.publicSourceDir = tempFile.absolutePath val label = archiveInfo.applicationInfo?.loadLabel(pm).toString() val drawable = archiveInfo.applicationInfo?.loadIcon(pm) val version = archiveInfo.versionName ?: "Unknown" val versionCode = archiveInfo.longVersionCode val pkgName = archiveInfo.packageName val permissions = archiveInfo.requestedPermissions?.toList() ?: emptyList() Result.success( AppMetadata( label = label, packageName = pkgName, version = version, versionCode = versionCode, icon = drawable?.toBitmap(), permissions = permissions ) ) } catch (e: Exception) { Result.failure(e) } finally { if (tempFile.exists()) { tempFile.delete() } } } private fun Drawable.toBitmap(): Bitmap { if (this is BitmapDrawable) return this.bitmap val bitmap = createBitmap(intrinsicWidth.coerceAtLeast(1), intrinsicHeight.coerceAtLeast(1)) val canvas = Canvas(bitmap) setBounds(0, 0, canvas.width, canvas.height) draw(canvas) return bitmap } } ================================================ FILE: app/src/main/java/com/valhalla/thor/data/repository/AppRepositoryImpl.kt ================================================ package com.valhalla.thor.data.repository import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageManager import android.os.Build import com.valhalla.thor.BuildConfig import com.valhalla.thor.data.source.local.room.AppDao import com.valhalla.thor.data.source.local.room.AppEntity import com.valhalla.thor.domain.model.AppInfo import com.valhalla.thor.domain.repository.AppRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class AppRepositoryImpl( private val context: Context, private val appDao: AppDao ) : AppRepository { private val pm = context.packageManager /** * RUTHLESS OPTIMIZATION V2: * We debounce the TRIGGER to prevent heavy package scanning during batch operations. */ override fun getAllApps(): Flow> = callbackFlow { val producer = this // A conflated channel acts as a signal buffer. // If 50 broadcasts come in, we only keep the latest "refresh needed" flag. val triggerChannel = Channel(Channel.CONFLATED) // The Worker: Consumes triggers, waits for quiet, then fetches ONCE. val worker = launch(Dispatchers.IO) { // Initial load from cache and baseline for comparison val cachedMap = try { val entities = appDao.getAllApps() if (entities.isNotEmpty()) { producer.send(entities.map { it.toDomain() }) } entities.associateBy { it.packageName }.toMutableMap() } catch (e: Exception) { if (BuildConfig.DEBUG) e.printStackTrace() mutableMapOf() } var lastLocale = context.resources.configuration.locales[0].toString() // Signal the worker to refresh triggerChannel.send(Unit) for (signal in triggerChannel) { // Drain any extra signals that arrived while we were waiting while (triggerChannel.tryReceive().isSuccess) { // Do nothing, just consume them so we don't loop immediately again } // Now Perform the Heavy Fetch ONE time try { val currentLocale = context.resources.configuration.locales[0].toString() val forceRefresh = currentLocale != lastLocale if (forceRefresh) { lastLocale = currentLocale } val flags = PackageManager.MATCH_UNINSTALLED_PACKAGES.toLong() val installedPackages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { pm.getInstalledPackages(PackageManager.PackageInfoFlags.of(flags)) } else { pm.getInstalledPackages(PackageManager.MATCH_UNINSTALLED_PACKAGES) } val currentList = ArrayList(installedPackages.size) val toUpdate = mutableListOf() for (packInfo in installedPackages) { val appInfo = packInfo.applicationInfo ?: continue val packageName = packInfo.packageName val cachedEntry = cachedMap[packageName] val isSuspended = (appInfo.flags and android.content.pm.ApplicationInfo.FLAG_SUSPENDED) != 0 if (!forceRefresh && cachedEntry != null && cachedEntry.lastUpdateTime == packInfo.lastUpdateTime && cachedEntry.enabled == appInfo.enabled && cachedEntry.isSuspended == isSuspended ) { currentList.add(cachedEntry.toDomain()) } else { val mapped = AppInfo.mapToAppInfo(packInfo, appInfo, pm, isLightweight = true) currentList.add(mapped) val entity = AppEntity.fromDomain(mapped) toUpdate.add(entity) cachedMap[packageName] = entity } } // Handle uninstalled apps: Cleanup cache val currentPackageNames = installedPackages.map { it.packageName }.toSet() val toDelete = cachedMap.keys.filter { it !in currentPackageNames } if (toUpdate.isNotEmpty() || toDelete.isNotEmpty()) { appDao.syncCache(toUpdate, toDelete) toDelete.forEach { cachedMap.remove(it) } } // Emit a single complete snapshot of all installed apps producer.send(currentList.toList()) } catch (e: Exception) { if (BuildConfig.DEBUG) e.printStackTrace() } } } // Receiver for Package-specific changes (requires "package" data scheme) val packageReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { triggerChannel.trySend(Unit) } } val packageFilter = IntentFilter().apply { addAction(Intent.ACTION_PACKAGE_ADDED) addAction(Intent.ACTION_PACKAGE_REMOVED) addAction(Intent.ACTION_PACKAGE_FULLY_REMOVED) addAction(Intent.ACTION_PACKAGE_REPLACED) addAction(Intent.ACTION_PACKAGE_CHANGED) addDataScheme("package") } // Receiver for General Package changes (No data scheme) val generalReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { triggerChannel.trySend(Unit) } } val generalFilter = IntentFilter().apply { addAction(Intent.ACTION_PACKAGES_SUSPENDED) addAction(Intent.ACTION_PACKAGES_UNSUSPENDED) } context.registerReceiver(packageReceiver, packageFilter) context.registerReceiver(generalReceiver, generalFilter) awaitClose { context.unregisterReceiver(packageReceiver) context.unregisterReceiver(generalReceiver) worker.cancel() } }.flowOn(Dispatchers.IO) override suspend fun getAppDetails(packageName: String): AppInfo? = withContext(Dispatchers.IO) { try { val flags = (PackageManager.MATCH_UNINSTALLED_PACKAGES).toLong() val packInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { pm.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(flags)) } else { pm.getPackageInfo(packageName, PackageManager.MATCH_UNINSTALLED_PACKAGES) } val appInfo = packInfo.applicationInfo ?: return@withContext null AppInfo.mapToAppInfo(packInfo, appInfo, pm, isLightweight = false) } catch (e: Exception) { if (BuildConfig.DEBUG) e.printStackTrace() null } } override suspend fun getApkDetails(apkPath: String): AppInfo? = withContext(Dispatchers.IO) { val flags = PackageManager.GET_PERMISSIONS val packInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { pm.getPackageArchiveInfo(apkPath, PackageManager.PackageInfoFlags.of(flags.toLong())) } else { @Suppress("DEPRECATION") pm.getPackageArchiveInfo(apkPath, flags) } ?: return@withContext null val appInfo = packInfo.applicationInfo?.apply { sourceDir = apkPath publicSourceDir = apkPath } ?: return@withContext null AppInfo.mapToAppInfo(packInfo, appInfo, pm, isLightweight = false).apply { this.appName = pm.getApplicationLabel(appInfo).toString() } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/data/repository/InstallerRepositoryImpl.kt ================================================ package com.valhalla.thor.data.repository import android.annotation.SuppressLint import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.PackageInstaller import android.database.Cursor import android.net.Uri import android.os.Build import android.provider.OpenableColumns import com.valhalla.bypass.Bypass import com.valhalla.thor.data.ACTION_INSTALL_STATUS import com.valhalla.thor.data.gateway.RootSystemGateway import com.valhalla.thor.data.receivers.InstallReceiver import com.valhalla.thor.data.source.local.shizuku.ShizukuPackageInstallerUtils import com.valhalla.thor.data.source.local.shizuku.ShizukuReflector import com.valhalla.thor.domain.InstallState import com.valhalla.thor.domain.InstallerEventBus import com.valhalla.thor.domain.repository.InstallMode import com.valhalla.thor.domain.repository.InstallerRepository import com.valhalla.thor.util.Logger import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File import java.io.FileOutputStream import java.io.InputStream import java.util.zip.ZipInputStream class InstallerRepositoryImpl( private val context: Context, private val eventBus: InstallerEventBus, private val rootGateway: RootSystemGateway, private val shizukuReflector: ShizukuReflector ) : InstallerRepository { private val defaultInstaller = context.packageManager.packageInstaller override suspend fun installPackage(uri: Uri, mode: InstallMode, canDowngrade: Boolean) = withContext(Dispatchers.IO) { try { when (mode) { InstallMode.ROOT -> { installWithRoot(uri, canDowngrade) } InstallMode.SHIZUKU -> { val privilegedInstaller = try { getShizukuPackageInstaller() } catch (e: Throwable) { if (e is CancellationException) throw e Logger.e( "InstallerRepo", "Failed to get Shizuku installer, will use normal installer: ${e.message}" ) null } if (privilegedInstaller != null) { try { // Try privileged path but suppress error emission so we can fall back silently performPackageInstallerInstall( uri, privilegedInstaller, canDowngrade, emitErrors = false ) } catch (e: Throwable) { if (e is CancellationException) throw e Logger.e( "InstallerRepo", "Shizuku privileged install failed, falling back to normal: ${e.message}" ) performPackageInstallerInstall( uri, defaultInstaller, canDowngrade, emitErrors = true ) } } else { // No privileged installer available, use normal installer and allow errors performPackageInstallerInstall( uri, defaultInstaller, canDowngrade, emitErrors = true ) } } InstallMode.DHIZUKU -> { val privilegedInstaller = try { getDhizukuPackageInstaller() } catch (e: Throwable) { if (e is CancellationException) throw e Logger.e( "InstallerRepo", "Failed to get Dhizuku installer, will use normal installer: ${e.message}" ) null } if (privilegedInstaller != null) { try { // Try privileged path but suppress error emission so we can fall back silently performPackageInstallerInstall( uri, privilegedInstaller, canDowngrade, emitErrors = false ) } catch (e: Throwable) { if (e is CancellationException) throw e Logger.e( "InstallerRepo", "Dhizuku privileged install failed, falling back to normal: ${e.message}" ) performPackageInstallerInstall( uri, defaultInstaller, canDowngrade, emitErrors = true ) } } else { // No privileged installer available, use normal installer and allow errors performPackageInstallerInstall( uri, defaultInstaller, canDowngrade, emitErrors = true ) } } InstallMode.NORMAL -> { performPackageInstallerInstall( uri, defaultInstaller, canDowngrade, emitErrors = true ) } InstallMode.EXTERNAL -> { installWithExternal(uri) } } } catch (e: Exception) { if (e is CancellationException) throw e eventBus.emit(InstallState.Error(e.message ?: "Unknown error during installation")) } } // Create a PackageInstaller using Dhizuku's binder wrapper but make the installer package // be this app's package name so created sessions belong to the app UID (avoids UID mismatch). private fun getDhizukuPackageInstaller(): PackageInstaller { // Prefer using the existing Shizuku helper which returns a privileged IPackageInstaller. // This avoids calling IPackageManager.getPackageInstaller() directly (which may not exist // on some ROMs / API versions and caused NoSuchMethodError). try { val iPackageInstaller = ShizukuPackageInstallerUtils.getPrivilegedPackageInstaller() val root = try { rikka.shizuku.Shizuku.getUid() == 0 } catch (_: Exception) { false } val userId = if (root) android.os.Process.myUserHandle().hashCode() else 0 val installerPackageName = context.packageName return ShizukuPackageInstallerUtils.createPackageInstaller( iPackageInstaller, installerPackageName, userId ) } catch (e: Throwable) { if (e is CancellationException) throw e // Bubble up so caller falls back to normal installer; log for debugging. Logger.e("InstallerRepo", "getDhizukuPackageInstaller failed: ${e.message}") throw e } } // Create a PackageInstaller using Shizuku's privileged installer helper (like ShizukuReflector) // and make the installer package be this app's package name so sessions belong to app UID. private fun getShizukuPackageInstaller(): PackageInstaller { // Reuse ShizukuPackageInstallerUtils to get a privileged IPackageInstaller safely across API levels val iPackageInstaller = ShizukuPackageInstallerUtils.getPrivilegedPackageInstaller() val shizukuUid = try { rikka.shizuku.Shizuku.getUid() } catch (_: Exception) { -1 } val isRoot = shizukuUid == 0 val isShell = shizukuUid == 2000 // If Shizuku is running as root, set userId to current user; otherwise use 0 val userId = if (isRoot) android.os.Process.myUserHandle().hashCode() else 0 // For ADB-based Shizuku (shell), using "com.android.shell" often works better // than the app's own package name to avoid permission/UID mismatch issues. val installerPackageName = if (isShell) "com.android.shell" else context.packageName return ShizukuPackageInstallerUtils.createPackageInstaller( iPackageInstaller, installerPackageName, userId ) } private suspend fun installWithExternal(uri: Uri) { withContext(Dispatchers.Main) { try { val intent = Intent(Intent.ACTION_VIEW).apply { setDataAndType(uri, "application/vnd.android.package-archive") addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } val chooser = Intent.createChooser(intent, "Install with...") chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(chooser) // We consider this a success in terms of handing off the job eventBus.emit(InstallState.Success) } catch (e: Exception) { if (e is CancellationException) throw e eventBus.emit(InstallState.Error("Could not open external installer: ${e.message}")) } } } private suspend fun installWithRoot(uri: Uri, canDowngrade: Boolean) { eventBus.emit(InstallState.Installing(0f)) val tempFile = File(context.cacheDir, "install_temp_${System.currentTimeMillis()}.apk") try { // Copy uri to temp file context.contentResolver.openInputStream(uri)?.use { input -> FileOutputStream(tempFile).use { output -> input.copyTo(output) } } ?: run { eventBus.emit(InstallState.Error("Failed to read input file")) return } eventBus.emit(InstallState.Installing(0.5f)) // Execute root install val result = rootGateway.installApp(tempFile.absolutePath, canDowngrade) if (result.isSuccess) { eventBus.emit(InstallState.Installing(1.0f)) eventBus.emit(InstallState.Success) } else { eventBus.emit( InstallState.Error( result.exceptionOrNull()?.message ?: "Root install failed" ) ) } } catch (e: Exception) { if (e is CancellationException) throw e eventBus.emit(InstallState.Error("Root install error: ${e.message}")) } finally { if (tempFile.exists()) { tempFile.delete() } } } @SuppressLint("RequestInstallPackagesPolicy") private suspend fun performPackageInstallerInstall( uri: Uri, packageInstaller: PackageInstaller, canDowngrade: Boolean, emitErrors: Boolean = true ) { val totalBytes = getFileSize(uri) var bytesProcessed = 0L var lastProgressEmitted = 0 var filesWritten = false eventBus.emit(InstallState.Parsing) val params = PackageInstaller.SessionParams( PackageInstaller.SessionParams.MODE_FULL_INSTALL ) if (canDowngrade) { try { // Use reflection via Bypass as it might be unresolved in some SDK configurations Bypass.invoke(params::class.java, params, "setRequestDowngrade", true) } catch (e: Exception) { if (e is CancellationException) throw e Logger.e("InstallerRepo", "Failed to setRequestDowngrade", e) if (emitErrors) { eventBus.emit(InstallState.Error("Failed to request downgrade: ${e.message}")) return } else throw Exception("Failed to request downgrade: ${e.message}") } } val sessionId = try { packageInstaller.createSession(params) } catch (e: Exception) { if (e is CancellationException) throw e if (emitErrors) { eventBus.emit(InstallState.Error("Failed to create session: ${e.message}")) return } else throw e } val session = try { packageInstaller.openSession(sessionId) } catch (e: Exception) { if (e is CancellationException) throw e if (emitErrors) { eventBus.emit(InstallState.Error("Failed to open session: ${e.message}")) return } else throw e } // Helper to track progress across different streams fun getTrackedStream(baseStream: InputStream): InputStream { return object : InputStream() { override fun read(): Int { val b = baseStream.read() if (b != -1) updateProgress(1) return b } override fun read(b: ByteArray, off: Int, len: Int): Int { val read = baseStream.read(b, off, len) if (read != -1) updateProgress(read.toLong()) return read } override fun close() { baseStream.close() } private fun updateProgress(readBytes: Long) { bytesProcessed += readBytes if (totalBytes > 0) { val currentProgress = ((bytesProcessed.toDouble() / totalBytes) * 100).toInt() if (currentProgress > lastProgressEmitted) { lastProgressEmitted = currentProgress CoroutineScope(Dispatchers.IO).launch { eventBus.emit(InstallState.Installing(bytesProcessed.toFloat() / totalBytes)) } } } } } } try { // ATTEMPT 1: Try as Bundle (XAPK/APKS/APKM) val bundleStream: InputStream? = context.contentResolver.openInputStream(uri) if (bundleStream != null) { try { ZipInputStream(bundleStream).use { zipStream -> var entry = zipStream.nextEntry while (entry != null) { val name = entry.name if (name.endsWith(".apk", ignoreCase = true)) { filesWritten = true val size = entry.size if (size == -1L) { // Unknown size in Zip: Buffer to temp val tempFile = File( context.cacheDir, "temp_${System.currentTimeMillis()}_${File(name).name}" ) FileOutputStream(tempFile).use { fos -> zipStream.copyTo(fos) } val actualSize = tempFile.length() val outStream = session.openWrite(name, 0, actualSize) tempFile.inputStream().use { fis -> fis.copyTo(outStream) } session.fsync(outStream) outStream.close() tempFile.delete() } else { // Known size: Stream directly val outStream = session.openWrite(name, 0, size) val buffer = ByteArray(65536) var len: Int while (zipStream.read(buffer).also { len = it } > 0) { outStream.write(buffer, 0, len) } session.fsync(outStream) outStream.close() } } zipStream.closeEntry() entry = zipStream.nextEntry } } } catch (e: Exception) { Logger.e("thor", "Not a valid bundle zip, trying fallback. Error: ${e.message}") } } // ATTEMPT 2: Fallback to Monolithic APK if (!filesWritten) { Logger.d("thor", "Fallback: Treating stream as monolithic base.apk") bytesProcessed = 0 lastProgressEmitted = 0 val rawStream = context.contentResolver.openInputStream(uri) if (rawStream == null) { session.abandon() Logger.e("thor", "Could not open file stream.") if (emitErrors) { eventBus.emit(InstallState.Error("Could not open file stream.")) return } else throw Exception("Could not open file stream.") } val trackedStream = getTrackedStream(rawStream) trackedStream.use { input -> val size = if (totalBytes > 0) totalBytes else -1L val outStream = session.openWrite("base.apk", 0, size) input.copyTo(outStream) session.fsync(outStream) outStream.close() filesWritten = true } } eventBus.emit(InstallState.Installing(1.0f)) val intent = Intent(context, InstallReceiver::class.java).apply { action = ACTION_INSTALL_STATUS setPackage(context.packageName) } val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE } else { PendingIntent.FLAG_UPDATE_CURRENT } val pendingIntent = PendingIntent.getBroadcast( context, sessionId, intent, flags ) session.commit(pendingIntent.intentSender) session.close() } catch (e: Exception) { if (e is CancellationException) throw e try { session.abandon() } catch (_: Exception) { } Logger.e("thorInstaller", "Install failed", e) if (emitErrors) { eventBus.emit(InstallState.Error(e.message ?: "Unknown installation error")) } else throw e } } private fun getFileSize(uri: Uri): Long { var size = -1L val cursor: Cursor? = context.contentResolver.query(uri, null, null, null, null) cursor?.use { if (it.moveToFirst()) { val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE) if (sizeIndex != -1) { size = it.getLong(sizeIndex) } } } return size } } ================================================ FILE: app/src/main/java/com/valhalla/thor/data/repository/PreferenceRepositoryImpl.kt ================================================ package com.valhalla.thor.data.repository import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.valhalla.thor.domain.model.FilterType import com.valhalla.thor.domain.model.PrivilegeMode import com.valhalla.thor.domain.model.SortBy import com.valhalla.thor.domain.model.SortOrder import com.valhalla.thor.domain.model.ThemeMode import com.valhalla.thor.domain.model.UserPreferences import com.valhalla.thor.domain.repository.PreferenceRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map private val Context.dataStore: DataStore by preferencesDataStore(name = "thor_preferences") class PreferenceRepositoryImpl( private val context: Context ) : PreferenceRepository { private object Keys { // App List val SORT_BY = stringPreferencesKey("sort_by") val SORT_ORDER = stringPreferencesKey("sort_order") val FILTER_TYPE = stringPreferencesKey("filter_type") val SELECTED_FILTER = stringPreferencesKey("selected_filter") val SHOW_REINSTALL_ALL = booleanPreferencesKey("show_reinstall_all") // Theme val THEME_MODE = stringPreferencesKey("theme_mode") val USE_DYNAMIC_COLOR = booleanPreferencesKey("use_dynamic_color") val USE_AMOLED = booleanPreferencesKey("use_amoled") // Security val BIOMETRIC_LOCK = booleanPreferencesKey("biometric_lock") // Work Mode val PRIVILEGE_MODE = stringPreferencesKey("privilege_mode") // Localization val LANGUAGE = stringPreferencesKey("language") } override val userPreferences: Flow = context.dataStore.data .map { prefs -> val sortBy = prefs[Keys.SORT_BY] ?.let { runCatching { SortBy.valueOf(it) }.getOrNull() } ?: SortBy.NAME val sortOrder = prefs[Keys.SORT_ORDER] ?.let { runCatching { SortOrder.valueOf(it) }.getOrNull() } ?: SortOrder.ASCENDING val filterType = when (prefs[Keys.FILTER_TYPE]) { "STATE" -> FilterType.State else -> FilterType.Source } val themeMode = prefs[Keys.THEME_MODE] ?.let { runCatching { ThemeMode.valueOf(it) }.getOrNull() } ?: ThemeMode.SYSTEM val privilegeMode = prefs[Keys.PRIVILEGE_MODE] ?.let { runCatching { PrivilegeMode.valueOf(it) }.getOrNull() } UserPreferences( appSortBy = sortBy, appSortOrder = sortOrder, appFilterType = filterType, appSelectedFilter = prefs[Keys.SELECTED_FILTER] ?: "All", showReinstallAllCard = prefs[Keys.SHOW_REINSTALL_ALL] ?: true, themeMode = themeMode, useDynamicColor = prefs[Keys.USE_DYNAMIC_COLOR] ?: false, useAmoled = prefs[Keys.USE_AMOLED] ?: false, biometricLockEnabled = prefs[Keys.BIOMETRIC_LOCK] ?: false, preferredPrivilegeMode = privilegeMode, language = prefs[Keys.LANGUAGE] ) } // --- App List --- override suspend fun updateAppSort(sortBy: SortBy) { context.dataStore.edit { it[Keys.SORT_BY] = sortBy.name } } override suspend fun updateAppSortOrder(sortOrder: SortOrder) { context.dataStore.edit { it[Keys.SORT_ORDER] = sortOrder.name } } override suspend fun updateAppFilter(filterType: FilterType, selectedFilter: String) { context.dataStore.edit { it[Keys.FILTER_TYPE] = if (filterType is FilterType.State) "STATE" else "SOURCE" it[Keys.SELECTED_FILTER] = selectedFilter } } override suspend fun setReinstallAllCardVisibility(isVisible: Boolean) { context.dataStore.edit { it[Keys.SHOW_REINSTALL_ALL] = isVisible } } // --- Theme --- override suspend fun setThemeMode(themeMode: ThemeMode) { context.dataStore.edit { it[Keys.THEME_MODE] = themeMode.name } } override suspend fun setDynamicColor(enabled: Boolean) { context.dataStore.edit { it[Keys.USE_DYNAMIC_COLOR] = enabled } } override suspend fun setUseAmoled(enabled: Boolean) { context.dataStore.edit { it[Keys.USE_AMOLED] = enabled } } // --- Security --- override suspend fun setBiometricLock(enabled: Boolean) { context.dataStore.edit { it[Keys.BIOMETRIC_LOCK] = enabled } } // --- Work Mode --- override suspend fun setPrivilegeMode(mode: PrivilegeMode?) { context.dataStore.edit { if (mode == null) it.remove(Keys.PRIVILEGE_MODE) else it[Keys.PRIVILEGE_MODE] = mode.name } } override suspend fun setLanguage(language: String?) { context.dataStore.edit { if (language == null) it.remove(Keys.LANGUAGE) else it[Keys.LANGUAGE] = language } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/data/repository/SystemRepositoryImpl.kt ================================================ package com.valhalla.thor.data.repository import com.valhalla.thor.data.gateway.DhizukuSystemGateway import com.valhalla.thor.data.gateway.RootSystemGateway import com.valhalla.thor.data.gateway.ShizukuSystemGateway import com.valhalla.thor.domain.gateway.SystemGateway import com.valhalla.thor.domain.model.PrivilegeMode import com.valhalla.thor.domain.repository.PreferenceRepository import com.valhalla.thor.domain.repository.SystemRepository import kotlinx.coroutines.flow.first class SystemRepositoryImpl( private val rootGateway: RootSystemGateway, private val shizukuGateway: ShizukuSystemGateway, private val dhizukuGateway: DhizukuSystemGateway, private val preferenceRepository: PreferenceRepository ) : SystemRepository { override suspend fun isRootAvailable(): Boolean { return rootGateway.isRootAvailable() } override fun isShizukuAvailable(): Boolean = shizukuGateway.isShizukuAvailable() override fun isDhizukuAvailable(): Boolean = dhizukuGateway.isDhizukuAvailable() // Dynamic Resolution Strategy: Respect user preference if available, else auto-detect. // Must be suspend because checking root and reading preferences are suspend operations. private suspend fun getActiveGateway(): SystemGateway { val prefs = preferenceRepository.userPreferences.first() // 1. Try User Preference prefs.preferredPrivilegeMode?.let { mode -> when (mode) { PrivilegeMode.ROOT -> if (rootGateway.isRootAvailable()) return rootGateway PrivilegeMode.SHIZUKU -> if (shizukuGateway.isShizukuAvailable()) return shizukuGateway PrivilegeMode.DHIZUKU -> if (dhizukuGateway.isDhizukuAvailable()) return dhizukuGateway } } // 2. Fallback to Auto-Detection return when { rootGateway.isRootAvailable() -> rootGateway shizukuGateway.isShizukuAvailable() -> shizukuGateway dhizukuGateway.isDhizukuAvailable() -> dhizukuGateway else -> throw IllegalStateException("No privileged gateway available (Root, Shizuku or Dhizuku required)") } } override suspend fun forceStopApp(packageName: String): Result = getActiveGateway().forceStopApp(packageName) override suspend fun clearCache(packageName: String): Result = getActiveGateway().clearCache(packageName) override suspend fun clearAppData(packageName: String): Result = getActiveGateway().clearAppData(packageName) override suspend fun setAppDisabled(packageName: String, isDisabled: Boolean): Result = getActiveGateway().setAppDisabled(packageName, isDisabled) override suspend fun setAppSuspended(packageName: String, isSuspended: Boolean): Result = getActiveGateway().setAppSuspended(packageName, isSuspended) override suspend fun setAppRestricted( packageName: String, isRestricted: Boolean ): Result = getActiveGateway().setAppRestricted(packageName, isRestricted) override suspend fun uninstallApp(packageName: String): Result = getActiveGateway().uninstallApp(packageName) override suspend fun rebootDevice(reason: String): Result { return if (rootGateway.isRootAvailable()) { rootGateway.rebootDevice(reason) } else { Result.failure(Exception("Reboot requires Root access")) } } override suspend fun aggressiveCleanup(packageName: String): Result { return try { val gateway = getActiveGateway() gateway.forceStopApp(packageName) gateway.clearCache(packageName) Result.success(Unit) } catch (e: Exception) { Result.failure(e) } } override suspend fun reinstallAppWithGoogle(packageName: String): Result = getActiveGateway().reinstallAppWithGoogle(packageName) override suspend fun copyFileWithRoot( sourcePath: String, destinationPath: String ): Result { return if (rootGateway.isRootAvailable()) { try { rootGateway.copyFile(sourcePath, destinationPath) Result.success(Unit) } catch (e: Exception) { Result.failure(e) } } else { Result.failure(Exception("Root required for privileged copy")) } } override suspend fun getAppPaths(packageName: String): Result> { return try { if (rootGateway.isRootAvailable()) { val paths = rootGateway.getAppPaths(packageName) if (paths.isNotEmpty()) Result.success(paths) else Result.failure(Exception("No paths found")) } else { Result.failure(Exception("Root required to fetch split paths reliably")) } } catch (e: Exception) { Result.failure(e) } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/data/security/BiometricHelper.kt ================================================ package com.valhalla.thor.data.security import android.content.Context import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL /** * Thin wrapper around [BiometricManager] that answers capability questions * without touching any UI. Lives in the data layer — no Compose dependency. */ class BiometricHelper(private val context: Context) { private val allowedAuthenticators = BIOMETRIC_STRONG or DEVICE_CREDENTIAL /** Returns true if the device can authenticate via biometric or device credential. */ fun canAuthenticate(): Boolean { return BiometricManager.from(context) .canAuthenticate(allowedAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS } /** Returns true if the device has biometric hardware, regardless of enrollment state. */ fun hasHardware(): Boolean { val status = BiometricManager.from(context).canAuthenticate(allowedAuthenticators) return status != BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE } } ================================================ FILE: app/src/main/java/com/valhalla/thor/data/source/local/ShellDataSource.kt ================================================ package com.valhalla.thor.data.source.local import com.valhalla.superuser.Shell import com.valhalla.thor.BuildConfig import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext /** * A clean data source for executing shell commands. * No logic, no formatting, just execution. */ class ShellDataSource { init { // Initialize LibSu configuration once, cleanly. // In a real app, you might want to do this in your Application class, // but it's safe to ensure it's set here. Shell.enableVerboseLogging = BuildConfig.DEBUG Shell.setDefaultBuilder(Shell.Builder.create().setFlags(Shell.FLAG_MOUNT_MASTER)) } suspend fun isRootAvailable(): Boolean = withContext(Dispatchers.IO) { // LibSu caches this, so it's safe to call. Shell.isAppGrantedRoot == true || Shell.shell.isRoot } /** * Executes a command with Root privileges. * Returns true if exit code is 0 (Success). */ suspend fun executeRootCommand(command: String): Boolean = withContext(Dispatchers.IO) { val result = Shell.cmd(command).exec() result.isSuccess } /** * Executes a command and returns the output (STDOUT). * Useful for things like getting file paths or disk stats. */ suspend fun executeRootCommandWithOutput(command: String): String = withContext(Dispatchers.IO) { val result = Shell.cmd(command).exec() if (result.isSuccess) { result.out.joinToString("\n") } else { throw Exception("Command failed: $command | Error: ${result.err.joinToString("\n")}") } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/data/source/local/dhizuku/Dhizuku.kt ================================================ package com.valhalla.thor.data.source.local.dhizuku import android.annotation.SuppressLint import android.content.Context import android.content.pm.PackageManager import android.os.IBinder import com.valhalla.bypass.Bypass import com.valhalla.thor.BuildConfig import com.valhalla.thor.data.source.local.shizuku.Packages import rikka.shizuku.ShizukuBinderWrapper import rikka.shizuku.SystemServiceHelper import com.rosan.dhizuku.api.Dhizuku as DhizukuAPI /** * Helper to interact with Dhizuku service using the actual API. */ object DhizukuHelper { fun isDhizukuAvailable(): Boolean { return try { DhizukuAPI.isPermissionGranted() } catch (_: Exception) { false } } fun getSystemService(serviceName: String): IBinder? { return try { val binder = SystemServiceHelper.getSystemService(serviceName) DhizukuAPI.binderWrapper(binder) } catch (_: Exception) { null } } private fun asInterface(className: String, original: IBinder): Any { val clazz = Class.forName("$className\$Stub") return Bypass.invoke( clazz, null, "asInterface", arrayOf(IBinder::class.java), ShizukuBinderWrapper(original) ) } private fun asInterface(className: String, serviceName: String): Any? { val binder = getSystemService(serviceName) ?: return null return asInterface(className, binder) } fun forceStopApp(context: Context, packageName: String): Boolean { val userId = Packages(context).myUserId val result = execute("am force-stop --user $userId $packageName") if (result.first == 0) return true // Fallback to reflection return runCatching { val am = asInterface("android.app.IActivityManager", Context.ACTIVITY_SERVICE) ?: return false Bypass.invoke( am::class.java, am, "forceStopPackage", packageName, userId ) true }.getOrElse { com.valhalla.thor.util.Logger.e( "DhizukuHelper", "forceStopApp failed for $packageName", it ) false } } fun setAppDisabled(context: Context, packageName: String, disabled: Boolean): Boolean { Packages(context).getApplicationInfoOrNull(packageName) ?: return false val userId = Packages(context).myUserId val command = if (disabled) { "pm disable-user --user $userId $packageName" } else { "pm enable --user $userId $packageName" } val result = execute(command) if (result.first != 0) { // Fallback to Bypass reflection runCatching { val pm = asInterface("android.content.pm.IPackageManager", "package") ?: return false val newState = when { !disabled -> PackageManager.COMPONENT_ENABLED_STATE_ENABLED else -> PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER } Bypass.invoke( pm.javaClass, pm, "setApplicationEnabledSetting", arrayOf( String::class.java, Int::class.javaPrimitiveType!!, Int::class.javaPrimitiveType!!, Int::class.javaPrimitiveType!!, String::class.java ), packageName, newState, 0, userId, BuildConfig.APPLICATION_ID ) }.onFailure { com.valhalla.thor.util.Logger.e( "DhizukuHelper", "setAppDisabled fallback failed for $packageName", it ) } } return Packages(context).isAppDisabled(packageName) == disabled } fun uninstallApp(packageName: String): Boolean { return execute( "pm uninstall --user current ${ com.valhalla.superuser.ShellUtils.escapedString( packageName ) }" ).first == 0 } fun execute(command: String): Pair = runCatching { // Dhizuku 2.x supports newProcess for shell commands val process = DhizukuAPI.newProcess(arrayOf("sh", "-c", command), null, null) val exitCode = process.waitFor() val output = process.inputStream.bufferedReader().readText() val error = process.errorStream.bufferedReader().readText() exitCode to (output.ifBlank { error }) }.getOrElse { -1 to it.stackTraceToString() } @SuppressLint("PrivateApi") fun clearCache(packageName: String): Boolean { val reflectionResult = runCatching { val pm = asInterface("android.content.pm.IPackageManager", "package") ?: return false val observerClass = Class.forName("android.content.pm.IPackageDataObserver") try { Bypass.invoke( pm.javaClass, pm, "deleteApplicationCacheFiles", arrayOf(String::class.java, observerClass), packageName, null /* IPackageDataObserver */ ) } catch (_: NoSuchMethodException) { Bypass.invoke( pm.javaClass, pm, "deleteApplicationCacheFilesAsUser", arrayOf(String::class.java, Int::class.javaPrimitiveType!!, observerClass), packageName, android.os.Process.myUserHandle().hashCode(), null ) } true }.getOrDefault(false) if (reflectionResult) return true // Fallback to shell rm -rf on common cache paths val userId = android.os.Process.myUserHandle().hashCode() val paths = listOf( "/data/data/$packageName/cache", "/data/user/$userId/$packageName/cache", "/sdcard/Android/data/$packageName/cache" ) val command = "rm -rf ${paths.joinToString(" ")}" return execute(command).first == 0 } fun clearAppData(packageName: String): Boolean { val result = execute("pm clear $packageName") if (result.first == 0) return true // Fallback to reflection return runCatching { val pm = asInterface("android.content.pm.IPackageManager", "package") ?: return false val observerClass = Class.forName("android.content.pm.IPackageDataObserver") Bypass.invoke( pm.javaClass, pm, "clearApplicationUserData", arrayOf(String::class.java, observerClass, Int::class.javaPrimitiveType!!), packageName, null, android.os.Process.myUserHandle().hashCode() ) true }.getOrElse { false } } fun setAppSuspended(context: Context, packageName: String, suspended: Boolean): Boolean { Packages(context).getApplicationInfoOrNull(packageName) ?: return false val userId = Packages(context).myUserId // Try reflection first through Dhizuku's binder wrapper to show proper branding if (suspended && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { val reflectionResult = runCatching { val pm = asInterface("android.content.pm.IPackageManager", "package") ?: return false val dialogInfoClass = Class.forName("android.content.pm.SuspendDialogInfo") val builderClass = Class.forName("android.content.pm.SuspendDialogInfo\$Builder") val dialogInfo = Bypass.newInstance(builderClass).let { b -> Bypass.invoke(builderClass, b, "setTitle", "Thor") Bypass.invoke( builderClass, b, "setMessage", "This app has been suspended by Thor." ) Bypass.invoke(builderClass, b, "build") } // In Dhizuku mode, we can try to use Thor's package name since it's a device owner proxy val caller = com.valhalla.thor.BuildConfig.APPLICATION_ID try { // Try Android 13+ (8 args) Bypass.invoke>( pm.javaClass, pm, "setPackagesSuspendedAsUser", arrayOf( Array::class.java, Boolean::class.javaPrimitiveType!!, android.os.PersistableBundle::class.java, android.os.PersistableBundle::class.java, dialogInfoClass, Int::class.javaPrimitiveType!!, String::class.java, Int::class.javaPrimitiveType!! ), arrayOf(packageName), true, null, null, dialogInfo, 0, caller, userId ) } catch (_: NoSuchMethodException) { // Try Android 10-12 (7 args) Bypass.invoke>( pm.javaClass, pm, "setPackagesSuspendedAsUser", arrayOf( Array::class.java, Boolean::class.javaPrimitiveType!!, android.os.PersistableBundle::class.java, android.os.PersistableBundle::class.java, dialogInfoClass, String::class.java, Int::class.javaPrimitiveType!! ), arrayOf(packageName), true, null, null, dialogInfo, caller, userId ) } true }.getOrDefault(false) if (reflectionResult) return true } val command = if (suspended) { "pm suspend --user $userId $packageName" } else { "pm unsuspend --user $userId $packageName" } val result = execute(command) return result.first == 0 } fun setAppRestricted(context: Context, packageName: String, restricted: Boolean): Boolean { val result = execute("appops set $packageName RUN_ANY_IN_BACKGROUND ${if (restricted) "ignore" else "allow"}") if (result.first == 0) return true // Fallback to reflection return runCatching { val appops = asInterface("com.android.internal.app.IAppOpsService", Context.APP_OPS_SERVICE) ?: return false val userId = Packages(context).myUserId val uid = Packages(context).packageUid(packageName) Bypass.invoke( appops::class.java, appops, "setMode", arrayOf( Int::class.javaPrimitiveType!!, Int::class.javaPrimitiveType!!, String::class.java, Int::class.javaPrimitiveType!! ), Bypass.invoke( android.app.AppOpsManager::class.java, null, "strOpToOp", "android:run_any_in_background" ), uid, packageName, if (restricted) android.app.AppOpsManager.MODE_IGNORED else android.app.AppOpsManager.MODE_ALLOWED ) true }.getOrElse { false } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/data/source/local/dhizuku/DhizukuReflector.kt ================================================ package com.valhalla.thor.data.source.local.dhizuku import android.content.Context import com.valhalla.thor.BuildConfig import com.valhalla.thor.util.Logger class DhizukuReflector( private val context: Context ) { fun forceStop(packageName: String): Boolean { return try { DhizukuHelper.forceStopApp(context, packageName) } catch (e: Exception) { Logger.e("DhizukuReflector", "forceStop failed", e) false } } fun clearCache(packageName: String): Boolean { return try { DhizukuHelper.clearCache(packageName) } catch (e: Exception) { Logger.e("DhizukuReflector", "clearCache failed", e) false } } fun clearData(packageName: String): Boolean { return try { DhizukuHelper.clearAppData(packageName) } catch (e: Exception) { Logger.e("DhizukuReflector", "clearData failed", e) false } } fun setAppEnabled(packageName: String, enabled: Boolean): Boolean { return try { DhizukuHelper.setAppDisabled(context, packageName, !enabled) } catch (e: Exception) { if (BuildConfig.DEBUG) Logger.e("DhizukuReflector", "setAppEnabled failed", e) false } } fun uninstallApp(packageName: String): Boolean { return try { DhizukuHelper.uninstallApp(packageName) } catch (_: Exception) { false } } fun setAppSuspended(packageName: String, suspended: Boolean): Boolean { return try { DhizukuHelper.setAppSuspended(context, packageName, suspended) } catch (e: Exception) { Logger.e("DhizukuReflector", "setAppSuspended failed", e) false } } fun setAppRestricted(packageName: String, restricted: Boolean): Boolean { return try { DhizukuHelper.setAppRestricted(context, packageName, restricted) } catch (e: Exception) { Logger.e("DhizukuReflector", "setAppRestricted failed", e) false } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/data/source/local/room/AppDao.kt ================================================ package com.valhalla.thor.data.source.local.room import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import kotlinx.coroutines.flow.Flow @Dao interface AppDao { @Query("SELECT * FROM apps") fun getAllAppsFlow(): Flow> @Query("SELECT * FROM apps") suspend fun getAllApps(): List @Query("SELECT * FROM apps WHERE packageName = :packageName") suspend fun getApp(packageName: String): AppEntity? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertApps(apps: List) @Query("DELETE FROM apps WHERE packageName = :packageName") suspend fun deleteApp(packageName: String) @Transaction suspend fun syncCache(toUpdate: List, toDelete: List) { if (toUpdate.isNotEmpty()) insertApps(toUpdate) toDelete.forEach { deleteApp(it) } } @Query("DELETE FROM apps") suspend fun clearAll() } ================================================ FILE: app/src/main/java/com/valhalla/thor/data/source/local/room/AppDatabase.kt ================================================ package com.valhalla.thor.data.source.local.room import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters @Database(entities = [AppEntity::class], version = 2, exportSchema = true) @TypeConverters(AppTypeConverters::class) abstract class AppDatabase : RoomDatabase() { abstract fun appDao(): AppDao } ================================================ FILE: app/src/main/java/com/valhalla/thor/data/source/local/room/AppEntity.kt ================================================ package com.valhalla.thor.data.source.local.room import androidx.room.Entity import androidx.room.PrimaryKey import com.valhalla.thor.domain.model.AppInfo @Entity(tableName = "apps") data class AppEntity( @PrimaryKey val packageName: String, val appName: String?, val versionName: String?, val versionCode: Int, val minSdk: Int, val targetSdk: Int, val isSystem: Boolean, val installerPackageName: String?, val publicSourceDir: String?, val splitPublicSourceDirs: List, val enabled: Boolean, val dataDir: String?, val nativeLibraryDir: String?, val deviceProtectedDataDir: String?, val sharedLibraryFiles: List?, val obbFilePath: String?, val sourceDir: String?, val sharedDataDir: String, val lastUpdateTime: Long, val firstInstallTime: Long, val isDebuggable: Boolean, val isSuspended: Boolean ) { fun toDomain(): AppInfo { return AppInfo( appName = appName, packageName = packageName, versionName = versionName, versionCode = versionCode, minSdk = minSdk, targetSdk = targetSdk, isSystem = isSystem, installerPackageName = installerPackageName, publicSourceDir = publicSourceDir, splitPublicSourceDirs = splitPublicSourceDirs, enabled = enabled, dataDir = dataDir, nativeLibraryDir = nativeLibraryDir, deviceProtectedDataDir = deviceProtectedDataDir, sharedLibraryFiles = sharedLibraryFiles, obbFilePath = obbFilePath, sourceDir = sourceDir, sharedDataDir = sharedDataDir, lastUpdateTime = lastUpdateTime, firstInstallTime = firstInstallTime, isDebuggable = isDebuggable, isSuspended = isSuspended ) } companion object { fun fromDomain(appInfo: AppInfo): AppEntity { return AppEntity( packageName = appInfo.packageName, appName = appInfo.appName, versionName = appInfo.versionName, versionCode = appInfo.versionCode, minSdk = appInfo.minSdk, targetSdk = appInfo.targetSdk, isSystem = appInfo.isSystem, installerPackageName = appInfo.installerPackageName, publicSourceDir = appInfo.publicSourceDir, splitPublicSourceDirs = appInfo.splitPublicSourceDirs, enabled = appInfo.enabled, dataDir = appInfo.dataDir, nativeLibraryDir = appInfo.nativeLibraryDir, deviceProtectedDataDir = appInfo.deviceProtectedDataDir, sharedLibraryFiles = appInfo.sharedLibraryFiles, obbFilePath = appInfo.obbFilePath, sourceDir = appInfo.sourceDir, sharedDataDir = appInfo.sharedDataDir, lastUpdateTime = appInfo.lastUpdateTime, firstInstallTime = appInfo.firstInstallTime, isDebuggable = appInfo.isDebuggable, isSuspended = appInfo.isSuspended ) } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/data/source/local/room/AppTypeConverters.kt ================================================ package com.valhalla.thor.data.source.local.room import androidx.room.TypeConverter import kotlinx.serialization.json.Json class AppTypeConverters { @TypeConverter fun fromStringList(value: List?): String? { return value?.let { Json.encodeToString(it) } } @TypeConverter fun toStringList(value: String?): List? { return value?.let { Json.decodeFromString(it) } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/data/source/local/root/RootMain.kt ================================================ package com.valhalla.thor.data.source.local.root import android.os.Build import android.os.IBinder import com.valhalla.thor.BuildConfig /** * Entry point for one-shot Root commands. * This runs in a separate process as root, bypassing Hidden API restrictions. */ object RootMain { @JvmStatic fun main(args: Array) { if (args.isEmpty()) return try { when (args[0]) { "suspend" -> { val packageName = args[1] val suspended = args[2].toBoolean() setAppSuspended(packageName, suspended) } "clear-data" -> { val packageName = args[1] clearData(packageName) } } } catch (e: Exception) { e.printStackTrace() System.exit(1) } System.exit(0) } private fun setAppSuspended(packageName: String, suspended: Boolean) { val pmStub = Class.forName("android.content.pm.IPackageManager\$Stub") val serviceManager = Class.forName("android.os.ServiceManager") val getService = serviceManager.getMethod("getService", String::class.java) val binder = getService.invoke(null, "package") as IBinder val asInterface = pmStub.getMethod("asInterface", IBinder::class.java) val pm = asInterface.invoke(null, binder) val pmClass = Class.forName("android.content.pm.IPackageManager") val userId = 0 // Root if (suspended && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val dialogInfoClass = Class.forName("android.content.pm.SuspendDialogInfo") val builderClass = Class.forName("android.content.pm.SuspendDialogInfo\$Builder") val builder = builderClass.getDeclaredConstructor().newInstance() builderClass.getMethod("setTitle", CharSequence::class.java).invoke(builder, "Thor") builderClass.getMethod("setMessage", CharSequence::class.java).invoke(builder, "This app has been suspended by Thor.") val dialogInfo = builderClass.getMethod("build").invoke(builder) val caller = "com.android.shell" // Use shell identity for better compatibility from root try { // Android 13+ (8 args) val method = pmClass.getDeclaredMethod( "setPackagesSuspendedAsUser", Array::class.java, Boolean::class.javaPrimitiveType, android.os.PersistableBundle::class.java, android.os.PersistableBundle::class.java, dialogInfoClass, Int::class.javaPrimitiveType, String::class.java, Int::class.javaPrimitiveType ) method.invoke(pm, arrayOf(packageName), true, null, null, dialogInfo, 0, caller, userId) } catch (e: NoSuchMethodException) { // Android 10-12 (7 args) val method = pmClass.getDeclaredMethod( "setPackagesSuspendedAsUser", Array::class.java, Boolean::class.javaPrimitiveType, android.os.PersistableBundle::class.java, android.os.PersistableBundle::class.java, dialogInfoClass, String::class.java, Int::class.javaPrimitiveType ) method.invoke(pm, arrayOf(packageName), true, null, null, dialogInfo, caller, userId) } } else { // Unsuspend logic val suspendDialogInfoClass = Class.forName("android.content.pm.SuspendDialogInfo") val method = pmClass.getDeclaredMethod( "setPackagesSuspendedAsUser", Array::class.java, Boolean::class.javaPrimitiveType, android.os.PersistableBundle::class.java, android.os.PersistableBundle::class.java, suspendDialogInfoClass, String::class.java, Int::class.javaPrimitiveType ) method.invoke(pm, arrayOf(packageName), suspended, null, null, null, "com.android.shell", userId) } } private fun clearData(packageName: String) { val pmStub = Class.forName("android.content.pm.IPackageManager\$Stub") val serviceManager = Class.forName("android.os.ServiceManager") val getService = serviceManager.getMethod("getService", String::class.java) val binder = getService.invoke(null, "package") as IBinder val asInterface = pmStub.getMethod("asInterface", IBinder::class.java) val pm = asInterface.invoke(null, binder) val pmClass = Class.forName("android.content.pm.IPackageManager") val method = pmClass.getDeclaredMethod("clearApplicationUserData", String::class.java, Class.forName("android.content.pm.IPackageDataObserver"), Int::class.javaPrimitiveType) method.invoke(pm, packageName, null, 0) } } ================================================ FILE: app/src/main/java/com/valhalla/thor/data/source/local/shizuku/PackageManagerExt.kt ================================================ package com.valhalla.thor.data.source.local.shizuku import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.content.pm.PackageManager.NameNotFoundException import android.os.Build import com.valhalla.thor.domain.model.AppInfo private fun PackageManager.getUninstalledPackages(installedPackages: List): List { val flags = PackageManager.MATCH_UNINSTALLED_PACKAGES // Get uninstalled packages + installed packages val uninstalledPackages = getPackages(flags).toSet() val installed = installedPackages.map { it.packageName } val minus = uninstalledPackages.filter { !installed.contains(it.packageName) } // Return only apps that have been uninstalled return minus.toList() } fun PackageManager.getAllPackagesInfo(): List { val installedPackages = getInstalledPackages() val uninstalledPackages = getUninstalledPackages(installedPackages) val all = (uninstalledPackages.map { app -> val appInfo = app.applicationInfo if (appInfo != null) AppInfo.mapToAppInfo( packInfo = app, appInfo = appInfo, pm = this ) else null } + installedPackages.map { app -> val appInfo = app.applicationInfo if (appInfo != null) AppInfo.mapToAppInfo( packInfo = app, appInfo = appInfo, pm = this ) else null }).filterNotNull() return all } fun PackageManager.getInstalledPackages(): List { val flags = PackageManager.GET_META_DATA return getPackages(flags) } private fun PackageManager.getPackages(flags: Int): List { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { this.getInstalledPackages( PackageManager.PackageInfoFlags.of(flags.toLong()) ) } else { this.getInstalledPackages(flags) } } fun PackageManager.getInfoForPackage( packageName: String, ): PackageInfo? { return try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { this.getPackageInfo( packageName, PackageManager.PackageInfoFlags.of(PackageManager.GET_META_DATA.toLong()) ) } else { this.getPackageInfo( packageName, PackageManager.GET_META_DATA ) } } catch (e: NameNotFoundException) { null } } ================================================ FILE: app/src/main/java/com/valhalla/thor/data/source/local/shizuku/Packages.kt ================================================ package com.valhalla.thor.data.source.local.shizuku import android.app.ActivityManager import android.app.AppOpsManager import android.content.Context import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import android.os.Process import androidx.core.content.getSystemService import com.valhalla.bypass.Bypass class Packages(private val app: Context) { val myUserId get() = Process.myUserHandle().hashCode() fun packageUri(packageName: String) = "package:$packageName" fun packageUid(packageName: String) = if (Targets.T) app.packageManager.getPackageUid( packageName, PackageManager.PackageInfoFlags.of(PackageManager.MATCH_UNINSTALLED_PACKAGES.toLong()) ) else app.packageManager.getPackageUid(packageName, PackageManager.MATCH_UNINSTALLED_PACKAGES) fun getInstalledApplications(flags: Int = PackageManager.MATCH_UNINSTALLED_PACKAGES): List = if (Targets.T) app.packageManager.getInstalledApplications( PackageManager.ApplicationInfoFlags.of(flags.toLong()) ) else app.packageManager.getInstalledApplications(flags) fun getUnhiddenPackageInfoOrNull( packageName: String, flags: Int = PackageManager.MATCH_UNINSTALLED_PACKAGES ) = runCatching { if (Targets.T) app.packageManager.getPackageInfo( packageName, PackageManager.PackageInfoFlags.of(flags.toLong()) ) else app.packageManager.getPackageInfo(packageName, flags) }.getOrNull() fun getApplicationInfoOrNull( packageName: String, flags: Int = PackageManager.MATCH_UNINSTALLED_PACKAGES ) = runCatching { if (Targets.T) app.packageManager.getApplicationInfo( packageName, PackageManager.ApplicationInfoFlags.of(flags.toLong()) ) else app.packageManager.getApplicationInfo(packageName, flags) }.getOrNull() fun isAppDisabled(packageName: String): Boolean = getApplicationInfoOrNull(packageName)?.enabled?.not() ?: false fun isAppHidden(packageName: String): Boolean = getApplicationInfoOrNull(packageName)?.let { (ApplicationInfo::class.java.getField("privateFlags").get(it) as Int) and 1 == 1 } ?: false fun isAppStopped(packageName: String): Boolean = getApplicationInfoOrNull(packageName)?.run { flags and ApplicationInfo.FLAG_STOPPED == ApplicationInfo.FLAG_STOPPED } ?: false fun isAppUninstalled(packageName: String): Boolean = getApplicationInfoOrNull(packageName)?.run { flags and ApplicationInfo.FLAG_INSTALLED != ApplicationInfo.FLAG_INSTALLED } ?: true fun isPrivilegedApp(packageName: String): Boolean = getApplicationInfoOrNull(packageName)?.let { (ApplicationInfo::class.java.getField("privateFlags").get(it) as Int) and 8 == 8 } ?: false fun canUninstallNormally(packageName: String): Boolean = getApplicationInfoOrNull(packageName)?.sourceDir?.startsWith("/data") ?: false fun forceStopApp(packageName: String): Boolean = runCatching { app.getSystemService()?.let { Bypass.invoke( it::class.java, it, "forceStopPackage", packageName ) } true }.getOrElse { it.printStackTrace() false } fun setAppDisabled(packageName: String, disabled: Boolean): Boolean { getApplicationInfoOrNull(packageName) ?: return false if (disabled) forceStopApp(packageName) runCatching { val newState = when { !disabled -> PackageManager.COMPONENT_ENABLED_STATE_ENABLED else -> PackageManager.COMPONENT_ENABLED_STATE_DISABLED } app.packageManager.setApplicationEnabledSetting(packageName, newState, 0) }.onFailure { it.printStackTrace() } return isAppDisabled(packageName) == disabled } fun setAppRestricted(packageName: String, restricted: Boolean): Boolean = runCatching { app.getSystemService()?.let { Bypass.invoke( it::class.java, it, "setMode", "android:run_any_in_background", packageUid(packageName), packageName, if (restricted) AppOpsManager.MODE_IGNORED else AppOpsManager.MODE_ALLOWED ) } true }.getOrElse { it.printStackTrace() false } } ================================================ FILE: app/src/main/java/com/valhalla/thor/data/source/local/shizuku/Shizuku.kt ================================================ package com.valhalla.thor.data.source.local.shizuku import android.content.Context import android.os.IBinder import android.os.ParcelFileDescriptor import com.valhalla.bypass.Bypass import moe.shizuku.server.IShizukuService import rikka.shizuku.Shizuku import rikka.shizuku.ShizukuBinderWrapper import rikka.shizuku.SystemServiceHelper import java.text.NumberFormat import java.util.Locale object Shizuku { val isRoot get() = Shizuku.getUid() == 0 private fun asInterface(className: String, original: IBinder): Any { val clazz = Class.forName("$className\$Stub") return Bypass.invoke( clazz, null, "asInterface", arrayOf(IBinder::class.java), ShizukuBinderWrapper(original) ) } private fun asInterface(className: String, serviceName: String): Any = asInterface(className, SystemServiceHelper.getSystemService(serviceName)) val lockScreen get() = runCatching { execute("input keyevent 26").first == 0 }.getOrElse { it.printStackTrace() false } fun forceStopApp(context: Context, packageName: String): Boolean { val userId = Packages(context).myUserId val result = execute("am force-stop --user $userId $packageName") if (result.first == 0) return true // Fallback to reflection return runCatching { val am = asInterface("android.app.IActivityManager", Context.ACTIVITY_SERVICE) Bypass.invoke( am::class.java, am, "forceStopPackage", packageName, userId ) true }.getOrElse { it.printStackTrace() false } } fun setAppDisabled(context: Context, packageName: String, disabled: Boolean): Boolean { Packages(context).getApplicationInfoOrNull(packageName) ?: return false val userId = Packages(context).myUserId val command = if (disabled) { "pm disable-user --user $userId $packageName" } else { "pm enable --user $userId $packageName" } val result = execute(command) if (result.first != 0) { // Fallback to Bypass reflection runCatching { val pm = asInterface("android.content.pm.IPackageManager", "package") val newState = when { !disabled -> android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_ENABLED isRoot -> android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DISABLED else -> android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER } Bypass.invoke( pm.javaClass, pm, "setApplicationEnabledSetting", arrayOf( String::class.java, Int::class.javaPrimitiveType!!, Int::class.javaPrimitiveType!!, Int::class.javaPrimitiveType!!, String::class.java ), packageName, newState, 0, userId, com.valhalla.thor.BuildConfig.APPLICATION_ID ) }.onFailure { it.printStackTrace() } } return Packages(context).isAppDisabled(packageName) == disabled } fun setAppSuspended(context: Context, packageName: String, suspended: Boolean): Boolean { Packages(context).getApplicationInfoOrNull(packageName) ?: return false val userId = Packages(context).myUserId // Try reflection first for both Root and Shizuku to show proper branding if (suspended && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { val reflectionResult = runCatching { val pm = asInterface("android.content.pm.IPackageManager", "package") val dialogInfoClass = Class.forName("android.content.pm.SuspendDialogInfo") val builderClass = Class.forName("android.content.pm.SuspendDialogInfo\$Builder") val dialogInfo = Bypass.newInstance(builderClass).let { b -> Bypass.invoke(builderClass, b, "setTitle", "Thor") Bypass.invoke( builderClass, b, "setMessage", "This app has been suspended by Thor." ) Bypass.invoke(builderClass, b, "build") } val caller = if (isRoot) com.valhalla.thor.BuildConfig.APPLICATION_ID else "com.android.shell" try { // Try Android 13+ (8 args) Bypass.invoke>( pm.javaClass, pm, "setPackagesSuspendedAsUser", arrayOf( Array::class.java, Boolean::class.javaPrimitiveType!!, android.os.PersistableBundle::class.java, android.os.PersistableBundle::class.java, dialogInfoClass, Int::class.javaPrimitiveType!!, String::class.java, Int::class.javaPrimitiveType!! ), arrayOf(packageName), true, null, null, dialogInfo, 0, caller, userId ) } catch (_: NoSuchMethodException) { // Try Android 10-12 (7 args) Bypass.invoke>( pm.javaClass, pm, "setPackagesSuspendedAsUser", arrayOf( Array::class.java, Boolean::class.javaPrimitiveType!!, android.os.PersistableBundle::class.java, android.os.PersistableBundle::class.java, dialogInfoClass, String::class.java, Int::class.javaPrimitiveType!! ), arrayOf(packageName), true, null, null, dialogInfo, caller, userId ) } true }.getOrDefault(false) if (reflectionResult) return true } val command = if (suspended) { "pm suspend --user $userId $packageName" } else { "pm unsuspend --user $userId $packageName" } return execute(command).first == 0 } fun clearCache(packageName: String): Boolean { val reflectionResult = runCatching { val pm = asInterface("android.content.pm.IPackageManager", "package") val observerClass = Class.forName("android.content.pm.IPackageDataObserver") try { Bypass.invoke( pm.javaClass, pm, "deleteApplicationCacheFiles", arrayOf(String::class.java, observerClass), packageName, null ) } catch (_: NoSuchMethodException) { Bypass.invoke( pm.javaClass, pm, "deleteApplicationCacheFilesAsUser", arrayOf(String::class.java, Int::class.javaPrimitiveType!!, observerClass), packageName, android.os.Process.myUserHandle().hashCode(), null ) } true }.getOrDefault(false) if (reflectionResult) return true // Fallback to shell rm -rf on common cache paths val userId = android.os.Process.myUserHandle().hashCode() val paths = listOf( "/data/data/$packageName/cache", "/data/user/$userId/$packageName/cache", "/sdcard/Android/data/$packageName/cache" ) val command = "rm -rf ${paths.joinToString(" ")}" return execute(command).first == 0 } fun clearAppData(packageName: String): Boolean { val result = execute("pm clear $packageName") if (result.first == 0) return true // Fallback to reflection return runCatching { val pm = asInterface("android.content.pm.IPackageManager", "package") val observerClass = Class.forName("android.content.pm.IPackageDataObserver") Bypass.invoke( pm.javaClass, pm, "clearApplicationUserData", arrayOf(String::class.java, observerClass, Int::class.javaPrimitiveType!!), packageName, null, android.os.Process.myUserHandle().hashCode() ) true }.getOrElse { false } } fun getTotalCacheSizeWithShizuku(): Long { var totalCacheBytes = 0L val result = execute("dumpsys diskstats") result.second?.lines()?.forEach { line -> val trimmedLine = line.trim() if (trimmedLine.startsWith("Cache Size:")) { try { val sizeString = trimmedLine.substringAfter(":").trim() val bytes = NumberFormat.getNumberInstance(Locale.US).parse(sizeString)?.toLong() ?: 0L totalCacheBytes += bytes } catch (e: Exception) { e.printStackTrace() } } } return totalCacheBytes } fun setAppRestricted(context: Context, packageName: String, restricted: Boolean): Boolean { val result = execute("appops set $packageName RUN_ANY_IN_BACKGROUND ${if (restricted) "ignore" else "allow"}") if (result.first == 0) return true // Fallback to reflection return runCatching { val appops = asInterface("com.android.internal.app.IAppOpsService", Context.APP_OPS_SERVICE) val uid = Packages(context).packageUid(packageName) Bypass.invoke( appops::class.java, appops, "setMode", arrayOf( Int::class.javaPrimitiveType!!, Int::class.javaPrimitiveType!!, String::class.java, Int::class.javaPrimitiveType!! ), Bypass.invoke( android.app.AppOpsManager::class.java, null, "strOpToOp", "android:run_any_in_background" ), uid, packageName, if (restricted) android.app.AppOpsManager.MODE_IGNORED else android.app.AppOpsManager.MODE_ALLOWED ) true }.getOrElse { false } } fun uninstallApp(context: Context, packageName: String): Boolean = execute("pm ${if (Packages(context).canUninstallNormally(packageName)) "uninstall" else "uninstall --user current"} $packageName").first == 0 fun reinstallApp(packageName: String): Boolean = execute("pm install-existing --user current $packageName").first == 0 fun execute(command: String, root: Boolean = isRoot): Pair = runCatching { IShizukuService.Stub.asInterface(Shizuku.getBinder()) .newProcess(arrayOf(if (root) "su" else "sh"), null, null) .run { ParcelFileDescriptor.AutoCloseOutputStream(outputStream).use { it.write(command.toByteArray()) } waitFor() to inputStream.text.ifBlank { errorStream.text }.also { destroy() } } }.getOrElse { -1 to it.stackTraceToString() } private val ParcelFileDescriptor.text get() = ParcelFileDescriptor.AutoCloseInputStream(this) .use { it.bufferedReader().readText() } } ================================================ FILE: app/src/main/java/com/valhalla/thor/data/source/local/shizuku/ShizukuPackageInstallerUtils.kt ================================================ package com.valhalla.thor.data.source.local.shizuku import android.content.pm.IPackageInstaller import android.content.pm.PackageInstaller import android.os.Build import android.os.IBinder import android.os.IInterface import com.valhalla.bypass.Bypass import rikka.shizuku.ShizukuBinderWrapper import rikka.shizuku.SystemServiceHelper /** * Taken from FDroid Priv. */ object ShizukuPackageInstallerUtils { private fun asInterface(className: String, binder: IBinder): Any { val clazz = Class.forName("$className\$Stub") return Bypass.invoke( clazz, null, "asInterface", arrayOf(IBinder::class.java), ShizukuBinderWrapper(binder) ) } fun getPrivilegedPackageInstaller(): IPackageInstaller { val pmBinder = SystemServiceHelper.getSystemService("package") val pm = asInterface("android.content.pm.IPackageManager", pmBinder) val packageInstallerProxy = Bypass.invoke( pm.javaClass, pm, "getPackageInstaller" ) val binder = (packageInstallerProxy as IInterface).asBinder() return asInterface("android.content.pm.IPackageInstaller", binder) as IPackageInstaller } /** * Taken from https://github.com/RikkaApps/Shizuku-API/blob/01e08879d58a5cb11a333535c6ddce9f7b7c88ff/demo/src/main/java/rikka/shizuku/demo/util/PackageInstallerUtils.java#L15 * @author RikkaW */ fun createPackageInstaller( installer: IPackageInstaller?, installerPackageName: String?, userId: Int ): PackageInstaller { val iPackageInstallerClass = Class.forName("android.content.pm.IPackageInstaller") return if (Build.VERSION.SDK_INT > Build.VERSION_CODES.R) { Bypass.newInstance( PackageInstaller::class.java, arrayOf( iPackageInstallerClass, String::class.java, String::class.java, Int::class.javaPrimitiveType!! ), installer, installerPackageName, null, userId ) } else { Bypass.newInstance( PackageInstaller::class.java, arrayOf(iPackageInstallerClass, String::class.java, Int::class.javaPrimitiveType!!), installer, installerPackageName, userId ) } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/data/source/local/shizuku/ShizukuReflector.kt ================================================ @file:Suppress("unused") package com.valhalla.thor.data.source.local.shizuku import android.annotation.SuppressLint import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.ApplicationInfo import android.content.pm.IPackageInstaller import android.content.pm.PackageInstaller import android.content.pm.PackageManager import android.os.Build import com.valhalla.bypass.Bypass import com.valhalla.thor.BuildConfig import com.valhalla.thor.util.Logger @SuppressLint("PrivateApi") class ShizukuReflector( val context: Context ) { fun clearCache(packageName: String): Boolean { return try { Shizuku.clearCache(packageName) } catch (e: Exception) { if (BuildConfig.DEBUG) Logger.e("ShizukuReflector", "clearCache failed: ${e.message}") false } } fun clearData(packageName: String): Boolean { return try { Shizuku.clearAppData(packageName) } catch (e: Exception) { if (BuildConfig.DEBUG) Logger.e("ShizukuReflector", "clearData failed: ${e.message}") false } } fun forceStop(packageName: String): Boolean { return try { Shizuku.forceStopApp(context, packageName) } catch (e: Exception) { if (BuildConfig.DEBUG) Logger.e("ShizukuReflector", "forceStop failed", e) false } } fun setAppEnabled(packageName: String, enabled: Boolean): Boolean { return try { Shizuku.setAppDisabled(context, packageName, !enabled) } catch (e: Exception) { if (BuildConfig.DEBUG) Logger.e("ShizukuReflector", "setAppEnabled failed", e) false } } fun packageUid(packageName: String) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) context.packageManager.getPackageUid( packageName, PackageManager.PackageInfoFlags.of(PackageManager.MATCH_UNINSTALLED_PACKAGES.toLong()) ) else context.packageManager.getPackageUid( packageName, PackageManager.MATCH_UNINSTALLED_PACKAGES ) fun getApplicationInfoOrNull( packageName: String, flags: Int = PackageManager.MATCH_UNINSTALLED_PACKAGES ) = runCatching { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) context.packageManager.getApplicationInfo( packageName, PackageManager.ApplicationInfoFlags.of(flags.toLong()) ) else context.packageManager.getApplicationInfo(packageName, flags) }.getOrNull() fun isAppDisabled(packageName: String): Boolean = getApplicationInfoOrNull(packageName)?.enabled?.not() ?: false fun isAppHidden(packageName: String): Boolean = getApplicationInfoOrNull(packageName)?.let { (Bypass.getField(it, "privateFlags")) and 1 == 1 } ?: false fun isAppStopped(packageName: String): Boolean = getApplicationInfoOrNull(packageName)?.run { flags and ApplicationInfo.FLAG_STOPPED == ApplicationInfo.FLAG_STOPPED } ?: false fun isAppUninstalled(packageName: String): Boolean = getApplicationInfoOrNull(packageName)?.run { flags and ApplicationInfo.FLAG_INSTALLED != ApplicationInfo.FLAG_INSTALLED } ?: true fun isPrivilegedApp(packageName: String): Boolean = getApplicationInfoOrNull(packageName)?.let { (Bypass.getField(it, "privateFlags")) and 8 == 8 } ?: false fun setAppRestricted(packageName: String, restricted: Boolean): Boolean = Shizuku.setAppRestricted(context, packageName, restricted) fun setAppSuspended(packageName: String, suspended: Boolean): Boolean = Shizuku.setAppSuspended(context, packageName, suspended) fun uninstallApp(packageName: String, resetToFactory: Boolean = false): Boolean { return runCatching { val packageInfo = context.packageManager.getInfoForPackage(packageName) ?: return false val isSystem = (packageInfo.applicationInfo!!.flags and ApplicationInfo.FLAG_SYSTEM) != 0 val hasUpdates = (packageInfo.applicationInfo!!.flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) != 0 val shouldReset = resetToFactory && isSystem && hasUpdates val broadcastIntent = Intent("io.github.samolego.canta.UNINSTALL_RESULT_ACTION") val intent = PendingIntent.getBroadcast( context, 0, broadcastIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) val packageInstaller = getPackageInstaller() // 0x00000004 = PackageManager.DELETE_SYSTEM_APP // 0x00000002 = PackageManager.DELETE_ALL_USERS val flags = if (isSystem) 0x00000004 else 0x00000002 if (shouldReset) { Bypass.invoke( PackageInstaller::class.java, packageInstaller, "uninstall", packageName, flags, intent.intentSender ) } Bypass.invoke( PackageInstaller::class.java, packageInstaller, "uninstall", packageName, flags, intent.intentSender ) true }.getOrElse { // Fallback to Shell uninstallation Logger.w( "ShizukuReflector", "Reflection uninstall failed, falling back to shell: ${it.message}" ) Shizuku.uninstallApp(context, packageName) } } /** * Installs an APK using the 'pm install' command via Shizuku. * Note: The file at [apkPath] must be readable by the shell user (e.g. /sdcard/). * * @param apkPath Absolute path to the APK file. * @param canDowngrade Whether to allow downgrade. * @return true if installation command exited with 0 (Success). */ fun installPackage(apkPath: String, canDowngrade: Boolean = false): Boolean { return try { val command = "pm install -r -g${if (canDowngrade) " -d" else ""} ${ com.valhalla.superuser.ShellUtils.escapedString(apkPath) }" val result = Shizuku.execute(command) result.first == 0 } catch (e: Exception) { e.printStackTrace() false } } /** * Reinstalls app using Shizuku. See ( IPackageInstaller::class.java, ShizukuPackageInstallerUtils.getPrivilegedPackageInstaller(), "installExistingPackage", packageName, installFlags, installReason, intent.intentSender, 0, null ) true } catch (e: Exception) { e.printStackTrace() false } } fun getPackageInstaller(): PackageInstaller { val iPackageInstaller = ShizukuPackageInstallerUtils.getPrivilegedPackageInstaller() val root = try { rikka.shizuku.Shizuku.getUid() == 0 } catch (_: Exception) { false } val userId = if (root) android.os.Process.myUserHandle().hashCode() else 0 // The reason for use "com.android.shell" as installer package under adb is that // getMySessions will check installer package's owner return ShizukuPackageInstallerUtils.createPackageInstaller( iPackageInstaller, "com.android.shell", userId ) } /** * Create a privileged PackageInstaller using the provided installer package name. * This mirrors `getPackageInstaller()` but allows specifying the installer package * (so sessions can be created as belonging to the app's package). */ fun createPackageInstallerFor(installerPackageName: String): PackageInstaller { val iPackageInstaller = ShizukuPackageInstallerUtils.getPrivilegedPackageInstaller() val root = try { rikka.shizuku.Shizuku.getUid() == 0 } catch (_: Exception) { false } val userId = if (root) android.os.Process.myUserHandle().hashCode() else 0 return ShizukuPackageInstallerUtils.createPackageInstaller( iPackageInstaller, installerPackageName, userId ) } } ================================================ FILE: app/src/main/java/com/valhalla/thor/data/source/local/shizuku/Targets.kt ================================================ package com.valhalla.thor.data.source.local.shizuku import android.os.Build import androidx.annotation.ChecksSdkIntAtLeast object Targets { @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.Q) val Q = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S) val S = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.TIRAMISU) val T = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE) val U = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE @get:ChecksSdkIntAtLeast(api = 35) val V = Build.VERSION.SDK_INT >= 35 @get:ChecksSdkIntAtLeast(api = 36) val B = Build.VERSION.SDK_INT >= 36 @get:ChecksSdkIntAtLeast(api = 37) val B_MINOR = Build.VERSION.SDK_INT >= 37 } ================================================ FILE: app/src/main/java/com/valhalla/thor/data/util/ApksMetadataGenerator.kt ================================================ package com.valhalla.thor.data.util import com.valhalla.thor.domain.model.AppInfo import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import java.io.File class ApksMetadataGenerator { @Serializable data class ApksMetadata( @SerialName("info_version") val infoVersion: Int = 1, @SerialName("package_name") val packageName: String, @SerialName("display_name") val displayName: String, @SerialName("version_name") val versionName: String, @SerialName("version_code") val versionCode: Int, @SerialName("min_sdk") val minSdkVersion: Int, @SerialName("target_sdk") val targetSdkVersion: Int, ) fun generateJson(appInfo: AppInfo) = Json.encodeToString( ApksMetadata( packageName = appInfo.packageName, displayName = appInfo.appName ?: "", versionName = appInfo.versionName ?: "", versionCode = appInfo.versionCode, minSdkVersion = appInfo.minSdk, targetSdkVersion = appInfo.targetSdk ) ) fun generateJson(appInfo: AppInfo, targetFile: File) { targetFile.writeText(generateJson(appInfo)) } } ================================================ FILE: app/src/main/java/com/valhalla/thor/data/util/PackageVerifier.kt ================================================ package com.valhalla.thor.data.util import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import com.valhalla.thor.domain.model.AppInstallable import java.io.File /** * Checks APKs for the debuggable flag. * Keep this decoupled so you can test it easily. */ class PackageVerifier(private val packageManager: PackageManager) { fun scanForDebuggableApps(apkPaths: List): List { return apkPaths.mapNotNull { path -> val file = File(path) if (!file.exists()) return@mapNotNull null // Use GET_META_DATA or 0. parsing headers is expensive, do not do on UI thread. val info = packageManager.getPackageArchiveInfo(path, 0) val appInfo = info?.applicationInfo // Bitwise check for the debuggable flag val isDebuggable = appInfo?.let { (it.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 } ?: false if (info != null) { AppInstallable( name = appInfo?.packageName ?: file.name, apkPath = path, isDebuggable = isDebuggable ) } else null } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/di/Modules.kt ================================================ package com.valhalla.thor.di import android.content.pm.PackageManager import androidx.room.Room import com.valhalla.superuser.ktx.RealShellRepository import com.valhalla.superuser.ktx.ShellRepository import com.valhalla.thor.data.gateway.DhizukuSystemGateway import com.valhalla.thor.data.gateway.RootSystemGateway import com.valhalla.thor.data.gateway.ShizukuSystemGateway import com.valhalla.thor.data.repository.AppAnalyzerImpl import com.valhalla.thor.data.repository.AppRepositoryImpl import com.valhalla.thor.data.repository.InstallerRepositoryImpl import com.valhalla.thor.data.repository.PreferenceRepositoryImpl import com.valhalla.thor.data.repository.SystemRepositoryImpl import com.valhalla.thor.data.security.BiometricHelper import com.valhalla.thor.data.source.local.dhizuku.DhizukuReflector import com.valhalla.thor.data.source.local.room.AppDatabase import com.valhalla.thor.data.source.local.shizuku.ShizukuReflector import com.valhalla.thor.data.util.ApksMetadataGenerator import com.valhalla.thor.domain.InstallerEventBus import com.valhalla.thor.domain.repository.AppAnalyzer import com.valhalla.thor.domain.repository.AppRepository import com.valhalla.thor.domain.repository.InstallerRepository import com.valhalla.thor.domain.repository.PreferenceRepository import com.valhalla.thor.domain.repository.SystemRepository import com.valhalla.thor.domain.usecase.GetAppDetailsUseCase import com.valhalla.thor.domain.usecase.GetInstalledAppsUseCase import com.valhalla.thor.domain.usecase.ManageAppUseCase import com.valhalla.thor.domain.usecase.ShareAppUseCase import com.valhalla.thor.presentation.appList.AppListViewModel import com.valhalla.thor.presentation.freezer.FreezerViewModel import com.valhalla.thor.presentation.home.HomeViewModel import com.valhalla.thor.presentation.installer.InstallerViewModel import com.valhalla.thor.presentation.main.MainViewModel import com.valhalla.thor.presentation.security.SecurityViewModel import com.valhalla.thor.presentation.settings.SettingsViewModel import com.valhalla.thor.util.LocaleManager import org.koin.android.ext.koin.androidContext import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.bind import org.koin.dsl.module val commonModule = module { single { androidContext().packageManager } singleOf(::LocaleManager) singleOf(::ApksMetadataGenerator) single { AppRepositoryImpl(androidContext(), get()) } factory { GetInstalledAppsUseCase(get()) } factory { GetAppDetailsUseCase(get()) } factory { ManageAppUseCase(get()) } factoryOf(::ShareAppUseCase) } val roomModule = module { single { Room.databaseBuilder( androidContext(), AppDatabase::class.java, "thor_database" ).fallbackToDestructiveMigration(dropAllTables = true).build() } single { get().appDao() } } val installerModule = module { // 1. The Singleton Event Bus (Critical for Receiver <-> VM comms) singleOf(::InstallerEventBus) single { InstallerRepositoryImpl( context = androidContext(), eventBus = get(), rootGateway = get(), shizukuReflector = get() ) } single { androidContext().packageManager } single { AppAnalyzerImpl(androidContext()) } } val preferenceModule = module { single { PreferenceRepositoryImpl(get()) } } val presentationModule = module { viewModelOf(::MainViewModel) viewModelOf(::HomeViewModel) viewModelOf(::AppListViewModel) viewModelOf(::FreezerViewModel) viewModelOf(::InstallerViewModel) viewModelOf(::SettingsViewModel) viewModelOf(::SecurityViewModel) } val coreModule = module { singleOf(::RealShellRepository).bind() singleOf(::ShizukuReflector) singleOf(::DhizukuReflector) singleOf(::BiometricHelper) // Singletons for the Gateways single { RootSystemGateway(androidContext(), get()) } single { ShizukuSystemGateway(get()) } single { DhizukuSystemGateway(get()) } // The Repository interacts with the Gateways singleOf(::SystemRepositoryImpl).bind() } ================================================ FILE: app/src/main/java/com/valhalla/thor/domain/InstallState.kt ================================================ package com.valhalla.thor.domain import android.content.Intent import com.valhalla.thor.domain.model.AppMetadata /** * Represents the distinct states of the installation process. * We use a Sealed Interface for strict state management in the UI.\n */ sealed interface InstallState { data object Idle : InstallState data object Parsing : InstallState data class ReadyToInstall( val meta: AppMetadata, val isUpdate: Boolean, val isDowngrade: Boolean = false, val oldVersion: String? = null ) : InstallState { @Suppress("unused") fun getVersionInfo(): String { return if (isUpdate) { "Update available: ${meta.version} (current: $oldVersion)" } else if (isDowngrade) { "Downgrade detected: ${meta.version} (current: $oldVersion)" } else { "Ready to install version ${meta.version}" } } fun getActionButtonText(): String { return when { isDowngrade -> "Install Anyway" isUpdate -> "Update" else -> "Install" } } fun getWarningMessage(): String? { return when { isDowngrade -> "Warning: Installing an older version may cause issues." isUpdate -> null else -> null } } fun shouldShowWarning(): Boolean { return isDowngrade } fun getActionWord(): String { return when { isDowngrade -> "downgrade" isUpdate -> "update" else -> "install" } } } data class Installing(val progress: Float) : InstallState // 0.0 to 1.0 data object Success : InstallState data class Error(val message: String) : InstallState // Critical: The OS has paused the session to ask the user for permission. data class UserConfirmationRequired(val intent: Intent) : InstallState } ================================================ FILE: app/src/main/java/com/valhalla/thor/domain/InstallerEventBus.kt ================================================ package com.valhalla.thor.domain import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow /** * A Singleton Event Bus to bridge the gap between the Android System (BroadcastReceiver) * and our App Scope (ViewModel). * * Since BroadcastReceivers are instantiated by the OS, we cannot scope them to the ViewModel. * This Bus acts as the synapse. */ class InstallerEventBus { val events: SharedFlow field = MutableSharedFlow(replay = 1) suspend fun emit(state: InstallState) { events.emit(state) } } ================================================ FILE: app/src/main/java/com/valhalla/thor/domain/gateway/SystemGateway.kt ================================================ package com.valhalla.thor.domain.gateway /** * The Contract: This defines every privileged action Thor can perform. * No Android dependencies (Context, Toast, Intent) allowed here. */ interface SystemGateway { // Status Checks suspend fun isRootAvailable(): Boolean fun isShizukuAvailable(): Boolean fun isDhizukuAvailable(): Boolean // Core Actions suspend fun forceStopApp(packageName: String): Result suspend fun clearCache(packageName: String): Result suspend fun clearAppData(packageName: String): Result suspend fun setAppDisabled(packageName: String, isDisabled: Boolean): Result suspend fun setAppSuspended(packageName: String, isSuspended: Boolean): Result suspend fun setAppRestricted(packageName: String, isRestricted: Boolean): Result suspend fun rebootDevice(reason: String): Result // Advanced suspend fun uninstallApp(packageName: String): Result suspend fun installApp(apkPath: String, canDowngrade: Boolean = false): Result suspend fun reinstallAppWithGoogle(packageName: String): Result // Metrics suspend fun getAppCacheSize(packageName: String): Long } ================================================ FILE: app/src/main/java/com/valhalla/thor/domain/model/ApkDetails.kt ================================================ package com.valhalla.thor.domain.model import android.graphics.drawable.Drawable data class ApkDetails( val appName: String?, val packageName: String?, val versionName: String?, val versionCode: Long?, val appIcon: Drawable?, val permissions: List?, val minSdk: Int?, val targetSdk: Int? ) ================================================ FILE: app/src/main/java/com/valhalla/thor/domain/model/AppClickAction.kt ================================================ package com.valhalla.thor.domain.model sealed interface AppClickAction { //data class Logcat(val appInfo: AppInfo): AppClickAction data class Launch(val appInfo: AppInfo) : AppClickAction data class Share(val appInfo: AppInfo) : AppClickAction data class Uninstall(val appInfo: AppInfo) : AppClickAction data class Reinstall(val appInfo: AppInfo) : AppClickAction data class Freeze(val appInfo: AppInfo) : AppClickAction data class UnFreeze(val appInfo: AppInfo) : AppClickAction data class Kill(val appInfo: AppInfo) : AppClickAction data class AppInfoSettings(val appInfo: AppInfo) : AppClickAction data object ReinstallAll : AppClickAction data class ClearCache(val appInfo: AppInfo) : AppClickAction data class ClearData(val appInfo: AppInfo) : AppClickAction data class Suspend(val appInfo: AppInfo) : AppClickAction data class UnSuspend(val appInfo: AppInfo) : AppClickAction } ================================================ FILE: app/src/main/java/com/valhalla/thor/domain/model/AppInfo.kt ================================================ package com.valhalla.thor.domain.model import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DEFAULT import android.os.Build import android.os.Environment import kotlinx.serialization.Serializable import java.io.File @Serializable data class AppInfo( var appName: String? = null, var packageName: String = "", var versionName: String? = "", var versionCode: Int = 0, var minSdk: Int = 0, var targetSdk: Int = 0, var isSystem: Boolean = false, var installerPackageName: String? = null, var publicSourceDir: String? = null, var splitPublicSourceDirs: List = emptyList(), var enabled: Boolean = true, var enabledState: Int = COMPONENT_ENABLED_STATE_DEFAULT, var dataDir: String? = null, var nativeLibraryDir: String? = null, var deviceProtectedDataDir: String? = null, var sharedLibraryFiles: List? = emptyList(), var obbFilePath: String? = null, var sourceDir: String? = null, var sharedDataDir: String = "", var lastUpdateTime: Long = 0L, var firstInstallTime: Long = 0L, val isDebuggable: Boolean = false, var isSuspended: Boolean = false, ) { companion object { fun mapToAppInfo( packInfo: PackageInfo, appInfo: ApplicationInfo, pm: PackageManager, isLightweight: Boolean = false ): AppInfo { val isDebuggable = (appInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 @Suppress("DEPRECATION") val mapped = AppInfo( appName = appInfo.loadLabel(pm).toString(), packageName = packInfo.packageName, versionName = packInfo.versionName, versionCode = packInfo.longVersionCode.toInt(), minSdk = appInfo.minSdkVersion, targetSdk = appInfo.targetSdkVersion, isSystem = (appInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0, installerPackageName = getInstallerPackageName(packInfo.packageName, pm), publicSourceDir = appInfo.publicSourceDir, splitPublicSourceDirs = appInfo.splitPublicSourceDirs?.toList() ?: emptyList(), enabled = appInfo.enabled, // enabledState = pm.getApplicationEnabledSetting(packInfo.packageName), // Warning: This can be slow, use cautiously dataDir = appInfo.dataDir, nativeLibraryDir = appInfo.nativeLibraryDir, deviceProtectedDataDir = appInfo.deviceProtectedDataDir, sourceDir = appInfo.sourceDir, lastUpdateTime = packInfo.lastUpdateTime, firstInstallTime = packInfo.firstInstallTime, isDebuggable = isDebuggable, isSuspended = (appInfo.flags and ApplicationInfo.FLAG_SUSPENDED) != 0 ) // The "Heavy" Logic - Only run if explicitly requested if (!isLightweight) { mapped.sharedLibraryFiles = appInfo.sharedLibraryFiles?.toList() ?: emptyList() // OBB Check val obbFile = File( Environment.getExternalStorageDirectory(), "Android/obb/${appInfo.packageName}" ) if (obbFile.exists()) { mapped.obbFilePath = obbFile.absolutePath } // Data Dir Check val dataFile = File( Environment.getExternalStorageDirectory(), "Android/data/${appInfo.packageName}" ) mapped.sharedDataDir = dataFile.absolutePath } return mapped } fun getInstallerPackageName(packageName: String, pm: PackageManager): String? { return try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { pm.getInstallSourceInfo(packageName).installingPackageName } else { @Suppress("DEPRECATION") pm.getInstallerPackageName(packageName) } } catch (_: Exception) { null } } } } fun AppInfo.formattedAppName() = appName?.replace(" ", "_") ?: packageName ================================================ FILE: app/src/main/java/com/valhalla/thor/domain/model/AppInstallable.kt ================================================ package com.valhalla.thor.domain.model data class AppInstallable( val name: String, val apkPath: String, val isDebuggable: Boolean ) ================================================ FILE: app/src/main/java/com/valhalla/thor/domain/model/AppListType.kt ================================================ package com.valhalla.thor.domain.model enum class AppListType { USER, SYSTEM } ================================================ FILE: app/src/main/java/com/valhalla/thor/domain/model/AppMetadata.kt ================================================ package com.valhalla.thor.domain.model import android.graphics.Bitmap data class AppMetadata( val label: String, val packageName: String, val version: String, val versionCode: Long, val icon: Bitmap?, val permissions: List = emptyList() ) ================================================ FILE: app/src/main/java/com/valhalla/thor/domain/model/FilterType.kt ================================================ package com.valhalla.thor.domain.model sealed interface FilterType { data object Source : FilterType data object State : FilterType { val types = listOf( "All", "Active", "Frozen", "Suspended" ) }; } val filterTypes = listOf( FilterType.State, FilterType.Source ) fun FilterType.asGeneralName() = when (this) { FilterType.State -> "Active State" FilterType.Source -> "Installation Source" } ================================================ FILE: app/src/main/java/com/valhalla/thor/domain/model/HistoryRecord.kt ================================================ package com.valhalla.thor.domain.model import kotlinx.serialization.Serializable @Serializable enum class OperationType { INSTALL, UPDATE } @Serializable data class HistoryRecord( val id: Long = 0, val packageName: String, val label: String, val version: String, val timestamp: Long, val type: OperationType, val path: String // Optional: Path to the file installed (for reference) ) ================================================ FILE: app/src/main/java/com/valhalla/thor/domain/model/MultiAppAction.kt ================================================ package com.valhalla.thor.domain.model sealed interface MultiAppAction { data class ReInstall(val appList: List) : MultiAppAction data class Uninstall(val appList: List) : MultiAppAction data class Freeze(val appList: List) : MultiAppAction data class UnFreeze(val appList: List) : MultiAppAction data class Share(val appList: List) : MultiAppAction data class Kill(val appList: List) : MultiAppAction data class ClearCache(val appList: List) : MultiAppAction data class ClearData(val appList: List) : MultiAppAction data class Suspend(val appList: List) : MultiAppAction data class UnSuspend(val appList: List) : MultiAppAction } ================================================ FILE: app/src/main/java/com/valhalla/thor/domain/model/PrivilegeMode.kt ================================================ package com.valhalla.thor.domain.model enum class PrivilegeMode { ROOT, SHIZUKU, DHIZUKU } ================================================ FILE: app/src/main/java/com/valhalla/thor/domain/model/SortBy.kt ================================================ package com.valhalla.thor.domain.model import com.valhalla.thor.R import kotlinx.serialization.Serializable @Serializable enum class SortBy { NAME, //SIZE, INSTALL_DATE, LAST_UPDATED, VERSION_CODE, VERSION_NAME, TARGET_SDK_VERSION, MIN_SDK_VERSION; fun asGeneralName(): String = when (this) { NAME -> "Name" //SIZE -> "Size" INSTALL_DATE -> "Install Date" LAST_UPDATED -> "Last Updated" VERSION_CODE -> "Version Code" VERSION_NAME -> "Version Name" TARGET_SDK_VERSION -> "Target SDK Version" MIN_SDK_VERSION -> "Min SDK Version" } } fun SortBy.isDateBased(): Boolean = this == SortBy.INSTALL_DATE || this == SortBy.LAST_UPDATED fun SortBy.isVersionBased(): Boolean = this == SortBy.VERSION_CODE || this == SortBy.VERSION_NAME fun SortBy.isSdkBased(): Boolean = this == SortBy.TARGET_SDK_VERSION || this == SortBy.MIN_SDK_VERSION fun SortBy.isNameBased(): Boolean = this == SortBy.NAME @Serializable enum class SortOrder { ASCENDING, DESCENDING; fun asGeneralName(): String = when (this) { ASCENDING -> "Ascending" DESCENDING -> "Descending" } fun icon() = when (this) { ASCENDING -> R.drawable.arrow_upward DESCENDING -> R.drawable.arrow_downward } fun flip(): SortOrder = when (this) { ASCENDING -> DESCENDING DESCENDING -> ASCENDING } fun angle(): Float = when (this) { ASCENDING -> 0f DESCENDING -> 180f } } ================================================ FILE: app/src/main/java/com/valhalla/thor/domain/model/ThemeMode.kt ================================================ package com.valhalla.thor.domain.model enum class ThemeMode { LIGHT, DARK, SYSTEM; fun label(): String = when (this) { LIGHT -> "Light" DARK -> "Dark" SYSTEM -> "System" } } ================================================ FILE: app/src/main/java/com/valhalla/thor/domain/model/UserPreferences.kt ================================================ package com.valhalla.thor.domain.model data class UserPreferences( // App List Sorting & Filtering val appSortBy: SortBy = SortBy.NAME, val appSortOrder: SortOrder = SortOrder.ASCENDING, val appFilterType: FilterType = FilterType.Source, val appSelectedFilter: String = "All", // Home Screen Config val showReinstallAllCard: Boolean = true, // Theme val themeMode: ThemeMode = ThemeMode.SYSTEM, val useDynamicColor: Boolean = false, val useAmoled: Boolean = false, // Security val biometricLockEnabled: Boolean = false, // Work Mode val preferredPrivilegeMode: PrivilegeMode? = null, // Localization val language: String? = null, // null means System Default ) ================================================ FILE: app/src/main/java/com/valhalla/thor/domain/repository/AppAnalyzer.kt ================================================ package com.valhalla.thor.domain.repository import android.net.Uri import com.valhalla.thor.domain.model.AppMetadata interface AppAnalyzer { /** * Extracts metadata from a URI (APK, XAPK, APKS) without installing it. */ suspend fun analyze(uri: Uri): Result } ================================================ FILE: app/src/main/java/com/valhalla/thor/domain/repository/AppRepository.kt ================================================ package com.valhalla.thor.domain.repository import com.valhalla.thor.domain.model.AppInfo import kotlinx.coroutines.flow.Flow interface AppRepository { /** * Fetches all installed applications. * Returns a Flow to allow emitting updates if packages change (optional), * or just a single emission for now. */ fun getAllApps(): Flow> /** * Get details for a specific package. * This is where we will do the heavy lifting (OBB checks, etc.) * so we don't slow down the main list. */ suspend fun getAppDetails(packageName: String): AppInfo? // Parser for XAPK/APK installation features suspend fun getApkDetails(apkPath: String): AppInfo? } ================================================ FILE: app/src/main/java/com/valhalla/thor/domain/repository/InstallerRepository.kt ================================================ package com.valhalla.thor.domain.repository import android.net.Uri enum class InstallMode { NORMAL, SHIZUKU, DHIZUKU, ROOT, EXTERNAL } /** * The Repository Contract. * The Domain layer doesn't care about PackageInstaller APIs, only that we can install a URI. */ interface InstallerRepository { suspend fun installPackage(uri: Uri, mode: InstallMode, canDowngrade: Boolean = false) } ================================================ FILE: app/src/main/java/com/valhalla/thor/domain/repository/PreferenceRepository.kt ================================================ package com.valhalla.thor.domain.repository import com.valhalla.thor.domain.model.FilterType import com.valhalla.thor.domain.model.PrivilegeMode import com.valhalla.thor.domain.model.SortBy import com.valhalla.thor.domain.model.SortOrder import com.valhalla.thor.domain.model.ThemeMode import com.valhalla.thor.domain.model.UserPreferences import kotlinx.coroutines.flow.Flow interface PreferenceRepository { // Observe all preferences as a single stream val userPreferences: Flow // --- App List --- suspend fun updateAppSort(sortBy: SortBy) suspend fun updateAppSortOrder(sortOrder: SortOrder) suspend fun updateAppFilter(filterType: FilterType, selectedFilter: String) suspend fun setReinstallAllCardVisibility(isVisible: Boolean) // --- Theme --- suspend fun setThemeMode(themeMode: ThemeMode) suspend fun setDynamicColor(enabled: Boolean) suspend fun setUseAmoled(enabled: Boolean) // --- Security --- suspend fun setBiometricLock(enabled: Boolean) // --- Work Mode --- suspend fun setPrivilegeMode(mode: PrivilegeMode?) // --- Localization --- suspend fun setLanguage(language: String?) } ================================================ FILE: app/src/main/java/com/valhalla/thor/domain/repository/SystemRepository.kt ================================================ package com.valhalla.thor.domain.repository interface SystemRepository { suspend fun isRootAvailable(): Boolean fun isShizukuAvailable(): Boolean fun isDhizukuAvailable(): Boolean // Core Actions suspend fun forceStopApp(packageName: String): Result suspend fun clearCache(packageName: String): Result suspend fun clearAppData(packageName: String): Result suspend fun setAppDisabled(packageName: String, isDisabled: Boolean): Result suspend fun setAppSuspended(packageName: String, isSuspended: Boolean): Result suspend fun setAppRestricted(packageName: String, isRestricted: Boolean): Result // Advanced suspend fun uninstallApp(packageName: String): Result suspend fun rebootDevice(reason: String): Result // Composite Actions suspend fun aggressiveCleanup(packageName: String): Result suspend fun reinstallAppWithGoogle(packageName: String): Result suspend fun copyFileWithRoot(sourcePath: String, destinationPath: String): Result suspend fun getAppPaths(packageName: String): Result> } ================================================ FILE: app/src/main/java/com/valhalla/thor/domain/usecase/GetAppDetailsUseCase.kt ================================================ package com.valhalla.thor.domain.usecase import com.valhalla.thor.domain.model.AppInfo import com.valhalla.thor.domain.repository.AppRepository class GetAppDetailsUseCase( private val appRepository: AppRepository ) { suspend operator fun invoke(packageName: String): Result { return try { val info = appRepository.getAppDetails(packageName) if (info != null) { Result.success(info) } else { Result.failure(Exception("App not found")) } } catch (e: Exception) { Result.failure(e) } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/domain/usecase/GetInstalledAppsUseCase.kt ================================================ package com.valhalla.thor.domain.usecase import com.valhalla.thor.domain.model.AppInfo import com.valhalla.thor.domain.repository.AppRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map class GetInstalledAppsUseCase( private val appRepository: AppRepository ) { /** * Returns a generic Pair: (UserApps, SystemApps) */ operator fun invoke(): Flow, List>> { return appRepository.getAllApps().map { allApps -> val (system, user) = allApps.partition { it.isSystem } // Additional filtering can go here (e.g. exclude own app?) Pair(user, system) } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/domain/usecase/ManageAppUseCase.kt ================================================ package com.valhalla.thor.domain.usecase class ManageAppUseCase( private val systemRepository: com.valhalla.thor.domain.repository.SystemRepository ) { suspend fun forceStop(packageName: String): Result = systemRepository.forceStopApp(packageName) suspend fun clearCache(packageName: String): Result = systemRepository.clearCache(packageName) suspend fun clearAppData(packageName: String): Result = systemRepository.clearAppData(packageName) suspend fun setAppDisabled(packageName: String, disabled: Boolean): Result = systemRepository.setAppDisabled(packageName, disabled) suspend fun setAppSuspended(packageName: String, suspended: Boolean): Result = systemRepository.setAppSuspended(packageName, suspended) suspend fun uninstallApp(packageName: String): Result = systemRepository.uninstallApp(packageName) /** * Reinstalls an app (usually via Play Store mechanism or existing APK). * For clean arch, we might need a specific method in Repo for "Reinstall". * Assuming for now we rely on the repository's generic install or a specific reinstall method. */ suspend fun reinstallAppWithGoogle(packageName: String): Result { return systemRepository.reinstallAppWithGoogle(packageName) } } ================================================ FILE: app/src/main/java/com/valhalla/thor/domain/usecase/ShareAppUseCase.kt ================================================ package com.valhalla.thor.domain.usecase import android.content.Context import androidx.core.content.FileProvider import com.valhalla.thor.BuildConfig import com.valhalla.thor.data.util.ApksMetadataGenerator import com.valhalla.thor.domain.model.AppInfo import com.valhalla.thor.domain.model.formattedAppName import com.valhalla.thor.domain.repository.SystemRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.BufferedInputStream import java.io.BufferedOutputStream import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream class ShareAppUseCase( private val context: Context, private val systemRepository: SystemRepository, private val apksMetadataGenerator: ApksMetadataGenerator ) { suspend operator fun invoke(appInfo: AppInfo): Result = withContext(Dispatchers.IO) { try { // 1. Prepare Cache Directory val cacheDir = File(context.cacheDir, "share_temp") if (cacheDir.exists()) cacheDir.deleteRecursively() cacheDir.mkdirs() val finalFile: File // 2. Check for Splits if (appInfo.splitPublicSourceDirs.isNullOrEmpty()) { // --- Single APK Mode --- val sourcePath = appInfo.publicSourceDir ?: appInfo.sourceDir ?: return@withContext Result.failure(Exception("No source path found")) val fileName = "${appInfo.formattedAppName()}_${appInfo.versionName}.apk" finalFile = File(cacheDir, fileName) if (!copyFileSafely(sourcePath, finalFile)) { return@withContext Result.failure(Exception("Failed to copy base APK")) } } else { // --- Split APK Mode (Zip/APKS) --- val fileName = "${appInfo.formattedAppName()}_${appInfo.versionName}.apks" finalFile = File(cacheDir, fileName) // Temp staging folder for individual parts val tempSplitDir = File(cacheDir, "splits_staging") tempSplitDir.mkdirs() // A. Gather APK Paths val allPaths = mutableListOf() appInfo.sourceDir?.let { allPaths.add(it) } allPaths.addAll(appInfo.splitPublicSourceDirs) // B. Copy APKs to staging val filesToZip = allPaths.mapNotNull { path -> val name = path.substringAfterLast("/") val destFile = File(tempSplitDir, name) if (copyFileSafely(path, destFile)) destFile else null }.toMutableList() if (filesToZip.isEmpty()) { return@withContext Result.failure(Exception("Failed to copy any APK files")) } // C. GENERATE METADATA (The Missing Piece) // We generate "metadata.json" so installers know what this bundle is. val metadataFile = File(tempSplitDir, "metadata.json") apksMetadataGenerator.generateJson(appInfo, metadataFile) filesToZip.add(metadataFile) // D. Zip Everything (APKs + JSON) zipFiles(filesToZip, finalFile) } // 3. Generate URI val uri = FileProvider.getUriForFile( context, "${BuildConfig.APPLICATION_ID}.provider", finalFile ) Result.success(uri) } catch (e: Exception) { if (BuildConfig.DEBUG) e.printStackTrace() Result.failure(e) } } /** * Tries standard copy, falls back to Root if permission denied. */ private suspend fun copyFileSafely(sourcePath: String, destFile: File): Boolean { return try { File(sourcePath).copyTo(destFile, overwrite = true) true } catch (_: Exception) { systemRepository.copyFileWithRoot(sourcePath, destFile.absolutePath).isSuccess } } private fun zipFiles(files: List, zipFile: File) { ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { out -> val data = ByteArray(1024) files.forEach { file -> FileInputStream(file).use { fi -> BufferedInputStream(fi).use { origin -> val entry = ZipEntry(file.name) out.putNextEntry(entry) while (true) { val readBytes = origin.read(data) if (readBytes == -1) break out.write(data, 0, readBytes) } } } } } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/appList/AppListScreen.kt ================================================ package com.valhalla.thor.presentation.appList import android.widget.Toast import androidx.compose.foundation.Image import androidx.compose.foundation.background 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.Spacer 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.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.ImageLoader import coil3.compose.rememberAsyncImagePainter import coil3.request.crossfade import com.valhalla.thor.R import com.valhalla.thor.domain.model.AppClickAction import com.valhalla.thor.domain.model.AppInfo import com.valhalla.thor.domain.model.MultiAppAction import com.valhalla.thor.presentation.utils.AppIconFetcher import com.valhalla.thor.presentation.utils.AppIconKeyer import com.valhalla.thor.presentation.utils.getAppIcon import com.valhalla.thor.presentation.widgets.AppInfoDialog import com.valhalla.thor.presentation.widgets.AppList import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun AppListScreen( modifier: Modifier = Modifier, title: String = stringResource(R.string.apps), icon: Int = R.drawable.thor_mono, viewModel: AppListViewModel = koinViewModel(), // These actions bubble up to MainScreen/HomeViewModel for execution onAppAction: (AppClickAction) -> Unit = {}, onMultiAppAction: (MultiAppAction) -> Unit = {} ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current // Create a custom ImageLoader that knows how to fetch App Icons in the background. // We use 'remember' so we don't recreate the loader on every recomposition. val imageLoader = remember(context) { ImageLoader.Builder(context) .components { add(AppIconKeyer()) add(AppIconFetcher.Factory(context)) } .crossfade(true) .build() } LaunchedEffect(Unit) { if (state.allUserApps.isEmpty() && state.allSystemApps.isEmpty() && state.isLoading) { viewModel.loadApps() } } // Handle Feedback (Toasts) LaunchedEffect(state.actionMessage) { state.actionMessage?.let { message -> Toast.makeText(context, message, Toast.LENGTH_SHORT).show() viewModel.dismissMessage() } } // UI-Specific State (Dialogs that don't need to persist in VM) var reinstallCandidate: AppInfo? by remember { mutableStateOf(null) } Column( modifier = modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) ) { // 1. Header Row Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { // LEFT: Brand/Title Block Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Icon( painter = painterResource(icon), contentDescription = title, modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.primary ) Text( text = title, style = MaterialTheme.typography.headlineMedium, fontWeight = androidx.compose.ui.text.font.FontWeight.Black, color = MaterialTheme.colorScheme.primary, letterSpacing = (-1).sp ) } // RIGHT: App Type Switcher moved to config Spacer(Modifier.width(48.dp)) } // 2. The List Content val refreshState = rememberPullToRefreshState() PullToRefreshBox( isRefreshing = state.isLoading, onRefresh = { viewModel.loadApps() }, state = refreshState, modifier = Modifier.weight(1f) // Fill remaining space ) { // Using your existing AppList widget, but feeding it PURE STATE AppList( appListType = state.appListType, installers = state.availableInstallers, selectedFilter = state.selectedFilter, filterType = state.filterType, sortBy = state.sortBy, sortOrder = state.sortOrder, searchQuery = state.searchQuery, isLoading = state.isLoading, appList = state.displayedApps, isRoot = state.isRoot, isShizuku = state.isShizuku, startAsGrid = true, imageLoader = imageLoader, installerNameMap = state.installerNameMap, // Actions forwarded to ViewModel onFilterTypeChanged = viewModel::updateFilterType, onSortByChanged = viewModel::updateSort, onSortOrderSelected = viewModel::updateSortOrder, onSearchQueryChange = viewModel::updateSearchQuery, onFilterSelected = { it?.let { filter -> viewModel.updateFilter(filter) } }, onAppInfoSelected = { appInfo -> viewModel.selectApp(appInfo.packageName) }, onListTypeChanged = { viewModel.updateListType(it) }, onMultiAppAction = { action -> if (action is MultiAppAction.Freeze || action is MultiAppAction.UnFreeze) { viewModel.performMultiAction(action) } else { onMultiAppAction(action) } } ) } } // --- DIALOGS --- // 1. Loading Dialog (When fetching heavy App Details) if (state.isLoadingDetails) { Dialog(onDismissRequest = { /* Prevent dismiss while loading */ }) { Box( contentAlignment = Alignment.Center, modifier = Modifier .size(100.dp) .clip(RoundedCornerShape(16.dp)) ) { CircularProgressIndicator() } } } // 2. App Info Dialog (Only shows when details are ready) state.selectedAppDetails?.let { details -> AppInfoDialog( appInfo = details, onDismiss = { viewModel.clearSelection() }, isRoot = state.isRoot, isShizuku = state.isShizuku, onAppAction = { action -> when (action) { is AppClickAction.Reinstall -> { // Intercept Reinstall to show confirmation locally reinstallCandidate = action.appInfo // Don't dismiss main dialog yet? Or dismiss it? // Typically, we dismiss the info dialog to show the alert viewModel.clearSelection() } is AppClickAction.Freeze -> { viewModel.freezeApp( action.appInfo.packageName, action.appInfo.appName, true ) viewModel.clearSelection() } is AppClickAction.UnFreeze -> { viewModel.freezeApp( action.appInfo.packageName, action.appInfo.appName, false ) viewModel.clearSelection() } else -> { // Forward all other actions (Freeze, Kill, etc) onAppAction(action) viewModel.clearSelection() } } } ) } // 3. Reinstall Confirmation Alert reinstallCandidate?.let { app -> AlertDialog( icon = { Image( painter = rememberAsyncImagePainter(getAppIcon(app.packageName, context)), contentDescription = null, modifier = Modifier.size(48.dp) ) }, onDismissRequest = { reinstallCandidate = null }, title = { Text(stringResource(R.string.reinstall_play_store_title)) }, text = { Text( stringResource(R.string.reinstall_play_store_desc, app.appName ?: ""), textAlign = TextAlign.Center ) }, confirmButton = { TextButton(onClick = { onAppAction(AppClickAction.Reinstall(app)) reinstallCandidate = null }) { Text(stringResource(R.string.action_reinstall)) } }, dismissButton = { TextButton(onClick = { reinstallCandidate = null }) { Text(stringResource(R.string.cancel)) } } ) } } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/appList/AppListViewModel.kt ================================================ package com.valhalla.thor.presentation.appList import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.valhalla.thor.domain.model.AppInfo import com.valhalla.thor.domain.model.AppListType import com.valhalla.thor.domain.model.FilterType import com.valhalla.thor.domain.model.MultiAppAction import com.valhalla.thor.domain.model.SortBy import com.valhalla.thor.domain.model.SortOrder import com.valhalla.thor.domain.repository.PreferenceRepository import com.valhalla.thor.domain.repository.SystemRepository import com.valhalla.thor.domain.usecase.GetAppDetailsUseCase import com.valhalla.thor.domain.usecase.GetInstalledAppsUseCase import com.valhalla.thor.domain.usecase.ManageAppUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch // ... AppListUiState remains same ... data class AppListUiState( val isLoading: Boolean = true, // Privileges val isRoot: Boolean = false, val isShizuku: Boolean = false, // Raw Data val allUserApps: List = emptyList(), val allSystemApps: List = emptyList(), // Filter State val appListType: AppListType = AppListType.USER, val filterType: FilterType = FilterType.Source, val selectedFilter: String = "All", val searchQuery: String = "", val sortBy: SortBy = SortBy.NAME, val sortOrder: SortOrder = SortOrder.ASCENDING, // Display Data val displayedApps: List = emptyList(), val availableInstallers: List = listOf("All"), val installerNameMap: Map = emptyMap(), // Detail View State val selectedAppDetails: AppInfo? = null, val isLoadingDetails: Boolean = false, // Action Feedback val actionMessage: String? = null ) class AppListViewModel( private val getInstalledAppsUseCase: GetInstalledAppsUseCase, private val getAppDetailsUseCase: GetAppDetailsUseCase, private val systemRepository: SystemRepository, private val manageAppUseCase: ManageAppUseCase, private val preferenceRepository: PreferenceRepository // Injected ) : ViewModel() { private val _rawState = MutableStateFlow(AppListUiState()) // Combine raw app data with user preferences from DataStore // OPTIMIZATION: flowOn(Dispatchers.Default) ensures sorting/filtering happens on background thread val uiState = combine(_rawState, preferenceRepository.userPreferences) { state, prefs -> val mergedState = state.copy( sortBy = prefs.appSortBy, sortOrder = prefs.appSortOrder, filterType = prefs.appFilterType, selectedFilter = prefs.appSelectedFilter ) processList(mergedState) } .flowOn(Dispatchers.Default) // Move computation off Main Thread .stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000), AppListUiState() ) init { loadApps() } fun loadApps() { viewModelScope.launch { _rawState.update { it.copy(isLoading = true) } // Allow navigation/bottom bar animations to finish fluidly delay(800) val hasRoot = systemRepository.isRootAvailable() val hasShizuku = systemRepository.isShizukuAvailable() getInstalledAppsUseCase().collect { (user, system) -> _rawState.update { it.copy( isLoading = false, isRoot = hasRoot, isShizuku = hasShizuku, allUserApps = user, allSystemApps = system ) } } } } fun freezeApp(packageName: String, appName: String?, freeze: Boolean) { viewModelScope.launch(Dispatchers.IO) { val result = manageAppUseCase.setAppDisabled(packageName, freeze) if (result.isSuccess) { _rawState.update { it.copy(actionMessage = "${if (freeze) "Frozen" else "Unfrozen"} ${appName ?: packageName}") } loadApps() } else { _rawState.update { it.copy(actionMessage = "Error: ${result.exceptionOrNull()?.message}") } } } } fun dismissMessage() { _rawState.update { it.copy(actionMessage = null) } } fun performMultiAction(action: MultiAppAction) { viewModelScope.launch(Dispatchers.IO) { when (action) { is MultiAppAction.Freeze -> { action.appList.forEach { manageAppUseCase.setAppDisabled(it.packageName, true) } _rawState.update { it.copy(actionMessage = "Froze ${action.appList.size} apps") } loadApps() } is MultiAppAction.UnFreeze -> { action.appList.forEach { manageAppUseCase.setAppDisabled(it.packageName, false) } _rawState.update { it.copy(actionMessage = "Unfrozen ${action.appList.size} apps") } loadApps() } else -> { // Fallback or forward? If we forward, we need a callback. // For now let's just stay consistent with single app actions. } } } } // --- Actions (Write to DataStore) --- fun selectApp(packageName: String) { viewModelScope.launch { _rawState.update { it.copy(isLoadingDetails = true, selectedAppDetails = null) } getAppDetailsUseCase(packageName).onSuccess { fullDetails -> _rawState.update { it.copy( isLoadingDetails = false, selectedAppDetails = fullDetails ) } }.onFailure { _rawState.update { it.copy(isLoadingDetails = false) } } } } fun clearSelection() { _rawState.update { it.copy(selectedAppDetails = null) } } fun updateListType(type: AppListType) { // AppListType is usually session-only, but we reset filter to "All" when switching _rawState.update { it.copy(appListType = type) } viewModelScope.launch { preferenceRepository.updateAppFilter(FilterType.Source, "All") } } fun updateFilter(filter: String) { viewModelScope.launch { // We need to know current filter type to update properly // We read from _rawState because uiState might be updating asynchronously val currentType = _rawState.value.filterType preferenceRepository.updateAppFilter(currentType, filter) } } fun updateFilterType(type: FilterType) { viewModelScope.launch { preferenceRepository.updateAppFilter(type, "All") } } fun updateSort(sortBy: SortBy) { viewModelScope.launch { preferenceRepository.updateAppSort(sortBy) } } fun updateSortOrder(order: SortOrder) { viewModelScope.launch { preferenceRepository.updateAppSortOrder(order) } } fun updateSearchQuery(query: String) { _rawState.update { it.copy(searchQuery = query) } } private fun processList(state: AppListUiState): AppListUiState { // 1. Pick Source val rawList = if (state.appListType == AppListType.USER) state.allUserApps else state.allSystemApps // 2. Filter by Search Query (Early out for performance) val searched = if (state.searchQuery.isBlank()) { rawList } else { rawList.filter { it.appName?.contains(state.searchQuery, ignoreCase = true) == true || it.packageName.contains(state.searchQuery, ignoreCase = true) } } // 3. Filter by Source/State val filtered = when (state.filterType) { FilterType.Source -> { if (state.selectedFilter == "All") searched else searched.filter { it.installerPackageName == state.selectedFilter } } FilterType.State -> { when (state.selectedFilter) { "Active" -> searched.filter { it.enabled } "Frozen" -> searched.filter { !it.enabled } "Suspended" -> searched.filter { it.isSuspended } else -> searched } } } // 4. Sort val sorted = getSortedList(filtered, state.sortBy, state.sortOrder) // 5. Calculate Installers (Metadata) - OPTIMIZED // Only recalculate map if the full list changed (avoid doing this on search) val installers = rawList.mapNotNull { it.installerPackageName }.distinct().sorted().toMutableList() // Fast lookup map for app names to avoid O(N^2) associative logic val nameMap = rawList.associateBy({ it.packageName }, { it.appName }) val installerNames = installers.associateWith { pkg -> nameMap[pkg] ?: pkg } installers.add(0, "All") return state.copy( displayedApps = sorted, availableInstallers = installers, installerNameMap = installerNames ) } private fun getSortedList( list: List, sortBy: SortBy, order: SortOrder ): List { val comparator = when (sortBy) { SortBy.NAME -> compareBy { it.appName?.lowercase() } SortBy.INSTALL_DATE -> compareBy { it.firstInstallTime } SortBy.LAST_UPDATED -> compareBy { it.lastUpdateTime } SortBy.VERSION_CODE -> compareBy { it.versionCode } SortBy.VERSION_NAME -> compareBy { it.versionName } SortBy.TARGET_SDK_VERSION -> compareBy { it.targetSdk } SortBy.MIN_SDK_VERSION -> compareBy { it.minSdk } } return if (order == SortOrder.ASCENDING) list.sortedWith(comparator) else list.sortedWith(comparator).reversed() } } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/common/ShizukuPermissionHandler.kt ================================================ package com.valhalla.thor.presentation.common import android.content.pm.PackageManager import rikka.shizuku.Shizuku /** * Handles the messy boilerplate of listening to Shizuku's binder and requesting permissions. */ class ShizukuPermissionHandler( private val onPermissionGranted: () -> Unit = {}, private val onPermissionDenied: () -> Unit = {}, private val onBinderDead: () -> Unit = {} ) { private var isRequestInProgress = false private val binderReceivedListener = Shizuku.OnBinderReceivedListener { if (checkPermission()) { onPermissionGranted() } } private val binderDeadListener = Shizuku.OnBinderDeadListener { onBinderDead() } private val requestPermissionResultListener = Shizuku.OnRequestPermissionResultListener { requestCode, grantResult -> isRequestInProgress = false // Reset flag if (grantResult == PackageManager.PERMISSION_GRANTED) { onPermissionGranted() } else { onPermissionDenied() } } fun register() { Shizuku.addBinderReceivedListener(binderReceivedListener) Shizuku.addBinderDeadListener(binderDeadListener) Shizuku.addRequestPermissionResultListener(requestPermissionResultListener) } fun unregister() { Shizuku.removeBinderReceivedListener(binderReceivedListener) Shizuku.removeBinderDeadListener(binderDeadListener) Shizuku.removeRequestPermissionResultListener(requestPermissionResultListener) } /** * Checks if we have permission. If not, requests it. * Returns true if already granted. */ fun checkAndRequestPermission(requestCode: Int): Boolean { // If binder isn't ready, we can't do anything yet. if (!Shizuku.pingBinder()) { return false } if (checkPermission()) { onPermissionGranted() return true } // Prevent spamming requests if one is already pending if (isRequestInProgress) return false try { if (Shizuku.shouldShowRequestPermissionRationale()) { // Ideally show UI rationale here. } isRequestInProgress = true // Set flag Shizuku.requestPermission(requestCode) } catch (_: Exception) { isRequestInProgress = false return false } return false } private fun checkPermission(): Boolean { return try { if (Shizuku.pingBinder()) { Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED } else { false } } catch (e: Throwable) { false } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/common/components/ConnectedButtonGroup.kt ================================================ package com.valhalla.thor.presentation.common.components import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonGroupDefaults import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.material3.ToggleButton import androidx.compose.material3.ToggleButtonShapes import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource /** * A single-select Connected Button Group built on top of [ButtonGroup] + [ToggleButton]. * * Shape logic, press animation, and overflow menu wiring are handled internally. * Callers only describe *what* each button shows via [ConnectedButtonGroupItem] and * respond to selection changes. * * The expressive press animation (buttons physically expand and compress their * neighbours) is activated automatically via [Modifier.animateWidth]. * * --- * * **Icon-only** (DashboardHeader, AppListScreen): * ```kotlin * ConnectedButtonGroup( * items = AppListType.entries.map { type -> * ConnectedButtonGroupItem.Icon( * iconRes = if (type == AppListType.USER) R.drawable.apps else R.drawable.android, * contentDescription = type.name * ) * }, * selectedIndex = AppListType.entries.indexOf(selectedType), * onItemSelected = { onTypeChanged(AppListType.entries[it]) } * ) * ``` * * **Text labels** (SettingsScreen ThemeMode, AppFilterSheet tabs): * ```kotlin * ConnectedButtonGroup( * items = ThemeMode.entries.map { ConnectedButtonGroupItem.Label(it.label()) }, * selectedIndex = ThemeMode.entries.indexOf(prefs.themeMode), * onItemSelected = { viewModel.setThemeMode(ThemeMode.entries[it]) } * ) * ``` */ @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun ConnectedButtonGroup( items: List, selectedIndex: Int, onItemSelected: (index: Int) -> Unit, modifier: Modifier = Modifier, ) { require(items.isNotEmpty()) { "ConnectedButtonGroup requires at least one item" } val lastIndex = items.lastIndex androidx.compose.material3.ButtonGroup( overflowIndicator = {}, modifier = modifier, horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween), ) { items.forEachIndexed { index, item -> customItem( buttonGroupContent = { val interactionSource = remember { MutableInteractionSource() } ToggleButton( checked = index == selectedIndex, onCheckedChange = { checked -> if (checked) onItemSelected(index) }, modifier = Modifier.animateWidth(interactionSource), shapes = connectedShapesFor(index, lastIndex), interactionSource = interactionSource, ) { ItemContent(item) } }, menuContent = { menuState -> // Overflow fallback — never visible for fixed-count groups, // but required by the ButtonGroup API contract. DropdownMenuItem( text = { Text(item.menuLabel) }, leadingIcon = item.menuIcon?.let { res -> { Icon(painterResource(res), contentDescription = null) } }, onClick = { onItemSelected(index) menuState.dismiss() } ) } ) } } } // ─── Item descriptor ────────────────────────────────────────────────────────── /** * Sealed hierarchy that describes the visual content of a single button. * Keeps the reusable composable generic without requiring caller-side lambdas. */ sealed interface ConnectedButtonGroupItem { /** Label shown in the overflow [DropdownMenuItem]. */ val menuLabel: String /** Optional icon shown in the overflow [DropdownMenuItem]. */ val menuIcon: Int? get() = null // ── Concrete variants ───────────────────────────────────────────────────── /** Button shows only an icon (e.g. User / System app-type switcher). */ data class Icon( val iconRes: Int, val contentDescription: String, ) : ConnectedButtonGroupItem { override val menuLabel: String get() = contentDescription override val menuIcon: Int get() = iconRes } /** Button shows only a text label (e.g. ThemeMode picker, tab switcher). */ data class Label(val text: String) : ConnectedButtonGroupItem { override val menuLabel: String get() = text } /** Button shows an icon followed by a text label. */ data class IconWithLabel( val iconRes: Int, val contentDescription: String, val text: String, ) : ConnectedButtonGroupItem { override val menuLabel: String get() = text override val menuIcon: Int get() = iconRes } } // ─── Private helpers ────────────────────────────────────────────────────────── /** Renders the correct inner content for each [ConnectedButtonGroupItem] variant. */ @Composable private fun ItemContent(item: ConnectedButtonGroupItem) { when (item) { is ConnectedButtonGroupItem.Icon -> Icon( painter = painterResource(item.iconRes), contentDescription = item.contentDescription ) is ConnectedButtonGroupItem.Label -> Text(item.text, maxLines = 1) is ConnectedButtonGroupItem.IconWithLabel -> Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(ButtonDefaults.IconSpacing) ) { Icon( painter = painterResource(item.iconRes), contentDescription = item.contentDescription ) Text(item.text, maxLines = 1) } } } /** * Maps a button's position within the group to the correct [ToggleButtonShapes], * following the Connected Button Group spec: * * - `index == 0` → pill-left, small inner-right *(leading)* * - `0 < index < lastIndex` → small corners on all sides *(middle)* * - `index == lastIndex` → small inner-left, pill-right *(trailing)* */ @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun connectedShapesFor(index: Int, lastIndex: Int): ToggleButtonShapes = when { index == 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() index == lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes() else -> ButtonGroupDefaults.connectedMiddleButtonShapes() } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/freezer/FreezerScreen.kt ================================================ package com.valhalla.thor.presentation.freezer import android.widget.Toast import androidx.compose.foundation.Image import androidx.compose.foundation.background 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.ImageLoader import coil3.compose.rememberAsyncImagePainter import coil3.request.crossfade import com.valhalla.thor.R import com.valhalla.thor.domain.model.AppClickAction import com.valhalla.thor.domain.model.AppInfo import com.valhalla.thor.domain.model.MultiAppAction import com.valhalla.thor.presentation.utils.AppIconFetcher import com.valhalla.thor.presentation.utils.AppIconKeyer import com.valhalla.thor.presentation.utils.getAppIcon import com.valhalla.thor.presentation.widgets.AppInfoDialog import com.valhalla.thor.presentation.widgets.AppList import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun FreezerScreen( modifier: Modifier = Modifier, viewModel: FreezerViewModel = koinViewModel(), onAppAction: (AppClickAction) -> Unit = {}, onMultiAppAction: (MultiAppAction) -> Unit = {} ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current // Local State for Dialogs var selectedAppInfo by remember { mutableStateOf(null) } var reinstallCandidate by remember { mutableStateOf(null) } LaunchedEffect(Unit) { if (state.allUserApps.isEmpty() && state.allSystemApps.isEmpty() && state.isLoading) { viewModel.loadApps() } } // Create a custom ImageLoader that knows how to fetch App Icons in the background. val imageLoader = remember(context) { ImageLoader.Builder(context) .components { add(AppIconKeyer()) add(AppIconFetcher.Factory(context)) } .crossfade(true) .build() } // Handle Feedback (Toasts from ViewModel actions) LaunchedEffect(state.actionMessage) { state.actionMessage?.let { message -> Toast.makeText(context, message, Toast.LENGTH_SHORT).show() viewModel.dismissMessage() } } Column( modifier = modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) ) { // --- Header --- Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { // LEFT: Brand/Title Block Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Icon( painter = painterResource(R.drawable.frozen), contentDescription = stringResource(R.string.freezer), modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.primary ) Text( text = stringResource(R.string.freezer), style = MaterialTheme.typography.headlineMedium, fontWeight = androidx.compose.ui.text.font.FontWeight.Black, color = MaterialTheme.colorScheme.primary, letterSpacing = (-1).sp ) } // RIGHT: App Source Switcher moved to config Spacer(Modifier.width(48.dp)) } // --- List Content --- val refreshState = rememberPullToRefreshState() PullToRefreshBox( isRefreshing = state.isLoading, onRefresh = { viewModel.loadApps() }, state = refreshState, modifier = Modifier.weight(1f) ) { AppList( appListType = state.appListType, installers = state.availableInstallers, selectedFilter = state.selectedFilter, filterType = state.filterType, sortBy = state.sortBy, sortOrder = state.sortOrder, searchQuery = state.searchQuery, isLoading = state.isLoading, appList = state.displayedApps, isRoot = state.isRoot, isShizuku = state.isShizuku, imageLoader = imageLoader, installerNameMap = state.installerNameMap, // Filtering / Sorting Actions onFilterTypeChanged = viewModel::updateFilterType, onSortByChanged = viewModel::updateSort, onSortOrderSelected = viewModel::updateSortOrder, onSearchQueryChange = viewModel::updateSearchQuery, onFilterSelected = { it?.let { filter -> viewModel.updateFilter(filter) } }, onListTypeChanged = { viewModel.updateListType(it) }, // Multi-Selection Actions onMultiAppAction = { action -> if (action is MultiAppAction.Freeze || action is MultiAppAction.UnFreeze) { viewModel.performMultiAction(action) } else { onMultiAppAction(action) // Forward others (e.g. Uninstall All) } }, // Single App Selection (Opens Dialog) onAppInfoSelected = { app -> selectedAppInfo = app } ) } } // --- DIALOGS --- // 1. App Info Dialog selectedAppInfo?.let { app -> AppInfoDialog( appInfo = app, isRoot = state.isRoot, isShizuku = state.isShizuku, onDismiss = { selectedAppInfo = null }, onAppAction = { action -> when (action) { // CASE A: Local Logic (Freeze/Unfreeze) is AppClickAction.Freeze -> { viewModel.toggleAppFreezeState(action.appInfo) selectedAppInfo = null } is AppClickAction.UnFreeze -> { viewModel.toggleAppFreezeState(action.appInfo) selectedAppInfo = null } // CASE B: Reinstall (Needs Confirmation) is AppClickAction.Reinstall -> { reinstallCandidate = action.appInfo selectedAppInfo = null // Dismiss info dialog, show confirmation } // CASE C: Forward everything else (Launch, Uninstall, etc.) else -> { onAppAction(action) selectedAppInfo = null } } } ) } // 2. Reinstall Confirmation (Matches AppListScreen behavior) reinstallCandidate?.let { app -> AlertDialog( icon = { Image( painter = rememberAsyncImagePainter(getAppIcon(app.packageName, context)), contentDescription = null, modifier = Modifier.size(48.dp) ) }, onDismissRequest = { reinstallCandidate = null }, title = { Text(stringResource(R.string.reinstall_play_store_title)) }, text = { Text( stringResource(R.string.reinstall_play_store_desc, app.appName ?: ""), textAlign = TextAlign.Center ) }, confirmButton = { TextButton(onClick = { onAppAction(AppClickAction.Reinstall(app)) // Forward to Main -> HomeViewModel reinstallCandidate = null }) { Text(stringResource(R.string.yes)) } }, dismissButton = { TextButton(onClick = { reinstallCandidate = null }) { Text(stringResource(R.string.cancel)) } } ) } } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/freezer/FreezerViewModel.kt ================================================ package com.valhalla.thor.presentation.freezer import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.valhalla.thor.domain.model.AppInfo import com.valhalla.thor.domain.model.AppListType import com.valhalla.thor.domain.model.FilterType import com.valhalla.thor.domain.model.MultiAppAction import com.valhalla.thor.domain.model.SortBy import com.valhalla.thor.domain.model.SortOrder import com.valhalla.thor.domain.repository.SystemRepository import com.valhalla.thor.domain.usecase.GetInstalledAppsUseCase import com.valhalla.thor.domain.usecase.ManageAppUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext data class FreezerUiState( val isLoading: Boolean = true, // Privileges val isRoot: Boolean = false, val isShizuku: Boolean = false, // Complete App Lists val allUserApps: List = emptyList(), val allSystemApps: List = emptyList(), // Display State val displayedApps: List = emptyList(), val appListType: AppListType = AppListType.USER, // Freezer defaults to showing "Frozen" apps first, usually val filterType: FilterType = FilterType.State, val selectedFilter: String = "Frozen", val searchQuery: String = "", val sortBy: SortBy = SortBy.NAME, val sortOrder: SortOrder = SortOrder.ASCENDING, val availableInstallers: List = listOf("All"), val installerNameMap: Map = emptyMap(), // Action Feedback val actionMessage: String? = null ) class FreezerViewModel( private val getInstalledAppsUseCase: GetInstalledAppsUseCase, private val manageAppUseCase: ManageAppUseCase, private val systemRepository: SystemRepository ) : ViewModel() { private val _uiState = MutableStateFlow(FreezerUiState()) val uiState: StateFlow = _uiState.asStateFlow() private var filterJob: Job? = null private var loadAppsJob: Job? = null init { loadApps() } fun loadApps() { // Cancel any existing collection to prevent duplicates loadAppsJob?.cancel() // RUTHLESS: IO Dispatcher for heavy data fetching loadAppsJob = viewModelScope.launch(Dispatchers.IO) { _uiState.update { it.copy(isLoading = true) } // Allow bottom nav animations to finish fluidly delay(800) // Check privileges val hasRoot = systemRepository.isRootAvailable() val hasShizuku = systemRepository.isShizukuAvailable() getInstalledAppsUseCase().collect { (user, system) -> // Data arrived on IO. val partialState = _uiState.value.copy( isLoading = false, isRoot = hasRoot, isShizuku = hasShizuku, allUserApps = user, allSystemApps = system ) // Switch to Default (CPU) for sorting val finalState = processListState(partialState) _uiState.update { finalState } } } } // --- Actions --- fun toggleAppFreezeState(app: AppInfo) { viewModelScope.launch { val shouldFreeze = app.enabled // If enabled, we freeze. If disabled, we unfreeze. // Note: We are using packageName, assuming ManageAppUseCase handles it correctly. val result = manageAppUseCase.setAppDisabled(app.packageName, shouldFreeze) result.onSuccess { _uiState.update { s -> s.copy(actionMessage = "${if (shouldFreeze) "Frozen" else "Unfrozen"} ${app.appName}") } // No need to reload manually, repository flow triggers update }.onFailure { e -> _uiState.update { s -> s.copy(actionMessage = "Error: ${e.message}") } } } } fun performMultiAction(action: MultiAppAction) { viewModelScope.launch { when (action) { is MultiAppAction.Freeze -> { action.appList.forEach { manageAppUseCase.setAppDisabled(it.packageName, true) } _uiState.update { it.copy(actionMessage = "Froze ${action.appList.size} apps") } } is MultiAppAction.UnFreeze -> { action.appList.forEach { manageAppUseCase.setAppDisabled(it.packageName, false) } _uiState.update { it.copy(actionMessage = "Unfrozen ${action.appList.size} apps") } } else -> { _uiState.update { it.copy(actionMessage = "Action not supported in Freezer yet") } } } } } fun dismissMessage() { _uiState.update { it.copy(actionMessage = null) } } // --- Filter / Sort Updates (Async) --- fun updateListType(type: AppListType) { triggerAsyncUpdate { it.copy(appListType = type) } } fun updateFilter(filter: String) { triggerAsyncUpdate { it.copy(selectedFilter = filter) } } fun updateFilterType(type: FilterType) { triggerAsyncUpdate { it.copy( filterType = type, selectedFilter = if (type == FilterType.State) "Frozen" else "All" ) } } fun updateSort(sortBy: SortBy) { triggerAsyncUpdate { it.copy(sortBy = sortBy) } } fun updateSortOrder(order: SortOrder) { triggerAsyncUpdate { it.copy(sortOrder = order) } } fun updateSearchQuery(query: String) { triggerAsyncUpdate { it.copy(searchQuery = query) } } private fun triggerAsyncUpdate(reducer: (FreezerUiState) -> FreezerUiState) { filterJob?.cancel() filterJob = viewModelScope.launch(Dispatchers.Default) { // Apply the change to the CURRENT state val pendingState = reducer(_uiState.value) // Re-process list val finalState = processListState(pendingState) _uiState.update { finalState } } } // --- Core Logic (CPU Bound) --- private suspend fun processListState(state: FreezerUiState): FreezerUiState = withContext(Dispatchers.Default) { val rawList = if (state.appListType == AppListType.USER) state.allUserApps else state.allSystemApps // 1. Search Query Filter val searched = if (state.searchQuery.isBlank()) { rawList } else { rawList.filter { it.appName?.contains(state.searchQuery, ignoreCase = true) == true || it.packageName.contains(state.searchQuery, ignoreCase = true) } } // 2. State/Source Filter val filtered = when (state.filterType) { FilterType.State -> { when (state.selectedFilter) { "Frozen" -> searched.filter { !it.enabled } "Active" -> searched.filter { it.enabled } "Suspended" -> searched.filter { it.isSuspended } else -> searched } } FilterType.Source -> { if (state.selectedFilter == "All") searched else searched.filter { it.installerPackageName == state.selectedFilter } } } // 3. Sort val sorted = getSortedList(filtered, state.sortBy, state.sortOrder) // 4. Metadata - OPTIMIZED val installers = rawList.mapNotNull { it.installerPackageName }.distinct().sorted().toMutableList() val nameMap = rawList.associateBy({ it.packageName }, { it.appName }) val installerNames = installers.associateWith { pkg -> nameMap[pkg] ?: pkg } installers.add(0, "All") state.copy( displayedApps = sorted, availableInstallers = installers, installerNameMap = installerNames ) } private fun getSortedList( list: List, sortBy: SortBy, order: SortOrder ): List { val comparator = when (sortBy) { SortBy.NAME -> compareBy { it.appName?.lowercase() } SortBy.INSTALL_DATE -> compareBy { it.firstInstallTime } SortBy.LAST_UPDATED -> compareBy { it.lastUpdateTime } SortBy.VERSION_CODE -> compareBy { it.versionCode } SortBy.VERSION_NAME -> compareBy { it.versionName } SortBy.TARGET_SDK_VERSION -> compareBy { it.targetSdk } SortBy.MIN_SDK_VERSION -> compareBy { it.minSdk } } return if (order == SortOrder.ASCENDING) list.sortedWith(comparator) else list.sortedWith(comparator).reversed() } } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/home/AppDestinations.kt ================================================ package com.valhalla.thor.presentation.home import com.valhalla.thor.R import kotlinx.serialization.Serializable @Serializable enum class AppDestinations( val label: Int, val icon: Int, val selectedIcon: Int, val contentDescription: Int ) { HOME(R.string.home, R.drawable.home_outline, R.drawable.home, R.string.home_desc), APPS(R.string.apps, R.drawable.apps, R.drawable.apps, R.string.apps_desc), FREEZER(R.string.freezer, R.drawable.frozen, R.drawable.snowflake, R.string.freezer_desc), SETTINGS( R.string.settings, R.drawable.settings_outline, R.drawable.settings, R.string.settings_desc ) } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/home/HomeScreen.kt ================================================ package com.valhalla.thor.presentation.home import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background 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.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton 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 import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.valhalla.thor.R import com.valhalla.thor.domain.model.AppListType import com.valhalla.thor.presentation.home.components.AppDistributionChart import com.valhalla.thor.presentation.home.components.DashboardHeader import com.valhalla.thor.presentation.home.components.SocialLinksRow import com.valhalla.thor.presentation.home.components.SummaryStatRow import com.valhalla.thor.presentation.installer.InstallerViewModel import com.valhalla.thor.presentation.installer.PortableInstaller import org.koin.androidx.compose.koinViewModel @Composable fun HomeScreen( onNavigateToApps: () -> Unit, onNavigateToFreezer: () -> Unit, onReinstallAll: () -> Unit, onClearAllCache: (AppListType) -> Unit, viewModel: HomeViewModel = koinViewModel(), installerViewModel: InstallerViewModel = koinViewModel() ) { val state by viewModel.state.collectAsStateWithLifecycle() var showCacheDialog by remember { mutableStateOf(false) } var showPrivilegeDialog by remember { mutableStateOf(false) } var showInstallerSheet by remember { mutableStateOf(false) } val filePickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocument() ) { uri -> uri?.let { installerViewModel.installFile(it) showInstallerSheet = true } } Column( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) .verticalScroll(rememberScrollState()) .padding(bottom = 100.dp) // Nav bar space ) { // 1. Header DashboardHeader( isRoot = state.isRootAvailable, isShizuku = state.isShizukuAvailable, isDhizuku = state.isDhizukuAvailable, activeMode = state.activePrivilegeMode, selectedType = state.selectedType, onTypeChanged = { viewModel.onTypeChanged(it) }, onPrivilegeChanged = { viewModel.onPrivilegeModeChanged(it) }, onRestrictedStatusClick = { showPrivilegeDialog = true } ) Spacer(Modifier.height(8.dp)) // 2. Summary Cards SummaryStatRow( activeCount = state.activeAppCount, frozenCount = state.frozenAppCount, suspendedCount = state.suspendedAppCount, onActiveClick = onNavigateToApps, onFrozenClick = onNavigateToFreezer, onSuspendedClick = onNavigateToFreezer // For now just go to freezer ) Spacer(Modifier.height(12.dp)) // --- ACTIONS --- // B. Reinstall All (Warning style card) AnimatedVisibility(state.activePrivilegeMode != null && state.unknownInstallerCount > 0 && state.showReinstallCard) { Column { ActionCard( title = stringResource(R.string.reinstall_all), subtitle = stringResource( R.string.reinstall_all_subtitle, state.unknownInstallerCount, state.selectedType.name.lowercase() ), icon = R.drawable.apk_install, isWarning = true, onClick = onReinstallAll, onClose = { viewModel.dismissReinstallCard() } ) Spacer(Modifier.height(12.dp)) } } // C. Portable Installer (Primary style card) ActionCard( title = stringResource(R.string.install_from_file), subtitle = stringResource(R.string.install_from_file_subtitle), icon = R.drawable.apk_install, isPrimary = true, onClick = { filePickerLauncher.launch(arrayOf("*/*")) } ) Spacer(Modifier.height(24.dp)) // 3. Distribution Chart AnimatedVisibility(state.distributionData.isNotEmpty() && !state.isLoading) { Column( modifier = Modifier .padding(horizontal = 24.dp) .clip(RoundedCornerShape(48.dp)) .background(MaterialTheme.colorScheme.surfaceContainerLow) .padding(24.dp) ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Bottom ) { Text( text = stringResource(R.string.app_distribution), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, modifier = Modifier.weight(1f) ) Text( text = stringResource( R.string.total_apps, state.activeAppCount + state.frozenAppCount ), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1 ) } Spacer(Modifier.height(24.dp)) AppDistributionChart( data = state.distributionData, modifier = Modifier.fillMaxWidth() ) } } // 4. Social Links Spacer(Modifier.height(8.dp)) SocialLinksRow() Spacer(Modifier.height(32.dp)) } // --- Dialogs --- if (showCacheDialog) { AlertDialog( onDismissRequest = { showCacheDialog = false }, icon = { Icon(painterResource(R.drawable.clear_all), null) }, title = { Text(stringResource(R.string.clear_all_cache)) }, text = { Text(stringResource(R.string.clear_cache_prompt)) }, confirmButton = { Button(onClick = { onClearAllCache(AppListType.USER) showCacheDialog = false }) { Text(stringResource(R.string.user_apps)) } }, dismissButton = { OutlinedButton(onClick = { onClearAllCache(AppListType.SYSTEM) showCacheDialog = false }) { Text(stringResource(R.string.system_apps)) } } ) } if (showPrivilegeDialog) { AlertDialog( onDismissRequest = { showPrivilegeDialog = false }, icon = { Icon(painterResource(R.drawable.privacy_tip), null) }, title = { Text(stringResource(R.string.privilege_check)) }, text = { Text(stringResource(R.string.privilege_check_desc)) }, confirmButton = { Button(onClick = { viewModel.loadDashboardData() showPrivilegeDialog = false }) { Text(stringResource(R.string.refresh)) } }, dismissButton = { OutlinedButton(onClick = { showPrivilegeDialog = false }) { Text(stringResource(R.string.cancel)) } } ) } if (showInstallerSheet) { PortableInstaller( onDismiss = { showInstallerSheet = false }, viewModel = installerViewModel ) } } @Composable private fun ActionCard( title: String, subtitle: String, icon: Int, isPrimary: Boolean = false, isWarning: Boolean = false, onClick: () -> Unit, onClose: (() -> Unit)? = null ) { val containerColor = when { isPrimary -> MaterialTheme.colorScheme.primaryContainer isWarning -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.1f) else -> MaterialTheme.colorScheme.surfaceContainerHigh } val contentColor = when { isPrimary -> MaterialTheme.colorScheme.onPrimaryContainer isWarning -> MaterialTheme.colorScheme.tertiary else -> MaterialTheme.colorScheme.onSurface } Box( modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp) .clip(RoundedCornerShape(32.dp)) .background(containerColor) .then( if (isWarning) { Modifier.background( Brush.linearGradient( colors = listOf( MaterialTheme.colorScheme.tertiary.copy(alpha = 0.05f), Color.Transparent ) ) ) } else Modifier ) .clickable(onClick = onClick) .padding(if (isPrimary) 24.dp else 20.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp) ) { Box( modifier = Modifier .clip(RoundedCornerShape(24.dp)) .background( if (isPrimary) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.tertiary.copy(alpha = 0.2f) ) .padding(if (isPrimary) 16.dp else 12.dp) ) { Icon( painter = painterResource(icon), contentDescription = null, modifier = Modifier.size(if (isPrimary) 24.dp else 20.dp), tint = if (isPrimary) MaterialTheme.colorScheme.primaryContainer else contentColor ) } Column(modifier = Modifier.weight(1f)) { Text( text = title, style = if (isPrimary) MaterialTheme.typography.titleLarge else MaterialTheme.typography.titleMedium, fontWeight = FontWeight.ExtraBold, color = contentColor ) Text( text = subtitle, style = MaterialTheme.typography.bodySmall, color = if (isPrimary) contentColor.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant ) } if (onClose != null) { IconButton(onClick = onClose) { Icon( painter = painterResource(R.drawable.round_close), contentDescription = stringResource(R.string.dismiss), tint = MaterialTheme.colorScheme.onSurfaceVariant ) } } else if (isPrimary) { Icon( painter = painterResource(R.drawable.open_in_new), // Arrow forward fallback contentDescription = null, tint = contentColor.copy(alpha = 0.4f) ) } } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/home/HomeViewModel.kt ================================================ package com.valhalla.thor.presentation.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.valhalla.thor.domain.model.AppInfo import com.valhalla.thor.domain.model.AppListType import com.valhalla.thor.domain.model.PrivilegeMode import com.valhalla.thor.domain.repository.PreferenceRepository import com.valhalla.thor.domain.repository.SystemRepository import com.valhalla.thor.domain.usecase.GetInstalledAppsUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch data class HomeUiState( val isLoading: Boolean = true, val selectedType: AppListType = AppListType.USER, // Stats val activeAppCount: Int = 0, val frozenAppCount: Int = 0, val suspendedAppCount: Int = 0, val unknownInstallerCount: Int = 0, val distributionData: Map = emptyMap(), // Status val isRootAvailable: Boolean = false, val isShizukuAvailable: Boolean = false, val isDhizukuAvailable: Boolean = false, val activePrivilegeMode: PrivilegeMode? = null, // Preferences val showReinstallCard: Boolean = true // <--- Controlled by DataStore ) class HomeViewModel( private val getInstalledAppsUseCase: GetInstalledAppsUseCase, private val systemRepository: SystemRepository, private val preferenceRepository: PreferenceRepository // Injected ) : ViewModel() { private var dashboardJob: Job? = null private var typeChangeJob: Job? = null private val _internalState = MutableStateFlow(HomeUiState()) private var lastUserApps: List = emptyList() private var lastSystemApps: List = emptyList() // Combine internal data processing with user preferences val state = combine(_internalState, preferenceRepository.userPreferences) { internal, prefs -> val activeMode = prefs.preferredPrivilegeMode ?: when { internal.isRootAvailable -> PrivilegeMode.ROOT internal.isShizukuAvailable -> PrivilegeMode.SHIZUKU internal.isDhizukuAvailable -> PrivilegeMode.DHIZUKU else -> null } internal.copy( showReinstallCard = prefs.showReinstallAllCard, activePrivilegeMode = activeMode ) }.stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000), HomeUiState() ) init { loadDashboardData() } fun loadDashboardData() { // Cancel any existing job to ensure we restart with fresh system status dashboardJob?.cancel() dashboardJob = viewModelScope.launch(Dispatchers.IO) { _internalState.update { it.copy(isLoading = true) } val hasRoot = systemRepository.isRootAvailable() val hasShizuku = systemRepository.isShizukuAvailable() val hasDhizuku = systemRepository.isDhizukuAvailable() getInstalledAppsUseCase().collect { (userApps, systemApps) -> lastUserApps = userApps lastSystemApps = systemApps processData( userApps, systemApps, _internalState.value.selectedType, hasRoot, hasShizuku, hasDhizuku ) } } } fun onTypeChanged(type: AppListType) { _internalState.update { it.copy(selectedType = type) } typeChangeJob?.cancel() typeChangeJob = viewModelScope.launch(Dispatchers.IO) { val s = _internalState.value processData( lastUserApps, lastSystemApps, type, s.isRootAvailable, s.isShizukuAvailable, s.isDhizukuAvailable ) } } fun onPrivilegeModeChanged(mode: PrivilegeMode) { viewModelScope.launch { preferenceRepository.setPrivilegeMode(mode) loadDashboardData() // Refresh everything } } fun dismissReinstallCard() { viewModelScope.launch { preferenceRepository.setReinstallAllCardVisibility(false) } } private fun processData( userApps: List, systemApps: List, selectedType: AppListType, hasRoot: Boolean, hasShizuku: Boolean, hasDhizuku: Boolean ) { val filteredApps = if (selectedType == AppListType.USER) userApps else systemApps val activeCount = filteredApps.count { it.enabled && !it.isSuspended } val frozenCount = filteredApps.count { !it.enabled } val suspendedCount = filteredApps.count { it.isSuspended && it.enabled } val unknownCount = if (selectedType == AppListType.USER) { userApps.count { it.installerPackageName != "com.android.vending" && it.installerPackageName != "com.google.android.packageinstaller" } } else 0 val labelCounts = filteredApps .groupBy { when (val pkg = it.installerPackageName) { "com.android.vending" -> "Play Store" "org.fdroid.fdroid" -> "F-Droid" "com.google.android.packageinstaller" -> "Sideloaded" null, "Unknown" -> "Others" else -> pkg.substringAfterLast(".").uppercase() } } .mapValues { it.value.size } // --- TOP 3 / 4 GROUPING LOGIC --- val sortedLabels = labelCounts.entries.sortedByDescending { it.value } val distribution = if (sortedLabels.size <= 4) { // If 4 or fewer categories, show them exactly as they are labelCounts } else { // If more than 4, take top 3 and bunch the rest into "Others" val top3Entries = sortedLabels.take(3) val restEntries = sortedLabels.drop(3) val result = mutableMapOf() top3Entries.forEach { result[it.key] = it.value } val othersCount = restEntries.sumOf { it.value } // Add 'othersCount' to 'Others' label (merge if 'Others' was already in top 3) result["Others"] = result.getOrDefault("Others", 0) + othersCount result } _internalState.update { it.copy( isLoading = false, selectedType = selectedType, activeAppCount = activeCount, frozenAppCount = frozenCount, suspendedAppCount = suspendedCount, unknownInstallerCount = unknownCount, distributionData = distribution, isRootAvailable = hasRoot, isShizukuAvailable = hasShizuku, isDhizukuAvailable = hasDhizuku ) } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/home/components/AnimatedCounter.kt ================================================ package com.valhalla.thor.presentation.home.components import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateIntAsState import androidx.compose.animation.core.tween import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle @Composable fun AnimatedCounter( modifier: Modifier = Modifier, count: Int, style: TextStyle = MaterialTheme.typography.displaySmall ) { // This animates the value from current (0) to target (count) over 1 second val animatedValue by animateIntAsState( targetValue = count, animationSpec = tween( durationMillis = 1000, easing = FastOutSlowInEasing ), label = "CounterAnimation" ) Text( text = animatedValue.toString(), style = style, modifier = modifier, maxLines = 1 ) } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/home/components/AppDistributionChart.kt ================================================ package com.valhalla.thor.presentation.home.components import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.foundation.background 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.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf 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.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.valhalla.thor.R private data class ChartSlice( val label: String, val count: Int, val color: Color ) @Composable fun AppDistributionChart( data: Map, modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme // 1. Prepare Data & Colors val chartSlices = remember(data, colorScheme) { val sortedData = data.toList().sortedByDescending { it.second } sortedData.mapIndexed { index, (label, count) -> val color = when (label.uppercase()) { "PLAY STORE" -> colorScheme.primary "F-DROID" -> colorScheme.secondary "SIDELOADED" -> colorScheme.tertiary "OTHERS" -> colorScheme.error else -> { val colors = listOf( colorScheme.primary, colorScheme.secondary, colorScheme.tertiary, colorScheme.error, colorScheme.outline, colorScheme.inversePrimary ) colors[index % colors.size] } } ChartSlice(label, count, color) } } Column( modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(32.dp) ) { // 1. The Horizontal Bar DistributionBar(slices = chartSlices) // 2. The Legend Grid LegendGrid(slices = chartSlices) } } @Composable private fun DistributionBar( slices: List, modifier: Modifier = Modifier ) { val total = slices.sumOf { it.count }.toFloat() var startAnimation by remember { mutableFloatStateOf(0f) } val animatedProgress by animateFloatAsState( targetValue = startAnimation, animationSpec = spring( dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessLow ), label = "barAnimation" ) LaunchedEffect(Unit) { startAnimation = 1f } Box( modifier = modifier .fillMaxWidth() .height(44.dp) .clip(RoundedCornerShape(22.dp)) .background(Color.Black.copy(alpha = 0.5f)) ) { Row(modifier = Modifier.fillMaxSize()) { slices.forEachIndexed { index, slice -> val weight = if (total > 0) slice.count / total else 0f val animWeight = weight * animatedProgress if (animWeight > 0f) { Box( modifier = Modifier .fillMaxHeight() .weight(animWeight) .background(slice.color) .padding(end = if (index < slices.lastIndex) 4.dp else 0.dp) ) } } } } } @Composable private fun LegendGrid( slices: List, modifier: Modifier = Modifier ) { Column(modifier = modifier.fillMaxWidth()) { slices.chunked(2).forEach { rowSlices -> Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(16.dp) ) { rowSlices.forEach { slice -> LegendItem( slice = slice, modifier = Modifier.weight(1f) ) } if (rowSlices.size == 1) { Spacer(modifier = Modifier.weight(1f)) } } } } } @Composable private fun LegendItem( slice: ChartSlice, modifier: Modifier = Modifier ) { val localizedLabel = when (slice.label.uppercase()) { "PLAY STORE" -> stringResource(R.string.play_store) "F-DROID" -> stringResource(R.string.f_droid) "SIDELOADED" -> stringResource(R.string.sideloaded) "OTHERS" -> stringResource(R.string.others) else -> slice.label } Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically ) { Box( modifier = Modifier .size(10.dp) .background(slice.color, CircleShape) ) Spacer(Modifier.width(12.dp)) Column { Text( text = localizedLabel.uppercase(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis ) Text( text = slice.count.toString(), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Black, color = MaterialTheme.colorScheme.onSurface, maxLines = 1 ) } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/home/components/DashboardHeader.kt ================================================ package com.valhalla.thor.presentation.home.components import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.ExperimentalMaterial3Api 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.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.valhalla.thor.R import com.valhalla.thor.domain.model.AppListType import com.valhalla.thor.domain.model.PrivilegeMode import com.valhalla.thor.presentation.common.components.ConnectedButtonGroup import com.valhalla.thor.presentation.common.components.ConnectedButtonGroupItem @OptIn(ExperimentalMaterial3Api::class) @Composable fun DashboardHeader( isRoot: Boolean, isShizuku: Boolean, isDhizuku: Boolean, activeMode: PrivilegeMode?, selectedType: AppListType, onTypeChanged: (AppListType) -> Unit, onPrivilegeChanged: (PrivilegeMode) -> Unit, onRestrictedStatusClick: () -> Unit, modifier: Modifier = Modifier ) { Row( modifier = modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.background) .padding(horizontal = 24.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { // LEFT: Brand Block // ... (existing code) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Icon( painter = painterResource(R.drawable.thor_mono), contentDescription = null, modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.primary ) Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Black, color = MaterialTheme.colorScheme.primary, letterSpacing = (-1).sp ) } // RIGHT: Controls (Status + Type Switcher) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { // Work Mode Icon/Selector StatusIcon( isRoot = isRoot, isShizuku = isShizuku, isDhizuku = isDhizuku, activeMode = activeMode, onModeSelected = onPrivilegeChanged, onClick = onRestrictedStatusClick ) // App Type Switcher ConnectedButtonGroup( items = AppListType.entries.map { type -> ConnectedButtonGroupItem.Icon( iconRes = if (type == AppListType.USER) R.drawable.apps else R.drawable.android, contentDescription = type.name ) }, selectedIndex = AppListType.entries.indexOf(selectedType), onItemSelected = { onTypeChanged(AppListType.entries[it]) } ) } } } @Composable private fun StatusIcon( isRoot: Boolean, isShizuku: Boolean, isDhizuku: Boolean, activeMode: PrivilegeMode?, onModeSelected: (PrivilegeMode) -> Unit, onClick: () -> Unit ) { val availableModes = buildList { if (isRoot) add(PrivilegeMode.ROOT) if (isShizuku) add(PrivilegeMode.SHIZUKU) if (isDhizuku) add(PrivilegeMode.DHIZUKU) } val (icon, color) = when (activeMode) { PrivilegeMode.ROOT -> R.drawable.magisk_icon to MaterialTheme.colorScheme.primary PrivilegeMode.SHIZUKU -> R.drawable.shizuku to MaterialTheme.colorScheme.primary PrivilegeMode.DHIZUKU -> R.drawable.dhizuku to MaterialTheme.colorScheme.primary else -> R.drawable.round_close to MaterialTheme.colorScheme.error } Box( modifier = Modifier .size(40.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) .clickable { if (availableModes.size > 1) { // Cycle through available modes val currentIndex = availableModes.indexOf(activeMode) val nextIndex = (currentIndex + 1) % availableModes.size onModeSelected(availableModes[nextIndex]) } else if (availableModes.isEmpty()) { onClick() } }, contentAlignment = Alignment.Center ) { Icon( painter = painterResource(icon), contentDescription = stringResource(R.string.privilege_check), modifier = Modifier.size(20.dp), tint = color ) } } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/home/components/SocialLinksRow.kt ================================================ package com.valhalla.thor.presentation.home.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.valhalla.thor.R // You can define a simple data class for links data class SocialLink( val url: String, val icon: Int, // Resource ID val description: String ) @Composable fun SocialLinksRow( modifier: Modifier = Modifier ) { val uriHandler = LocalUriHandler.current // Define your links here val links = listOf( SocialLink("https://github.com/trinadhthatakula", R.drawable.brand_github, "GitHub"), SocialLink("https://patreon.com/trinadh", R.drawable.brand_patreon, "Patreon"), SocialLink("https://t.me/thorAppDev", R.drawable.brand_telegram, "Telegram") // Add more as needed ) Row( modifier = modifier .fillMaxWidth() .padding(vertical = 16.dp), horizontalArrangement = Arrangement.Center ) { links.forEach { link -> IconButton( onClick = { uriHandler.openUri(link.url) }, modifier = Modifier.padding(horizontal = 8.dp) ) { // If you don't have these drawables yet, replace with Icons.Default.Link temporarily Icon( painter = painterResource(link.icon), contentDescription = link.description, tint = MaterialTheme.colorScheme.onBackground ) } } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/home/components/SummaryStatRow.kt ================================================ package com.valhalla.thor.presentation.home.components import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.valhalla.thor.R import com.valhalla.thor.presentation.theme.animateExpressiveResize @Composable fun SummaryStatRow( activeCount: Int, frozenCount: Int, suspendedCount: Int, onActiveClick: () -> Unit, onFrozenClick: () -> Unit, onSuspendedClick: () -> Unit ) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { StatCard( title = stringResource(R.string.active), count = activeCount, color = MaterialTheme.colorScheme.primary, modifier = Modifier .weight(1f) .animateExpressiveResize(), onClick = onActiveClick ) if (frozenCount > 0) StatCard( title = stringResource(R.string.frozen), count = frozenCount, color = MaterialTheme.colorScheme.secondary, modifier = Modifier .weight(1f) .animateExpressiveResize(), onClick = onFrozenClick ) if (suspendedCount > 0) StatCard( title = stringResource(R.string.suspended), count = suspendedCount, color = MaterialTheme.colorScheme.tertiary, modifier = Modifier .weight(1f) .animateExpressiveResize(), onClick = onSuspendedClick ) } } @Composable fun StatCard( title: String, count: Int, color: Color, modifier: Modifier, onClick: () -> Unit ) { Column( modifier = modifier .clip(RoundedCornerShape(32.dp)) // squircle-lg approx .background(MaterialTheme.colorScheme.surfaceContainerLow) .clickable { onClick() } .padding(24.dp) ) { AnimatedCounter( count = count, style = MaterialTheme.typography.displayMedium.copy( color = color, fontWeight = FontWeight.ExtraBold ) ) Text( text = title.uppercase(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.Medium, maxLines = 1, letterSpacing = androidx.compose.ui.unit.TextUnit.Unspecified // tracking-wider ) } } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/installer/InstallerViewModel.kt ================================================ package com.valhalla.thor.presentation.installer import android.content.pm.PackageManager import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.valhalla.thor.domain.InstallState import com.valhalla.thor.domain.InstallerEventBus import com.valhalla.thor.domain.repository.AppAnalyzer import com.valhalla.thor.domain.repository.InstallMode import com.valhalla.thor.domain.repository.InstallerRepository import com.valhalla.thor.domain.repository.SystemRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch class InstallerViewModel( private val repository: InstallerRepository, private val analyzer: AppAnalyzer, private val eventBus: InstallerEventBus, private val packageManager: PackageManager, private val systemRepository: SystemRepository ) : ViewModel() { val installState = eventBus.events val installMode: StateFlow field = MutableStateFlow(InstallMode.NORMAL) val availableModes: StateFlow> field = MutableStateFlow(listOf(InstallMode.NORMAL)) var currentPackageName: String? = null private set private var pendingUri: Uri? = null private var isUpdateOperation: Boolean = false private var isDowngrade: Boolean = false init { // Clear sticky state logic val lastState = eventBus.events.replayCache.firstOrNull() if (lastState is InstallState.Success || lastState is InstallState.Error) { viewModelScope.launch { eventBus.emit(InstallState.Idle) } } checkAvailableModes() } private fun checkAvailableModes() { viewModelScope.launch { val modes = mutableListOf(InstallMode.NORMAL, InstallMode.EXTERNAL) if (systemRepository.isRootAvailable()) { modes.add(InstallMode.ROOT) } if (systemRepository.isShizukuAvailable()) { modes.add(InstallMode.SHIZUKU) } if (systemRepository.isDhizukuAvailable()) { modes.add(InstallMode.DHIZUKU) } availableModes.value = modes if (availableModes.value.contains(InstallMode.ROOT)) { installMode.value = InstallMode.ROOT } else if (availableModes.value.contains(InstallMode.SHIZUKU)) { installMode.value = InstallMode.SHIZUKU } else if (availableModes.value.contains(InstallMode.DHIZUKU)) { installMode.value = InstallMode.DHIZUKU } else { installMode.value = InstallMode.NORMAL } } } @Suppress("unused") fun setInstallMode(mode: InstallMode) { if (availableModes.value.contains(mode)) { installMode.value = mode } } fun setInstallModeAlsoInstall(mode: InstallMode) { if (availableModes.value.contains(mode)) { installMode.value = mode } confirmInstall() } fun installFile(uri: Uri) { viewModelScope.launch { currentPackageName = null eventBus.emit(InstallState.Parsing) val analysis = analyzer.analyze(uri) analysis.onSuccess { meta -> pendingUri = uri currentPackageName = meta.packageName var oldVersion: String? = null isDowngrade = false isUpdateOperation = try { val installedPkg = packageManager.getPackageInfo(meta.packageName, 0) oldVersion = installedPkg.versionName val installedVersionCode = installedPkg.longVersionCode isDowngrade = meta.versionCode < installedVersionCode true } catch (_: PackageManager.NameNotFoundException) { false } eventBus.emit( InstallState.ReadyToInstall( meta, isUpdateOperation, isDowngrade, oldVersion ) ) }.onFailure { eventBus.emit(InstallState.Error("Failed to parse package.")) } } } fun confirmInstall() { val uri = pendingUri ?: return // Validation: Only allow downgrade with Root, Shizuku or Dhizuku if (isDowngrade && (installMode.value == InstallMode.NORMAL || installMode.value == InstallMode.EXTERNAL)) { viewModelScope.launch { eventBus.emit(InstallState.Error("Downgrade is only supported with privileged access (Root, Shizuku, or Dhizuku mode).")) } return } viewModelScope.launch { repository.installPackage(uri, installMode.value, canDowngrade = isDowngrade) } } fun resetState() { viewModelScope.launch { eventBus.emit(InstallState.Idle) pendingUri = null currentPackageName = null } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/installer/PortableInstaller.kt ================================================ package com.valhalla.thor.presentation.installer import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import androidx.activity.ComponentActivity import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.Image 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.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SplitButtonDefaults import androidx.compose.material3.SplitButtonLayout import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.valhalla.thor.R import com.valhalla.thor.domain.InstallState import com.valhalla.thor.domain.repository.InstallMode import kotlinx.coroutines.delay import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun PortableInstaller( onDismiss: () -> Unit, viewModel: InstallerViewModel = koinViewModel() ) { val state by viewModel.installState.collectAsState(initial = InstallState.Idle) val availableModes by viewModel.availableModes.collectAsStateWithLifecycle() val installerMode by viewModel.installMode.collectAsStateWithLifecycle() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val context = LocalContext.current // Auto-start installation process if intent is present LaunchedEffect(Unit) { val activity = context as? ComponentActivity val intent = activity?.intent if (state is InstallState.Idle && intent?.action == Intent.ACTION_VIEW) { intent.data?.let { uri -> viewModel.installFile(uri) } } } // Handle System Dialogs LaunchedEffect(state) { if (state is InstallState.UserConfirmationRequired) { val intent = (state as InstallState.UserConfirmationRequired).intent intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(intent) } } // Launch Button Logic var launchIntent by remember { mutableStateOf(null) } val currentPackageName = viewModel.currentPackageName fun refreshLaunchState() { if (currentPackageName != null) { launchIntent = context.packageManager.getLaunchIntentForPackage(currentPackageName) } } DisposableEffect(currentPackageName) { val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (intent.action == Intent.ACTION_PACKAGE_ADDED || intent.action == Intent.ACTION_PACKAGE_REPLACED ) { val installedPkg = intent.data?.schemeSpecificPart if (installedPkg == currentPackageName) { refreshLaunchState() } } } } val filter = IntentFilter().apply { addAction(Intent.ACTION_PACKAGE_ADDED) addAction(Intent.ACTION_PACKAGE_REPLACED) addDataScheme("package") } context.registerReceiver(receiver, filter) onDispose { context.unregisterReceiver(receiver) } } // Check launch state on Success LaunchedEffect(state) { if (state is InstallState.Success) { refreshLaunchState() if (launchIntent == null) { delay(500) refreshLaunchState() } } } // The Bottom Sheet ModalBottomSheet( onDismissRequest = { viewModel.resetState() onDismiss() }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surface, dragHandle = null // Optional: remove handle for cleaner look ) { Column( modifier = Modifier .fillMaxWidth() .padding(24.dp) .padding(bottom = 24.dp), // Extra padding for nav bar horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp) ) { // Header Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( text = "THOR INSTALLER", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold, letterSpacing = 2.sp ) // Optional Close Button // IconButton(onClick = onDismiss) { Icon(Icons.Default.Close, null) } } // Content when (val s = state) { is InstallState.Idle, is InstallState.Parsing -> { Column(horizontalAlignment = Alignment.CenterHorizontally) { CircularProgressIndicator( modifier = Modifier.size(48.dp), color = MaterialTheme.colorScheme.primary ) Spacer(Modifier.height(16.dp)) Text("Analyzing Package...", style = MaterialTheme.typography.bodyMedium) } } is InstallState.ReadyToInstall -> { val meta = s.meta Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp) ) { if (meta.icon != null) { Image( bitmap = meta.icon.asImageBitmap(), contentDescription = null, modifier = Modifier.size(64.dp) ) } Column(modifier = Modifier.weight(1f)) { Text( text = meta.label, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold ) Text( text = "${meta.version} (${meta.versionCode})", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) Text( text = meta.packageName, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), maxLines = 1, overflow = TextOverflow.Ellipsis ) } } if (s.isUpdate) { Text( text = if (s.isDowngrade) "This will downgrade the app." else "This will update the existing app.", style = MaterialTheme.typography.bodySmall, color = if (s.isDowngrade) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center ) } val totalPermissions = remember(s.meta) { s.meta.permissions.size } Row( verticalAlignment = Alignment.CenterVertically ) { if (totalPermissions > 0) { val warningMessage = if (s.shouldShowWarning()) s.getWarningMessage().orEmpty() else "" Text( "This package requests $totalPermissions permission${if (totalPermissions > 1) "s" else ""}. $warningMessage", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier .weight(1f) .padding(horizontal = 4.dp) ) } if (availableModes.size == 1) { Button( onClick = { viewModel.confirmInstall() }, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary ) ) { Text( s.getActionButtonText() ) } } else { Box( modifier = Modifier ) { var checked by remember { mutableStateOf(false) } SplitButtonLayout( leadingButton = { SplitButtonDefaults.ElevatedLeadingButton( onClick = { viewModel.confirmInstall() }, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary ) ) { Icon( painterResource( when (installerMode) { InstallMode.ROOT -> R.drawable.magisk_icon InstallMode.SHIZUKU -> R.drawable.shizuku InstallMode.DHIZUKU -> R.drawable.dhizuku // Placeholder InstallMode.NORMAL -> R.drawable.ic_launcher_foreground // Fallback InstallMode.EXTERNAL -> R.drawable.open_in } ), modifier = Modifier.size(SplitButtonDefaults.LeadingIconSize), contentDescription = "Install Mode Icon", ) Spacer(Modifier.size(ButtonDefaults.IconSpacing)) Text(s.getActionButtonText()) } }, trailingButton = { val description = "Install Options: " + availableModes.joinToString(", ") { mode -> when (mode) { InstallMode.NORMAL -> "Normal" InstallMode.SHIZUKU -> "Shizuku" InstallMode.DHIZUKU -> "Dhizuku" InstallMode.ROOT -> "Root" InstallMode.EXTERNAL -> "External" } } SplitButtonDefaults.ElevatedTrailingButton( checked = checked, onCheckedChange = { checked = it }, modifier = Modifier.semantics { stateDescription = if (checked) "Expanded" else "Collapsed" this.contentDescription = description }, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary ) ) { val rotation: Float by animateFloatAsState( targetValue = if (checked) 180f else 0f, label = "Trailing Icon Rotation", ) Icon( painterResource(R.drawable.arrow_drop_down), modifier = Modifier .size(SplitButtonDefaults.TrailingIconSize) .graphicsLayer { this.rotationZ = rotation }, contentDescription = "Localized description", ) } }, modifier = Modifier ) DropdownMenu( expanded = checked, onDismissRequest = { checked = false }) { availableModes.forEach { mode -> DropdownMenuItem( text = { Text( when (mode) { InstallMode.NORMAL -> "Normal ${s.getActionWord()}" InstallMode.SHIZUKU -> "${s.getActionWord()} via Shizuku" InstallMode.DHIZUKU -> "${s.getActionWord()} via Dhizuku" InstallMode.ROOT -> "${s.getActionWord()} with Root" InstallMode.EXTERNAL -> "Open with..." } ) }, onClick = { viewModel.setInstallModeAlsoInstall(mode) checked = false } ) } } } } } } is InstallState.Installing -> { Column(horizontalAlignment = Alignment.CenterHorizontally) { val percentage = (s.progress * 100).toInt() LinearProgressIndicator( progress = { s.progress }, modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.primary, trackColor = MaterialTheme.colorScheme.surfaceVariant, ) Spacer(Modifier.height(8.dp)) Text( "Assembling: $percentage%", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } is InstallState.UserConfirmationRequired -> { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth() ) { LinearProgressIndicator( modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.tertiary, trackColor = MaterialTheme.colorScheme.surfaceVariant ) Spacer(Modifier.height(16.dp)) Text( "Please confirm installation in the system dialog.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.tertiary, fontWeight = FontWeight.Medium ) } } is InstallState.Success -> { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( "Installed Successfully", color = Color.Green, fontWeight = FontWeight.Bold ) Spacer(Modifier.height(16.dp)) Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { OutlinedButton( onClick = { viewModel.resetState() onDismiss() }, modifier = Modifier.weight(1f) ) { Text("Done") } if (launchIntent != null) { Button( onClick = { launchIntent?.let { it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(it) viewModel.resetState() onDismiss() } }, modifier = Modifier.weight(1f) ) { Text("Open") } } } } } is InstallState.Error -> { Text( "Error: ${s.message}", color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium ) Button(onClick = onDismiss) { Text("Close") } } } } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/installer/PortableInstallerActivity.kt ================================================ package com.valhalla.thor.presentation.installer import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import com.valhalla.thor.presentation.theme.ThorTheme class PortableInstallerActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { ThorTheme { PortableInstaller( onDismiss = { finish() } ) } } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/main/MainScreen.kt ================================================ package com.valhalla.thor.presentation.main import android.content.Intent import android.provider.Settings import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.valhalla.thor.R import com.valhalla.thor.domain.model.AppClickAction import com.valhalla.thor.domain.model.MultiAppAction import com.valhalla.thor.presentation.appList.AppListScreen import com.valhalla.thor.presentation.freezer.FreezerScreen import com.valhalla.thor.presentation.home.AppDestinations import com.valhalla.thor.presentation.home.HomeScreen import com.valhalla.thor.presentation.home.HomeViewModel import com.valhalla.thor.presentation.settings.SettingsScreen import com.valhalla.thor.presentation.widgets.AffirmationDialog import com.valhalla.thor.presentation.widgets.MultiAppAffirmationDialog import com.valhalla.thor.presentation.widgets.TermLoggerDialog import org.koin.androidx.compose.koinViewModel @Composable fun MainScreen( mainViewModel: MainViewModel = koinViewModel(), homeViewModel: HomeViewModel = koinViewModel(), onExit: () -> Unit, ) { val state by mainViewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current // --- Safety Gates (Dialog State) --- var pendingMultiAction by remember { mutableStateOf(null) } var pendingSingleAction by remember { mutableStateOf(null) } var showExitConfirmation by remember { mutableStateOf(false) } // 1. Handle Back Button (Return to Home before Exiting) BackHandler(enabled = state.selectedDestination != AppDestinations.HOME) { mainViewModel.onDestinationSelected(AppDestinations.HOME) } // Secondary BackHandler for Home tab to Exit BackHandler(enabled = state.selectedDestination == AppDestinations.HOME) { showExitConfirmation = true } val canNotLaunchApp = stringResource(R.string.cannot_launch_app) val shareApp = stringResource(R.string.share_app) // 4. Handle Side Effects LaunchedEffect(Unit) { mainViewModel.effect.collect { effect -> when (effect) { is MainSideEffect.LaunchApp -> { val intent = context.packageManager.getLaunchIntentForPackage(effect.packageName) if (intent != null) context.startActivity(intent) else Toast.makeText(context, canNotLaunchApp, Toast.LENGTH_SHORT).show() } is MainSideEffect.OpenAppSettings -> { val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { data = "package:${effect.packageName}".toUri() } context.startActivity(intent) } is MainSideEffect.ShareApp -> { val intent = Intent(Intent.ACTION_SEND).apply { type = "application/vnd.android.package-archive" putExtra(Intent.EXTRA_STREAM, effect.uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } context.startActivity(Intent.createChooser(intent, shareApp)) } is MainSideEffect.NormalUninstall -> { val intent = Intent(Intent.ACTION_DELETE).apply { data = "package:${effect.packageName}".toUri() } context.startActivity(intent) } } } } LaunchedEffect(state.actionMessage) { state.actionMessage?.let { msg -> Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() mainViewModel.consumeMessage() } } Scaffold( bottomBar = { ThorNavigationBar( destinations = AppDestinations.entries, selectedDestination = state.selectedDestination, onDestinationSelected = { dest -> mainViewModel.onDestinationSelected(dest) } ) } ) { innerPadding -> Box( modifier = Modifier .padding(innerPadding) .fillMaxSize() ) { when (state.selectedDestination) { AppDestinations.HOME -> { HomeScreen( viewModel = homeViewModel, onNavigateToApps = { mainViewModel.onDestinationSelected(AppDestinations.APPS) }, onNavigateToFreezer = { mainViewModel.onDestinationSelected(AppDestinations.FREEZER) }, onReinstallAll = { mainViewModel.onAppAction(AppClickAction.ReinstallAll) }, onClearAllCache = { type -> mainViewModel.clearAllCache(type) } ) } AppDestinations.APPS -> { AppListScreen( onAppAction = { action -> checkAndProcessAction(action, { pendingSingleAction = it }) { mainViewModel.onAppAction(it) } }, onMultiAppAction = { pendingMultiAction = it } ) } AppDestinations.FREEZER -> { FreezerScreen( onAppAction = { action -> checkAndProcessAction(action, { pendingSingleAction = it }) { mainViewModel.onAppAction(it) } }, onMultiAppAction = { pendingMultiAction = it } ) } AppDestinations.SETTINGS -> { SettingsScreen() } } // --- GLOBAL OVERLAYS (Unchanged) --- if (pendingMultiAction != null) { MultiAppAffirmationDialog( multiAppAction = pendingMultiAction!!, onConfirm = { mainViewModel.onMultiAppAction(pendingMultiAction!!) pendingMultiAction = null }, onRejected = { pendingMultiAction = null } ) } if (pendingSingleAction != null) { val action = pendingSingleAction!! val (title, text, icon) = when (action) { is AppClickAction.Kill -> Triple( stringResource(R.string.kill_app_title), stringResource(R.string.kill_app_desc, action.appInfo.appName ?: ""), R.drawable.danger ) else -> Triple( stringResource(R.string.confirm), stringResource(R.string.are_you_sure), R.drawable.thor_mono ) } AffirmationDialog( title = title, text = text, icon = icon, onConfirm = { mainViewModel.onAppAction(action) pendingSingleAction = null }, onRejected = { pendingSingleAction = null } ) } if (state.loggerState.isVisible) { TermLoggerDialog( title = state.loggerState.title, logs = state.loggerState.logs, isOperationComplete = state.loggerState.isComplete, onDismiss = { mainViewModel.dismissLogger() } ) } if (showExitConfirmation) { AffirmationDialog( title = stringResource(R.string.exit_thor_title), text = stringResource(R.string.exit_thor_desc), icon = R.drawable.exit_to_app, onConfirm = { showExitConfirmation = false onExit() }, onRejected = { showExitConfirmation = false } ) } } } } private fun checkAndProcessAction( action: AppClickAction, onRequireConfirmation: (AppClickAction) -> Unit, onExecute: (AppClickAction) -> Unit ) { when (action) { is AppClickAction.Kill -> onRequireConfirmation(action) else -> onExecute(action) } } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/main/MainViewModel.kt ================================================ package com.valhalla.thor.presentation.main import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.valhalla.thor.domain.model.AppClickAction import com.valhalla.thor.domain.model.AppInfo import com.valhalla.thor.domain.model.AppListType import com.valhalla.thor.domain.model.MultiAppAction import com.valhalla.thor.domain.usecase.GetInstalledAppsUseCase import com.valhalla.thor.domain.usecase.ManageAppUseCase import com.valhalla.thor.domain.usecase.ShareAppUseCase import com.valhalla.thor.presentation.home.AppDestinations import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** * Side Effects: One-time events that the UI must handle (Navigation, Intents). */ sealed interface MainSideEffect { data class LaunchApp(val packageName: String) : MainSideEffect data class OpenAppSettings(val packageName: String) : MainSideEffect data class ShareApp(val uri: android.net.Uri) : MainSideEffect data class NormalUninstall(val packageName: String) : MainSideEffect } /** * State for the Terminal Logger Dialog. */ data class LoggerState( val isVisible: Boolean = false, val title: String = "", val logs: List = emptyList(), val isComplete: Boolean = false ) /** * Main UI State holding global feedback. */ data class MainUiState( val actionMessage: String? = null, // For transient Toasts val loggerState: LoggerState = LoggerState(), // For persistent Logs val selectedDestination: AppDestinations = AppDestinations.HOME // For Bottom Nav ) class MainViewModel( private val manageAppUseCase: ManageAppUseCase, private val getInstalledAppsUseCase: GetInstalledAppsUseCase, private val shareAppUseCase: ShareAppUseCase, private val packageManager: PackageManager ) : ViewModel() { private val _uiState = MutableStateFlow(MainUiState()) val uiState = _uiState.stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000), MainUiState() ) private val _effect = Channel() val effect = _effect.receiveAsFlow() // --- State Management Helpers --- fun consumeMessage() { _uiState.update { it.copy(actionMessage = null) } } fun dismissLogger() { _uiState.update { it.copy(loggerState = LoggerState(isVisible = false)) } } fun onDestinationSelected(destination: AppDestinations) { _uiState.update { it.copy(selectedDestination = destination) } } private fun startLogger(title: String) { _uiState.update { it.copy( loggerState = LoggerState( isVisible = true, title = title, logs = listOf("Initializing...") ) ) } } private fun addLog(message: String) { _uiState.update { state -> val newLogs = state.loggerState.logs + message state.copy(loggerState = state.loggerState.copy(logs = newLogs)) } } private fun finishLogger() { addLog("\nOperation Complete.") _uiState.update { state -> state.copy(loggerState = state.loggerState.copy(isComplete = true)) } } fun clearAllCache(type: AppListType) { viewModelScope.launch { startLogger("Preparing Cache Cleanup...") // 1. Fetch current list val (userApps, systemApps) = getInstalledAppsUseCase().first() val targetList = if (type == AppListType.USER) userApps else systemApps // 2. Filter out self and Play Store to be safe val safeList = targetList.filter { it.packageName != "com.valhalla.thor" && it.packageName != "com.android.vending" } if (safeList.isEmpty()) { addLog("No eligible apps found.") finishLogger() return@launch } dismissLogger() // Switch to batch logger onMultiAppAction(MultiAppAction.ClearCache(safeList)) } } // --- Single App Action Handler --- fun onAppAction(action: AppClickAction) { viewModelScope.launch { when (action) { // 1. SMART LAUNCH is AppClickAction.Launch -> { if (!action.appInfo.enabled) { // Quick toast for feedback, or could use logger if preferred. // Using Toast here for speed. _uiState.update { it.copy(actionMessage = "Unfreezing ${action.appInfo.appName}...") } val result = manageAppUseCase.setAppDisabled(action.appInfo.packageName, false) if (result.isSuccess) { _effect.send(MainSideEffect.LaunchApp(action.appInfo.packageName)) } else { _uiState.update { it.copy(actionMessage = "Failed to unfreeze: ${result.exceptionOrNull()?.message}") } } } else { _effect.send(MainSideEffect.LaunchApp(action.appInfo.packageName)) } } // 2. SETTINGS is AppClickAction.AppInfoSettings -> { _effect.send(MainSideEffect.OpenAppSettings(action.appInfo.packageName)) } // 3. SHARE (Heavy I/O -> Use Logger) is AppClickAction.Share -> { startLogger("Sharing ${action.appInfo.appName}") addLog("Preparing files...") val result = shareAppUseCase(action.appInfo) if (result.isSuccess) { addLog("✔ Files Ready") // Dismiss logger immediately so user sees the Share Sheet dismissLogger() _effect.send(MainSideEffect.ShareApp(result.getOrThrow())) } else { addLog("✘ Error: ${result.exceptionOrNull()?.message}") finishLogger() // Keep open to show error } } // 4. REINSTALL (Complex -> Use Logger) is AppClickAction.Reinstall -> { startLogger("Reinstalling ${action.appInfo.appName}") addLog("Applying Google Play Store signature...") withContext(Dispatchers.IO) { val result = manageAppUseCase.reinstallAppWithGoogle(action.appInfo.packageName) if (result.isSuccess) addLog("✔ Reinstall successful") else addLog("✘ Failed: ${result.exceptionOrNull()?.message}") } finishLogger() } // 5. UNINSTALL (System = Risky -> Logger / User = Fast -> Toast) is AppClickAction.Uninstall -> { if (action.appInfo.isSystem) { startLogger("Uninstalling System App") addLog("Target: ${action.appInfo.appName}") withContext(Dispatchers.IO) { val result = manageAppUseCase.uninstallApp(action.appInfo.packageName) if (result.isSuccess) addLog("✔ Uninstall successful") else { addLog("✘ Privileged uninstall failed") addLog("Attempting system uninstall...") _effect.send(MainSideEffect.NormalUninstall(action.appInfo.packageName)) } } finishLogger() } else { viewModelScope.launch(Dispatchers.IO) { val result = manageAppUseCase.uninstallApp(action.appInfo.packageName) if (result.isSuccess) { _uiState.update { it.copy(actionMessage = "Uninstalled ${action.appInfo.appName}") } } else { _effect.send(MainSideEffect.NormalUninstall(action.appInfo.packageName)) } } } } // 6. REINSTALL ALL (Batch Logic Triggered via Single Action Enum) AppClickAction.ReinstallAll -> { startLogger("Scanning Apps...") // Fetch user apps flows, take first emission val (userApps, _) = getInstalledAppsUseCase().first() val targets = userApps.filter { it.installerPackageName != "com.android.vending" && it.installerPackageName != "com.google.android.packageinstaller" } if (targets.isEmpty()) { addLog("No apps found that require fixing.") finishLogger() } else { addLog("Found ${targets.size} apps to fix.") dismissLogger() // Dismiss scan logger, start batch logger onMultiAppAction(MultiAppAction.ReInstall(targets)) } } // 7. QUICK ACTIONS (Kill, Freeze, Unfreeze, Cache) -> Toast is AppClickAction.Kill -> quickAction(action) { manageAppUseCase.forceStop(it.packageName) } is AppClickAction.Freeze -> quickAction(action) { manageAppUseCase.setAppDisabled( it.packageName, true ) } is AppClickAction.UnFreeze -> quickAction(action) { manageAppUseCase.setAppDisabled( it.packageName, false ) } is AppClickAction.ClearCache -> quickAction(action) { manageAppUseCase.clearCache(it.packageName) } is AppClickAction.ClearData -> quickAction(action) { manageAppUseCase.clearAppData( it.packageName ) } is AppClickAction.Suspend -> quickAction(action) { manageAppUseCase.setAppSuspended( it.packageName, true ) } is AppClickAction.UnSuspend -> quickAction(action) { manageAppUseCase.setAppSuspended( it.packageName, false ) } } } } // --- Multi App Action Handler --- fun onMultiAppAction(action: MultiAppAction) { viewModelScope.launch { when (action) { is MultiAppAction.ReInstall -> performLoggedMultiAction( "Reinstalling Apps", action.appList ) { appInfo -> val result = manageAppUseCase.reinstallAppWithGoogle(appInfo.packageName) if (result.isSuccess) { result } else { try { packageManager.getPackageInfo( appInfo.packageName, 0 ).applicationInfo?.let { appInfo -> val isDebuggable = (appInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 if (isDebuggable) { Result.failure(Exception("App is Debuggable (Signature mismatch likely)")) } else { result // Return original error } } ?: result } catch (_: Exception) { result // Return original error if package check fails } } } is MultiAppAction.Freeze -> performLoggedMultiAction( "Freezing Apps", action.appList ) { manageAppUseCase.setAppDisabled(it.packageName, disabled = true) } is MultiAppAction.UnFreeze -> performLoggedMultiAction( "Unfreezing Apps", action.appList ) { manageAppUseCase.setAppDisabled(it.packageName, disabled = false) } is MultiAppAction.Kill -> performLoggedMultiAction("Killing Apps", action.appList) { manageAppUseCase.forceStop(it.packageName) } is MultiAppAction.ClearCache -> performLoggedMultiAction( "Clearing Caches", action.appList ) { manageAppUseCase.clearCache(it.packageName) } is MultiAppAction.Uninstall -> performLoggedMultiAction( "Uninstalling Apps", action.appList ) { manageAppUseCase.uninstallApp(it.packageName) } is MultiAppAction.Suspend -> performLoggedMultiAction( "Suspending Apps", action.appList ) { manageAppUseCase.setAppSuspended(it.packageName, true) } is MultiAppAction.UnSuspend -> performLoggedMultiAction( "Unsuspending Apps", action.appList ) { manageAppUseCase.setAppSuspended(it.packageName, false) } is MultiAppAction.ClearData -> performLoggedMultiAction( "Clearing Data", action.appList ) { manageAppUseCase.clearAppData(it.packageName) } else -> { _uiState.update { it.copy(actionMessage = "Batch Share not supported yet") } } } } } // --- Helper Implementations --- /** * Executes a batch operation and updates the Logger Dialog. */ private suspend fun performLoggedMultiAction( title: String, apps: List, block: suspend (AppInfo) -> Result ) { startLogger(title) withContext(Dispatchers.IO) { apps.forEachIndexed { index, app -> addLog("[${index + 1}/${apps.size}] ${app.appName}...") val result = block(app) if (result.isSuccess) { addLog(" -> Success") } else { addLog(" -> Failed: ${result.exceptionOrNull()?.message}") } } } finishLogger() } /** * Executes a quick single action and shows a Toast on completion. */ private suspend fun quickAction( action: AppClickAction, block: suspend (AppInfo) -> Result ) { val app = action.appInfo() val actionName = action.javaClass.simpleName if (app != null) block(app) .onSuccess { _uiState.update { it.copy(actionMessage = "$actionName ${app.appName}") } } .onFailure { e -> _uiState.update { it.copy(actionMessage = "Error: ${e.message}") } } else { _uiState.update { it.copy(actionMessage = "Error: App info missing for action") } } } // Helper to extract AppInfo from the sealed interface safely private fun AppClickAction.appInfo(): AppInfo? = when (this) { is AppClickAction.Kill -> appInfo is AppClickAction.Freeze -> appInfo is AppClickAction.UnFreeze -> appInfo is AppClickAction.ClearCache -> appInfo is AppClickAction.Uninstall -> appInfo is AppClickAction.Launch -> appInfo is AppClickAction.Share -> appInfo is AppClickAction.Reinstall -> appInfo is AppClickAction.AppInfoSettings -> appInfo is AppClickAction.ClearData -> appInfo is AppClickAction.Suspend -> appInfo is AppClickAction.UnSuspend -> appInfo else -> null } } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/main/ThorNavigationBar.kt ================================================ package com.valhalla.thor.presentation.main import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.expandHorizontally import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkHorizontally import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue 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.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.valhalla.thor.presentation.home.AppDestinations import com.valhalla.thor.presentation.theme.expressivePress @Composable fun ThorNavigationBar( destinations: List, selectedDestination: AppDestinations, onDestinationSelected: (AppDestinations) -> Unit, modifier: Modifier = Modifier ) { Surface( modifier = modifier .fillMaxWidth() .clip(RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp)), color = MaterialTheme.colorScheme.surfaceContainer, ) { Row( modifier = Modifier .fillMaxWidth() .navigationBarsPadding() .padding(horizontal = 12.dp, vertical = 12.dp) .height(64.dp), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically ) { destinations.forEach { destination -> ThorNavigationBarItem( destination = destination, selected = destination == selectedDestination, onClick = { onDestinationSelected(destination) } ) } } } } @Composable private fun ThorNavigationBarItem( destination: AppDestinations, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier ) { val interactionSource = remember { MutableInteractionSource() } // Snappier spring for transitions val snappySpring = spring( dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMedium ) val containerColor by animateColorAsState( targetValue = if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent, animationSpec = spring(stiffness = Spring.StiffnessMedium), label = "containerColor" ) val contentColor by animateColorAsState( targetValue = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, animationSpec = spring(stiffness = Spring.StiffnessMedium), label = "contentColor" ) val contentAlpha by animateFloatAsState( targetValue = if (selected) 1f else 0.7f, animationSpec = snappySpring, label = "contentAlpha" ) Box( modifier = modifier .clip(RoundedCornerShape(32.dp)) .background(containerColor) .expressivePress(interactionSource) .clickable( interactionSource = interactionSource, indication = null, onClick = onClick ) .animateContentSize( animationSpec = spring( dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMedium ) ) .padding(horizontal = if (selected) 20.dp else 16.dp, vertical = 10.dp), contentAlignment = Alignment.Center ) { Row( modifier = Modifier.graphicsLayer { alpha = contentAlpha }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { Icon( painter = painterResource(if (selected) destination.selectedIcon else destination.icon), contentDescription = stringResource(destination.contentDescription), tint = contentColor ) AnimatedVisibility( visible = selected, enter = fadeIn(animationSpec = snappySpring) + expandHorizontally( animationSpec = spring( stiffness = Spring.StiffnessMedium, dampingRatio = Spring.DampingRatioNoBouncy ) ), exit = fadeOut(animationSpec = snappySpring) + shrinkHorizontally( animationSpec = spring( stiffness = Spring.StiffnessMedium, dampingRatio = Spring.DampingRatioNoBouncy ) ) ) { Text( text = stringResource(destination.label), color = contentColor, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 8.dp), maxLines = 1 ) } } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/security/AuthState.kt ================================================ package com.valhalla.thor.presentation.security /** * Represents the authentication state gate for the app. * The UI tree uses this to decide whether to show BiometricScreen or MainScreen. */ sealed interface AuthState { /** Biometric lock is disabled — proceed directly to the app. */ data object NotRequired : AuthState /** Biometric lock is enabled but the user has not yet authenticated this session. */ data object Locked : AuthState /** User has successfully authenticated this session. */ data object Unlocked : AuthState /** Authentication failed or the device has no enrolled biometrics. */ data class Error(val message: String) : AuthState } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/security/BiometricPromptHandler.kt ================================================ package com.valhalla.thor.presentation.security import android.content.Context import android.hardware.biometrics.BiometricManager.Authenticators.BIOMETRIC_STRONG import android.hardware.biometrics.BiometricManager.Authenticators.DEVICE_CREDENTIAL import android.hardware.biometrics.BiometricPrompt import android.os.Build import android.os.CancellationSignal import androidx.core.content.ContextCompat /** * Handles biometric authentication using the framework BiometricPrompt API (API 28+). * This implementation does NOT require FragmentActivity, making it compatible with ComponentActivity. */ internal class BiometricPromptHandler(private val context: Context) { private var cancellationSignal: CancellationSignal? = null fun authenticate( title: String, subtitle: String, onAuthenticated: () -> Unit, onError: (String) -> Unit ) { val executor = ContextCompat.getMainExecutor(context) val callback = object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult?) { super.onAuthenticationSucceeded(result) onAuthenticated() } override fun onAuthenticationError(errorCode: Int, errString: CharSequence?) { super.onAuthenticationError(errorCode, errString) // Error code 5 is developer-initiated cancellation, ignore it. if (errorCode != 5) { onError(errString?.toString() ?: "Authentication error") } } override fun onAuthenticationFailed() { super.onAuthenticationFailed() // Framework handles internal retries; we could notify UI if needed. } } val builder = BiometricPrompt.Builder(context) .setTitle(title) .setSubtitle(subtitle) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { builder.setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) } else { // On API 28-29, DEVICE_CREDENTIAL wasn't supported in setAllowedAuthenticators. // We fall back to a negative button if only BIOMETRIC_STRONG is possible. builder.setNegativeButton("Cancel", executor) { _, _ -> onError("User cancelled") } } cancellationSignal = CancellationSignal() builder.build().authenticate(cancellationSignal!!, executor, callback) } fun cancel() { cancellationSignal?.cancel() cancellationSignal = null } } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/security/BiometricScreen.kt ================================================ package com.valhalla.thor.presentation.security import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.background 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.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.valhalla.thor.R import com.valhalla.thor.presentation.theme.greenDark @Composable fun BiometricScreen( isError: Boolean, errorMessage: String, onAuthenticated: () -> Unit, onError: (String) -> Unit, onRetry: () -> Unit, onExit: () -> Unit ) { val context = LocalContext.current val handler = remember { BiometricPromptHandler(context) } // Clean up on dispose androidx.compose.runtime.DisposableEffect(handler) { onDispose { handler.cancel() } } Box( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) ) { // 1. Ambient Glow AmbientGlow() if (isError) { BiometricErrorView( message = errorMessage, onRetry = onRetry, onExit = onExit ) } else { BiometricLockView( onAuthenticated = onAuthenticated, onError = onError, handler = handler ) } } } @Composable private fun AmbientGlow() { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Box( modifier = Modifier .size(400.dp) .alpha(0.05f) .blur(120.dp) .background(greenDark, CircleShape) ) } } @Composable private fun BiometricLockView( onAuthenticated: () -> Unit, onError: (String) -> Unit, handler: BiometricPromptHandler ) { Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { // Logo Section Box(contentAlignment = Alignment.Center) { // Identity Ring (Pulsing) val infiniteTransition = rememberInfiniteTransition(label = "ring") val pulseAlpha by infiniteTransition.animateFloat( initialValue = 0.1f, targetValue = 0.3f, animationSpec = infiniteRepeatable( animation = tween(2000), repeatMode = RepeatMode.Reverse ), label = "alpha" ) Box( modifier = Modifier .size(110.dp) .alpha(pulseAlpha) .background(Color.Transparent, CircleShape) .padding(2.dp) .background(greenDark.copy(alpha = 0.2f), CircleShape) ) Box( modifier = Modifier .size(96.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.surfaceVariant) .padding(8.dp) ) { Box( modifier = Modifier .fillMaxSize() .clip(CircleShape) .background(Color.Black), contentAlignment = Alignment.Center ) { Icon( painter = painterResource(R.drawable.thor_mono), contentDescription = null, modifier = Modifier.size(48.dp), tint = Color.White ) } } } Spacer(modifier = Modifier.height(48.dp)) // Typography Header Text( text = "Thor", style = MaterialTheme.typography.displayLarge, fontWeight = FontWeight.Black, color = MaterialTheme.colorScheme.onSurface, letterSpacing = (-2).sp ) Text( text = "Unlock to continue", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) ) Spacer(modifier = Modifier.height(64.dp)) // Fingerprint Button Box(contentAlignment = Alignment.Center) { // Pulsing Background val infiniteTransition = rememberInfiniteTransition(label = "fingerprint") val scale by infiniteTransition.animateFloat( initialValue = 1f, targetValue = 1.2f, animationSpec = infiniteRepeatable( animation = tween(1500), repeatMode = RepeatMode.Reverse ), label = "scale" ) Box( modifier = Modifier .size(96.dp) .scale(scale) .alpha(0.1f) .background(greenDark, RoundedCornerShape(24.dp)) ) Box( modifier = Modifier .size(96.dp) .clip(RoundedCornerShape(24.dp)) .background( Brush.linearGradient( listOf(greenDark, greenDark.copy(alpha = 0.8f)) ) ) .clickable { handler.authenticate( title = "Unlock Thor", subtitle = "Authenticate to continue", onAuthenticated = onAuthenticated, onError = onError ) }, contentAlignment = Alignment.Center ) { Icon( painter = painterResource(R.drawable.round_key), // Fingerprint icon fallback contentDescription = "Unlock", modifier = Modifier.size(48.dp), tint = Color.Black ) } } } // Auto-trigger on first launch LaunchedEffect(Unit) { handler.authenticate( title = "Unlock Thor", subtitle = "Authenticate to continue", onAuthenticated = onAuthenticated, onError = onError ) } } @Composable private fun BiometricErrorView( message: String, onRetry: () -> Unit, onExit: () -> Unit ) { Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Icon( painter = painterResource(R.drawable.danger), contentDescription = null, modifier = Modifier.size(72.dp), tint = MaterialTheme.colorScheme.error ) Spacer(modifier = Modifier.height(24.dp)) Text( text = "Authentication Failed", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface ) Spacer(modifier = Modifier.height(8.dp)) Text( text = message, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, modifier = Modifier.padding(horizontal = 48.dp) ) Spacer(modifier = Modifier.height(48.dp)) Box( modifier = Modifier .clip(CircleShape) .background(MaterialTheme.colorScheme.errorContainer) .clickable { onRetry() } .padding(horizontal = 32.dp, vertical = 12.dp) ) { Text( text = "TRY AGAIN", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onErrorContainer ) } Spacer(modifier = Modifier.height(16.dp)) Text( text = "EXIT", modifier = Modifier .clickable { onExit() } .padding(16.dp), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/security/SecurityViewModel.kt ================================================ package com.valhalla.thor.presentation.security import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.valhalla.thor.domain.repository.PreferenceRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn class SecurityViewModel( preferenceRepository: PreferenceRepository ) : ViewModel() { // Tracks whether the user has authenticated in this session. private val _isSessionAuthenticated = MutableStateFlow(false) // Holds the last error message when auth fails permanently. private val _authError = MutableStateFlow(null) private val _biometricEnabled = preferenceRepository.userPreferences .map { it.biometricLockEnabled } .stateIn(viewModelScope, SharingStarted.Eagerly, false) /** * The single source of truth for auth state, derived from: * - Whether biometric lock is enabled in preferences * - Whether the user has authenticated this session * - Whether the last auth attempt produced an error */ val authState = combine( _biometricEnabled, _isSessionAuthenticated, _authError ) { enabled, authenticated, error -> when { !enabled -> AuthState.NotRequired authenticated -> AuthState.Unlocked error != null -> AuthState.Error(error) else -> AuthState.Locked } }.stateIn( viewModelScope, SharingStarted.Eagerly, AuthState.Locked ) /** Called by BiometricScreen on successful authentication. */ fun onAuthenticated() { _authError.value = null _isSessionAuthenticated.value = true } /** * Called when the biometric prompt is dismissed with an error (user cancel, * too many attempts, lockout, etc.). Surfaces the message to the UI so the * user can choose to retry or exit. */ fun onAuthError(message: String) { if (_biometricEnabled.value && !_isSessionAuthenticated.value) { _authError.value = message } } /** * Called when the user taps "Retry" on the error screen. * Clears the error and returns to Locked so BiometricScreen re-triggers the prompt. */ fun onRetry() { _authError.value = null } } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/settings/SettingsScreen.kt ================================================ package com.valhalla.thor.presentation.settings import android.content.Intent import android.os.Build import androidx.compose.foundation.background 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.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState 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 import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.valhalla.thor.R import com.valhalla.thor.domain.model.PrivilegeMode import com.valhalla.thor.domain.model.ThemeMode import com.valhalla.thor.presentation.common.components.ConnectedButtonGroup import com.valhalla.thor.presentation.common.components.ConnectedButtonGroupItem import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( viewModel: SettingsViewModel = koinViewModel() ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val prefs = state.prefs val context = LocalContext.current var showLanguageSheet by remember { mutableStateOf(false) } val versionName = remember(context) { runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "—" }.getOrDefault("—") } Column( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) .verticalScroll(rememberScrollState()) .padding(horizontal = 24.dp) .padding(top = 64.dp, bottom = 120.dp) ) { // Header Section Text( text = stringResource(R.string.settings), style = MaterialTheme.typography.displayMedium, fontWeight = FontWeight.Bold, letterSpacing = (-1).sp ) Text( text = stringResource(R.string.config_engine_v, versionName), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, letterSpacing = 2.sp ) Spacer(Modifier.height(48.dp)) // ── GENERAL ───────────────────────────────────────────────────────── SettingsSectionLabel(stringResource(R.string.general)) Column( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(32.dp)) .background(MaterialTheme.colorScheme.surfaceContainerLow) .padding(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp) ) { SettingsSwitchRow( icon = R.drawable.apk_install, title = stringResource(R.string.show_reinstall_card), subtitle = stringResource(R.string.show_reinstall_card_desc), checked = prefs.showReinstallAllCard, onCheckedChange = { viewModel.setReinstallAllCardVisibility(it) } ) } Spacer(Modifier.height(32.dp)) // ── APPEARANCE ────────────────────────────────────────────────────── SettingsSectionLabel(stringResource(R.string.appearance)) Column( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(32.dp)) .background(MaterialTheme.colorScheme.surfaceContainerLow) .padding(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp) ) { // Theme Row Column(modifier = Modifier.padding(16.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { IconBox(R.drawable.theme_panel) Spacer(Modifier.width(16.dp)) Column { Text( stringResource(R.string.theme), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( stringResource(R.string.theme_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis ) } } Spacer(Modifier.height(16.dp)) ConnectedButtonGroup( items = ThemeMode.entries.map { ConnectedButtonGroupItem.Label(it.label()) }, selectedIndex = ThemeMode.entries.indexOf(prefs.themeMode), onItemSelected = { viewModel.setThemeMode(ThemeMode.entries[it]) }, modifier = Modifier.fillMaxWidth() ) } SettingsSwitchRow( icon = R.drawable.theme_panel, title = stringResource(R.string.amoled_mode), subtitle = stringResource(R.string.amoled_desc), checked = prefs.useAmoled, onCheckedChange = { viewModel.setAmoledMode(it) } ) SettingsSwitchRow( icon = R.drawable.shield_with_heart, title = stringResource(R.string.dynamic_colors), subtitle = stringResource(R.string.dynamic_colors_desc), checked = prefs.useDynamicColor, enabled = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S, onCheckedChange = { viewModel.setDynamicColor(it) } ) SettingsClickRow( icon = R.drawable.settings_backup_restore, title = stringResource(R.string.app_language), subtitle = when (prefs.language) { "en" -> stringResource(R.string.english) "zh" -> stringResource(R.string.chinese) "fr" -> stringResource(R.string.french) "es" -> stringResource(R.string.spanish) "ar" -> stringResource(R.string.arabic) else -> stringResource(R.string.system_default) }, onClick = { showLanguageSheet = true } ) } Spacer(Modifier.height(32.dp)) // ── SECURITY ──────────────────────────────────────────────────────── SettingsSectionLabel(stringResource(R.string.security)) Column( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(32.dp)) .background(MaterialTheme.colorScheme.surfaceContainerLow) .padding(8.dp) ) { SettingsSwitchRow( icon = R.drawable.round_key, title = stringResource(R.string.biometric_lock), subtitle = if (state.canUseBiometric) { stringResource(R.string.biometric_lock_desc) } else { stringResource(R.string.biometric_not_available) }, checked = prefs.biometricLockEnabled, enabled = state.canUseBiometric, onCheckedChange = { viewModel.setBiometricLock(it) } ) } Spacer(Modifier.height(32.dp)) // ── WORK MODE ─────────────────────────────────────────────────────── val availableModes = buildList { if (state.isRootAvailable) add(PrivilegeMode.ROOT) if (state.isShizukuAvailable) add(PrivilegeMode.SHIZUKU) if (state.isDhizukuAvailable) add(PrivilegeMode.DHIZUKU) } if (availableModes.size > 1) { SettingsSectionLabel(stringResource(R.string.work_mode)) Column( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(32.dp)) .background(MaterialTheme.colorScheme.surfaceContainerLow) .padding(16.dp) ) { Row(verticalAlignment = Alignment.CenterVertically) { val activeMode = prefs.preferredPrivilegeMode ?: availableModes.first() val icon = when (activeMode) { PrivilegeMode.ROOT -> R.drawable.magisk_icon PrivilegeMode.SHIZUKU -> R.drawable.shizuku PrivilegeMode.DHIZUKU -> R.drawable.dhizuku } IconBox(icon) Spacer(Modifier.width(16.dp)) Column { Text( stringResource(R.string.active_engine), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( stringResource(R.string.active_engine_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis ) } } Spacer(Modifier.height(16.dp)) ConnectedButtonGroup( items = availableModes.map { mode -> ConnectedButtonGroupItem.Label(mode.name) }, selectedIndex = availableModes.indexOf( prefs.preferredPrivilegeMode ?: availableModes.first() ), onItemSelected = { viewModel.setPrivilegeMode(availableModes[it]) }, modifier = Modifier.fillMaxWidth() ) } Spacer(Modifier.height(32.dp)) } // ── ABOUT ─────────────────────────────────────────────────────────── SettingsSectionLabel(stringResource(R.string.about)) Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp) ) { // Version Tile Row( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(32.dp)) .background(MaterialTheme.colorScheme.surfaceContainerLow) .padding(20.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { Row(verticalAlignment = Alignment.CenterVertically) { IconBox(R.drawable.thor_mono) Spacer(Modifier.width(16.dp)) Column { Text( stringResource(R.string.version), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( stringResource(R.string.release_candidate), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis ) } } Box( modifier = Modifier .clip(CircleShape) .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)) .padding(horizontal = 12.dp, vertical = 4.dp) ) { Text( versionName, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary ) } } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { AboutTile( title = stringResource(R.string.github), subtitle = stringResource(R.string.source_code), icon = R.drawable.brand_github, modifier = Modifier.weight(1f), onClick = { context.startActivity( Intent( Intent.ACTION_VIEW, "https://github.com/trinadhthatakula/Thor".toUri() ) ) } ) AboutTile( title = stringResource(R.string.telegram), subtitle = stringResource(R.string.community), icon = R.drawable.brand_telegram, modifier = Modifier.weight(1f), onClick = { context.startActivity( Intent( Intent.ACTION_VIEW, "https://t.me/thorAppDev".toUri() ) ) } ) } } // Technical Stats Footer Spacer(Modifier.height(48.dp)) Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Box( modifier = Modifier .size(8.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.primary) ) Text( stringResource(R.string.kernel_status), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } Text( stringResource(R.string.built_with_precision), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), letterSpacing = 4.sp ) } Spacer(Modifier.height(32.dp)) } if (showLanguageSheet) { LanguageBottomSheet( selectedLanguage = prefs.language, onLanguageSelected = { lang -> viewModel.setLanguage(lang) showLanguageSheet = false }, onDismiss = { showLanguageSheet = false } ) } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun LanguageBottomSheet( selectedLanguage: String?, onLanguageSelected: (String?) -> Unit, onDismiss: () -> Unit ) { val languages = listOf( null to stringResource(R.string.system_default), "en" to stringResource(R.string.english), "zh" to stringResource(R.string.chinese), "fr" to stringResource(R.string.french), "es" to stringResource(R.string.spanish), "ar" to stringResource(R.string.arabic), ) ModalBottomSheet( onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), containerColor = MaterialTheme.colorScheme.surfaceContainerLow, shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp) ) { Column( modifier = Modifier .fillMaxWidth() .padding(bottom = 48.dp) .padding(horizontal = 24.dp) ) { Text( stringResource(R.string.select_language), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 24.dp) ) languages.forEach { (code, label) -> val isSelected = selectedLanguage == code Row( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(16.dp)) .background( if (isSelected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent ) .clickable { onLanguageSelected(code) } .padding(16.dp), verticalAlignment = Alignment.CenterVertically ) { Text( text = label, style = MaterialTheme.typography.bodyLarge, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface ) } } } } } @Composable private fun SettingsClickRow( icon: Int, title: String, subtitle: String, onClick: () -> Unit ) { Row( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(24.dp)) .background(MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.5f)) .clickable { onClick() } .padding(16.dp), verticalAlignment = Alignment.CenterVertically ) { IconBox(icon) Spacer(Modifier.width(16.dp)) Column { Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text( subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } @Composable private fun SettingsSectionLabel(label: String) { Text( text = label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 8.dp, bottom = 12.dp), letterSpacing = 2.sp ) } @Composable private fun IconBox(icon: Int) { Box( modifier = Modifier .size(40.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.secondaryContainer), contentAlignment = Alignment.Center ) { Icon( painter = painterResource(icon), contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary ) } } @Composable private fun SettingsSwitchRow( icon: Int, title: String, subtitle: String, checked: Boolean, enabled: Boolean = true, onCheckedChange: (Boolean) -> Unit ) { Row( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(24.dp)) .background(MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.5f)) .clickable(enabled = enabled) { onCheckedChange(!checked) } .padding(16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { IconBox(icon) Spacer(Modifier.width(16.dp)) Column { Text( title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis ) } } Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled) } } @Composable private fun AboutTile( title: String, subtitle: String, icon: Int, modifier: Modifier = Modifier, onClick: () -> Unit ) { Column( modifier = modifier .clip(RoundedCornerShape(32.dp)) .background(MaterialTheme.colorScheme.surfaceContainerLow) .clickable { onClick() } .padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { Box( modifier = Modifier .size(48.dp) .clip(RoundedCornerShape(16.dp)) .background(MaterialTheme.colorScheme.secondaryContainer), contentAlignment = Alignment.Center ) { Icon( painter = painterResource(icon), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.primary ) } Column { Text( title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( subtitle, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, letterSpacing = 1.sp, maxLines = 1, overflow = TextOverflow.Ellipsis ) } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/settings/SettingsViewModel.kt ================================================ package com.valhalla.thor.presentation.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.valhalla.thor.data.security.BiometricHelper import com.valhalla.thor.domain.model.PrivilegeMode import com.valhalla.thor.domain.model.ThemeMode import com.valhalla.thor.domain.model.UserPreferences import com.valhalla.thor.domain.repository.PreferenceRepository import com.valhalla.thor.domain.repository.SystemRepository import com.valhalla.thor.util.LocaleManager import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch class SettingsViewModel( private val preferenceRepository: PreferenceRepository, private val systemRepository: SystemRepository, private val biometricHelper: BiometricHelper, private val localeManager: LocaleManager ) : ViewModel() { data class SettingsUiState( val prefs: UserPreferences = UserPreferences(), val isRootAvailable: Boolean = false, val isShizukuAvailable: Boolean = false, val isDhizukuAvailable: Boolean = false, val canUseBiometric: Boolean = false, val hasBiometricHardware: Boolean = false ) private val _systemStatus = combine( preferenceRepository.userPreferences, // We can't really observe system status easily without a flow, // but we can check it once or periodically. // For simplicity, let's just use a flow that emits once and then combine. kotlinx.coroutines.flow.flow { emit( Triple( systemRepository.isRootAvailable(), systemRepository.isShizukuAvailable(), systemRepository.isDhizukuAvailable() ) ) } ) { prefs, status -> SettingsUiState( prefs = prefs, isRootAvailable = status.first, isShizukuAvailable = status.second, isDhizukuAvailable = status.third, canUseBiometric = biometricHelper.canAuthenticate(), hasBiometricHardware = biometricHelper.hasHardware() ) } val uiState: StateFlow = _systemStatus .stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000), SettingsUiState() ) val preferences = preferenceRepository.userPreferences .stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000), UserPreferences() ) /** True only if the device has enrolled biometrics or a device credential. */ val canUseBiometric: Boolean get() = biometricHelper.canAuthenticate() /** True if the device has biometric hardware at all (even if not enrolled). */ val hasBiometricHardware: Boolean get() = biometricHelper.hasHardware() fun setThemeMode(mode: ThemeMode) { viewModelScope.launch { preferenceRepository.setThemeMode(mode) } } fun setDynamicColor(enabled: Boolean) { viewModelScope.launch { preferenceRepository.setDynamicColor(enabled) } } fun setAmoledMode(enabled: Boolean) { viewModelScope.launch { preferenceRepository.setUseAmoled(enabled) } } fun setBiometricLock(enabled: Boolean) { viewModelScope.launch { preferenceRepository.setBiometricLock(enabled) } } fun setPrivilegeMode(mode: PrivilegeMode?) { viewModelScope.launch { preferenceRepository.setPrivilegeMode(mode) } } fun setReinstallAllCardVisibility(visible: Boolean) { viewModelScope.launch { preferenceRepository.setReinstallAllCardVisibility(visible) } } fun setLanguage(language: String?) { viewModelScope.launch { preferenceRepository.setLanguage(language) localeManager.applyLocale(language) } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/theme/Color.kt ================================================ package com.valhalla.thor.presentation.theme import androidx.compose.ui.graphics.Color val greenLight = Color(0xff4c662b) val greenDark = Color(0xffb1d18a) val OnTertiary = Color(0xff3f3386) val OnPrimaryFixed = Color(0xff2d460f) val TertiaryFixedDim = Color(0xffaca0fb) val SurfaceContainerLow = Color(0xff131313) val Secondary = Color(0xffc7c4dd) val Background = Color(0xff0e0e0e) val OnSecondaryFixedVariant = Color(0xff5e5d72) val ErrorDim = Color(0xffc74c2f) val OutlineVariant = Color(0xff484848) val TertiaryFixed = Color(0xffbaafff) val OnPrimaryFixedVariant = Color(0xff486329) val Tertiary = Color(0xffc8bfff) val OnBackground = Color(0xffe5e5e5) val PrimaryFixed = Color(0xffcceda4) val OnSecondary = Color(0xff3f3e52) val InverseSurface = Color(0xfff9f9f9) val OnPrimary = Color(0xff4c672c) val PrimaryDim = Color(0xffcff0a6) val OnTertiaryFixedVariant = Color(0xff3e3285) val OnTertiaryFixed = Color(0xff1e0b66) val OnTertiaryContainer = Color(0xff35287c) val OnPrimaryContainer = Color(0xff445e25) val TertiaryContainer = Color(0xffbaafff) val SurfaceBright = Color(0xff2c2c2c) val SurfaceTint = Color(0xfff0ffd7) val SurfaceDim = Color(0xff0e0e0e) val SurfaceContainerHigh = Color(0xff1f1f1f) val SurfaceContainer = Color(0xff191919) val Error = Color(0xfffe7453) val SurfaceContainerLowest = Color(0xff000000) val InverseOnSurface = Color(0xff555555) val Outline = Color(0xff757575) val SecondaryFixedDim = Color(0xffdad7f1) val Primary = Color(0xfff0ffd7) val SecondaryDim = Color(0xffb9b6ce) val SecondaryFixed = Color(0xffe9e5ff) val InversePrimary = Color(0xff4c672c) val OnError = Color(0xff450900) val Surface = Color(0xff0e0e0e) val SecondaryContainer = Color(0xff242436) val OnSecondaryContainer = Color(0xffa4a1b9) val TertiaryDim = Color(0xffa195ef) val OnSurfaceVariant = Color(0xffababab) val OnSurface = Color(0xffe5e5e5) val ErrorContainer = Color(0xff881f05) val PrimaryFixedDim = Color(0xffbfdf97) val SurfaceContainerHighest = Color(0xff262626) val PrimaryContainer = Color(0xffd5f6ab) val SurfaceVariant = Color(0xff262626) val OnSecondaryFixed = Color(0xff424155) val OnErrorContainer = Color(0xffff9b82) // --- LIGHT THEME (Asgardian Technical Alchemist) --- val LightSurface = Color(0xfff8faf3) val LightSurfaceContainer = Color(0xffedefe8) val LightSurfaceContainerHighest = Color(0xffe1e3dd) val LightSurfaceContainerHigh = Color(0xffe7e9e2) val LightSurfaceContainerLow = Color(0xfff2f4ed) val LightSurfaceContainerLowest = Color(0xffffffff) val LightPrimary = Color(0xff354e15) val LightOnPrimary = Color(0xffffffff) val LightPrimaryContainer = Color(0xff4c662b) val LightOnPrimaryContainer = Color(0xfff0ffd7) val LightSecondary = Color(0xff55624c) val LightOnSecondary = Color(0xffffffff) val LightSecondaryContainer = Color(0xffd9e7cb) val LightOnSecondaryContainer = Color(0xff131f0d) val LightTertiary = Color(0xff66355d) val LightOnTertiary = Color(0xffffffff) val LightTertiaryContainer = Color(0xfff8d8ee) val LightOnTertiaryContainer = Color(0xff2d112b) val LightOnSurface = Color(0xff191c18) val LightOnSurfaceVariant = Color(0xff43493e) val LightOutline = Color(0xff74796d) val LightOutlineVariant = Color(0xffc3c8bc) val LightError = Color(0xffba1a1a) val LightOnError = Color(0xffffffff) val LightErrorContainer = Color(0xffffdad6) val LightOnErrorContainer = Color(0xff410002) ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/theme/Motion.kt ================================================ package com.valhalla.thor.presentation.theme import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.IntSize /** * Applies the Expressive 'Spatial' spring to layout changes. * Use this instead of the standard [animateContentSize]. */ @Composable fun Modifier.animateExpressiveResize(): Modifier { // Explicitly resolving the spec from the composition local val spatialSpec = MaterialTheme.motionScheme.defaultSpatialSpec() return this.animateContentSize(animationSpec = spatialSpec) } /** * Adds a physical "squish" scale effect on press. * Best for Cards, Boxes, or custom Buttons that need tactile feedback. */ fun Modifier.expressivePress( interactionSource: MutableInteractionSource, scaleOnPress: Float = 0.95f ): Modifier = composed { val isPressed by interactionSource.collectIsPressedAsState() // Use 'fastSpatialSpec' for micro-interactions like touches val scale by animateFloatAsState( targetValue = if (isPressed) scaleOnPress else 1f, animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(), label = "expressivePressScale" ) this .graphicsLayer { scaleX = scale scaleY = scale } } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/theme/Theme.kt ================================================ package com.valhalla.thor.presentation.theme import android.os.Build import androidx.activity.compose.LocalActivity import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.material3.MotionScheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat private val AsgardianLightColorScheme = lightColorScheme( primary = LightPrimary, onPrimary = LightOnPrimary, primaryContainer = LightPrimaryContainer, onPrimaryContainer = LightOnPrimaryContainer, secondary = LightSecondary, onSecondary = LightOnSecondary, secondaryContainer = LightSecondaryContainer, onSecondaryContainer = LightOnSecondaryContainer, tertiary = LightTertiary, onTertiary = LightOnTertiary, tertiaryContainer = LightTertiaryContainer, onTertiaryContainer = LightOnTertiaryContainer, error = LightError, onError = LightOnError, errorContainer = LightErrorContainer, onBackground = LightOnSurface, surface = LightSurface, onSurface = LightOnSurface, surfaceVariant = LightSurfaceContainer, onSurfaceVariant = LightOnSurfaceVariant, outline = LightOutline, outlineVariant = LightOutlineVariant, background = LightSurface, surfaceContainerLowest = LightSurfaceContainerLowest, surfaceContainerLow = LightSurfaceContainerLow, surfaceContainer = LightSurfaceContainer, surfaceContainerHigh = LightSurfaceContainerHigh, surfaceContainerHighest = LightSurfaceContainerHighest, ) private val AsgardianDarkColorScheme = darkColorScheme( primary = Primary, onPrimary = OnPrimary, primaryContainer = PrimaryContainer, onPrimaryContainer = OnPrimaryContainer, secondary = Secondary, onSecondary = OnSecondary, secondaryContainer = SecondaryContainer, onSecondaryContainer = OnSecondaryContainer, tertiary = Tertiary, onTertiary = OnTertiary, tertiaryContainer = TertiaryContainer, onTertiaryContainer = OnTertiaryContainer, error = Error, onError = OnError, errorContainer = ErrorContainer, onBackground = OnBackground, surface = Surface, onSurface = OnSurface, surfaceVariant = SurfaceVariant, onSurfaceVariant = OnSurfaceVariant, outline = Outline, outlineVariant = OutlineVariant, inverseSurface = InverseSurface, inverseOnSurface = InverseOnSurface, inversePrimary = InversePrimary, surfaceTint = SurfaceTint, background = Background, surfaceContainerLowest = SurfaceContainerLowest, surfaceContainerLow = SurfaceContainerLow, surfaceContainer = SurfaceContainer, surfaceContainerHigh = SurfaceContainerHigh, surfaceContainerHighest = SurfaceContainerHighest, ) @Composable fun ThorTheme( darkTheme: Boolean = isSystemInDarkTheme(), // Dynamic color is available on Android 12+ dynamicColor: Boolean = false, // Disabled for Asgardian Terminal look amoledMode: Boolean = true, content: @Composable () -> Unit ) { val context = LocalContext.current val colorScheme = when { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { if (darkTheme) { dynamicDarkColorScheme(context).run { if(amoledMode){ copy( background = Color.Black, surface = Color.Black, surfaceVariant = Color.Black ) } else { this } } } else { dynamicLightColorScheme(context) } } darkTheme -> { if (amoledMode) { AsgardianDarkColorScheme.copy( background = Color.Black, surface = Color.Black, surfaceVariant = Color.Black ) } else { AsgardianDarkColorScheme } } else -> AsgardianLightColorScheme } //make status bar icon match the dark theme mode val view = LocalView.current val activity = LocalActivity.current if (!view.isInEditMode) { SideEffect { activity?.window?.let { WindowCompat.getInsetsController(it, view) .isAppearanceLightStatusBars = !darkTheme } } } MaterialExpressiveTheme( colorScheme = colorScheme, motionScheme = MotionScheme.expressive(), typography = AppTypography, content = content ) } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/theme/Type.kt ================================================ package com.valhalla.thor.presentation.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import com.valhalla.thor.R import androidx.compose.ui.text.font.Font as ResFont val firaMonoFontFamily = FontFamily( ResFont(resId = R.font.firacode_variable) ) val bodyFontFamily = FontFamily( ResFont(resId = R.font.outfit_regular, weight = FontWeight.Normal, style = FontStyle.Normal), ResFont(resId = R.font.outfit_black, weight = FontWeight.Black, style = FontStyle.Normal), ResFont(resId = R.font.outfit_bold, weight = FontWeight.Bold, style = FontStyle.Normal), ResFont( resId = R.font.outfit_extrabold, weight = FontWeight.ExtraBold, style = FontStyle.Normal ), ResFont( resId = R.font.outfit_extralight, weight = FontWeight.ExtraLight, style = FontStyle.Normal ), ResFont(resId = R.font.outfit_light, weight = FontWeight.Light, style = FontStyle.Normal), ResFont(resId = R.font.outfit_medium, weight = FontWeight.Medium, style = FontStyle.Normal), ResFont(resId = R.font.outfit_semibold, weight = FontWeight.SemiBold, style = FontStyle.Normal), ResFont(resId = R.font.outfit_thin, weight = FontWeight.Thin, style = FontStyle.Normal), ResFont(resId = R.font.outfit_regular, weight = FontWeight.Normal, style = FontStyle.Italic), ResFont(resId = R.font.outfit_black, weight = FontWeight.Black, style = FontStyle.Italic), ResFont(resId = R.font.outfit_bold, weight = FontWeight.Bold, style = FontStyle.Italic), ResFont( resId = R.font.outfit_extrabold, weight = FontWeight.ExtraBold, style = FontStyle.Italic ), ResFont( resId = R.font.outfit_extralight, weight = FontWeight.ExtraLight, style = FontStyle.Italic ), ResFont(resId = R.font.outfit_light, weight = FontWeight.Light, style = FontStyle.Italic), ResFont(resId = R.font.outfit_medium, weight = FontWeight.Medium, style = FontStyle.Italic), ResFont(resId = R.font.outfit_semibold, weight = FontWeight.SemiBold, style = FontStyle.Italic), ResFont(resId = R.font.outfit_thin, weight = FontWeight.Thin, style = FontStyle.Italic), ) val displayFontFamily = bodyFontFamily // Default Material 3 typography values val baseline = Typography() val AppTypography = Typography( displayLarge = baseline.displayLarge.copy( fontFamily = displayFontFamily, fontWeight = FontWeight.Black ), displayMedium = baseline.displayMedium.copy( fontFamily = displayFontFamily, fontWeight = FontWeight.ExtraBold ), displaySmall = baseline.displaySmall.copy( fontFamily = displayFontFamily, fontWeight = FontWeight.Bold ), headlineLarge = baseline.headlineLarge.copy( fontFamily = displayFontFamily, fontWeight = FontWeight.Bold ), headlineMedium = baseline.headlineMedium.copy( fontFamily = displayFontFamily, fontWeight = FontWeight.Bold ), headlineSmall = baseline.headlineSmall.copy( fontFamily = displayFontFamily, fontWeight = FontWeight.Medium ), titleLarge = baseline.titleLarge.copy( fontFamily = displayFontFamily, fontWeight = FontWeight.SemiBold ), titleMedium = baseline.titleMedium.copy( fontFamily = displayFontFamily, fontWeight = FontWeight.Medium ), titleSmall = baseline.titleSmall.copy( fontFamily = displayFontFamily, fontWeight = FontWeight.Normal ), bodyLarge = baseline.bodyLarge.copy(fontFamily = bodyFontFamily), bodyMedium = baseline.bodyMedium.copy(fontFamily = bodyFontFamily), bodySmall = baseline.bodySmall.copy(fontFamily = bodyFontFamily), labelLarge = baseline.labelLarge.copy( fontFamily = firaMonoFontFamily, fontWeight = FontWeight.Medium ), labelMedium = baseline.labelMedium.copy( fontFamily = firaMonoFontFamily, fontWeight = FontWeight.Normal ), labelSmall = baseline.labelSmall.copy( fontFamily = firaMonoFontFamily, fontWeight = FontWeight.Normal ), ) ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/utils/AppIconLoader.kt ================================================ package com.valhalla.thor.presentation.utils import android.content.Context import android.content.pm.PackageManager import coil3.ImageLoader import coil3.asImage import coil3.decode.DataSource import coil3.fetch.FetchResult import coil3.fetch.Fetcher import coil3.fetch.ImageFetchResult import coil3.key.Keyer import coil3.request.Options /** * A lightweight data class to trigger our custom fetcher. */ data class AppIconModel(val packageName: String) /** * The worker that loads the icon on the IO thread. */ class AppIconFetcher( private val model: AppIconModel, private val context: Context ) : Fetcher { override suspend fun fetch(): FetchResult? { return try { // RUTHLESS: This is where the heavy lifting happens, safely on the IO thread // because Coil calls fetch() on its own dispatcher. val drawable = context.packageManager.getApplicationIcon(model.packageName) // Return the result to Coil ImageFetchResult( image = drawable.asImage(), isSampled = false, dataSource = DataSource.DISK ) } catch (_: PackageManager.NameNotFoundException) { null // Let Coil handle the error/fallback } } class Factory(private val context: Context) : Fetcher.Factory { override fun create( data: AppIconModel, options: Options, imageLoader: ImageLoader ): Fetcher { return AppIconFetcher(data, context) } } } /** * Tells Coil how to cache this image in memory. * Using just the packageName is usually enough, but technically if the app updates, * the icon might change. For a system manager, packageName is sufficient for session cache. */ class AppIconKeyer : Keyer { override fun key(data: AppIconModel, options: Options): String { return "app_icon:${data.packageName}" } } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/utils/CacheScanner.kt ================================================ package com.valhalla.thor.presentation.utils import com.valhalla.thor.domain.repository.SystemRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.BufferedReader import java.io.InputStreamReader class CacheScanner( private val systemRepository: SystemRepository ) { /** * Scans cache sizes using Root. * @param filterPackageNames If provided, only calculates cache for these specific packages. * This filters out "zombie" folders from uninstalled apps. */ suspend fun getCacheSize(filterPackageNames: List? = null): String = withContext(Dispatchers.IO) { if (!systemRepository.isRootAvailable()) { return@withContext "N/A" } try { // Command: List summary (-s) in KB (-k) for all directories in standard cache locations. // 2>/dev/null suppresses "Permission denied" or "No such file" errors val command = "du -k -s /data/data/*/cache /sdcard/Android/data/*/cache 2>/dev/null" val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command)) val reader = BufferedReader(InputStreamReader(process.inputStream)) var totalSizeKb = 0L val filterSet = filterPackageNames?.toSet() var line: String? = reader.readLine() while (line != null) { // Output format: "1234 /data/data/com.package/cache" val parts = line.trim().split("\\s+".toRegex()) if (parts.size >= 2) { val size = parts[0].toLongOrNull() ?: 0L val path = parts[1] if (filterSet != null) { // Robustly extract package name. // It's the segment immediately preceding "/cache" val packageName = path.substringBeforeLast("/cache").substringAfterLast("/") if (packageName in filterSet) { totalSizeKb += size } } else { // No filter -> Add everything totalSizeKb += size } } line = reader.readLine() } process.waitFor() formatSize(totalSizeKb * 1024) } catch (e: Exception) { e.printStackTrace() "Error" } } private fun formatSize(sizeBytes: Long): String { if (sizeBytes <= 0) return "0 B" val units = arrayOf("B", "KB", "MB", "GB", "TB") val digitGroups = (Math.log10(sizeBytes.toDouble()) / Math.log10(1024.0)).toInt() return String.format( "%.1f %s", sizeBytes / Math.pow(1024.0, digitGroups.toDouble()), units[digitGroups] ) } } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/utils/UiUtils.kt ================================================ package com.valhalla.thor.presentation.utils import android.content.Context import android.graphics.drawable.Drawable import com.valhalla.thor.BuildConfig fun getAppIcon(packageName: String?, context: Context): Drawable? { return packageName?.let { try { context.packageManager.getApplicationIcon(packageName) } catch (e: Exception) { if (BuildConfig.DEBUG) e.printStackTrace() null } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/widgets/AffirmationDialog.kt ================================================ package com.valhalla.thor.presentation.widgets import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.valhalla.thor.domain.model.MultiAppAction @Composable fun AffirmationDialog( modifier: Modifier = Modifier, title: String = "Are you sure?", text: String = "Some Message", icon: Int? = null, onConfirm: () -> Unit, onRejected: () -> Unit ) { AlertDialog( modifier = modifier, onDismissRequest = onRejected, containerColor = MaterialTheme.colorScheme.surfaceContainer, shape = RoundedCornerShape(48.dp), confirmButton = { TextButton(onClick = onConfirm) { Text( "Confirm", fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary ) } }, dismissButton = { TextButton(onClick = onRejected) { Text("Cancel", color = MaterialTheme.colorScheme.onSurfaceVariant) } }, icon = { icon?.let { Box( modifier = Modifier .size(64.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.surfaceContainerHigh), contentAlignment = Alignment.Center ) { Icon( painter = painterResource(it), contentDescription = null, modifier = Modifier.size(32.dp), tint = MaterialTheme.colorScheme.primary ) } } }, title = { Text( title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Black, letterSpacing = (-1).sp ) }, text = { Text( text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } ) } @Composable fun MultiAppAffirmationDialog( modifier: Modifier = Modifier, multiAppAction: MultiAppAction, title: String = "Are you sure?", onConfirm: () -> Unit, onRejected: () -> Unit ) { AlertDialog( modifier = modifier, onDismissRequest = onRejected, containerColor = MaterialTheme.colorScheme.surfaceContainer, shape = RoundedCornerShape(48.dp), confirmButton = { TextButton(onClick = onConfirm) { Text( "Confirm", fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary ) } }, dismissButton = { TextButton(onClick = onRejected) { Text("Cancel", color = MaterialTheme.colorScheme.onSurfaceVariant) } }, title = { Text( title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Black, letterSpacing = (-1).sp ) }, text = { Text( text = when (multiAppAction) { is MultiAppAction.ClearCache -> { "This will clear Cache of ${multiAppAction.appList.size} apps. Proceed?" } is MultiAppAction.Freeze -> { val activeAppsCount = multiAppAction.appList.count { it.enabled } "$activeAppsCount of ${multiAppAction.appList.size} apps are active. Freeze them?" } is MultiAppAction.Kill -> { "Force stop ${multiAppAction.appList.size} apps?" } is MultiAppAction.ReInstall -> { "Reinstall ${multiAppAction.appList.size} apps with Google Play Store signature?" } is MultiAppAction.Share -> "Share ${multiAppAction.appList.size} apps?" is MultiAppAction.UnFreeze -> { val frozenAppsCount = multiAppAction.appList.count { !it.enabled } "$frozenAppsCount of ${multiAppAction.appList.size} apps are frozen. Unfreeze them?" } is MultiAppAction.Uninstall -> "Uninstall ${multiAppAction.appList.size} apps?" is MultiAppAction.ClearData -> "Permanently clear data for ${multiAppAction.appList.size} apps? This cannot be undone." is MultiAppAction.Suspend -> "Suspend ${multiAppAction.appList.size} apps? This will restrict background activities." is MultiAppAction.UnSuspend -> "Unsuspend ${multiAppAction.appList.size} apps?" }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } ) } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/widgets/AnimateLottieRaw.kt ================================================ package com.valhalla.thor.presentation.widgets import androidx.annotation.RawRes import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import com.airbnb.lottie.compose.LottieAnimation import com.airbnb.lottie.compose.LottieCompositionSpec import com.airbnb.lottie.compose.LottieConstants import com.airbnb.lottie.compose.animateLottieCompositionAsState import com.airbnb.lottie.compose.rememberLottieComposition @Composable fun AnimateLottieRaw( modifier: Modifier = Modifier, @RawRes resId: Int, shouldLoop: Boolean = false, repeatCount: Int = LottieConstants.IterateForever, contentScale: ContentScale = ContentScale.None ) { val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(resId)) val progress by animateLottieCompositionAsState( composition = composition, iterations = if (shouldLoop) repeatCount else 1 ) LottieAnimation( composition = composition, progress = { progress }, modifier = modifier, contentScale = contentScale ) } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/widgets/AppInfoDialog.kt ================================================ package com.valhalla.thor.presentation.widgets import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll 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.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState 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 import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.rememberAsyncImagePainter import com.valhalla.thor.R import com.valhalla.thor.domain.model.AppClickAction import com.valhalla.thor.domain.model.AppInfo import com.valhalla.thor.presentation.utils.getAppIcon @OptIn(ExperimentalMaterial3Api::class) @Composable fun AppInfoDialog( appInfo: AppInfo, isRoot: Boolean = false, isShizuku: Boolean = false, onDismiss: () -> Unit, onAppAction: (AppClickAction) -> Unit = {} ) { // FIX: skipPartiallyExpanded = true prevents the "offset not initialized" crash // by avoiding ambiguous anchor calculations for dynamic content. val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) var showUninstallConfirmation by remember { mutableStateOf(false) } var showReinstallWarning by remember { mutableStateOf(false) } var showClearDataConfirmation by remember { mutableStateOf(false) } ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surfaceContainer, contentColor = MaterialTheme.colorScheme.onSurface, shape = RoundedCornerShape(topStart = 48.dp, topEnd = 48.dp), tonalElevation = 0.dp ) { Column( modifier = Modifier .fillMaxWidth() .padding(bottom = 32.dp), // Add bottom padding for nav bar horizontalAlignment = Alignment.CenterHorizontally ) { // 1. Header (Icon + Title) AppHeader(appInfo) { onAppAction(AppClickAction.AppInfoSettings(appInfo)) } Spacer(modifier = Modifier.height(16.dp)) // 2. Action Buttons (Scrollable Row) AppActionRow( appInfo = appInfo, isRoot = isRoot, isShizuku = isShizuku, onAction = { action -> // Intercept dangerous actions for local confirmation when (action) { is AppClickAction.Uninstall -> { if (appInfo.isSystem) showUninstallConfirmation = true else { onAppAction(action) onDismiss() } } is AppClickAction.Reinstall -> showReinstallWarning = true is AppClickAction.ClearData -> showClearDataConfirmation = true else -> { onAppAction(action) if (action is AppClickAction.Launch) onDismiss() } } } ) } } // --- ALERTS --- if (showClearDataConfirmation) { AlertDialog( onDismissRequest = { showClearDataConfirmation = false }, icon = { Icon( painterResource(R.drawable.danger), null, tint = MaterialTheme.colorScheme.error ) }, title = { Text(stringResource(R.string.clear_app_data_title)) }, text = { Text(stringResource(R.string.clear_app_data_desc, appInfo.appName ?: "")) }, confirmButton = { TextButton(onClick = { onAppAction(AppClickAction.ClearData(appInfo)) showClearDataConfirmation = false onDismiss() }) { Text(stringResource(R.string.clear_all_data)) } }, dismissButton = { TextButton(onClick = { showClearDataConfirmation = false }) { Text(stringResource(R.string.cancel)) } } ) } if (showUninstallConfirmation) { AlertDialog( onDismissRequest = { showUninstallConfirmation = false }, title = { Text(stringResource(R.string.uninstall_system_app_title)) }, text = { Text(stringResource(R.string.uninstall_system_app_desc)) }, confirmButton = { TextButton(onClick = { onAppAction(AppClickAction.Uninstall(appInfo)) showUninstallConfirmation = false onDismiss() }) { Text(stringResource(R.string.yes)) } }, dismissButton = { TextButton(onClick = { showUninstallConfirmation = false }) { Text(stringResource(R.string.no)) } } ) } if (showReinstallWarning) { AlertDialog( icon = { Icon( painterResource(R.drawable.warning), null, tint = MaterialTheme.colorScheme.error ) }, onDismissRequest = { showReinstallWarning = false }, title = { Text(stringResource(R.string.risk_warning_title)) }, text = { Text(stringResource(R.string.risk_warning_desc)) }, confirmButton = { TextButton(onClick = { onAppAction(AppClickAction.Reinstall(appInfo)) showReinstallWarning = false onDismiss() }) { Text(stringResource(R.string.proceed)) } }, dismissButton = { TextButton(onClick = { showReinstallWarning = false }) { Text(stringResource(R.string.cancel)) } } ) } } @Composable private fun AppHeader( appInfo: AppInfo, onSettingsClick: () -> Unit ) { val context = LocalContext.current Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp) ) { // Top row with settings and close? Or just settings. Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End ) { IconButton( onClick = onSettingsClick, modifier = Modifier .clip(CircleShape) .background(MaterialTheme.colorScheme.surfaceContainerHigh) ) { Icon( painterResource(R.drawable.settings), stringResource(R.string.cd_settings), tint = MaterialTheme.colorScheme.primary ) } } Spacer(Modifier.height(16.dp)) // Icon with a nice background Box( modifier = Modifier .size(100.dp) .clip(RoundedCornerShape(32.dp)) .background(MaterialTheme.colorScheme.surfaceContainerHigh) .padding(16.dp), contentAlignment = Alignment.Center ) { Image( painter = rememberAsyncImagePainter(getAppIcon(appInfo.packageName, context)), contentDescription = null, modifier = Modifier.fillMaxSize() ) } Spacer(Modifier.height(24.dp)) // Title Text( text = appInfo.appName ?: stringResource(R.string.unknown), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Black, textAlign = TextAlign.Center, letterSpacing = (-1).sp, maxLines = 2, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis ) Spacer(Modifier.height(8.dp)) // Metadata Chips Row( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { if (appInfo.splitPublicSourceDirs.isNotEmpty()) { StatusChip( text = stringResource(R.string.status_split), color = MaterialTheme.colorScheme.tertiaryContainer ) } if (!appInfo.enabled) { StatusChip( text = stringResource(R.string.status_frozen), color = MaterialTheme.colorScheme.errorContainer ) } if (appInfo.isSuspended) { StatusChip( text = stringResource(R.string.status_suspended), color = MaterialTheme.colorScheme.secondaryContainer ) } StatusChip( text = "v${appInfo.versionName}", color = MaterialTheme.colorScheme.surfaceContainerHighest, textColor = MaterialTheme.colorScheme.onSurfaceVariant ) } Spacer(Modifier.height(12.dp)) // Package Name Text( text = appInfo.packageName, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, fontFamily = com.valhalla.thor.presentation.theme.firaMonoFontFamily ) } } @Composable private fun StatusChip( text: String, color: Color, textColor: Color = MaterialTheme.colorScheme.onSurface ) { Text( text = text, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, modifier = Modifier .clip(CircleShape) .background(color) .padding(horizontal = 12.dp, vertical = 4.dp), color = textColor ) } @Composable private fun AppActionRow( appInfo: AppInfo, isRoot: Boolean, isShizuku: Boolean, onAction: (AppClickAction) -> Unit ) { val hasPrivilege = isRoot || isShizuku val isFrozen = !appInfo.enabled val isSuspended = appInfo.isSuspended // Need to ensure this is in AppInfo Row( modifier = Modifier .fillMaxWidth() .horizontalScroll(rememberScrollState()) .padding(horizontal = 16.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically ) { // 1. Standard Actions ActionItem(R.drawable.open_in_new, stringResource(R.string.action_launch)) { onAction( AppClickAction.Launch(appInfo) ) } ActionItem(R.drawable.share, stringResource(R.string.action_share)) { onAction( AppClickAction.Share(appInfo) ) } // 2. Privileged Actions if (hasPrivilege) { val (freezeIcon, freezeLabel) = if (isFrozen) R.drawable.freeze_off to stringResource(R.string.action_unfreeze) else R.drawable.frozen to stringResource( R.string.action_freeze ) ActionItem(freezeIcon, freezeLabel) { onAction( if (isFrozen) AppClickAction.UnFreeze(appInfo) else AppClickAction.Freeze( appInfo ) ) } val (suspendIcon, suspendLabel) = if (isSuspended) R.drawable.bolt to stringResource(R.string.action_unsuspend) else R.drawable.warning to stringResource( R.string.action_suspend ) ActionItem(suspendIcon, suspendLabel) { onAction( if (isSuspended) AppClickAction.UnSuspend(appInfo) else AppClickAction.Suspend( appInfo ) ) } if (appInfo.enabled) { ActionItem(R.drawable.danger, stringResource(R.string.action_kill)) { onAction( AppClickAction.Kill(appInfo) ) } } ActionItem(R.drawable.clear_all, stringResource(R.string.action_cache)) { onAction( AppClickAction.ClearCache(appInfo) ) } ActionItem(R.drawable.delete, stringResource(R.string.action_data)) { onAction( AppClickAction.ClearData(appInfo) ) } } // 3. App Store Fix if (hasPrivilege && !appInfo.isSystem && appInfo.installerPackageName != "com.android.vending") { ActionItem(R.drawable.apk_install, stringResource(R.string.fix_store)) { onAction(AppClickAction.Reinstall(appInfo)) } } // 4. Uninstall if (appInfo.packageName != "com.valhalla.thor") { ActionItem(R.drawable.delete_forever, stringResource(R.string.action_uninstall)) { onAction(AppClickAction.Uninstall(appInfo)) } } } } @Composable private fun ActionItem(icon: Int, label: String, onClick: () -> Unit) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .clip(RoundedCornerShape(24.dp)) .clickable(onClick = onClick) .padding(8.dp) ) { // Use a Tonal Button style for better touch targets Box( modifier = Modifier .size(64.dp) .clip(RoundedCornerShape(24.dp)) .background(MaterialTheme.colorScheme.surfaceContainerHigh), contentAlignment = Alignment.Center ) { Icon( painter = painterResource(icon), contentDescription = label, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.primary ) } Spacer(Modifier.height(8.dp)) Text( text = label, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, maxLines = 1 ) } } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/widgets/AppList.kt ================================================ package com.valhalla.thor.presentation.widgets import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.isImeVisible import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.Checkbox import androidx.compose.material3.ContainedLoadingIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.ImageLoader import coil3.compose.AsyncImage import com.valhalla.thor.R import com.valhalla.thor.domain.model.AppInfo import com.valhalla.thor.domain.model.AppListType import com.valhalla.thor.domain.model.FilterType import com.valhalla.thor.domain.model.MultiAppAction import com.valhalla.thor.domain.model.SortBy import com.valhalla.thor.domain.model.SortOrder import com.valhalla.thor.domain.model.asGeneralName import com.valhalla.thor.domain.model.filterTypes import com.valhalla.thor.presentation.common.components.ConnectedButtonGroup import com.valhalla.thor.presentation.common.components.ConnectedButtonGroupItem import com.valhalla.thor.presentation.theme.expressivePress import com.valhalla.thor.presentation.utils.AppIconModel @Composable fun AppList( modifier: Modifier = Modifier, appListType: AppListType, installers: List, appList: List, selectedFilter: String?, filterType: FilterType = FilterType.Source, sortBy: SortBy = SortBy.NAME, sortOrder: SortOrder = SortOrder.ASCENDING, searchQuery: String = "", isLoading: Boolean = false, startAsGrid: Boolean = true, isRoot: Boolean = false, isShizuku: Boolean = false, imageLoader: ImageLoader, installerNameMap: Map = emptyMap(), onSortOrderSelected: (SortOrder) -> Unit = {}, onSortByChanged: (SortBy) -> Unit = {}, onFilterSelected: (String?) -> Unit, onSearchQueryChange: (String) -> Unit = {}, onFilterTypeChanged: (FilterType) -> Unit = {}, onListTypeChanged: (AppListType) -> Unit = {}, onAppInfoSelected: (AppInfo) -> Unit, onMultiAppAction: (MultiAppAction) -> Unit = {} ) { // 1. Local State var isGrid by rememberSaveable { mutableStateOf(startAsGrid) } var showFilterSheet by rememberSaveable { mutableStateOf(false) } var multiSelection by rememberSaveable { mutableStateOf(emptyList()) } // Optimization: Use a Set for O(1) lookups val selectedPackageNames by remember(multiSelection) { derivedStateOf { multiSelection.map { it.packageName }.toSet() } } val isMultiSelectMode = multiSelection.isNotEmpty() // 2. Logic BackHandler(isMultiSelectMode) { multiSelection = emptyList() } LaunchedEffect(appListType) { multiSelection = emptyList() } // 3. UI Layout Box(modifier = modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) { // Search Bar AppSearchBar( query = searchQuery, onQueryChange = onSearchQueryChange, onOpenConfig = { showFilterSheet = true } ) // System App Warning if (appListType == AppListType.SYSTEM) { Text( text = stringResource(R.string.system_apps_warning), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(start = 24.dp, top = 4.dp, bottom = 4.dp), maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis ) } // Headers (Control Bar or MultiSelect Header) Box { this@Column.AnimatedVisibility( visible = !isMultiSelectMode, enter = expandVertically(), exit = shrinkVertically() ) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { AppQuickFilters( installers = installers, selectedFilter = selectedFilter, filterType = filterType, appListType = appListType, installerNameMap = installerNameMap, onFilterSelected = onFilterSelected ) } } this@Column.AnimatedVisibility( visible = isMultiSelectMode, enter = expandVertically(), exit = shrinkVertically() ) { MultiSelectHeader( count = multiSelection.size, isAllSelected = multiSelection.size == appList.size && appList.isNotEmpty(), onSelectAll = { selectAll -> multiSelection = if (selectAll) appList else emptyList() }, onClear = { multiSelection = emptyList() } ) } } // App Content (Grid or List) if (appList.isEmpty()) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { if (isLoading) { ContainedLoadingIndicator() } else { EmptyStatePlaceholder( isFiltering = searchQuery.isNotEmpty() || selectedFilter != "All" ) } } } else { AppListContent( list = appList, isGrid = isGrid, selectedPackageNames = selectedPackageNames, // Pass Set instead of List imageLoader = imageLoader, onAppClick = { app -> if (isMultiSelectMode) { multiSelection = toggleSelection(multiSelection, app) } else { onAppInfoSelected(app) } }, onAppLongClick = { app -> multiSelection = toggleSelection(multiSelection, app) } ) } } // Floating Action Toolbar (Multi-Select) if (isMultiSelectMode) { MultiSelectToolBox( selected = multiSelection, modifier = Modifier .padding(horizontal = 16.dp, vertical = 32.dp) .align(Alignment.BottomEnd), isRoot = isRoot, isShizuku = isShizuku, onCancel = { multiSelection = emptyList() }, onMultiAppAction = { action -> onMultiAppAction(action) multiSelection = emptyList() } ) } } if (showFilterSheet) { AppFilterSheet( onDismiss = { showFilterSheet = false }, filterType = filterType, sortBy = sortBy, sortOrder = sortOrder, isGrid = isGrid, appListType = appListType, onFilterTypeChanged = onFilterTypeChanged, onSortByChanged = onSortByChanged, onSortOrderChanged = onSortOrderSelected, onToggleView = { isGrid = !isGrid }, onListTypeChanged = onListTypeChanged ) } } // --- SUB-COMPONENTS --- @Composable private fun AppQuickFilters( installers: List, selectedFilter: String?, filterType: FilterType, appListType: AppListType, installerNameMap: Map, onFilterSelected: (String?) -> Unit ) { Row( modifier = Modifier .fillMaxWidth() .horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { val chips = if (filterType == FilterType.Source) installers else (filterType as? FilterType.State)?.types ?: emptyList() chips.forEach { item -> val label = when { filterType == FilterType.Source -> { if (item == "All") "All" else installerNameMap[item] ?: item ?: if (appListType != AppListType.SYSTEM) "Others" else stringResource(R.string.system_apps) } else -> item ?: "" } FilterChip( selected = item == selectedFilter, onClick = { onFilterSelected(item) }, label = { Text(label) }, colors = androidx.compose.material3.FilterChipDefaults.filterChipColors( selectedContainerColor = MaterialTheme.colorScheme.primary, selectedLabelColor = MaterialTheme.colorScheme.onPrimary ) ) } } } @OptIn(ExperimentalLayoutApi::class) @Composable private fun AppSearchBar( query: String, onQueryChange: (String) -> Unit, onOpenConfig: () -> Unit ) { val keyboardController = LocalSoftwareKeyboardController.current val isImeVisible = WindowInsets.isImeVisible BackHandler(enabled = isImeVisible || query.isNotEmpty()) { if (isImeVisible) keyboardController?.hide() else onQueryChange("") } Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { Box( modifier = Modifier .weight(1f) .clip(RoundedCornerShape(32.dp)) .background(MaterialTheme.colorScheme.surfaceContainerLow) .padding(4.dp) ) { BasicTextField( value = query, onValueChange = onQueryChange, modifier = Modifier .fillMaxWidth() .padding(12.dp), textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Text, capitalization = KeyboardCapitalization.Words, imeAction = ImeAction.Search ), decorationBox = { innerTextField -> Row(verticalAlignment = Alignment.CenterVertically) { Icon( painter = painterResource(R.drawable.round_search), contentDescription = "Search", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(start = 8.dp) ) Spacer(modifier = Modifier.width(12.dp)) Box(modifier = Modifier.weight(1f)) { if (query.isEmpty()) { Text( stringResource(R.string.search_apps), color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis ) } innerTextField() } if (query.isNotEmpty()) { Icon( painter = painterResource(R.drawable.round_close), contentDescription = stringResource(R.string.cd_clear), tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier .clip(CircleShape) .clickable { onQueryChange("") } .padding(8.dp) ) } } } ) } IconButton( onClick = onOpenConfig, modifier = Modifier .size(48.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.surfaceContainerLow) ) { Icon( painter = painterResource(R.drawable.filter_list), contentDescription = stringResource(R.string.cd_config), tint = MaterialTheme.colorScheme.primary ) } } } @Composable private fun MultiSelectHeader( count: Int, isAllSelected: Boolean, onSelectAll: (Boolean) -> Unit, onClear: () -> Unit ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp, vertical = 8.dp) .clip(RoundedCornerShape(32.dp)) .background(MaterialTheme.colorScheme.secondaryContainer) .padding(horizontal = 16.dp, vertical = 8.dp) ) { Checkbox( checked = isAllSelected, onCheckedChange = onSelectAll ) Text( text = stringResource(R.string.selected_count, count), style = MaterialTheme.typography.titleMedium, fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, color = MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier .weight(1f) .padding(start = 8.dp), maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis ) IconButton(onClick = onClear) { Icon( painterResource(R.drawable.round_close), stringResource(R.string.cd_close), tint = MaterialTheme.colorScheme.onSecondaryContainer ) } } } @Composable private fun AppListContent( list: List, isGrid: Boolean, selectedPackageNames: Set, imageLoader: ImageLoader, onAppClick: (AppInfo) -> Unit, onAppLongClick: (AppInfo) -> Unit ) { // Shared padding for list/grid val padding = PaddingValues(bottom = 100.dp, top = 8.dp) if (isGrid) { LazyVerticalGrid( columns = GridCells.Adaptive(minSize = 100.dp), contentPadding = padding ) { items(list, key = { it.packageName }) { app -> AppItemGrid( app = app, isSelected = selectedPackageNames.contains(app.packageName), imageLoader = imageLoader, onClick = { onAppClick(app) }, onLongClick = { onAppLongClick(app) } ) } } } else { LazyColumn(contentPadding = padding) { items(list, key = { it.packageName }) { app -> AppItemList( app = app, isSelected = selectedPackageNames.contains(app.packageName), imageLoader = imageLoader, onClick = { onAppClick(app) }, onLongClick = { onAppLongClick(app) } ) } } } } @OptIn(ExperimentalFoundationApi::class) @Composable private fun AppItemList( app: AppInfo, isSelected: Boolean, imageLoader: ImageLoader, onClick: () -> Unit, onLongClick: () -> Unit ) { val interactionSource = remember { MutableInteractionSource() } ListItem( modifier = Modifier .padding(horizontal = 12.dp, vertical = 2.dp) .clip(RoundedCornerShape(24.dp)) .expressivePress(interactionSource) .combinedClickable( interactionSource = interactionSource, onClick = onClick, onLongClick = onLongClick ) .background( if (isSelected) MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f) else MaterialTheme.colorScheme.surfaceContainerLow ), leadingContent = { AppIcon(app.packageName, app.enabled, app.isSuspended, 48.dp, imageLoader) }, headlineContent = { Row(verticalAlignment = Alignment.CenterVertically) { Text( app.appName ?: stringResource(R.string.unknown), maxLines = 1, fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false) ) if (!app.enabled) { Icon( painterResource(R.drawable.frozen), stringResource(R.string.cd_frozen), modifier = Modifier .size(16.dp) .padding(start = 4.dp), tint = MaterialTheme.colorScheme.primary ) } else if (app.isSuspended) { Icon( painterResource(R.drawable.bolt), stringResource(R.string.cd_suspended), modifier = Modifier .size(16.dp) .padding(start = 4.dp), tint = MaterialTheme.colorScheme.secondary ) } } }, supportingContent = { Text( app.packageName, maxLines = 1, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) }, trailingContent = { if (isSelected) { Icon( painterResource(R.drawable.check_circle), stringResource(R.string.cd_selected), tint = MaterialTheme.colorScheme.primary ) } }, colors = androidx.compose.material3.ListItemDefaults.colors( containerColor = Color.Transparent ) ) } @OptIn(ExperimentalFoundationApi::class) @Composable private fun AppItemGrid( app: AppInfo, isSelected: Boolean, imageLoader: ImageLoader, onClick: () -> Unit, onLongClick: () -> Unit ) { val interactionSource = remember { MutableInteractionSource() } Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .padding(6.dp) .expressivePress(interactionSource) .clip(RoundedCornerShape(32.dp)) .background( if (isSelected) MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f) else MaterialTheme.colorScheme.surfaceContainerLow ) .combinedClickable( interactionSource = interactionSource, onClick = onClick, onLongClick = onLongClick ) .padding(16.dp) ) { Box { AppIcon(app.packageName, app.enabled, app.isSuspended, 56.dp, imageLoader) if (isSelected) { Icon( painterResource(R.drawable.check_circle), stringResource(R.string.cd_selected), tint = MaterialTheme.colorScheme.primary, modifier = Modifier .align(Alignment.TopEnd) .background(MaterialTheme.colorScheme.surface, CircleShape) ) } else { // Status Indicator if (!app.enabled) { Icon( painterResource(R.drawable.frozen), stringResource(R.string.cd_frozen), tint = MaterialTheme.colorScheme.primary, modifier = Modifier .align(Alignment.TopEnd) .size(16.dp) .background(MaterialTheme.colorScheme.surface, CircleShape) .padding(2.dp) ) } else if (app.isSuspended) { Icon( painterResource(R.drawable.bolt), stringResource(R.string.cd_suspended), tint = MaterialTheme.colorScheme.secondary, modifier = Modifier .align(Alignment.TopEnd) .size(16.dp) .background(MaterialTheme.colorScheme.surface, CircleShape) .padding(2.dp) ) } } } Spacer(Modifier.height(8.dp)) Text( text = app.appName ?: stringResource(R.string.unknown), maxLines = 1, style = MaterialTheme.typography.labelSmall, fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, textAlign = TextAlign.Center, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis ) } } @Composable private fun AppIcon( packageName: String, isEnabled: Boolean, isSuspended: Boolean, size: androidx.compose.ui.unit.Dp, imageLoader: ImageLoader ) { // Hoisted static matrices to avoid recreation val greyScaleMatrix = remember { ColorMatrix().apply { setToSaturation(0f) } } val dullMatrix = remember { ColorMatrix().apply { setToSaturation(0.3f) } } Box(contentAlignment = Alignment.Center) { AsyncImage( model = AppIconModel(packageName), imageLoader = imageLoader, contentDescription = null, modifier = Modifier .size(size) .then(if (isSuspended && isEnabled) Modifier.graphicsLayer(alpha = 0.7f) else Modifier), colorFilter = when { !isEnabled -> ColorFilter.colorMatrix(greyScaleMatrix) isSuspended -> ColorFilter.colorMatrix(dullMatrix) else -> null }, error = painterResource(R.drawable.android) ) } } private fun toggleSelection(currentSelection: List, item: AppInfo): List { return if (currentSelection.contains(item)) currentSelection - item else currentSelection + item } @Composable private fun EmptyStatePlaceholder( isFiltering: Boolean ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, modifier = Modifier.padding(32.dp) ) { Icon( painter = painterResource( if (isFiltering) R.drawable.round_search else R.drawable.apps ), contentDescription = null, modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) ) Spacer(Modifier.height(16.dp)) Text( text = if (isFiltering) stringResource(R.string.no_matching_apps) else stringResource(R.string.no_apps_display), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) if (isFiltering) { Text( text = stringResource(R.string.adjust_filters_hint), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), modifier = Modifier.padding(top = 4.dp) ) } } } private enum class SheetTab { FILTERS, SORT } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun AppFilterSheet( onDismiss: () -> Unit, filterType: FilterType, sortBy: SortBy, sortOrder: SortOrder, isGrid: Boolean, appListType: AppListType, onFilterTypeChanged: (FilterType) -> Unit, onSortByChanged: (SortBy) -> Unit, onSortOrderChanged: (SortOrder) -> Unit, onToggleView: () -> Unit, onListTypeChanged: (AppListType) -> Unit ) { var activeTab by remember { mutableStateOf(SheetTab.FILTERS) } ModalBottomSheet( onDismissRequest = onDismiss, containerColor = MaterialTheme.colorScheme.surfaceContainer, shape = RoundedCornerShape(topStart = 48.dp, topEnd = 48.dp), tonalElevation = 0.dp ) { Column(modifier = Modifier.padding(24.dp)) { Text( stringResource(R.string.configuration), style = MaterialTheme.typography.headlineMedium, fontWeight = androidx.compose.ui.text.font.FontWeight.Black, letterSpacing = (-1).sp ) Spacer(Modifier.height(24.dp)) // 1. App Type Selector (Top Row) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( stringResource(R.string.app_source), style = MaterialTheme.typography.titleMedium, fontWeight = androidx.compose.ui.text.font.FontWeight.Bold ) ConnectedButtonGroup( items = AppListType.entries.map { type -> ConnectedButtonGroupItem.Icon( iconRes = if (type == AppListType.USER) R.drawable.apps else R.drawable.android, contentDescription = type.name ) }, selectedIndex = AppListType.entries.indexOf(appListType), onItemSelected = { onListTypeChanged(AppListType.entries[it]) } ) } Spacer(Modifier.height(24.dp)) // 2. View Toggle Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( stringResource(R.string.view_mode), style = MaterialTheme.typography.titleMedium, fontWeight = androidx.compose.ui.text.font.FontWeight.Bold ) ConnectedButtonGroup( items = listOf( ConnectedButtonGroupItem.Icon( R.drawable.grid_view, stringResource(R.string.grid) ), ConnectedButtonGroupItem.Icon( R.drawable.view_stream, stringResource(R.string.list) ) ), selectedIndex = if (isGrid) 0 else 1, onItemSelected = { onToggleView() } ) } Spacer(Modifier.height(32.dp)) ConnectedButtonGroup( items = SheetTab.entries.map { ConnectedButtonGroupItem.Label(stringResource(if (it == SheetTab.FILTERS) R.string.filters else R.string.sort_by)) }, selectedIndex = SheetTab.entries.indexOf(activeTab), onItemSelected = { activeTab = SheetTab.entries[it] }, modifier = Modifier.fillMaxWidth() ) Spacer(Modifier.height(16.dp)) when (activeTab) { SheetTab.FILTERS -> { LazyColumn( modifier = Modifier.height(200.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { items(filterTypes) { type -> ListItem( headlineContent = { Text( type.asGeneralName(), maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis ) }, trailingContent = { if (filterType == type) Icon( painterResource(R.drawable.check_circle), null, tint = MaterialTheme.colorScheme.primary ) }, modifier = Modifier .clip(RoundedCornerShape(16.dp)) .background( MaterialTheme.colorScheme.surfaceContainerHigh.copy( alpha = 0.5f ) ) .clickable { onFilterTypeChanged(type) }, colors = androidx.compose.material3.ListItemDefaults.colors( containerColor = Color.Transparent ) ) } } } SheetTab.SORT -> { Row(verticalAlignment = Alignment.CenterVertically) { Text( stringResource(R.string.order), style = MaterialTheme.typography.titleMedium ) Spacer(Modifier.width(8.dp)) FilterChip( selected = sortOrder == SortOrder.ASCENDING, onClick = { onSortOrderChanged(SortOrder.ASCENDING) }, label = { Text(stringResource(R.string.ascending)) }, leadingIcon = { Icon(painterResource(R.drawable.arrow_upward), null) } ) Spacer(Modifier.width(8.dp)) FilterChip( selected = sortOrder == SortOrder.DESCENDING, onClick = { onSortOrderChanged(SortOrder.DESCENDING) }, label = { Text(stringResource(R.string.descending)) }, leadingIcon = { Icon(painterResource(R.drawable.arrow_downward), null) } ) } Spacer(Modifier.height(12.dp)) LazyColumn( modifier = Modifier.height(200.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { items(SortBy.entries) { item -> ListItem( headlineContent = { Text( item.asGeneralName(), maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis ) }, trailingContent = { if (sortBy == item) Icon( painterResource(R.drawable.check_circle), null, tint = MaterialTheme.colorScheme.primary ) }, modifier = Modifier .clip(RoundedCornerShape(16.dp)) .background( MaterialTheme.colorScheme.surfaceContainerHigh.copy( alpha = 0.5f ) ) .clickable { onSortByChanged(item) }, colors = androidx.compose.material3.ListItemDefaults.colors( containerColor = Color.Transparent ) ) } } } } Spacer(Modifier.height(24.dp)) Button( onClick = onDismiss, modifier = Modifier.fillMaxWidth() ) { Text(stringResource(R.string.done)) } } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/widgets/MultiSelectToolBox.kt ================================================ package com.valhalla.thor.presentation.widgets import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll 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.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.valhalla.thor.R import com.valhalla.thor.domain.model.AppInfo import com.valhalla.thor.domain.model.MultiAppAction @Composable fun MultiSelectToolBox( modifier: Modifier = Modifier, selected: List = emptyList(), isRoot: Boolean = false, isShizuku: Boolean = false, onCancel: () -> Unit = {}, onMultiAppAction: (MultiAppAction) -> Unit = {} ) { var hasFrozen by remember { mutableStateOf(selected.any { !it.enabled }) } var hasUnFrozen by remember { mutableStateOf(selected.any { it.enabled }) } var hasSuspended by remember { mutableStateOf(selected.any { it.isSuspended }) } var hasUnSuspended by remember { mutableStateOf(selected.any { !it.isSuspended }) } LaunchedEffect(selected) { hasFrozen = selected.any { !it.enabled } hasUnFrozen = selected.any { it.enabled } hasSuspended = selected.any { it.isSuspended } hasUnSuspended = selected.any { !it.isSuspended } } Card( modifier = modifier, shape = RoundedCornerShape(32.dp), colors = androidx.compose.material3.CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerHighest ) ) { Row( modifier = Modifier .padding(12.dp) .horizontalScroll(rememberScrollState()), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { // Close Action (Leftmost for easy exit) ToolBoxItem( icon = R.drawable.round_close, label = "Close", onClick = onCancel ) // ReInstall (Root only) if (isRoot) { ToolBoxItem( icon = R.drawable.apk_install, label = "ReInstall", onClick = { onMultiAppAction(MultiAppAction.ReInstall(selected)) } ) } // Freeze/Unfreeze (Root OR Shizuku) if (isRoot || isShizuku) { if (hasUnFrozen) { ToolBoxItem( icon = R.drawable.frozen, label = "Freeze", onClick = { onMultiAppAction(MultiAppAction.Freeze(selected)) } ) } if (hasFrozen) { ToolBoxItem( icon = R.drawable.unfreeze, label = "UnFreeze", onClick = { onMultiAppAction(MultiAppAction.UnFreeze(selected)) } ) } if (hasUnSuspended) { ToolBoxItem( icon = R.drawable.warning, label = "Suspend", onClick = { onMultiAppAction(MultiAppAction.Suspend(selected)) } ) } if (hasSuspended) { ToolBoxItem( icon = R.drawable.bolt, label = "Unsuspend", onClick = { onMultiAppAction(MultiAppAction.UnSuspend(selected)) } ) } } // Standard Actions ToolBoxItem( icon = R.drawable.clear_all, label = "Cache", onClick = { onMultiAppAction(MultiAppAction.ClearCache(selected)) } ) ToolBoxItem( icon = R.drawable.delete_forever, label = "Uninstall", onClick = { onMultiAppAction(MultiAppAction.Uninstall(selected)) } ) ToolBoxItem( icon = R.drawable.danger, label = "Kill", onClick = { onMultiAppAction(MultiAppAction.Kill(selected)) } ) } } } @Composable private fun ToolBoxItem( icon: Int, label: String, onClick: () -> Unit ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .clip(RoundedCornerShape(24.dp)) .clickable(onClick = onClick) .padding(8.dp) ) { Box( modifier = Modifier .size(48.dp) .clip(RoundedCornerShape(16.dp)) .background(MaterialTheme.colorScheme.surfaceContainerHigh), contentAlignment = Alignment.Center ) { Icon( painter = painterResource(icon), contentDescription = label, modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.primary ) } Spacer(Modifier.height(4.dp)) Text( text = label, style = MaterialTheme.typography.labelSmall, fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, maxLines = 1 ) } } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/widgets/TermLogger.kt ================================================ package com.valhalla.thor.presentation.widgets import androidx.compose.foundation.background 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.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.valhalla.thor.R import com.valhalla.thor.presentation.theme.firaMonoFontFamily @Composable fun TermLoggerDialog( modifier: Modifier = Modifier, title: String, logs: List, isOperationComplete: Boolean, onDismiss: () -> Unit ) { Dialog( onDismissRequest = { // Only allow dismiss if operation is done if (isOperationComplete) { onDismiss() } }, properties = DialogProperties( dismissOnBackPress = false, dismissOnClickOutside = false, usePlatformDefaultWidth = false ) ) { Column(modifier = modifier.fillMaxSize(), verticalArrangement = Arrangement.Bottom) { Column( modifier = Modifier .fillMaxWidth() .background( color = MaterialTheme.colorScheme.background, shape = RoundedCornerShape(topEnd = 20.dp, topStart = 20.dp) ) .padding(10.dp) .padding(bottom = 50.dp), horizontalAlignment = Alignment.CenterHorizontally ) { // Header Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() ) { if (!isOperationComplete) { AnimateLottieRaw( resId = R.raw.rearrange, shouldLoop = true, modifier = Modifier.size(50.dp), contentScale = ContentScale.Crop ) } else { Icon( painterResource(R.drawable.check_circle), contentDescription = stringResource(R.string.cd_selected), modifier = Modifier.size(40.dp), tint = MaterialTheme.colorScheme.primary ) } Text( text = if (isOperationComplete) stringResource(R.string.done) else title, color = MaterialTheme.colorScheme.onBackground, style = MaterialTheme.typography.titleMedium, modifier = Modifier .weight(1f) .padding(start = 12.dp), maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis ) if (isOperationComplete) { IconButton(onClick = onDismiss) { Icon( painterResource(R.drawable.round_close), stringResource(R.string.cd_close), tint = MaterialTheme.colorScheme.onBackground ) } } } // Logs List val lazyListState = rememberLazyListState() LaunchedEffect(logs.size) { if (logs.isNotEmpty()) { lazyListState.animateScrollToItem(logs.lastIndex) } } LazyColumn( state = lazyListState, modifier = Modifier .fillMaxWidth() .padding(top = 10.dp) ) { items(logs) { logTxt -> Text( text = "> $logTxt", softWrap = false, modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.bodySmall.copy( fontFamily = firaMonoFontFamily ), maxLines = 1, textAlign = TextAlign.Start, color = MaterialTheme.colorScheme.onBackground ) } } if (isOperationComplete) { Button( onClick = onDismiss, modifier = Modifier .padding(top = 16.dp) .fillMaxWidth() ) { Text(stringResource(R.string.close)) } } } } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/presentation/widgets/TypeWriterText.kt ================================================ package com.valhalla.thor.presentation.widgets import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import kotlinx.coroutines.delay @Composable fun TypeWriterText( modifier: Modifier = Modifier, text: String, style: TextStyle = MaterialTheme.typography.bodyLarge, fontWeight: FontWeight = FontWeight.Normal, fontStyle: FontStyle = FontStyle.Normal, maxLines: Int = 1, delay: Long = 100, delayOnLoop: Long = 2000, loop: Boolean = false, reverse: Boolean = true, textAlign: TextAlign = TextAlign.Center, softWrap: Boolean = false, onEnd: () -> Unit = {} ) { var textCharList by remember { mutableStateOf( emptyList() ) } var textToDisplay by remember { mutableStateOf("") } var currentIndex by remember { mutableIntStateOf(0) } var isReversing by remember { mutableStateOf(false) } LaunchedEffect(text) { textCharList = emptyList() text.codePoints().forEach { textCharList += Char(it).toString() } textToDisplay = "" currentIndex = 0 isReversing = false while (currentIndex < textCharList.size || (isReversing && currentIndex > 0)) { if (!isReversing) { currentIndex++ textToDisplay += textCharList[currentIndex - 1] } else { currentIndex-- textToDisplay = textCharList.subList(0, currentIndex).joinToString("") } if (currentIndex == textCharList.size || currentIndex == 0) { onEnd() if (loop) { if (reverse) { isReversing = !isReversing } else { textToDisplay = "" currentIndex = 0 } delay(delayOnLoop) } } else delay(delay) } } Text( text = textToDisplay, style = style, fontWeight = fontWeight, fontStyle = fontStyle, maxLines = maxLines, textAlign = textAlign, modifier = modifier ) } @Preview(showBackground = true) @Composable fun TypeWriterTextPreview() { TypeWriterText(text = "Hello, World! \uD83D\uDC9C\uD83D\uDC4B", loop = true, reverse = true) } ================================================ FILE: app/src/main/java/com/valhalla/thor/util/LocaleManager.kt ================================================ package com.valhalla.thor.util import android.app.LocaleManager as AndroidLocaleManager import android.content.Context import android.os.Build import android.os.LocaleList import androidx.appcompat.app.AppCompatDelegate import androidx.core.os.LocaleListCompat /** * Modern Locale Manager that uses the system LocaleManager on Android 13+ (API 33) * and falls back to AppCompatDelegate for older versions. */ class LocaleManager(private val context: Context) { /** * Applies the given language code to the application. * `@param` languageCode The language tag (e.g., "en", "zh-CN"), or null for system default. */ fun applyLocale(languageCode: String?) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val localeManager = context.getSystemService(Context.LOCALE_SERVICE) as AndroidLocaleManager localeManager.applicationLocales = if (languageCode == null) { LocaleList.getEmptyLocaleList() } else { LocaleList.forLanguageTags(languageCode) } } else { val appLocale: LocaleListCompat = if (languageCode == null) { LocaleListCompat.getEmptyLocaleList() } else { LocaleListCompat.forLanguageTags(languageCode) } AppCompatDelegate.setApplicationLocales(appLocale) } } /** * Returns the currently applied application locales. */ fun getAppliedLocales(): LocaleListCompat { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val localeManager = context.getSystemService(Context.LOCALE_SERVICE) as AndroidLocaleManager LocaleListCompat.wrap(localeManager.applicationLocales) } else { AppCompatDelegate.getApplicationLocales() } } } ================================================ FILE: app/src/main/java/com/valhalla/thor/util/Logger.kt ================================================ @file:Suppress("unused") package com.valhalla.thor.util import android.util.Log import com.valhalla.thor.BuildConfig import org.koin.core.logger.Level object Logger { /** * Koin Log Level Configuration. * Usage in startKoin: androidLogger(Logger.koinLogLevel) */ val koinLogLevel: Level = if (BuildConfig.DEBUG) Level.DEBUG else Level.NONE fun d(tag: String, message: String) { if (BuildConfig.DEBUG) { Log.d(tag, message) } } fun i(tag: String, message: String) { if (BuildConfig.DEBUG) { Log.i(tag, message) } } fun w(tag: String, message: String) { if (BuildConfig.DEBUG) { Log.w(tag, message) } } fun v(tag: String, message: String) { if (BuildConfig.DEBUG) { Log.v(tag, message) } } fun e(tag: String, message: String, throwable: Throwable? = null) { if (BuildConfig.DEBUG) { Log.e(tag, message, throwable) } } } ================================================ FILE: app/src/main/res/drawable/android.xml ================================================ ================================================ FILE: app/src/main/res/drawable/apk_install.xml ================================================ ================================================ FILE: app/src/main/res/drawable/apps.xml ================================================ ================================================ FILE: app/src/main/res/drawable/arrow_downward.xml ================================================ ================================================ FILE: app/src/main/res/drawable/arrow_drop_down.xml ================================================ ================================================ FILE: app/src/main/res/drawable/arrow_upward.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bolt.xml ================================================ ================================================ FILE: app/src/main/res/drawable/brand_github.xml ================================================ ================================================ FILE: app/src/main/res/drawable/brand_patreon.xml ================================================ ================================================ FILE: app/src/main/res/drawable/brand_telegram.xml ================================================ ================================================ FILE: app/src/main/res/drawable/cat.xml ================================================ ================================================ FILE: app/src/main/res/drawable/check_circle.xml ================================================ ================================================ FILE: app/src/main/res/drawable/clear_all.xml ================================================ ================================================ FILE: app/src/main/res/drawable/danger.xml ================================================ ================================================ FILE: app/src/main/res/drawable/delete.xml ================================================ ================================================ FILE: app/src/main/res/drawable/delete_forever.xml ================================================ ================================================ FILE: app/src/main/res/drawable/dhizuku.xml ================================================ ================================================ FILE: app/src/main/res/drawable/exit_to_app.xml ================================================ ================================================ FILE: app/src/main/res/drawable/filter_list.xml ================================================ ================================================ FILE: app/src/main/res/drawable/force_close.xml ================================================ ================================================ FILE: app/src/main/res/drawable/freeze_off.xml ================================================ ================================================ FILE: app/src/main/res/drawable/frozen.xml ================================================ ================================================ FILE: app/src/main/res/drawable/grid_view.xml ================================================ ================================================ FILE: app/src/main/res/drawable/home.xml ================================================ ================================================ FILE: app/src/main/res/drawable/home_outline.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher_foreground.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ios_share.xml ================================================ ================================================ FILE: app/src/main/res/drawable/key.xml ================================================ ================================================ FILE: app/src/main/res/drawable/key_outline.xml ================================================ ================================================ FILE: app/src/main/res/drawable/list.xml ================================================ ================================================ FILE: app/src/main/res/drawable/magisk_icon.xml ================================================ ================================================ FILE: app/src/main/res/drawable/open_in.xml ================================================ ================================================ FILE: app/src/main/res/drawable/open_in_new.xml ================================================ ================================================ FILE: app/src/main/res/drawable/privacy_tip.xml ================================================ ================================================ FILE: app/src/main/res/drawable/round_close.xml ================================================ ================================================ FILE: app/src/main/res/drawable/round_key.xml ================================================ ================================================ FILE: app/src/main/res/drawable/round_search.xml ================================================ ================================================ FILE: app/src/main/res/drawable/settings.xml ================================================ ================================================ FILE: app/src/main/res/drawable/settings_backup_restore.xml ================================================ ================================================ FILE: app/src/main/res/drawable/settings_outline.xml ================================================ ================================================ FILE: app/src/main/res/drawable/share.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shield.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shield_bad.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shield_countdown.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shield_encrypted.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shield_maybe.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shield_search.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shield_verified.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shield_with_heart.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shizuku.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shizuku_outline_icon.xml ================================================ ================================================ FILE: app/src/main/res/drawable/snowflake.xml ================================================ ================================================ FILE: app/src/main/res/drawable/sort.xml ================================================ ================================================ FILE: app/src/main/res/drawable/sort_by_alpha.xml ================================================ ================================================ FILE: app/src/main/res/drawable/storage.xml ================================================ ================================================ FILE: app/src/main/res/drawable/theme_panel.xml ================================================ ================================================ FILE: app/src/main/res/drawable/thor_animated.xml ================================================ ================================================ FILE: app/src/main/res/drawable/thor_drawn_foreground.xml ================================================ ================================================ FILE: app/src/main/res/drawable/thor_icon_foreground.xml ================================================ ================================================ FILE: app/src/main/res/drawable/thor_mono.xml ================================================ ================================================ FILE: app/src/main/res/drawable/unfreeze.xml ================================================ ================================================ FILE: app/src/main/res/drawable/view_stream.xml ================================================ ================================================ FILE: app/src/main/res/drawable/warning.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi-v26/thor_drawn.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi-v26/thor_drawn_round.xml ================================================ ================================================ FILE: app/src/main/res/raw/rearrange.json ================================================ { "nm": "BOXS", "ddd": 0, "h": 1080, "w": 1080, "meta": { "g": "@lottiefiles/toolkit-js 0.33.2" }, "layers": [ { "ty": 3, "nm": "Adjustment Layer 7", "sr": 1, "st": 0, "op": 301, "ip": 0, "hd": false, "ddd": 0, "bm": 0, "hasMask": false, "ao": 0, "ks": { "a": { "a": 0, "k": [ 540, 540, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 100, 100, 100 ], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [ 540, 540, 0 ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, "ef": [ { "ty": 20, "mn": "ADBE Tint", "nm": "Tint", "ix": 1, "en": 1, "ef": [ { "ty": 2, "mn": "ADBE Tint-0001", "nm": "Map Black To", "ix": 1, "v": { "a": 0, "k": [ 0, 0, 0, 0 ], "ix": 1 } }, { "ty": 2, "mn": "ADBE Tint-0002", "nm": "Map White To", "ix": 2, "v": { "a": 0, "k": [ 1, 1, 1, 0 ], "ix": 2 } }, { "ty": 0, "mn": "ADBE Tint-0003", "nm": "Amount to Tint", "ix": 3, "v": { "a": 0, "k": 100, "ix": 3 } }, { "ty": 6, "mn": "ADBE Tint-0004", "nm": "", "ix": 4, "v": 0 } ] } ], "ind": 1 }, { "ty": 0, "nm": "Boxs Animation", "sr": 1, "st": 0, "op": 301, "ip": 0, "hd": false, "ddd": 0, "bm": 0, "hasMask": false, "ao": 0, "ks": { "a": { "a": 0, "k": [ 540, 540, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 42, 42, 100 ], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [ 540, 540, 0 ], "ix": 2 }, "r": { "a": 0, "k": 225.1, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, "ef": [], "w": 1080, "h": 1080, "refId": "comp_0", "tm": { "a": 1, "k": [ { "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [ 0 ], "t": 0 }, { "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [ 0.217 ], "t": 13 }, { "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [ 1.15 ], "t": 109 }, { "s": [ 5.017 ], "t": 301 } ], "ix": 2 }, "ind": 2 } ], "v": "5.7.3", "fr": 60, "op": 110, "ip": 13, "assets": [ { "nm": "", "id": "comp_0", "layers": [ { "ty": 4, "nm": "7", "sr": 1, "st": 0, "op": 301, "ip": 0, "hd": false, "ddd": 0, "bm": 0, "hasMask": false, "ao": 0, "ks": { "a": { "a": 0, "k": [ 0, 0, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 100, 100, 100 ], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 1, "k": [ { "o": { "x": 0.714, "y": 0 }, "i": { "x": 0.218, "y": 1 }, "s": [ 239.25, 840.25, 0 ], "t": 41.236, "ti": [ 0, 50.042, 0 ], "to": [ 0, -50.042, 0 ] }, { "s": [ 239.25, 540, 0 ], "t": 75.599609375 } ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, "ef": [], "shapes": [ { "ty": "gr", "bm": 0, "hd": false, "mn": "ADBE Vector Group", "nm": "Rectangle 1", "ix": 1, "cix": 2, "np": 3, "it": [ { "ty": "rc", "bm": 0, "hd": false, "mn": "ADBE Vector Shape - Rect", "nm": "Rectangle Path 1", "d": 1, "p": { "a": 0, "k": [ 0, 0 ], "ix": 3 }, "r": { "a": 0, "k": 150, "ix": 4 }, "s": { "a": 0, "k": [ 1080, 1080 ], "ix": 2 } }, { "ty": "st", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Stroke", "nm": "Stroke 1", "lc": 1, "lj": 1, "ml": 4, "o": { "a": 0, "k": 100, "ix": 4 }, "w": { "a": 0, "k": 74, "ix": 5 }, "c": { "a": 0, "k": [ 0, 0, 0 ], "ix": 3 } }, { "ty": "fl", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Fill", "nm": "Fill 1", "c": { "a": 0, "k": [ 0, 0, 0 ], "ix": 4 }, "r": 1, "o": { "a": 0, "k": 100, "ix": 5 } }, { "ty": "tr", "a": { "a": 0, "k": [ 0, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 18.493, 18.493 ], "ix": 3 }, "sk": { "a": 0, "k": 0, "ix": 4 }, "p": { "a": 0, "k": [ 0, 0 ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 6 }, "sa": { "a": 0, "k": 0, "ix": 5 }, "o": { "a": 0, "k": 100, "ix": 7 } } ] } ], "ind": 1 }, { "ty": 4, "nm": "6", "sr": 1, "st": 0, "op": 301, "ip": 0, "hd": false, "ddd": 0, "bm": 0, "hasMask": false, "ao": 0, "ks": { "a": { "a": 0, "k": [ 0, 0, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 100, 100, 100 ], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 1, "k": [ { "o": { "x": 0.714, "y": 0 }, "i": { "x": 0.218, "y": 1 }, "s": [ 239.25, 540, 0 ], "t": 34.363, "ti": [ 0, 50.083, 0 ], "to": [ 0, -50.083, 0 ] }, { "s": [ 239.25, 239.5, 0 ], "t": 68.7265625 } ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, "ef": [], "shapes": [ { "ty": "gr", "bm": 0, "hd": false, "mn": "ADBE Vector Group", "nm": "Rectangle 1", "ix": 1, "cix": 2, "np": 3, "it": [ { "ty": "rc", "bm": 0, "hd": false, "mn": "ADBE Vector Shape - Rect", "nm": "Rectangle Path 1", "d": 1, "p": { "a": 0, "k": [ 0, 0 ], "ix": 3 }, "r": { "a": 0, "k": 150, "ix": 4 }, "s": { "a": 0, "k": [ 1080, 1080 ], "ix": 2 } }, { "ty": "st", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Stroke", "nm": "Stroke 1", "lc": 1, "lj": 1, "ml": 4, "o": { "a": 0, "k": 100, "ix": 4 }, "w": { "a": 0, "k": 74, "ix": 5 }, "c": { "a": 0, "k": [ 0, 0, 0 ], "ix": 3 } }, { "ty": "fl", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Fill", "nm": "Fill 1", "c": { "a": 0, "k": [ 0, 0, 0 ], "ix": 4 }, "r": 1, "o": { "a": 0, "k": 100, "ix": 5 } }, { "ty": "tr", "a": { "a": 0, "k": [ 0, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 18.493, 18.493 ], "ix": 3 }, "sk": { "a": 0, "k": 0, "ix": 4 }, "p": { "a": 0, "k": [ 0, 0 ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 6 }, "sa": { "a": 0, "k": 0, "ix": 5 }, "o": { "a": 0, "k": 100, "ix": 7 } } ] } ], "ind": 2 }, { "ty": 4, "nm": "5", "sr": 1, "st": 0, "op": 301, "ip": 0, "hd": false, "ddd": 0, "bm": 0, "hasMask": false, "ao": 0, "ks": { "a": { "a": 0, "k": [ 0, 0, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 100, 100, 100 ], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 1, "k": [ { "o": { "x": 0.714, "y": 0 }, "i": { "x": 0.218, "y": 1 }, "s": [ 239.25, 239.5, 0 ], "t": 27.49, "ti": [ -50.125, 0, 0 ], "to": [ 50.125, 0, 0 ] }, { "s": [ 540, 239.5, 0 ], "t": 61.853515625 } ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, "ef": [], "shapes": [ { "ty": "gr", "bm": 0, "hd": false, "mn": "ADBE Vector Group", "nm": "Rectangle 1", "ix": 1, "cix": 2, "np": 3, "it": [ { "ty": "rc", "bm": 0, "hd": false, "mn": "ADBE Vector Shape - Rect", "nm": "Rectangle Path 1", "d": 1, "p": { "a": 0, "k": [ 0, 0 ], "ix": 3 }, "r": { "a": 0, "k": 150, "ix": 4 }, "s": { "a": 0, "k": [ 1080, 1080 ], "ix": 2 } }, { "ty": "st", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Stroke", "nm": "Stroke 1", "lc": 1, "lj": 1, "ml": 4, "o": { "a": 0, "k": 100, "ix": 4 }, "w": { "a": 0, "k": 74, "ix": 5 }, "c": { "a": 0, "k": [ 0, 0, 0 ], "ix": 3 } }, { "ty": "fl", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Fill", "nm": "Fill 1", "c": { "a": 0, "k": [ 0, 0, 0 ], "ix": 4 }, "r": 1, "o": { "a": 0, "k": 100, "ix": 5 } }, { "ty": "tr", "a": { "a": 0, "k": [ 0, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 18.493, 18.493 ], "ix": 3 }, "sk": { "a": 0, "k": 0, "ix": 4 }, "p": { "a": 0, "k": [ 0, 0 ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 6 }, "sa": { "a": 0, "k": 0, "ix": 5 }, "o": { "a": 0, "k": 100, "ix": 7 } } ] } ], "ind": 3 }, { "ty": 4, "nm": "4", "sr": 1, "st": 0, "op": 301, "ip": 0, "hd": false, "ddd": 0, "bm": 0, "hasMask": false, "ao": 0, "ks": { "a": { "a": 0, "k": [ 0, 0, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 100, 100, 100 ], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 1, "k": [ { "o": { "x": 0.714, "y": 0 }, "i": { "x": 0.218, "y": 1 }, "s": [ 540, 239.5, 0 ], "t": 20.617, "ti": [ -49.917, 0, 0 ], "to": [ 49.917, 0, 0 ] }, { "s": [ 839.5, 239.5, 0 ], "t": 54.98046875 } ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, "ef": [], "shapes": [ { "ty": "gr", "bm": 0, "hd": false, "mn": "ADBE Vector Group", "nm": "Rectangle 1", "ix": 1, "cix": 2, "np": 3, "it": [ { "ty": "rc", "bm": 0, "hd": false, "mn": "ADBE Vector Shape - Rect", "nm": "Rectangle Path 1", "d": 1, "p": { "a": 0, "k": [ 0, 0 ], "ix": 3 }, "r": { "a": 0, "k": 150, "ix": 4 }, "s": { "a": 0, "k": [ 1080, 1080 ], "ix": 2 } }, { "ty": "st", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Stroke", "nm": "Stroke 1", "lc": 1, "lj": 1, "ml": 4, "o": { "a": 0, "k": 100, "ix": 4 }, "w": { "a": 0, "k": 74, "ix": 5 }, "c": { "a": 0, "k": [ 0, 0, 0 ], "ix": 3 } }, { "ty": "fl", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Fill", "nm": "Fill 1", "c": { "a": 0, "k": [ 0, 0, 0 ], "ix": 4 }, "r": 1, "o": { "a": 0, "k": 100, "ix": 5 } }, { "ty": "tr", "a": { "a": 0, "k": [ 0, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 18.493, 18.493 ], "ix": 3 }, "sk": { "a": 0, "k": 0, "ix": 4 }, "p": { "a": 0, "k": [ 0, 0 ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 6 }, "sa": { "a": 0, "k": 0, "ix": 5 }, "o": { "a": 0, "k": 100, "ix": 7 } } ] } ], "ind": 4 }, { "ty": 4, "nm": "3", "sr": 1, "st": 0, "op": 301, "ip": 0, "hd": false, "ddd": 0, "bm": 0, "hasMask": false, "ao": 0, "ks": { "a": { "a": 0, "k": [ 0, 0, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 100, 100, 100 ], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 1, "k": [ { "o": { "x": 0.714, "y": 0 }, "i": { "x": 0.218, "y": 1 }, "s": [ 839.5, 239.5, 0 ], "t": 13.746, "ti": [ 0, -50.083, 0 ], "to": [ 0, 50.083, 0 ] }, { "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [ 839.5, 540, 0 ], "t": 48.109, "ti": [ 0, 0, 0 ], "to": [ 0, 0, 0 ] }, { "o": { "x": 0.546, "y": 0 }, "i": { "x": 0.218, "y": 1 }, "s": [ 839.5, 540, 0 ], "t": 64.908, "ti": [ 0, 0, 0 ], "to": [ -49.917, 0, 0 ] }, { "s": [ 540, 540, 0 ], "t": 98 } ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, "ef": [], "shapes": [ { "ty": "gr", "bm": 0, "hd": false, "mn": "ADBE Vector Group", "nm": "Rectangle 1", "ix": 1, "cix": 2, "np": 3, "it": [ { "ty": "rc", "bm": 0, "hd": false, "mn": "ADBE Vector Shape - Rect", "nm": "Rectangle Path 1", "d": 1, "p": { "a": 0, "k": [ 0, 0 ], "ix": 3 }, "r": { "a": 0, "k": 150, "ix": 4 }, "s": { "a": 0, "k": [ 1080, 1080 ], "ix": 2 } }, { "ty": "st", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Stroke", "nm": "Stroke 1", "lc": 1, "lj": 1, "ml": 4, "o": { "a": 0, "k": 100, "ix": 4 }, "w": { "a": 0, "k": 74, "ix": 5 }, "c": { "a": 0, "k": [ 0, 0, 0 ], "ix": 3 } }, { "ty": "fl", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Fill", "nm": "Fill 1", "c": { "a": 0, "k": [ 0, 0, 0 ], "ix": 4 }, "r": 1, "o": { "a": 0, "k": 100, "ix": 5 } }, { "ty": "tr", "a": { "a": 0, "k": [ 0, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 18.493, 18.493 ], "ix": 3 }, "sk": { "a": 0, "k": 0, "ix": 4 }, "p": { "a": 0, "k": [ 0, 0 ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 6 }, "sa": { "a": 0, "k": 0, "ix": 5 }, "o": { "a": 0, "k": 100, "ix": 7 } } ] } ], "ind": 5 }, { "ty": 4, "nm": "2", "sr": 1, "st": 0, "op": 301, "ip": 0, "hd": false, "ddd": 0, "bm": 0, "hasMask": false, "ao": 0, "ks": { "a": { "a": 0, "k": [ 0, 0, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 100, 100, 100 ], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 1, "k": [ { "o": { "x": 0.714, "y": 0 }, "i": { "x": 0.218, "y": 1 }, "s": [ 839.5, 540, 0 ], "t": 6.873, "ti": [ 0, 0, 0 ], "to": [ 0, 0, 0 ] }, { "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [ 540, 540, 0 ], "t": 41.236, "ti": [ 0, 0, 0 ], "to": [ 0, 0, 0 ] }, { "o": { "x": 0.756, "y": 0 }, "i": { "x": 0.189, "y": 1 }, "s": [ 540, 540, 0 ], "t": 56, "ti": [ 0.083, -50.042, 0 ], "to": [ 0, 0, 0 ] }, { "s": [ 539.5, 840.25, 0 ], "t": 89.091796875 } ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, "ef": [], "shapes": [ { "ty": "gr", "bm": 0, "hd": false, "mn": "ADBE Vector Group", "nm": "Rectangle 1", "ix": 1, "cix": 2, "np": 3, "it": [ { "ty": "rc", "bm": 0, "hd": false, "mn": "ADBE Vector Shape - Rect", "nm": "Rectangle Path 1", "d": 1, "p": { "a": 0, "k": [ 0, 0 ], "ix": 3 }, "r": { "a": 0, "k": 150, "ix": 4 }, "s": { "a": 0, "k": [ 1080, 1080 ], "ix": 2 } }, { "ty": "st", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Stroke", "nm": "Stroke 1", "lc": 1, "lj": 1, "ml": 4, "o": { "a": 0, "k": 100, "ix": 4 }, "w": { "a": 0, "k": 74, "ix": 5 }, "c": { "a": 0, "k": [ 0, 0, 0 ], "ix": 3 } }, { "ty": "fl", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Fill", "nm": "Fill 1", "c": { "a": 0, "k": [ 0, 0, 0 ], "ix": 4 }, "r": 1, "o": { "a": 0, "k": 100, "ix": 5 } }, { "ty": "tr", "a": { "a": 0, "k": [ 0, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 18.493, 18.493 ], "ix": 3 }, "sk": { "a": 0, "k": 0, "ix": 4 }, "p": { "a": 0, "k": [ 0, 0 ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 6 }, "sa": { "a": 0, "k": 0, "ix": 5 }, "o": { "a": 0, "k": 100, "ix": 7 } } ] } ], "ind": 6 }, { "ty": 4, "nm": "1", "sr": 1, "st": 0, "op": 301, "ip": 0, "hd": false, "ddd": 0, "bm": 0, "hasMask": false, "ao": 0, "ks": { "a": { "a": 0, "k": [ 0, 0, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 100, 100, 100 ], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 1, "k": [ { "o": { "x": 0.714, "y": 0 }, "i": { "x": 0.218, "y": 1 }, "s": [ 540, 540, 0 ], "t": 0, "ti": [ 0.083, -50.042, 0 ], "to": [ -0.083, 50.042, 0 ] }, { "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [ 539.5, 840.25, 0 ], "t": 34.363, "ti": [ 0, 0, 0 ], "to": [ 0, 0, 0 ] }, { "o": { "x": 0.742, "y": 0 }, "i": { "x": 0.186, "y": 1 }, "s": [ 539.5, 840.25, 0 ], "t": 48.109, "ti": [ 50.042, 0, 0 ], "to": [ -50.042, 0, 0 ] }, { "s": [ 239.25, 840.25, 0 ], "t": 80.181640625 } ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, "ef": [], "shapes": [ { "ty": "gr", "bm": 0, "hd": false, "mn": "ADBE Vector Group", "nm": "Rectangle 1", "ix": 1, "cix": 2, "np": 3, "it": [ { "ty": "rc", "bm": 0, "hd": false, "mn": "ADBE Vector Shape - Rect", "nm": "Rectangle Path 1", "d": 1, "p": { "a": 0, "k": [ 0, 0 ], "ix": 3 }, "r": { "a": 0, "k": 150, "ix": 4 }, "s": { "a": 0, "k": [ 1080, 1080 ], "ix": 2 } }, { "ty": "st", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Stroke", "nm": "Stroke 1", "lc": 1, "lj": 1, "ml": 4, "o": { "a": 0, "k": 100, "ix": 4 }, "w": { "a": 0, "k": 74, "ix": 5 }, "c": { "a": 0, "k": [ 0, 0, 0 ], "ix": 3 } }, { "ty": "fl", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Fill", "nm": "Fill 1", "c": { "a": 0, "k": [ 0, 0, 0 ], "ix": 4 }, "r": 1, "o": { "a": 0, "k": 100, "ix": 5 } }, { "ty": "tr", "a": { "a": 0, "k": [ 0, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 18.493, 18.493 ], "ix": 3 }, "sk": { "a": 0, "k": 0, "ix": 4 }, "p": { "a": 0, "k": [ 0, 0 ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 6 }, "sa": { "a": 0, "k": 0, "ix": 5 }, "o": { "a": 0, "k": 100, "ix": 7 } } ] } ], "ind": 7 } ] } ] } ================================================ FILE: app/src/main/res/raw-night/rearrange.json ================================================ { "nm": "BOXS", "ddd": 0, "h": 1080, "w": 1080, "meta": { "g": "@lottiefiles/toolkit-js 0.33.2" }, "layers": [ { "ty": 3, "nm": "Adjustment Layer 7", "sr": 1, "st": 0, "op": 301, "ip": 0, "hd": false, "ddd": 0, "bm": 0, "hasMask": false, "ao": 0, "ks": { "a": { "a": 0, "k": [ 540, 540, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 100, 100, 100 ], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [ 540, 540, 0 ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, "ef": [ { "ty": 20, "mn": "ADBE Tint", "nm": "Tint", "ix": 1, "en": 1, "ef": [ { "ty": 2, "mn": "ADBE Tint-0001", "nm": "Map Black To", "ix": 1, "v": { "a": 0, "k": [ 0, 0, 0, 0 ], "ix": 1 } }, { "ty": 2, "mn": "ADBE Tint-0002", "nm": "Map White To", "ix": 2, "v": { "a": 0, "k": [ 1, 1, 1, 0 ], "ix": 2 } }, { "ty": 0, "mn": "ADBE Tint-0003", "nm": "Amount to Tint", "ix": 3, "v": { "a": 0, "k": 100, "ix": 3 } }, { "ty": 6, "mn": "ADBE Tint-0004", "nm": "", "ix": 4, "v": 0 } ] } ], "ind": 1 }, { "ty": 0, "nm": "Boxs Animation", "sr": 1, "st": 0, "op": 301, "ip": 0, "hd": false, "ddd": 0, "bm": 0, "hasMask": false, "ao": 0, "ks": { "a": { "a": 0, "k": [ 540, 540, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 42, 42, 100 ], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [ 540, 540, 0 ], "ix": 2 }, "r": { "a": 0, "k": 225.1, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, "ef": [], "w": 1080, "h": 1080, "refId": "comp_0", "tm": { "a": 1, "k": [ { "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [ 0 ], "t": 0 }, { "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [ 0.217 ], "t": 13 }, { "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [ 1.15 ], "t": 109 }, { "s": [ 5.017 ], "t": 301 } ], "ix": 2 }, "ind": 2 } ], "v": "5.7.3", "fr": 60, "op": 110, "ip": 13, "assets": [ { "nm": "", "id": "comp_0", "layers": [ { "ty": 4, "nm": "7", "sr": 1, "st": 0, "op": 301, "ip": 0, "hd": false, "ddd": 0, "bm": 0, "hasMask": false, "ao": 0, "ks": { "a": { "a": 0, "k": [ 0, 0, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 100, 100, 100 ], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 1, "k": [ { "o": { "x": 0.714, "y": 0 }, "i": { "x": 0.218, "y": 1 }, "s": [ 239.25, 840.25, 0 ], "t": 41.236, "ti": [ 0, 50.042, 0 ], "to": [ 0, -50.042, 0 ] }, { "s": [ 239.25, 540, 0 ], "t": 75.599609375 } ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, "ef": [], "shapes": [ { "ty": "gr", "bm": 0, "hd": false, "mn": "ADBE Vector Group", "nm": "Rectangle 1", "ix": 1, "cix": 2, "np": 3, "it": [ { "ty": "rc", "bm": 0, "hd": false, "mn": "ADBE Vector Shape - Rect", "nm": "Rectangle Path 1", "d": 1, "p": { "a": 0, "k": [ 0, 0 ], "ix": 3 }, "r": { "a": 0, "k": 150, "ix": 4 }, "s": { "a": 0, "k": [ 1080, 1080 ], "ix": 2 } }, { "ty": "st", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Stroke", "nm": "Stroke 1", "lc": 1, "lj": 1, "ml": 4, "o": { "a": 0, "k": 100, "ix": 4 }, "w": { "a": 0, "k": 74, "ix": 5 }, "c": { "a": 0, "k": [ 1, 1, 1 ], "ix": 3 } }, { "ty": "fl", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Fill", "nm": "Fill 1", "c": { "a": 0, "k": [ 0.9961, 0.9961, 0.9961 ], "ix": 4 }, "r": 1, "o": { "a": 0, "k": 100, "ix": 5 } }, { "ty": "tr", "a": { "a": 0, "k": [ 0, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 18.493, 18.493 ], "ix": 3 }, "sk": { "a": 0, "k": 0, "ix": 4 }, "p": { "a": 0, "k": [ 0, 0 ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 6 }, "sa": { "a": 0, "k": 0, "ix": 5 }, "o": { "a": 0, "k": 100, "ix": 7 } } ] } ], "ind": 1 }, { "ty": 4, "nm": "6", "sr": 1, "st": 0, "op": 301, "ip": 0, "hd": false, "ddd": 0, "bm": 0, "hasMask": false, "ao": 0, "ks": { "a": { "a": 0, "k": [ 0, 0, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 100, 100, 100 ], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 1, "k": [ { "o": { "x": 0.714, "y": 0 }, "i": { "x": 0.218, "y": 1 }, "s": [ 239.25, 540, 0 ], "t": 34.363, "ti": [ 0, 50.083, 0 ], "to": [ 0, -50.083, 0 ] }, { "s": [ 239.25, 239.5, 0 ], "t": 68.7265625 } ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, "ef": [], "shapes": [ { "ty": "gr", "bm": 0, "hd": false, "mn": "ADBE Vector Group", "nm": "Rectangle 1", "ix": 1, "cix": 2, "np": 3, "it": [ { "ty": "rc", "bm": 0, "hd": false, "mn": "ADBE Vector Shape - Rect", "nm": "Rectangle Path 1", "d": 1, "p": { "a": 0, "k": [ 0, 0 ], "ix": 3 }, "r": { "a": 0, "k": 150, "ix": 4 }, "s": { "a": 0, "k": [ 1080, 1080 ], "ix": 2 } }, { "ty": "st", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Stroke", "nm": "Stroke 1", "lc": 1, "lj": 1, "ml": 4, "o": { "a": 0, "k": 100, "ix": 4 }, "w": { "a": 0, "k": 74, "ix": 5 }, "c": { "a": 0, "k": [ 1, 1, 1 ], "ix": 3 } }, { "ty": "fl", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Fill", "nm": "Fill 1", "c": { "a": 0, "k": [ 0.9961, 0.9961, 0.9961 ], "ix": 4 }, "r": 1, "o": { "a": 0, "k": 100, "ix": 5 } }, { "ty": "tr", "a": { "a": 0, "k": [ 0, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 18.493, 18.493 ], "ix": 3 }, "sk": { "a": 0, "k": 0, "ix": 4 }, "p": { "a": 0, "k": [ 0, 0 ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 6 }, "sa": { "a": 0, "k": 0, "ix": 5 }, "o": { "a": 0, "k": 100, "ix": 7 } } ] } ], "ind": 2 }, { "ty": 4, "nm": "5", "sr": 1, "st": 0, "op": 301, "ip": 0, "hd": false, "ddd": 0, "bm": 0, "hasMask": false, "ao": 0, "ks": { "a": { "a": 0, "k": [ 0, 0, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 100, 100, 100 ], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 1, "k": [ { "o": { "x": 0.714, "y": 0 }, "i": { "x": 0.218, "y": 1 }, "s": [ 239.25, 239.5, 0 ], "t": 27.49, "ti": [ -50.125, 0, 0 ], "to": [ 50.125, 0, 0 ] }, { "s": [ 540, 239.5, 0 ], "t": 61.853515625 } ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, "ef": [], "shapes": [ { "ty": "gr", "bm": 0, "hd": false, "mn": "ADBE Vector Group", "nm": "Rectangle 1", "ix": 1, "cix": 2, "np": 3, "it": [ { "ty": "rc", "bm": 0, "hd": false, "mn": "ADBE Vector Shape - Rect", "nm": "Rectangle Path 1", "d": 1, "p": { "a": 0, "k": [ 0, 0 ], "ix": 3 }, "r": { "a": 0, "k": 150, "ix": 4 }, "s": { "a": 0, "k": [ 1080, 1080 ], "ix": 2 } }, { "ty": "st", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Stroke", "nm": "Stroke 1", "lc": 1, "lj": 1, "ml": 4, "o": { "a": 0, "k": 100, "ix": 4 }, "w": { "a": 0, "k": 74, "ix": 5 }, "c": { "a": 0, "k": [ 1, 1, 1 ], "ix": 3 } }, { "ty": "fl", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Fill", "nm": "Fill 1", "c": { "a": 0, "k": [ 0.9961, 0.9961, 0.9961 ], "ix": 4 }, "r": 1, "o": { "a": 0, "k": 100, "ix": 5 } }, { "ty": "tr", "a": { "a": 0, "k": [ 0, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 18.493, 18.493 ], "ix": 3 }, "sk": { "a": 0, "k": 0, "ix": 4 }, "p": { "a": 0, "k": [ 0, 0 ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 6 }, "sa": { "a": 0, "k": 0, "ix": 5 }, "o": { "a": 0, "k": 100, "ix": 7 } } ] } ], "ind": 3 }, { "ty": 4, "nm": "4", "sr": 1, "st": 0, "op": 301, "ip": 0, "hd": false, "ddd": 0, "bm": 0, "hasMask": false, "ao": 0, "ks": { "a": { "a": 0, "k": [ 0, 0, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 100, 100, 100 ], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 1, "k": [ { "o": { "x": 0.714, "y": 0 }, "i": { "x": 0.218, "y": 1 }, "s": [ 540, 239.5, 0 ], "t": 20.617, "ti": [ -49.917, 0, 0 ], "to": [ 49.917, 0, 0 ] }, { "s": [ 839.5, 239.5, 0 ], "t": 54.98046875 } ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, "ef": [], "shapes": [ { "ty": "gr", "bm": 0, "hd": false, "mn": "ADBE Vector Group", "nm": "Rectangle 1", "ix": 1, "cix": 2, "np": 3, "it": [ { "ty": "rc", "bm": 0, "hd": false, "mn": "ADBE Vector Shape - Rect", "nm": "Rectangle Path 1", "d": 1, "p": { "a": 0, "k": [ 0, 0 ], "ix": 3 }, "r": { "a": 0, "k": 150, "ix": 4 }, "s": { "a": 0, "k": [ 1080, 1080 ], "ix": 2 } }, { "ty": "st", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Stroke", "nm": "Stroke 1", "lc": 1, "lj": 1, "ml": 4, "o": { "a": 0, "k": 100, "ix": 4 }, "w": { "a": 0, "k": 74, "ix": 5 }, "c": { "a": 0, "k": [ 1, 1, 1 ], "ix": 3 } }, { "ty": "fl", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Fill", "nm": "Fill 1", "c": { "a": 0, "k": [ 0.9961, 0.9961, 0.9961 ], "ix": 4 }, "r": 1, "o": { "a": 0, "k": 100, "ix": 5 } }, { "ty": "tr", "a": { "a": 0, "k": [ 0, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 18.493, 18.493 ], "ix": 3 }, "sk": { "a": 0, "k": 0, "ix": 4 }, "p": { "a": 0, "k": [ 0, 0 ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 6 }, "sa": { "a": 0, "k": 0, "ix": 5 }, "o": { "a": 0, "k": 100, "ix": 7 } } ] } ], "ind": 4 }, { "ty": 4, "nm": "3", "sr": 1, "st": 0, "op": 301, "ip": 0, "hd": false, "ddd": 0, "bm": 0, "hasMask": false, "ao": 0, "ks": { "a": { "a": 0, "k": [ 0, 0, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 100, 100, 100 ], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 1, "k": [ { "o": { "x": 0.714, "y": 0 }, "i": { "x": 0.218, "y": 1 }, "s": [ 839.5, 239.5, 0 ], "t": 13.746, "ti": [ 0, -50.083, 0 ], "to": [ 0, 50.083, 0 ] }, { "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [ 839.5, 540, 0 ], "t": 48.109, "ti": [ 0, 0, 0 ], "to": [ 0, 0, 0 ] }, { "o": { "x": 0.546, "y": 0 }, "i": { "x": 0.218, "y": 1 }, "s": [ 839.5, 540, 0 ], "t": 64.908, "ti": [ 0, 0, 0 ], "to": [ -49.917, 0, 0 ] }, { "s": [ 540, 540, 0 ], "t": 98 } ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, "ef": [], "shapes": [ { "ty": "gr", "bm": 0, "hd": false, "mn": "ADBE Vector Group", "nm": "Rectangle 1", "ix": 1, "cix": 2, "np": 3, "it": [ { "ty": "rc", "bm": 0, "hd": false, "mn": "ADBE Vector Shape - Rect", "nm": "Rectangle Path 1", "d": 1, "p": { "a": 0, "k": [ 0, 0 ], "ix": 3 }, "r": { "a": 0, "k": 150, "ix": 4 }, "s": { "a": 0, "k": [ 1080, 1080 ], "ix": 2 } }, { "ty": "st", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Stroke", "nm": "Stroke 1", "lc": 1, "lj": 1, "ml": 4, "o": { "a": 0, "k": 100, "ix": 4 }, "w": { "a": 0, "k": 74, "ix": 5 }, "c": { "a": 0, "k": [ 1, 1, 1 ], "ix": 3 } }, { "ty": "fl", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Fill", "nm": "Fill 1", "c": { "a": 0, "k": [ 0.9961, 0.9961, 0.9961 ], "ix": 4 }, "r": 1, "o": { "a": 0, "k": 100, "ix": 5 } }, { "ty": "tr", "a": { "a": 0, "k": [ 0, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 18.493, 18.493 ], "ix": 3 }, "sk": { "a": 0, "k": 0, "ix": 4 }, "p": { "a": 0, "k": [ 0, 0 ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 6 }, "sa": { "a": 0, "k": 0, "ix": 5 }, "o": { "a": 0, "k": 100, "ix": 7 } } ] } ], "ind": 5 }, { "ty": 4, "nm": "2", "sr": 1, "st": 0, "op": 301, "ip": 0, "hd": false, "ddd": 0, "bm": 0, "hasMask": false, "ao": 0, "ks": { "a": { "a": 0, "k": [ 0, 0, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 100, 100, 100 ], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 1, "k": [ { "o": { "x": 0.714, "y": 0 }, "i": { "x": 0.218, "y": 1 }, "s": [ 839.5, 540, 0 ], "t": 6.873, "ti": [ 0, 0, 0 ], "to": [ 0, 0, 0 ] }, { "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [ 540, 540, 0 ], "t": 41.236, "ti": [ 0, 0, 0 ], "to": [ 0, 0, 0 ] }, { "o": { "x": 0.756, "y": 0 }, "i": { "x": 0.189, "y": 1 }, "s": [ 540, 540, 0 ], "t": 56, "ti": [ 0.083, -50.042, 0 ], "to": [ 0, 0, 0 ] }, { "s": [ 539.5, 840.25, 0 ], "t": 89.091796875 } ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, "ef": [], "shapes": [ { "ty": "gr", "bm": 0, "hd": false, "mn": "ADBE Vector Group", "nm": "Rectangle 1", "ix": 1, "cix": 2, "np": 3, "it": [ { "ty": "rc", "bm": 0, "hd": false, "mn": "ADBE Vector Shape - Rect", "nm": "Rectangle Path 1", "d": 1, "p": { "a": 0, "k": [ 0, 0 ], "ix": 3 }, "r": { "a": 0, "k": 150, "ix": 4 }, "s": { "a": 0, "k": [ 1080, 1080 ], "ix": 2 } }, { "ty": "st", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Stroke", "nm": "Stroke 1", "lc": 1, "lj": 1, "ml": 4, "o": { "a": 0, "k": 100, "ix": 4 }, "w": { "a": 0, "k": 74, "ix": 5 }, "c": { "a": 0, "k": [ 1, 1, 1 ], "ix": 3 } }, { "ty": "fl", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Fill", "nm": "Fill 1", "c": { "a": 0, "k": [ 0.9961, 0.9961, 0.9961 ], "ix": 4 }, "r": 1, "o": { "a": 0, "k": 100, "ix": 5 } }, { "ty": "tr", "a": { "a": 0, "k": [ 0, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 18.493, 18.493 ], "ix": 3 }, "sk": { "a": 0, "k": 0, "ix": 4 }, "p": { "a": 0, "k": [ 0, 0 ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 6 }, "sa": { "a": 0, "k": 0, "ix": 5 }, "o": { "a": 0, "k": 100, "ix": 7 } } ] } ], "ind": 6 }, { "ty": 4, "nm": "1", "sr": 1, "st": 0, "op": 301, "ip": 0, "hd": false, "ddd": 0, "bm": 0, "hasMask": false, "ao": 0, "ks": { "a": { "a": 0, "k": [ 0, 0, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 100, 100, 100 ], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 1, "k": [ { "o": { "x": 0.714, "y": 0 }, "i": { "x": 0.218, "y": 1 }, "s": [ 540, 540, 0 ], "t": 0, "ti": [ 0.083, -50.042, 0 ], "to": [ -0.083, 50.042, 0 ] }, { "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [ 539.5, 840.25, 0 ], "t": 34.363, "ti": [ 0, 0, 0 ], "to": [ 0, 0, 0 ] }, { "o": { "x": 0.742, "y": 0 }, "i": { "x": 0.186, "y": 1 }, "s": [ 539.5, 840.25, 0 ], "t": 48.109, "ti": [ 50.042, 0, 0 ], "to": [ -50.042, 0, 0 ] }, { "s": [ 239.25, 840.25, 0 ], "t": 80.181640625 } ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, "ef": [], "shapes": [ { "ty": "gr", "bm": 0, "hd": false, "mn": "ADBE Vector Group", "nm": "Rectangle 1", "ix": 1, "cix": 2, "np": 3, "it": [ { "ty": "rc", "bm": 0, "hd": false, "mn": "ADBE Vector Shape - Rect", "nm": "Rectangle Path 1", "d": 1, "p": { "a": 0, "k": [ 0, 0 ], "ix": 3 }, "r": { "a": 0, "k": 150, "ix": 4 }, "s": { "a": 0, "k": [ 1080, 1080 ], "ix": 2 } }, { "ty": "st", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Stroke", "nm": "Stroke 1", "lc": 1, "lj": 1, "ml": 4, "o": { "a": 0, "k": 100, "ix": 4 }, "w": { "a": 0, "k": 74, "ix": 5 }, "c": { "a": 0, "k": [ 1, 1, 1 ], "ix": 3 } }, { "ty": "fl", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Fill", "nm": "Fill 1", "c": { "a": 0, "k": [ 0.9961, 0.9961, 0.9961 ], "ix": 4 }, "r": 1, "o": { "a": 0, "k": 100, "ix": 5 } }, { "ty": "tr", "a": { "a": 0, "k": [ 0, 0 ], "ix": 1 }, "s": { "a": 0, "k": [ 18.493, 18.493 ], "ix": 3 }, "sk": { "a": 0, "k": 0, "ix": 4 }, "p": { "a": 0, "k": [ 0, 0 ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 6 }, "sa": { "a": 0, "k": 0, "ix": 5 }, "o": { "a": 0, "k": 100, "ix": 7 } } ] } ], "ind": 7 } ] } ] } ================================================ FILE: app/src/main/res/values/colors.xml ================================================ #FFBB86FC #FF6200EE #FF3700B3 #FF03DAC5 #FF018786 #FF000000 #FFFFFFFF ================================================ FILE: app/src/main/res/values/font_certs.xml ================================================ @array/com_google_android_gms_fonts_certs_dev @array/com_google_android_gms_fonts_certs_prod MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpFeVyxW0qMBujb8X8ETrWy550NaFtI6t9+u7hZeTfHwqNvacKhp1RbE6dBRGWynwMVX8XW8N1+UjFaq6GCJukT4qmpN2afb8sCjUigq0GuMwYXrFVee74bQgLHWGJwPmvmLHC69EH6kWr22ijx4OKXlSIx2xT1AsSHee70w5iDBiK4aph27yH3TxkXy9V89TDdexAcKk/cVHYNnDBapcavl7y0RiQ4biu8ymM8Ga/nmzhRKya6G0cGw8CAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0cxb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrPzgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXclaXjoBADb0kkjVEJu/Lh5hgYZnOjvlba8Ld7HCKePCVePoTJBdI4fvugnL8TsgK05aIskyY0hKI9L8KfqfGTl1lzOv2KoWD0KWwtAWPoGChZxmQ+nBli+gwYMzM1vAkP+aayLe0a1EQimlOalO762r0GXO0ks+UeXde2Z4e+8S/pf7pITEI/tP+MxJTALw9QUWEv9lKTk+jkbqxbsh8nfBUapfKqYn0eidpwq2AzVp3juYl7//fKnaPhJD9gs= MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAQOjgdkwgdYwHQYDVR0OBBYEFMd9jMIhF1Ylmn/Tgt9r45jk14alMIGmBgNVHSMEgZ4wgZuAFMd9jMIhF1Ylmn/Tgt9r45jk14aloXikdjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEUMBIGA1UEChMLR29vZ2xlIEluYy4xEDAOBgNVBAsTB0FuZHJvaWQxEDAOBgNVBAMTB0FuZHJvaWSCCQDC4IdGZEowjTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBAUAA4IBAQBt0lLO74UwLDYKqs6Tm8/yzKkEu116FmH4rkaymUIE0P9KaMftGlMexFlaYjzmB2OxZyl6euNXEsQH8gjwyxCUKRJNexBiGcCEyj6z+a1fuHHvkiaai+KL8W1EyNmgjmyy8AW7P+LLlkR+ho5zEHatRbM/YAnqGcFh5iZBqpknHf1SKMXFh4dd239FJ1jWYfbMDMy3NS5CTMQ2XFI1MvcyUTdZPErjQfTbQe3aDQsQcafEQPD+nqActifKZ0Np0IS9L9kR/wbNvyz6ENwPiTrjV2KRkEjH78ZMcUQXg0L3BYHJ3lc69Vs5Ddf9uUGGMYldX3WfMBEmh/9iFBDAaTCK ================================================ FILE: app/src/main/res/values/non-translatable.xml ================================================ Thor Play Store F-Droid Sideloaded GitHub Telegram ================================================ FILE: app/src/main/res/values/strings.xml ================================================ ScriptRunner HomeActivity Home Home Page App List Shows A list of all apps available Freezer Freeze/Unfreeze apps from here Settings Settings Page to change app settings Configuration Engine • v%1$s GENERAL Show Reinstall Card Show Fix Store reminder on home screen APPEARANCE Theme Visual interface style AMOLED Mode Pure black background Dynamic Colors Material You integration SECURITY Biometric Lock Require auth on launch Biometrics not enrolled or available WORK MODE Active Engine Switch between available providers ABOUT Version Release candidate SOURCE CODE COMMUNITY KERNEL_STATUS: OPTIMIZED BUILT WITH PRECISION FOR POWER USERS App Language Select Language System Default English Chinese French Spanish Arabic Cancel Confirm Proceed Yes No Done Close Refresh Dismiss Search apps… Unknown Reinstall All %1$d %2$s apps not from Play Store. Fix them? Install from File Install APK, XAPK, APKS or Split bundles App Distribution TOTAL: %1$d Others Clear All Cache Which apps would you like to clear? User Apps System Apps Active Frozen Suspended Privilege Check Thor requires Root or Shizuku access to function correctly.\n\nPlease grant access in your manager app and click Refresh. ⚠ System Apps %1$d Selected No matching apps found No apps to display Try adjusting your search or filters Configuration App Source View Mode Grid List Order: Ascending Descending Filters Sort By Clear App Data? This will permanently delete all data for %1$s. This action cannot be undone. Clear All Data Uninstall System App? This allows you to uninstall updates or factory reset this system app. Proceed? Risk Warning This forces the installer record to \'Google Play Store\'.\n\nUpdates may fail if the signature doesn\'t match the official store version. Reinstall with Play Store? This will attempt to reinstall %1$s using the Google Play Store. Fix Store SPLIT FROZEN SUSPENDED Launch Share Freeze Unfreeze Suspend Unsuspend Kill Cache Data Uninstall Reinstall Cannot launch app Share App Kill App? Force stop %1$s? This may cause data loss. Exit Thor? Are you sure you want to close the application? Are you sure? Config Close Frozen Suspended Selected Search Clear Settings ================================================ FILE: app/src/main/res/values/themes.xml ================================================ ================================================ FILE: app/src/main/res/values/thor_drawn_background.xml ================================================ #000000 ================================================ FILE: app/src/main/res/values/thor_icon_background.xml ================================================ #000000 ================================================ FILE: app/src/main/res/values-ar/strings.xml ================================================ ScriptRunner HomeActivity الرئيسية الصفحة الرئيسية قائمة التطبيقات يعرض قائمة بجميع التطبيقات المتاحة المجمد تجميد/إلغاء تجميد التطبيقات من هنا الإعدادات صفحة الإعدادات لتغيير إعدادات التطبيق محرك التكوين • إصدار %1$s عام إظهار بطاقة إعادة التثبيت إظهار تذكير إصلاح المتجر على الشاشة الرئيسية المظهر المظهر نمط الواجهة المرئية وضع AMOLED خلفية سوداء نقية ألوان ديناميكية تكامل Material You الأمان قفل القياسات الحيوية يتطلب المصادقة عند التشغيل المقاييس الحيوية غير مسجلة أو غير متوفرة وضع العمل المحرك النشط التبديل بين الموفرين المتاحين حول الإصدار نسخة مرشحة كود المصدر المجتمع حالة النواة: محسنة بني بدقة للمستخدمين المحترفين لغة التطبيق اختر اللغة افتراضي النظام الإنجليزية الصينية الفرنسية الإسبانية العربية إلغاء تأكيد متابعة نعم لا تم إغلاق تحديث تجاهل بحث عن تطبيقات… غير معروف إعادة تثبيت الكل %1$d تطبيق %2$s ليس من متجر Play. هل تريد إصلاحها؟ تثبيت من ملف تثبيت APK أو XAPK أو APKS أو حزم مقسمة توزيع التطبيقات الإجمالي: %1$d أخرى مسح جميع الذاكرة المؤقتة أي التطبيقات ترغب في مسحها؟ تطبيقات المستخدم تطبيقات النظام نشط مجمد معلق فحص الصلاحيات يتطلب Thor صلاحيات Root أو Shizuku ليعمل بشكل صحيح.\n\nيرجى منح الصلاحيات في تطبيق الإدارة والنقر فوق تحديث. ⚠ تطبيقات النظام %1$d مختار لم يتم العثور على تطبيقات مطابقة لا توجد تطبيقات لعرضها حاول ضبط البحث أو الفلاتر التكوين مصدر التطبيق وضع العرض شبكة قائمة الترتيب: تصاعدي تنازلي الفلاتر فرز حسب مسح بيانات التطبيق؟ سيؤدي هذا إلى حذف جميع بيانات %1$s نهائياً. لا يمكن التراجع عن هذا الإجراء. مسح جميع البيانات إلغاء تثبيت تطبيق النظام؟ يتيح لك هذا إلغاء تثبيت التحديثات أو استعادة ضبط المصنع لتطبيق النظام هذا. متابعة؟ تحذير من المخاطر هذا يجبر سجل المثبت على \'Google Play Store\'.\n\nقد تفشل التحديثات إذا لم يتطابق التوقيع مع نسخة المتجر الرسمية. إعادة التثبيت باستخدام متجر Play؟ سيحاول هذا إعادة تثبيت %1$s باستخدام متجر Google Play. إصلاح المتجر مقسم مجمد معلق تشغيل مشاركة تجميد إلغاء التجميد تعليق إلغاء التعليق إنهاء ذاكرة مؤقتة بيانات إلغاء التثبيت إعادة تثبيت لا يمكن تشغيل التطبيق مشاركة التطبيق إنهاء التطبيق؟ إيقاف إجباري لـ %1$s؟ قد يؤدي هذا إلى فقدان البيانات. الخروج من Thor؟ هل أنت متأكد أنك تريد إغلاق التطبيق؟ هل أنت متأكد؟ تكوين إغلاق مجمد معلق مختار بحث مسح الإعدادات ================================================ FILE: app/src/main/res/values-es/strings.xml ================================================ ScriptRunner HomeActivity Inicio Página de inicio Lista de aplicaciones Muestra una lista de todas las aplicaciones disponibles Congelador Congelar/Descongelar aplicaciones desde aquí Ajustes Página de ajustes para cambiar las opciones de la aplicación Motor de configuración • v%1$s GENERAL Mostrar tarjeta de reinstalación Mostrar recordatorio de Fix Store en la pantalla de inicio APARIENCIA Tema Estilo de interfaz visual Modo AMOLED Fondo negro puro Colores dinámicos Integración con Material You SEGURIDAD Bloqueo biométrico Requerir autenticación al iniciar Biometría no registrada o disponible MODO DE TRABAJO Motor activo Cambiar entre proveedores disponibles ACERCA DE Versión Versión candidata CÓDIGO FUENTE COMUNIDAD ESTADO_DEL_KERNEL: OPTIMIZADO CONSTRUIDO CON PRECISIÓN PARA USUARIOS AVANZADOS Idioma de la aplicación Seleccionar idioma Predeterminado del sistema Inglés Chino Francés Español Árabe Cancelar Confirmar Continuar No Hecho Cerrar Actualizar Descartar Buscar aplicaciones… Desconocido Reinstalar todo %1$d aplicaciones %2$s no son de Play Store. ¿Repararlas? Instalar desde archivo Instalar APK, XAPK, APKS o paquetes divididos Distribución de aplicaciones TOTAL: %1$d Otros Limpiar todos los cachés ¿Qué aplicaciones te gustaría limpiar? Aplicaciones de usuario Aplicaciones del sistema Activo Congelado Suspendido Verificación de privilegios Thor requiere acceso Root o Shizuku para funcionar correctamente.\n\nPor favor, concede acceso en tu aplicación de gestión y pulsa Actualizar. ⚠ Aplicaciones del sistema %1$d Seleccionado(s) No se encontraron aplicaciones No hay aplicaciones para mostrar Intenta ajustar tu búsqueda o filtros Configuración Fuente de la aplicación Modo de vista Cuadrícula Lista Orden: Ascendente Descendente Filtros Ordenar por ¿Borrar datos de la aplicación? Esto borrará permanentemente todos los datos de %1$s. Esta acción no se puede deshacer. Borrar todos los datos ¿Desinstalar aplicación del sistema? Esto te permite desinstalar actualizaciones o restablecer de fábrica esta aplicación del sistema. ¿Continuar? Advertencia de riesgo Esto fuerza el registro del instalador a \'Google Play Store\'.\n\nLas actualizaciones pueden fallar si la firma no coincide con la versión oficial de la tienda. ¿Reinstalar con Play Store? Esto intentará reinstalar %1$s usando Google Play Store. Reparar tienda DIVIDIDO CONGELADO SUSPENDIDO Abrir Compartir Congelar Descongelar Suspender Habilitar Detener Caché Datos Desinstalar Reinstalar No se puede abrir la aplicación Compartir aplicación ¿Detener aplicación? ¿Forzar detención de %1$s? Esto puede causar pérdida de datos. ¿Salir de Thor? ¿Estás seguro de que quieres cerrar la aplicación? ¿Estás seguro? Configuración Cerrar Congelado Suspendido Seleccionado Buscar Limpiar Ajustes ================================================ FILE: app/src/main/res/values-fr/strings.xml ================================================ ScriptRunner HomeActivity Accueil Page d\'accueil Liste des applis Affiche une liste de toutes les applications installées Congélateur Geler/Dégeler les applications ici Paramètres Page de paramètres pour modifier les options de l\'application Moteur de configuration • v%1$s GÉNÉRAL Afficher la carte de réinstallation Afficher le rappel Fix Store sur l\'écran d\'accueil APPARENCE Thème Style d\'interface visuelle Mode AMOLED Arrière-plan noir pur Couleurs dynamiques Intégration Material You SÉCURITÉ Verrouillage biométrique Exiger une authentification au lancement Biométrie non enregistrée ou indisponible MODE DE TRAVAIL Moteur actif Passer d\'un fournisseur à l\'autre À PROPOS Version Version candidate CODE SOURCE COMMUNAUTÉ ÉTAT_DU_NOYAU : OPTIMISÉ CONÇU AVEC PRÉCISION POUR LES UTILISATEURS AVANCÉS Langue de l\'application Choisir la langue Par défaut du système Anglais Chinois Français Espagnol Arabe Annuler Confirmer Continuer Oui Non Terminé Fermer Actualiser Ignorer Rechercher des applis… Inconnu Tout réinstaller %1$d applications %2$s ne provenant pas du Play Store. Voulez-vous les réparer ? Installer depuis un fichier Installer APK, XAPK, APKS ou lots de fichiers Distribution des applis TOTAL : %1$d Autres Vider tous les caches Quelles applications souhaitez-vous vider ? Applis utilisateur Applis système Actif Gelé Suspendu Vérification des privilèges Thor nécessite un accès Root ou Shizuku pour fonctionner correctement.\n\nVeuillez accorder l\'accès dans votre application de gestion et cliquer sur Actualiser. ⚠ Applis Système %1$d Sélectionné(s) Aucune application correspondante Aucune application à afficher Essayez d\'ajuster votre recherche ou vos filtres Configuration Source des applis Mode d\'affichage Grille Liste Ordre : Croissant Décroissant Filtres Trier par Effacer les données ? Cela supprimera définitivement toutes les données de %1$s. Cette action est irréversible. Effacer toutes les données Désinstaller l\'appli système ? Cela vous permet de désinstaller les mises à jour ou de réinitialiser cette application système. Continuer ? Avertissement de risque Ceci force l\'enregistrement de l\'installateur sur \'Google Play Store\'.\n\nLes mises à jour peuvent échouer si la signature ne correspond pas à la version officielle du store. Réinstaller avec Play Store ? Cela tentera de réinstaller %1$s en utilisant le Google Play Store. Réparer Store DIVISÉ GELÉ SUSPENDU Lancer Partager Geler Dégeler Suspendre Rétablir Arrêter Cache Données Désinstaller Réinstaller Impossible de lancer l\'appli Partager l\'appli Arrêter l\'appli ? Arrêt forcé de %1$s ? Cela peut entraîner une perte de données. Quitter Thor ? Voulez-vous vraiment fermer l\'application ? Êtes-vous sûr ? Configuration Fermer Gelé Suspendu Sélectionné Rechercher Effacer Paramètres ================================================ FILE: app/src/main/res/values-v31/themes.xml ================================================ ================================================ FILE: app/src/main/res/values-zh-rCN/strings.xml ================================================ 脚本运行器 主页 主页 主页 应用列表 显示所有可用应用程序的列表 应用冻结 从这里冻结/解冻应用程序 设置 设置页面以更改应用设置 配置引擎 • v%1$s 通用 显示重新安装卡片 在主屏幕上显示修复商店提醒 外观 主题 视觉界面风格 AMOLED 模式 纯黑背景 动态色彩 Material You 集成 安全 生物识别锁定 启动时需要验证 生物识别未注册或不可用 工作模式 活动引擎 在可用提供者之间切换 关于 版本 发布候选版 源代码 社区 内核状态: 已优化 为高级用户精准打造 应用语言 选择语言 系统默认 英语 中文 法语 西班牙语 阿拉伯语 取消 确认 继续 完成 关闭 刷新 关闭 搜索应用… 未知 全部重新安装 %1$d 个非来自 Play 商店的 %2$s 应用。要修复它们吗? 从文件安装 安装 APK、XAPK、APKS 或分割包 应用分布 总计: %1$d 其他 清除所有缓存 您想清除哪些应用? 用户应用 系统应用 权限检查 活跃 已冻结 已暂停 Thor 需要 Root 或 Shizuku 权限才能正常运行。\n\n请在管理应用中授予权限并点击刷新。 ⚠ 系统应用 已选择 %1$d 个 未找到匹配的应用 无应用可显示 尝试调整搜索或过滤器 配置 应用来源 视图模式 网格 列表 排序: 升序 降序 过滤器 排序方式 清除应用数据? 这将永久删除 %1$s 的所有数据。此操作无法撤销。 清除所有数据 卸载系统应用? 这允许您卸载更新或恢复此系统应用的出厂设置。继续吗? 风险警告 这会强制将安装程序记录更改为“Google Play 商店”。\n\n如果签名与官方商店版本不匹配,更新可能会失败。 使用 Play 商店重新安装? 这将尝试使用 Google Play 商店重新安装 %1$s。 修复商店 分割 已冻结 已暂停 启动 分享 冻结 解冻 暂停 取消暂停 结束 缓存 数据 卸载 重新安装 无法启动应用 分享应用 结束应用? 强制停止 %1$s?这可能会导致数据丢失。 退出 Thor? 您确定要关闭应用程序吗? 您确定吗? 配置 关闭 已冻结 已暂停 已选择 搜索 清除 设置 ================================================ FILE: app/src/main/res/xml/backup_rules.xml ================================================ ================================================ FILE: app/src/main/res/xml/data_extraction_rules.xml ================================================ ================================================ FILE: app/src/main/res/xml/locales_config.xml ================================================ ================================================ FILE: app/src/main/res/xml/provider_paths.xml ================================================ ================================================ FILE: app/src/test/java/com/valhalla/thor/ExampleUnitTest.kt ================================================ package com.valhalla.thor import org.junit.Assert.assertEquals import org.junit.Test /** * Example local unit test, which will execute on the development machine (host). * * See [testing documentation](http://d.android.com/tools/testing). */ class ExampleUnitTest { @Test fun addition_isCorrect() { assertEquals(4, 2 + 2) } } ================================================ FILE: build.gradle.kts ================================================ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlinSerialization) apply false alias(libs.plugins.android.test) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlinJvm) apply false } ================================================ FILE: bypass/.gitignore ================================================ /build ================================================ FILE: bypass/README.md ================================================ # bypass An internal module that replaces the [AndroidHiddenApiBypass](https://github.com/LSPosed/AndroidHiddenApiBypass) dependency for accessing Android's restricted (hidden) APIs at runtime. ## Why this exists Android enforces hidden API restrictions via a denylist checked in `java.lang.reflect` and in the native linker. The standard bypass technique calls `VMRuntime.setHiddenApiExemptions()` — a hidden method itself — before any restricted calls are made. Rather than pulling in an external AAR dependency for this single responsibility, `bypass` implements it directly with full control over the exemption signatures. ## Module structure ``` :bypass — the runtime implementation (Bypass.kt) :vm-runtime — compileOnly Java stubs for dalvik.system.VMRuntime ``` `:vm-runtime` is a plain `java-library` that provides stub classes so `:bypass` can reference `VMRuntime.setHiddenApiExemptions()` at compile time without those classes being on the normal classpath. The real implementations are always present on-device. ## API reference All functionality lives in the `com.valhalla.bypass.Bypass` singleton. ### Setup ```kotlin // Call once, early in Application.onCreate() Bypass.prepareThor() // Optional: wire in your own logger Bypass.setLogger { message, throwable -> Log.e("Bypass", message, throwable) } ``` `prepareThor()` exempts the package prefixes Thor uses most: | Exempted prefix | Covers | |-----------------------------|-------------------------------------------| | `Landroid/app` | ActivityManager, hidden app ops, etc. | | `Landroid/content/pm` | PackageManager internals, IPackageManager | | `Landroid/hardware/input` | Input manager internals | | `Lcom/android/internal/app` | Internal app utilities | ### Exemption methods ```kotlin // Exempt specific signatures (Dalvik descriptor prefix format) Bypass.addExemptions("Landroid/content/pm", "Lcom/android/internal") // Exempt every hidden API (equivalent to HiddenApiBypass.addHiddenApiExemptions("L")) Bypass.exemptAll() ``` Exemptions are additive and permanent for the process lifetime. Prefer `addExemptions()` with tight prefixes over `exemptAll()` in production builds. ### Reflection helpers These helpers provide a unified API for reflection on hidden members. Ensure `prepareThor()` or `exemptAll()` is called first to allow hidden API access. ```kotlin // Call a hidden method val result = Bypass.invoke( clazz = ActivityManager::class.java, instance = activityManagerInstance, methodName = "getRunningServiceControlPanel", intent ) // Get a declared Method object (already set accessible) val method: Method? = Bypass.getDeclaredMethod( SomeHiddenClass::class.java, "hiddenMethodName", String::class.java, Int::class.java // parameter types ) // Read a field value (bypasses access checks) val value: Any? = Bypass.getField(instance, "mHiddenField") // Instantiate a class with a hidden constructor val obj = Bypass.newInstance(HiddenClass::class.java, arg1, arg2) ``` ## Migration from AndroidHiddenApiBypass | AndroidHiddenApiBypass | bypass equivalent | |----------------------------------------------------------|-------------------------------------------------| | `HiddenApiBypass.addHiddenApiExemptions("L")` | `Bypass.exemptAll()` | | `HiddenApiBypass.addHiddenApiExemptions("Landroid/app")` | `Bypass.addExemptions("Landroid/app")` | | `HiddenApiBypass.invoke(clazz, obj, method, args)` | `Bypass.invoke(clazz, obj, method, args)` | | `HiddenApiBypass.getDeclaredMethod(clazz, name, params)` | `Bypass.getDeclaredMethod(clazz, name, params)` | | `HiddenApiBypass.newInstance(clazz, args)` | `Bypass.newInstance(clazz, args)` | ## Usage in the project Add the module dependency: ```kotlin // in your module's build.gradle.kts implementation(project(":bypass")) ``` `:vm-runtime` must **not** be added as a runtime dependency — it is only needed as `compileOnly` inside `:bypass` itself and is already declared there. ## How it works 1. **`VMRuntime.setHiddenApiExemptions()`** — the primary path. `VMRuntime` is itself a hidden class; `:vm-runtime` provides a compile-time stub in the `dalvik.system` package so the call compiles. At runtime the real `dalvik.system.VMRuntime` on the device is used, and calling `setHiddenApiExemptions` with a set of Dalvik descriptor prefixes whitelists all matching members for the current process. 2. **Reflection-based access** — once exemptions are added, standard reflection ( `getDeclaredMethod`, `getDeclaredField`, etc.) works even for hidden members. ================================================ FILE: bypass/build.gradle.kts ================================================ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.android.library) } kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } } android { namespace = "com.valhalla.bypass" compileSdk = 37 defaultConfig { minSdk = 28 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") } compileOptions { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } buildFeatures { buildConfig = true } } dependencies { implementation(libs.androidx.core.ktx) compileOnly(project(":vm-runtime")) } ================================================ FILE: bypass/consumer-rules.pro ================================================ ================================================ FILE: bypass/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile ================================================ FILE: bypass/src/main/AndroidManifest.xml ================================================ ================================================ FILE: bypass/src/main/java/com/valhalla/bypass/Bypass.kt ================================================ package com.valhalla.bypass import android.annotation.SuppressLint import com.valhalla.bypass.Bypass.exemptAll import com.valhalla.bypass.Bypass.prepareThor import dalvik.system.VMRuntime import java.lang.reflect.Method /** * Modern implementation of Hidden API Bypass for Thor. * Integrated directly as a core module to reduce external dependencies. */ @SuppressLint("DiscouragedPrivateApi") object Bypass { private var logger: ((String, Throwable?) -> Unit)? = null /** * Set a custom logger to trace bypass operations without creating a direct * dependency on the app module's logger. */ fun setLogger(logger: (String, Throwable?) -> Unit) { this.logger = logger } private val runtime: VMRuntime by lazy { VMRuntime.getRuntime() } /** * Consolidates common exemptions used across the Thor project. * This avoids having to call addExemptions in multiple places. */ fun prepareThor() { addExemptions( "Landroid/app", "Landroid/content/pm", "Landroid/hardware/input", "Lcom/android/internal/app" ) } /** * Exempts specific signatures or the entire app from hidden API restrictions. * Use "L" to exempt everything (default). */ fun addExemptions(vararg signatures: String) { runCatching { runtime.setHiddenApiExemptions(*signatures) }.onFailure { logger?.invoke("Failed to add exemptions for: ${signatures.joinToString()}", it) } } /** * Convenience to exempt everything (replicates original HiddenApiBypass.addHiddenApiExemptions("L")) */ fun exemptAll() { addExemptions("L") } /** * Advanced: Reflection-based invocation of hidden methods without globally exempting. * Ensure [prepareThor] or [exemptAll] is called first to allow hidden API access. */ fun invoke( clazz: Class<*>, instance: Any?, methodName: String, vararg args: Any? ): T { val paramTypes = args.map { it?.javaClass ?: Any::class.java }.toTypedArray() return invoke(clazz, instance, methodName, paramTypes, *args) } /** * Advanced: Reflection-based invocation with explicit parameter types. * @throws NoSuchMethodException if the method cannot be resolved. */ fun invoke( clazz: Class<*>, instance: Any?, methodName: String, paramTypes: Array>, vararg args: Any? ): T { val method = getDeclaredMethod(clazz, methodName, *paramTypes) @Suppress("UNCHECKED_CAST") return method.invoke(instance, *args) as T } /** * Instantiates a class using reflection. */ fun newInstance(clazz: Class<*>, vararg args: Any?): T { val paramTypes = args.map { it?.javaClass ?: Any::class.java }.toTypedArray() return newInstance(clazz, paramTypes, *args) } /** * Instantiates a class using reflection with explicit parameter types. * @throws NoSuchMethodException if the constructor cannot be resolved. */ fun newInstance(clazz: Class<*>, paramTypes: Array>, vararg args: Any?): T { val constructor = getDeclaredConstructor(clazz, *paramTypes) @Suppress("UNCHECKED_CAST") return constructor.newInstance(*args) as T } private fun getDeclaredConstructor( clazz: Class<*>, vararg parameterTypes: Class<*> ): java.lang.reflect.Constructor<*> { val exactConstructor = runCatching { clazz.getDeclaredConstructor(*parameterTypes).apply { isAccessible = true } }.getOrNull() if (exactConstructor != null) return exactConstructor // Fallback for compatible types (primitives, subtypes, nulls) return runCatching { clazz.declaredConstructors.find { constructor -> constructor.parameterCount == parameterTypes.size && constructor.parameterTypes.zip(parameterTypes).all { (declared, provided) -> isCompatible(declared, provided) } }?.apply { isAccessible = true } }.getOrNull() ?: throw NoSuchMethodException("Constructor not found on ${clazz.name}") } /** * Finds a method and ensures it is accessible. * Traverses the class hierarchy to find methods in superclasses. * @throws NoSuchMethodException if the method cannot be resolved. */ fun getDeclaredMethod( clazz: Class<*>, name: String, vararg parameterTypes: Class<*> ): Method { var current: Class<*>? = clazz while (current != null) { val exactMethod = runCatching { current.getDeclaredMethod(name, *parameterTypes).apply { isAccessible = true } }.getOrNull() if (exactMethod != null) return exactMethod // Fallback for compatible types (primitives, subtypes, nulls) val fallbackMethod = runCatching { current.declaredMethods.find { method -> method.name == name && method.parameterCount == parameterTypes.size && method.parameterTypes.zip(parameterTypes).all { (declared, provided) -> isCompatible(declared, provided) } }?.apply { isAccessible = true } }.getOrNull() if (fallbackMethod != null) return fallbackMethod current = current.superclass } throw NoSuchMethodException("Method $name not found on ${clazz.name}") } private fun isCompatible(declared: Class<*>, provided: Class<*>): Boolean { if (provided == Any::class.java) return !declared.isPrimitive if (declared.isAssignableFrom(provided)) return true if (declared.isPrimitive) { return when (provided) { Int::class.javaObjectType -> declared == Int::class.javaPrimitiveType Boolean::class.javaObjectType -> declared == Boolean::class.javaPrimitiveType Long::class.javaObjectType -> declared == Long::class.javaPrimitiveType Double::class.javaObjectType -> declared == Double::class.javaPrimitiveType Float::class.javaObjectType -> declared == Float::class.javaPrimitiveType Byte::class.javaObjectType -> declared == Byte::class.javaPrimitiveType Char::class.javaObjectType -> declared == Char::class.javaPrimitiveType Short::class.javaObjectType -> declared == Short::class.javaPrimitiveType else -> false } } return false } /** * Directly get a field bypassing access checks. * Supports both instance and static fields (by passing the Class as instance). * Traverses the class hierarchy to find fields in superclasses. * @throws NoSuchFieldException if the field cannot be found. */ fun getField(instance: Any, fieldName: String): T { val target = if (instance is Class<*>) null else instance var clazz: Class<*>? = instance as? Class<*> ?: instance.javaClass while (clazz != null) { try { val field = clazz.getDeclaredField(fieldName) field.isAccessible = true @Suppress("UNCHECKED_CAST") return field.get(target) as T } catch (e: NoSuchFieldException) { clazz = clazz.superclass } } throw NoSuchFieldException("Field $fieldName not found on ${if (instance is Class<*>) instance.name else instance.javaClass.name}") } } ================================================ FILE: fastlane/Appfile ================================================ package_name("com.valhalla.thor") # DYNAMIC KEY PATH # 1. Tries to use ENV["JSON_KEY_FILE"] (CI/CD) # 2. Falls back to path relative to this file (Local) json_key_file( ENV["JSON_KEY_FILE"] || File.expand_path("../app/google-play-api.json", __dir__) ) ================================================ FILE: fastlane/Fastfile ================================================ default_platform(:android) platform :android do # --- HELPER: Common Build Logic --- # UPDATED: Now accepts 'track' to distinguish between Internal (Dev) and Alpha/Closed (Prod) def prepare_release_artifacts(upload_to_store: false, track: 'internal', version_code: nil) # 1. PREPARATION fastlane_dir = Dir.pwd project_root = File.expand_path("..", fastlane_dir) gradle_properties_path = File.join(project_root, "gradle.properties") # 2. CONFIGURATION & CHECKS UI.message("📝 Reading configuration...") unless File.exist?(gradle_properties_path) UI.user_error!("❌ gradle.properties not found at #{gradle_properties_path}") end # Parse properties properties = File.readlines(gradle_properties_path) .map(&:strip) .reject { |l| l.empty? || l.start_with?('#') } .map { |l| l.split('=', 2).map(&:strip) } .to_h # 3. DETERMINE VERSION CODE final_version_code = nil if version_code final_version_code = version_code.to_i UI.important("⚠️ Using MANUAL version code override: #{final_version_code}") else final_version_code = properties["versionCode"]&.to_i UI.user_error!("❌ 'versionCode' missing in gradle.properties") unless final_version_code UI.message("✅ Using defined version code from properties: #{final_version_code}") end # 4. GET VERSION NAME new_version_name = "" Dir.chdir(project_root) do new_version_name = sh("./gradlew -q app:printVersionName -PversionCode=#{final_version_code}").strip end UI.message("📦 Build Target: v#{new_version_name} (#{final_version_code}) - Track: #{track}") # Save for GitHub Actions steps File.write(File.join(project_root, "version_name.txt"), new_version_name) File.write(File.join(project_root, "version_code.txt"), final_version_code.to_s) # 5. BUILD ARTIFACTS # We build APKs (for GitHub/Telegram) AND Bundle (for Play Store) build_tasks = "clean copyStoreReleaseApk copyFossReleaseApk" build_tasks += " bundleStoreRelease" if upload_to_store gradle( task: build_tasks, project_dir: project_root, properties: { "versionCode" => final_version_code, "android.injected.version.code" => final_version_code } ) # 6. DEPLOY (Conditional) if upload_to_store generated_aab_path = lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH] UI.user_error!("❌ No AAB generated") unless generated_aab_path # Upload to specific track (internal or alpha/closed) upload_to_play_store( track: track, release_status: 'completed', # Draft or completed? usually completed for internal/alpha aab: generated_aab_path, skip_upload_metadata: true, skip_upload_images: true, skip_upload_screenshots: true, skip_upload_apk: true ) UI.success("🚀 Deployed to Play Store [#{track}]!") else UI.success("📦 Artifacts built successfully (Upload skipped)") end end # --- LANES --- desc "Dev: Build -> Play Store (Internal) -> Telegram -> GitHub Pre-release" lane :distribute_dev do prepare_release_artifacts(upload_to_store: true, track: 'internal') end desc "Prod: Build -> Play Store (Closed/Alpha) -> GitHub Release" lane :distribute_production do # 'alpha' is the standard track key for "Closed Testing" in Google Play Console prepare_release_artifacts(upload_to_store: true, track: 'alpha') end # Helper for manual triggering desc "Build Release Candidates Only (No Store)" lane :build_release_candidates do |options| prepare_release_artifacts( upload_to_store: false, version_code: options[:version_code] ) end end ================================================ FILE: fastlane/metadata/android/en-US/changelogs/1600.txt ================================================ Major backend improvements for root operations Initial support for Android 16 New filters: filter apps by state or installation source Sorting options: sort apps by name, install date, last updated, version, etc., Removed integrity checks, downloadable fonts, and unused dependencies Added new grid view for app lists Switched to Material Expressive Theme Frozen app icons are now greyed out Bug Fixes: Fixed system app uninstaller bug; system apps can now be uninstalled individually ================================================ FILE: fastlane/metadata/android/en-US/full_description.txt ================================================

Thor App Manager is a modern, open-source Android app manager and installer, designed for power users and enthusiasts who value control, privacy, and efficiency.

Thor is built 100% in Kotlin, following Material 3 design principles and leveraging Jetpack Compose for a smooth, responsive user experience. With a focus on minimalism, Thor delivers a full-featured app management suite in an APK smaller than 2.20 MB.

Key Features:

  • Fully reproducible, copylefted libre software (GPLv3.0)
  • Material 3 UI with dynamic color support
  • Displays installed apps with advanced sorting and filtering (including by installation source)
  • Launch app activities directly
  • Install, uninstall, freeze, and unfreeze apps (including system apps)
  • Reinstall APKs or apps via Google Play
  • Share APK files easily
  • Batch operations: reinstall, uninstall, or kill multiple apps at once
  • Split APK indicator for apps using multiple APKs
  • App state indicator (e.g., frozen = disabled)
  • Uninstall and freeze/unfreeze system apps
  • Lightweight and fast, with a focus on privacy and no analytics or trackers

Features in Testing:

  • New settings UI
  • Packages.xml editor (based on community scripts)

Upcoming Features:

  • Overall application overview (new home UI)
  • Advanced Packages.xml editing
  • App installer and batch install
  • App data backup
  • Option to choose installers when reinstalling
  • And many more enhancements

Technical Highlights:

Credits:

  • Portions of this app use code from libsu by topjohnwu, adapted and integrated as the suCore module.
  • libsu was fully converted from Java to Kotlin and modularized for Thor, with significant code size reduction and optimizations.

License:

  • Thor App Manager is licensed under the GNU General Public License v3.0 (GPL-3.0).
  • libsu is licensed under the Apache License 2.0. All modifications and usage comply with Apache-2.0 requirements.

No analytics, no trackers, no nonsense. Thor is free and open source—forever.

================================================ FILE: fastlane/metadata/android/en-US/short_description.txt ================================================ Android App Manager and App Installer utility ================================================ FILE: fastlane/metadata/android/en-US/title.txt ================================================ Thor - App Manager ================================================ FILE: gradle/gradle-daemon-jvm.properties ================================================ #This file is generated by updateDaemonJvm toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c5760d82d08e6c26884debb23736ea57/redirect toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/879378f84c64b2c76003b97a32968399/redirect toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ff1d4fc92bcfc9d3799beabb4e70cfa3/redirect toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/08ce182188ada0b93565cd9ca4a4ab32/redirect toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/021e528cbed860c875a9016f29ee13c1/redirect toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ee5178090598fb4291558827b9f00e0d/redirect toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c5760d82d08e6c26884debb23736ea57/redirect toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/879378f84c64b2c76003b97a32968399/redirect toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/3dc48436acf46a9c2958682158988183/redirect toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/cd15c9dc71cc4176d084ef04cfa97a5e/redirect toolchainVersion=21 ================================================ FILE: gradle/libs.versions.toml ================================================ [versions] accompanistDrawablepainter = "0.37.3" agp = "9.2.0-rc01" biometric = "1.4.0-alpha02" datastorePreferences = "1.2.1" kotlin = "2.3.20" coreKtx = "1.18.0" junit = "4.13.2" room = "2.8.4" ksp = "2.3.6" junitVersion = "1.3.0" espressoCore = "3.7.0" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.13.0" composeBom = "2026.03.01" lottieCompose = "6.7.1" material3 = "1.5.0-alpha17" kotlinxSerializationJson = "1.11.0" splashscreen = "1.2.0" shizuku = "13.1.5" dhizuku = "2.5.4" koin = "4.2.1" coil3 = "3.4.0" navigationCompose = "2.9.7" [libraries] accompanist-drawablepainter = { module = "com.google.accompanist:accompanist-drawablepainter", version.ref = "accompanistDrawablepainter" } #Core androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } androidx-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-biometric = { module = "androidx.biometric:biometric-ktx", version.ref = "biometric" } #Compose androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-ui = { group = "androidx.compose.ui", name = "ui" } androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } #Navigation androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } #Lifecycle androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } #Room DB room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } #Kotlin kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } #Lottie lottie-compose = { module = "com.airbnb.android:lottie-compose", version.ref = "lottieCompose" } #Shizuku shizuku-api = { module = "dev.rikka.shizuku:api", version.ref = "shizuku" } shizuku-provider = { module = "dev.rikka.shizuku:provider", version.ref = "shizuku" } dhizuku-api = { module = "io.github.iamr0s:Dhizuku-API", version.ref = "dhizuku" } #Koin koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" } koin-androidx-startup = { module = "io.insert-koin:koin-androidx-startup", version.ref = "koin" } #Coil coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil3" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } android-test = { id = "com.android.test", version.ref = "agp" } room = { id = "androidx.room", version.ref = "room" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } android-library = { id = "com.android.library", version.ref = "agp" } [bundles] koin = [ "koin-android", "koin-androidx-startup", "koin-androidx-compose", ] coil = [ "coil-compose", ] room = [ "room-runtime", "room-ktx", "room-compiler" ] ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ #Fri Feb 27 20:39:07 IST 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: gradle.properties ================================================ # ----------------------------------------------------------------------------- # PERFORMANCE (THE BASICS) # ----------------------------------------------------------------------------- org.gradle.configuration-cache=true org.gradle.caching=true org.gradle.parallel=true org.gradle.vfs.watch=true # ----------------------------------------------------------------------------- # MEMORY TUNING (UPDATED FOR RESPONSIVENESS) # ----------------------------------------------------------------------------- # Switched to G1GC based on your preference for system responsiveness. # G1GC handles large heaps better and avoids long "stop-the-world" pauses. # Note: G1GC performs best with more breathing room. # If you have 32GB RAM, bump this to -Xmx6g or -Xmx8g. org.gradle.jvmargs=-Xmx4g -XX:+UseG1GC -XX:MaxMetaspaceSize=1g # Kotlin Daemon: Separate from Gradle daemon. kotlin.daemon.jvmargs=-Xmx2g -XX:+UseG1GC # ----------------------------------------------------------------------------- # R8 / SHRINKER OPTIMIZATIONS (RUTHLESS) # ----------------------------------------------------------------------------- android.r8.strictFullModeForKeepRules=true android.r8.optimizedResourceShrinking=true android.enableR8.fullMode=true # ----------------------------------------------------------------------------- # KOTLIN & COMPOSE (KOTLIN 2.0 ERA) # ----------------------------------------------------------------------------- kotlin.code.style=official kotlin.incremental=true kotlin.parallel.tasks.in.project=true # ----------------------------------------------------------------------------- # KSP (SINCE YOU USE KOIN) # ----------------------------------------------------------------------------- ksp.incremental=true # ksp.use.k2=true # ----------------------------------------------------------------------------- # ANDROID BUILD FEATURES # ----------------------------------------------------------------------------- android.nonTransitiveRClass=true android.useAndroidX=true # ----------------------------------------------------------------------------- # LOGGING # ----------------------------------------------------------------------------- org.gradle.warning.mode=all org.gradle.welcome=never # ----------------------------------------------------------------------------- # APPLICATION VERSIONING (REPRODUCIBLE BUILD SETUP) # ----------------------------------------------------------------------------- # Kept as fallback for clean builds initialVersionCode=1806 # REQUIRED: The active source of truth for your release manager # Logic: 1801 -> 1.80.1 (based on math: code/1000 . code%1000/10 . code%10) versionCode=1806 versionName=1.80.6 ================================================ FILE: gradlew ================================================ #!/bin/sh # # Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # SPDX-License-Identifier: Apache-2.0 # ############################################################################## # # Gradle start up script for POSIX generated by Gradle. # # Important for running: # # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is # noncompliant, but you have some other compliant shell such as ksh or # bash, then to run this script, type that shell name before the whole # command line, like: # # ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: # * functions; # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», # «${var#prefix}», «${var%suffix}», and «$( cmd )»; # * compound commands having a testable exit status, especially «case»; # * various built-in commands including «command», «set», and «ulimit». # # Important for patching: # # (2) This script targets any POSIX shell, so it avoids extensions provided # by Bash, Ksh, etc; in particular arrays are avoided. # # The "traditional" practice of packing multiple parameters into a # space-separated string is a well documented source of bugs and security # problems, so this is (mostly) avoided, by progressively accumulating # options in "$@", and eventually passing that to Java. # # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; # see the in-line comments for details. # # There are tweaks for specific operating systems such as AIX, CygWin, # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. # ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link app_path=$0 # Need this for daisy-chained symlinks. while APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path [ -h "$app_path" ] do ls=$( ls -ld "$app_path" ) link=${ls#*' -> '} case $link in #( /*) app_path=$link ;; #( *) app_path=$APP_HOME$link ;; esac done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum warn () { echo "$*" } >&2 die () { echo echo "$*" echo exit 1 } >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "$( uname )" in #( CYGWIN* ) cygwin=true ;; #( Darwin* ) darwin=true ;; #( MSYS* | MINGW* ) msys=true ;; #( NONSTOP* ) nonstop=true ;; esac # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD=$JAVA_HOME/jre/sh/java else JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD=java if ! command -v java >/dev/null 2>&1 then die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi # Collect all arguments for the java command, stacking in reverse order: # * args from the command line # * the main class name # * -classpath # * -D...appname settings # * --module-path (only if needed) # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) # Now convert the arguments - kludge to limit ourselves to /bin/sh for arg do if case $arg in #( -*) false ;; # don't mess with options #( /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath [ -e "$t" ] ;; #( *) false ;; esac then arg=$( cygpath --path --ignore --mixed "$arg" ) fi # Roll the args list around exactly as many times as the number of # args, so each arg winds up back in the position where it started, but # possibly modified. # # NB: a `for` loop captures its iteration list before it begins, so # changing the positional parameters here affects neither the number of # iterations, nor the values presented in `arg`. shift # remove old arg set -- "$@" "$arg" # push replacement arg done fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. if ! command -v xargs >/dev/null 2>&1 then die "xargs is not available" fi # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. # # In Bash we could simply go: # # readarray ARGS < <( xargs -n1 <<<"$var" ) && # set -- "${ARGS[@]}" "$@" # # but POSIX shell has neither arrays nor command substitution, so instead we # post-process each arg (as a line of input to sed) to backslash-escape any # character that might be a shell metacharacter, then use eval to reverse # that process (while maintaining the separation between arguments), and wrap # the whole thing up as a single "set" statement. # # This will of course break if any of these variables contains a newline or # an unmatched quote. # eval "set -- $( printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | xargs -n1 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | tr '\n' ' ' )" '"$@"' exec "$JAVACMD" "$@" ================================================ FILE: gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @rem SPDX-License-Identifier: Apache-2.0 @rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute echo. 1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. 1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! set EXIT_CODE=%ERRORLEVEL% if %EXIT_CODE% equ 0 set EXIT_CODE=1 if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: settings.gradle.kts ================================================ pluginManagement { repositories { google { content { includeGroupByRegex("com\\.android.*") includeGroupByRegex("com\\.google.*") includeGroupByRegex("androidx.*") } } mavenCentral() gradlePluginPortal() } } plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() maven("https://jitpack.io") } } buildCache { local { isEnabled = true } } rootProject.name = "Thor" include(":app") include(":suCore") include(":bypass") include(":vm-runtime") ================================================ FILE: suCore/.gitignore ================================================ /build ================================================ FILE: suCore/README.md ================================================ # suCore This module is based on the `core` module from the [libsu](https://github.com/topjohnwu/libsu) open-source library by [topjohnwu](https://github.com/topjohnwu). Original project: https://github.com/topjohnwu/libsu ## Credits - The original implementation and design are credited to the [libsu](https://github.com/topjohnwu/libsu) project and its contributors. ## Changes Made - Entire codebase has been converted from Java to Kotlin for improved readability and maintainability. - Removed usage of Android `Context` as a static field to prevent potential memory leaks. - Refactored code to follow Kotlin best practices and idioms. ## ⚠️ Caution The changes made here are specifically to support the Thor project and may not be compatible with other use cases. please use the original [libsu](https://github.com/topjohnwu/libsu) project for all your purposes. ## License - The original [libsu](https://github.com/topjohnwu/libsu) project is licensed under the Apache License 2.0. All modifications and usage in this module comply with the Apache-2.0 requirements. - This module, as part of the Thor project, is distributed under the GNU General Public License v3.0 (GPL-3.0). - See the [LICENSE](../LICENSE) file for the full license text. ================================================ FILE: suCore/build.gradle.kts ================================================ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.library") } kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } } android { namespace = "com.valhalla.superuser" compileSdk = 36 defaultConfig { minSdk = 24 consumerProguardFiles("proguard-rules.pro") } buildTypes { create("foss_release") { } } compileOptions { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } buildFeatures { buildConfig = true aidl = true } } dependencies { implementation(libs.androidx.core.ktx) } ================================================ FILE: suCore/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile # Ignore missing service definitions that are not relevant for Android runtime -dontwarn javax.annotation.processing.Processor -dontwarn javax.annotation.Nullable # Make sure R8/Proguard don't break things -keep,allowobfuscation class * extends com.valhalla.superuser.Shell$Initializer { *; } ================================================ FILE: suCore/src/main/AndroidManifest.xml ================================================ ================================================ FILE: suCore/src/main/aidl/com/valhalla/superuser/ipc/IIPC.aidl ================================================ package com.valhalla.superuser.ipc; interface IIPC { IBinder getService(String name); } ================================================ FILE: suCore/src/main/java/com/valhalla/superuser/CallbackList.kt ================================================ package com.valhalla.superuser import com.valhalla.superuser.internal.UiThreadHandler import java.util.AbstractList import java.util.concurrent.Executor /** * An [AbstractList] that calls `onAddElement` when a new element is added to the list. * * * To simplify the API of [Shell], both STDOUT and STDERR will output to [List]s. * This class is useful if you want to trigger a callback every time [Shell] * outputs a new line. * * * The `CallbackList` itself does not have a data store. If you need one, you can provide a * base [List], and this class will delegate its calls to it. */ abstract class CallbackList /** * [.onAddElement] runs with the executor; no backing list. */ protected constructor( protected var mExecutor: Executor = UiThreadHandler.executor, protected var mBase: MutableList? = null ) : AbstractList() { /** * [.onAddElement] runs on the main thread; sets a backing list. */ protected constructor(base: MutableList?) : this(UiThreadHandler.executor, base) /** * [.onAddElement] runs with the executor; sets a backing list. */ /** * [.onAddElement] runs on the main thread; no backing list. */ /** * The callback when a new element is added. * * * This method will be called after `add` is called. * Which thread it runs on depends on which constructor is used to construct the instance. * @param e the new element added to the list. */ abstract fun onAddElement(e: E?) /** * @see List.get */ override fun get(i: Int): E? { return if (mBase == null) null else mBase!![i] } override fun set(i: Int, s: E?): E? { return if (mBase == null) null else mBase!!.set(i, s) } override fun add(i: Int, s: E?) { if (mBase != null) mBase!!.add(i, s) mExecutor.execute(Runnable { onAddElement(s) }) } override fun remove(o: E?): Boolean { return if (mBase == null) false else mBase!!.remove(o) } override fun removeAt(index: Int): E? { return if (mBase == null) null else mBase!!.removeAt(index) } override fun removeFirst(): E? { return if (mBase == null || mBase!!.isEmpty()) null else mBase!!.removeAt(0) } override var size: Int get() = if (mBase == null) 0 else mBase!!.size set(value) { if (mBase == null) mBase = ArrayList(value) else mBase!!.clear() for (i in 0 until value) { mBase!!.add(null) } } } ================================================ FILE: suCore/src/main/java/com/valhalla/superuser/NoShellException.kt ================================================ package com.valhalla.superuser /** * Thrown when it is impossible to construct `Shell`. * This is a runtime exception, and should happen very rarely. */ class NoShellException : RuntimeException { constructor(msg: String?) : super(msg) constructor(message: String?, cause: Throwable?) : super(message, cause) } ================================================ FILE: suCore/src/main/java/com/valhalla/superuser/Shell.kt ================================================ @file:Suppress("unused") package com.valhalla.superuser import androidx.annotation.IntDef import com.valhalla.superuser.internal.BuilderImpl import com.valhalla.superuser.internal.MainShell import com.valhalla.superuser.internal.UiThreadHandler import com.valhalla.superuser.internal.Utils import java.io.Closeable import java.io.IOException import java.io.InputStream import java.io.OutputStream import java.util.concurrent.Executor import java.util.concurrent.Executors import java.util.concurrent.Future import java.util.concurrent.TimeUnit /** * A class providing APIs to an interactive Unix shell. * * * Similar to threads where there is a special "main thread", `libsu` also has the * concept of the "main shell". For each process, there is a single globally shared * "main shell" that is constructed on-demand and cached. * * * To obtain/create the main shell, use the static `Shell.getShell(...)` methods. * Developers can use these high level APIs to access the main shell: * * * [.cmd] * * [.cmd] * */ abstract class Shell : Closeable { /* Preserve 2 due to historical reasons */ @Retention(AnnotationRetention.SOURCE) @IntDef(UNKNOWN, NON_ROOT_SHELL, ROOT_SHELL) internal annotation class Status /* Preserve (1 << 2) due to historical reasons */ /* Preserve (1 << 3) due to historical reasons */ /* Preserve (1 << 4) due to historical reasons */ @Suppress("DEPRECATION") @Retention(AnnotationRetention.SOURCE) @IntDef(flag = true, value = [FLAG_NON_ROOT_SHELL, FLAG_MOUNT_MASTER, FLAG_REDIRECT_STDERR]) internal annotation class ConfigFlags /* *************** * Non-static APIs * ****************/ /** * Return whether the shell is still alive. * @return `true` if the shell is still alive. */ abstract val isAlive: Boolean /** * Execute a low-level [Task] using the shell. USE THIS METHOD WITH CAUTION! * * * This method exposes raw STDIN/STDOUT/STDERR directly to the developer. This is meant for * implementing low-level operations. The shell may stall if the buffer of STDOUT/STDERR * is full. It is recommended to use additional threads to consume STDOUT/STDERR in parallel. * * * STDOUT/STDERR is cleared before executing the task. No output from any previous tasks should * be left over. It is the developer's responsibility to make sure all operations are done; * the shell should be in idle and waiting for further input when the task returns. * @param task the desired task. * @throws IOException I/O errors when doing operations with STDIN/STDOUT/STDERR */ @Throws(IOException::class) abstract fun execTask(task: Task) /** * Submits a low-level [Task] for execution in a queue of the shell. * @param task the desired task. * @see .execTask */ abstract fun submitTask(task: Task) /** * Construct a new [Job] that uses the shell for execution. * * * Unlike [.cmd] and [.cmd], **NO** * output will be collected if the developer did not set the output destination with * [Job.to] or [Job.to]. * @return a job that the developer can execute or submit later. */ abstract fun newJob(): Job @get:Status abstract val status: Int val isRoot: Boolean /** * Return whether the shell has root access. * @return `true` if the shell has root access. */ get() = this.status >= ROOT_SHELL /** * Wait for any current/pending tasks to finish before closing this shell * and release any system resources associated with the shell. * * * Blocks until all current/pending tasks have completed execution, or * the timeout occurs, or the current thread is interrupted, * whichever happens first. * @param timeout the maximum time to wait * @param unit the time unit of the timeout argument * @return `true` if this shell is terminated and * `false` if the timeout elapsed before termination, in which * the shell can still to be used afterwards. * @throws IOException if an I/O error occurs. * @throws InterruptedException if interrupted while waiting. */ @Throws(IOException::class, InterruptedException::class) abstract fun waitAndClose(timeout: Long, unit: TimeUnit): Boolean /** * Wait indefinitely for any current/pending tasks to finish before closing this shell * and release any system resources associated with the shell. * @throws IOException if an I/O error occurs. */ @Throws(IOException::class) fun waitAndClose() { while (true) { try { if (waitAndClose(Long.MAX_VALUE, TimeUnit.NANOSECONDS)) break } catch (ignored: InterruptedException) { } } } /* ************** * Nested classes * ***************/ /** * Builder class for [Shell] instances. * * * Set the default builder for the main shell instance with * [.setDefaultBuilder], or directly use a builder object to create new * [Shell] instances. * * * Do not subclass this class! Use [.create] to get a new Builder object. */ abstract class Builder { var utils: Utils? = null /** * Set the desired [Initializer]s. * @see Initializer * * @param classes the classes of desired initializers. * @return this Builder object for chaining of calls. */ @SafeVarargs fun setInitializers(vararg classes: Class): Builder { (this as BuilderImpl).setInitializersImpl(classes) return this } /** * Set flags to control how a new `Shell` will be constructed. * @param flags the desired flags. * Value is either 0 or bitwise-or'd value of * [.FLAG_NON_ROOT_SHELL] or [.FLAG_MOUNT_MASTER] * @return this Builder object for chaining of calls. */ abstract fun setFlags(@ConfigFlags flags: Int): Builder /** * Set the maximum time to wait for shell verification. * * * After the timeout occurs and the shell still has no response, * the shell process will be force-closed and throw [NoShellException]. * @param timeout the maximum time to wait in seconds. * The default timeout is 20 seconds. * @return this Builder object for chaining of calls. */ abstract fun setTimeout(timeout: Long): Builder /** * Set the commands that will be used to create a new `Shell`. * @param commands commands that will be passed to [Runtime.exec] to create * a new [Process]. * @return this Builder object for chaining of calls. */ abstract fun setCommands(vararg commands: String?): Builder /** * Combine all of the options that have been set and build a new `Shell` instance. * * * If not [.setCommands], there are 3 methods to construct a Unix shell; * if any method fails, it will fallback to the next method: * * 1. If [.FLAG_NON_ROOT_SHELL] is not set and [.FLAG_MOUNT_MASTER] * is set, construct a Unix shell by calling `su --mount-master`. * It may fail if the root implementation does not support mount master. * 1. If [.FLAG_NON_ROOT_SHELL] is not set, construct a Unix shell by calling * `su`. It may fail if the device is not rooted, or root permission is * not granted. * 1. Construct a Unix shell by calling `sh`. This would never fail in normal * conditions, but should it fail, it will throw [NoShellException] * * The developer should check the status of the returned `Shell` with * [.getStatus] since it may be constructed with calling `sh`. * * * If [.setCommands] is called, the provided commands will be used to * create a new [Process] directly. If the process fails to create, or the process * is not a valid shell, it will throw [NoShellException]. * @return the created `Shell` instance. * @throws NoShellException impossible to construct a [Shell] instance, or * initialization failed when using the configured [Initializer]s. */ abstract fun build(): Shell /** * Combine all of the options that have been set and build a new `Shell` instance * with the provided commands. * @param commands commands that will be passed to [Runtime.exec] to create * a new [Process]. * @return the built `Shell` instance. * @throws NoShellException the provided command cannot create a [Shell] instance, or * initialization failed when using the configured [Initializer]s. */ fun build(vararg commands: String?): Shell { return setCommands(*commands).build() } /** * Combine all of the options that have been set and build a new `Shell` instance * with the provided process. * @param process a shell [Process] that has already been created. * @return the built `Shell` instance. * @throws NoShellException the provided process is not a valid shell, or * initialization failed when using the configured [Initializer]s. */ abstract fun build(process: Process?): Shell companion object { /** * Create a new [Builder]. * @return a new Builder object. */ fun create(): Builder { return BuilderImpl() } } } /** * The result of a [Job]. */ abstract class Result { /** * Get the output of STDOUT. * @return a list of strings that stores the output of STDOUT. Empty list if no output * is available. */ abstract val out: MutableList /** * Get the output of STDERR. * @return a list of strings that stores the output of STDERR. Empty list if no output * is available. */ abstract val err: MutableList /** * Get the return code of the job. * @return the return code of the last operation in the shell. If the job is executed * properly, the code should range from 0-255. If the job fails to execute, it will return * [.JOB_NOT_EXECUTED]. */ abstract val code: Int val isSuccess: Boolean /** * Whether the job succeeded. * `getCode() == 0`. * @return `true` if the return code is 0. */ get() = this.code == 0 companion object { /** * This code indicates that the job was not executed, and the outputs are all empty. * Constant value: {@value}. */ const val JOB_NOT_EXECUTED: Int = -1 } } /** * Represents a shell Job that could later be executed or submitted to background threads. * * * All operations added in [.add] and [.add] will be * executed in the order of addition. */ abstract class Job { /** * Store output of STDOUT to a specific list. * @param stdout the list to store STDOUT. Pass `null` to omit all outputs. * @return this Job object for chaining of calls. */ abstract fun to(stdout: MutableList?): Job /** * Store output of STDOUT and STDERR to specific lists. * @param stdout the list to store STDOUT. Pass `null` to omit STDOUT. * @param stderr the list to store STDERR. Pass `null` to omit STDERR. * @return this Job object for chaining of calls. */ abstract fun to(stdout: MutableList?, stderr: MutableList?): Job /** * Add a new operation running commands. * @param cmds the commands to run. * @return this Job object for chaining of calls. */ abstract fun add(vararg cmds: String): Job /** * Add a new operation serving an InputStream to STDIN. * * * This is NOT executing the script like `sh script.sh`. * This is similar to sourcing the script (`. script.sh`) as the * raw content of the script is directly fed into STDIN. If you call * `exit` in the script, **the shell will be killed and this * shell instance will no longer be alive!** * @param inputStream the InputStream to serve to STDIN. * The stream will be closed after consumption. * @return this Job object for chaining of calls. */ abstract fun add(inputStream: InputStream): Job /** * Execute the job immediately and returns the result. * @return the result of the job. */ abstract fun exec(): Result /** * Submit the job to an internal queue to run in the background. * The result will be returned with a callback running on the main thread. * @param cb the callback to receive the result of the job. */ @JvmOverloads fun submit(cb: ResultCallback? = null) { submit(UiThreadHandler.executor, cb) } /** * Submit the job to an internal queue to run in the background. * The result will be returned with a callback executed by the provided executor. * @param executor the executor used to handle the result callback event. * Pass `null` to run the callback on the same thread executing the job. * @param cb the callback to receive the result of the job. */ abstract fun submit(executor: Executor?, cb: ResultCallback?) /** * Submit the job to an internal queue to run in the background. * @return a [Future] to get the result of the job later. */ abstract fun enqueue(): Future } /** * The initializer when a new `Shell` is constructed. * * * This is an advanced feature. If you need to run specific operations when a new `Shell` * is constructed, extend this class, add your own implementation, and register it with * [Builder.setInitializers]. * The concept is similar to `.bashrc`: run specific scripts/commands when the shell * starts up. [.onInit] will be called as soon as the shell is * constructed and tested as a valid shell. * * * An initializer will be constructed and the callbacks will be invoked each time a new * shell is created. */ class Initializer { /** * Called when a new shell is constructed. * @param shell the newly constructed shell. * @return `false` when initialization fails, otherwise `true`. */ fun onInit(shell: Shell): Boolean { return true } } /* ********** * Interfaces * **********/ /** * A task that can be executed by a shell with the method [.execTask]. */ interface Task { /** * This method will be called when a task is executed by a shell. * Calling [Closeable.close] on any stream is NOP (does nothing). * @param stdin the STDIN of the shell. * @param stdout the STDOUT of the shell. * @param stderr the STDERR of the shell. * @throws IOException I/O errors when doing operations with STDIN/STDOUT/STDERR */ @Throws(IOException::class) fun run( stdin: OutputStream, stdout: InputStream, stderr: InputStream ) /** * This method will be called when a shell is unable to execute this task. */ fun shellDied() {} } /** * The callback used in [.getShell]. */ interface GetShellCallback { /** * @param shell the `Shell` obtained in the asynchronous operation. */ fun onShell(shell: Shell) } /** * The callback to receive a result in [Job.submit]. */ interface ResultCallback { /** * @param out the result of the job. */ fun onResult(out: Result) } companion object { /** * Shell status: Unknown. One possible result of [.getStatus]. * * * Constant value {@value}. */ const val UNKNOWN: Int = -1 /** * Shell status: Non-root shell. One possible result of [.getStatus]. * * * Constant value {@value}. */ const val NON_ROOT_SHELL: Int = 0 /** * Shell status: Root shell. One possible result of [.getStatus]. * * * Constant value {@value}. */ const val ROOT_SHELL: Int = 1 /** * If set, create a non-root shell. * * * Constant value {@value}. */ const val FLAG_NON_ROOT_SHELL: Int = (1 shl 0) /** * If set, create a root shell with the `--mount-master` option. * * * Constant value {@value}. */ const val FLAG_MOUNT_MASTER: Int = (1 shl 1) /** * The [Executor] that manages all worker threads used in `libsu`. * * * Note: If the developer decides to replace the default Executor, keep in mind that * each `Shell` instance requires at least 3 threads to operate properly. */ @JvmField var EXECUTOR: Executor = Executors.newCachedThreadPool() /** * Set to `true` to enable verbose logging throughout the library. */ @JvmField var enableVerboseLogging: Boolean = false /** * This flag exists for compatibility reasons. DO NOT use unless necessary. * * * If enabled, STDERR outputs will be redirected to the STDOUT output list * when a [Job] is configured with [Job.to]. * Since the `Shell.cmd(...)` methods are functionally equivalent to * `Shell.getShell().newJob().add(...).to(new ArrayList<>())`, this variable * also affects the behavior of those methods. * * * Note: The recommended way to redirect STDERR output to STDOUT is to assign the * same list to both STDOUT and STDERR with [Job.to]. * The behavior of this flag is unintuitive and error prone. */ @JvmField var enableLegacyStderrRedirection: Boolean = false /** * Override the default [Builder]. * * * This shell builder will be used to construct the main shell. * Set this before the main shell is created anywhere in the program. */ fun setDefaultBuilder(builder: Builder?) { MainShell.setBuilder(builder) } val shell: Shell /** * Get the main shell instance. * * * If [.getCachedShell] returns null, the default [Builder] will be used to * construct a new `Shell`. * * * Unless already cached, this method blocks until the main shell is created. * The process could take a very long time (e.g. root permission request prompt), * so be extra careful when calling this method from the main thread! * * * A good practice is to "preheat" the main shell during app initialization * (e.g. the splash screen) by either calling this method in a background thread or * calling [.getShell] so subsequent calls to this function * returns immediately. * @return the cached/created main shell instance. * @see Builder.build */ get() = MainShell.get() /** * Get the main shell instance asynchronously via a callback. * * * If [.getCachedShell] returns null, the default [Builder] will be used to * construct a new `Shell` in a background thread. * The cached/created shell instance is returned to the callback on the main thread. * @param callback invoked when a shell is acquired. */ fun getShell(callback: GetShellCallback) { MainShell.get(UiThreadHandler.executor, callback) } /** * Get the main shell instance asynchronously via a callback. * * * If [.getCachedShell] returns null, the default [Builder] will be used to * construct a new `Shell` in a background thread. * The cached/created shell instance is returned to the callback executed by provided executor. * @param executor the executor used to handle the result callback event. * If `null` is passed, the callback can run on any thread. * @param callback invoked when a shell is acquired. */ fun getShell(executor: Executor?, callback: GetShellCallback) { MainShell.get(executor, callback) } val cachedShell: Shell? /** * Get the cached main shell. * @return a `Shell` instance. `null` can be returned either when * no main shell has been cached, or the cached shell is no longer active. */ get() = MainShell.cached val isAppGrantedRoot: Boolean? /** * Whether the application has access to root. * * * This method returns `null` when it is currently unable to determine whether * root access has been granted to the application. A non-null value meant that the root * permission grant state has been accurately determined and finalized. The application * must have at least 1 root shell created to have this method return `true`. * This method will not block the calling thread; results will be returned immediately. * @return whether the application has access to root, or `null` when undetermined. */ get() = Utils.isAppGrantedRoot /* ************ * Static APIs * ************/ /** * Create a pending [Job] of the main shell with commands. * * * This method can be treated as functionally equivalent to * `Shell.getShell().newJob().add(commands).to(new ArrayList<>())`, but the internal * implementation is specialized for this use case and does not run this exact code. * The developer can manually override output destination(s) with either * [Job.to] or [Job.to]. * * * The main shell will NOT be requested until the developer invokes either * [Job.exec], [Job.enqueue], or `Job.submit(...)`. This makes it * possible to construct [Job]s before the program has created any root shell. * @return a job that the developer can execute or submit later. * @see Job.add */ fun cmd(vararg commands: String): Job { return MainShell.newJob(*commands) } /** * Create a pending [Job] of the main shell with an [InputStream]. * * * This method can be treated as functionally equivalent to * `Shell.getShell().newJob().add(in).to(new ArrayList<>())`, but the internal * implementation is specialized for this use case and does not run this exact code. * The developer can manually override output destination(s) with either * [Job.to] or [Job.to]. * * * The main shell will NOT be requested until the developer invokes either * [Job.exec], [Job.enqueue], or `Job.submit(...)`. This makes it * possible to construct [Job]s before the program has created any root shell. * @see Job.add */ fun cmd(`in`: InputStream): Job { return MainShell.newJob(`in`) } /* *********** * Deprecated * ***********/ @Deprecated("Not used anymore") const val ROOT_MOUNT_MASTER: Int = 2 /** * For compatibility, setting this flag will set [.enableLegacyStderrRedirection] * @see .enableLegacyStderrRedirection */ @Deprecated( """not used anymore""" ) const val FLAG_REDIRECT_STDERR: Int = (1 shl 3) /** * Whether the application has access to root. * * * This method would NEVER produce false negatives, but false positives can be returned before * actually constructing a root shell. A `false` returned is guaranteed to be * 100% accurate, while `true` may be returned if the device is rooted, but the user * did not grant root access to your application. However, after any root shell is constructed, * this method will accurately return `true`. * @return whether the application has access to root. */ @Deprecated("please switch to {@link #isAppGrantedRoot()}") fun rootAccess(): Boolean { return isAppGrantedRoot == java.lang.Boolean.TRUE } } } ================================================ FILE: suCore/src/main/java/com/valhalla/superuser/ShellUtils.kt ================================================ package com.valhalla.superuser import android.os.Looper import java.io.IOException import java.io.InputStream /** * Some handy utility methods that are used in `libSu`. * * * These methods are for internal use. I personally find them pretty handy, so I gathered them here. * However, since these are meant to be used internally, they are not stable APIs. * I would change them without too much consideration if needed. Also, these methods are not well * tested for public usage, many might not handle some edge cases correctly. * **You have been warned!!** */ @Suppress("unused") object ShellUtils { /** * Test whether the list is `null` or empty or all elements are empty strings. * @param out the output of a shell command. * @return `false` if the list is `null` or empty or all elements are empty strings. */ fun isValidOutput(out: List): Boolean { return out.any { !it.isNullOrEmpty() } } /** * Run commands with the main shell and get a single line output. * @param commands the commands. * @return the last line of the output of the command, empty string if no output is available. */ fun fastCmd(vararg commands: String?): String { return fastCmd(Shell.shell, *commands) } /** * Run commands and get a single line output. * @param shell a shell instance. * @param commands the commands. * @return the last line of the output of t * the command, empty string if no output is available. */ fun fastCmd(shell: Shell, vararg commands: String?): String { val out = shell.newJob().apply { commands.forEach { cmd -> if (cmd != null) add(cmd) } to(ArrayList(), null) }.exec().out return (if (isValidOutput(out)) out.last() else "") ?: "" } /** * Run commands with the main shell and return whether exits with 0 (success). * @param commands the commands. * @return `true` if the commands succeed. */ fun fastCmdResult(vararg commands: String?): Boolean { return fastCmdResult(Shell.shell, *commands) } /** * Run commands and return whether exits with 0 (success). * @param shell a shell instance. * @param commands the commands. * @return `true` if the commands succeed. */ fun fastCmdResult(shell: Shell, vararg commands: String?): Boolean { return shell.newJob().apply { commands.forEach { cmd -> if (cmd != null) add(cmd) } to(ArrayList(), null) }.exec().isSuccess } /** * Check if current thread is main thread. * @return `true` if the current thread is the main thread. */ fun onMainThread(): Boolean { return Looper.getMainLooper().thread === Thread.currentThread() } /** * Discard all data currently available in an [InputStream]. * @param inputStream the [InputStream] to be cleaned. */ fun cleanInputStream(inputStream: InputStream) { try { while (inputStream.available() != 0) inputStream.skip(inputStream.available().toLong()) } catch (_: IOException) { } } private const val SINGLE_QUOTE = '\'' /** * Format string to quoted and escaped string suitable for shell commands. * @param s the string to be formatted. * @return the formatted string. */ fun escapedString(s: String): String { val sb = StringBuilder() sb.append(SINGLE_QUOTE) val len = s.length for (i in 0.. v) { val t = v v = u u = t } v -= u } while (v != 0L) return u shl shift } } ================================================ FILE: suCore/src/main/java/com/valhalla/superuser/internal/BuilderImpl.kt ================================================ package com.valhalla.superuser.internal import android.text.TextUtils import androidx.annotation.RestrictTo import com.valhalla.superuser.NoShellException import com.valhalla.superuser.Shell import java.io.IOException import java.lang.reflect.Constructor @RestrictTo(RestrictTo.Scope.LIBRARY) class BuilderImpl : Shell.Builder() { var timeout: Long = 20 private var flags = 0 private var initializers: Array? = null private var command: Array? = null fun hasFlags(mask: Int): Boolean { return (flags and mask) == mask } override fun setFlags(flags: Int): Shell.Builder { this@BuilderImpl.flags = flags return this } override fun setTimeout(timeout: Long): Shell.Builder { this@BuilderImpl.timeout = timeout return this } override fun setCommands(vararg commands: String?): Shell.Builder { command = arrayOf(*commands) return this } fun setInitializersImpl(clz: Array>) { initializers = arrayOfNulls(clz.size) for (i in clz.indices) { try { val c: Constructor = clz[i].getDeclaredConstructor() c.isAccessible = true initializers!![i] = c.newInstance() } catch (e: ReflectiveOperationException) { Utils.err(e) } catch (e: ClassCastException) { Utils.err(e) } } } private fun start(): ShellImpl { var shell: ShellImpl? = null // Root mount master if (!hasFlags(Shell.FLAG_NON_ROOT_SHELL) && hasFlags(Shell.FLAG_MOUNT_MASTER)) { try { shell = exec("su", "--mount-master") if (!shell.isRoot) shell = null } catch (_: NoShellException) { } } // Normal root shell if (shell == null && !hasFlags(Shell.FLAG_NON_ROOT_SHELL)) { try { shell = exec("su") if (!shell.isRoot) { shell = null } } catch (_: NoShellException) { } } // Try normal non-root shell if (shell == null) { if (!hasFlags(Shell.FLAG_NON_ROOT_SHELL)) { Utils.setConfirmedRootState(false) } shell = exec("sh") } return shell } private fun exec(vararg commands: String?): ShellImpl { try { Utils.log(TAG, "exec " + TextUtils.join(" ", commands)) val process = Runtime.getRuntime().exec(commands) return build(process) } catch (e: IOException) { Utils.ex(e) throw NoShellException("Unable to create a shell!", e) } } override fun build(process: Process?): ShellImpl { if (process == null) { throw NoShellException("Process cannot be null!, Unable to create a shell!") } val shell: ShellImpl try { shell = ShellImpl(this, process) } catch (e: Exception) { Utils.ex(e) throw NoShellException("Unable to create a shell!", e) } @Suppress("DEPRECATION") if (hasFlags(Shell.FLAG_REDIRECT_STDERR)) { Shell.enableLegacyStderrRedirection = true } MainShell.cached = (shell) if (initializers != null) { for (init in initializers) { if (init != null && !init.onInit(shell)) { MainShell.cached = (null) throw NoShellException("Unable to init shell") } } } return shell } override fun build(): ShellImpl { return if (command != null) { exec(*command!!) } else { start() } } companion object { private const val TAG = "BUILDER" } } ================================================ FILE: suCore/src/main/java/com/valhalla/superuser/internal/CoroutineStreamGobbler.kt ================================================ package com.valhalla.superuser.internal import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.BufferedReader import java.io.InputStream import java.io.InputStreamReader import java.nio.charset.StandardCharsets /** * Reads streams using Coroutines on the IO dispatcher. * Replaces the legacy 'StreamGobbler' class. * Under development. */ internal abstract class CoroutineStreamGobbler( private val inputStream: InputStream, private val list: MutableList? ) { suspend fun process(): String? = withContext(Dispatchers.IO) { val br = BufferedReader(InputStreamReader(inputStream, StandardCharsets.UTF_8)) var lastLine: String? = null while (true) { val line = br.readLine() ?: break // Logic ported from legacy StreamGobbler to handle UUID markers val len = line.length val isEnd = line.startsWith(JobTask.END_UUID, len - JobTask.UUID_LEN) var content = line if (isEnd) { if (len == JobTask.UUID_LEN) { // Just the UUID, means we are done lastLine = br.readLine() // Read the return code line break } content = line.substring(0, len - JobTask.UUID_LEN) } if (list != null) { // Warning: list is likely not thread-safe, ensure the caller handles synchronization // or use a thread-safe list. synchronized(list) { list.add(content) } Utils.log("SHELL_OUT", content) } if (isEnd) { lastLine = br.readLine() break } } lastLine } class Out(inputStream: InputStream, list: MutableList?) : CoroutineStreamGobbler(inputStream, list) { suspend fun readCode(): Int { val codeStr = process() return try { codeStr?.toInt() ?: 1 } catch (_: NumberFormatException) { 1 } } } class Err(inputStream: InputStream, list: MutableList?) : CoroutineStreamGobbler(inputStream, list) { suspend fun drain() { process() } } } ================================================ FILE: suCore/src/main/java/com/valhalla/superuser/internal/JobTask.kt ================================================ package com.valhalla.superuser.internal import com.valhalla.superuser.Shell import com.valhalla.superuser.internal.StreamGobbler.ERR import com.valhalla.superuser.internal.StreamGobbler.OUT import java.io.IOException import java.io.InputStream import java.io.OutputStream import java.nio.charset.StandardCharsets import java.util.Collections import java.util.UUID import java.util.concurrent.ExecutionException import java.util.concurrent.Executor import java.util.concurrent.FutureTask internal abstract class JobTask : Shell.Job(), Shell.Task { private val sources: MutableList = ArrayList() private var out: MutableList? = null private var err: MutableList? = UNSET_LIST @JvmField protected var callbackExecutor: Executor? = null @JvmField protected var callback: Shell.ResultCallback? = null private fun setResult(result: ResultImpl) { if (callback != null) { if (callbackExecutor == null) callback!!.onResult(result) else callbackExecutor!!.execute { callback!!.onResult(result) } } } private fun close() { for (src in sources) src.close() } override fun run( stdin: OutputStream, stdout: InputStream, stderr: InputStream ) { val noOut = out === UNSET_LIST val noErr = err === UNSET_LIST var outList = if (noOut) (if (callback == null) null else ArrayList()) else out var errList = if (noErr) (if (Shell.enableLegacyStderrRedirection) outList else null) else err if (outList != null && outList === errList && !Utils.isSynchronized(outList)) { // Synchronize the list internally only if both lists are the same and are not // already synchronized by the user val list = Collections.synchronizedList(outList) outList = list errList = list } val outGobbler = FutureTask(OUT(stdout, outList)) val errGobbler = FutureTask(ERR(stderr, errList)) Shell.EXECUTOR.execute(outGobbler) Shell.EXECUTOR.execute(errGobbler) val result = ResultImpl() try { for (src in sources) src.serve(stdin) stdin.write(END_CMD) stdin.flush() val code: Int = outGobbler.get()!! errGobbler.get() result.code = code result.out = outList ?: mutableListOf() result.err = (if (noErr) null else err) ?: mutableListOf() } catch (e: IOException) { Utils.err(e) } catch (e: ExecutionException) { Utils.err(e) } catch (e: InterruptedException) { Utils.err(e) } close() setResult(result) } override fun shellDied() { close() setResult(ResultImpl()) } override fun to(stdout: MutableList?): Shell.Job { out = stdout err = UNSET_LIST return this } override fun to( stdout: MutableList?, stderr: MutableList? ): Shell.Job { out = stdout err = stderr return this } override fun add(inputStream: InputStream): Shell.Job { sources.add(InputStreamSource(inputStream)) return this } override fun add(vararg cmds: String): Shell.Job { if (cmds.isNotEmpty()) sources.add(CommandSource(cmds)) return this } companion object { @JvmField val UNSET_LIST: MutableList = ArrayList(0) @JvmField val END_UUID: String = UUID.randomUUID().toString() const val UUID_LEN: Int = 36 private val END_CMD: ByteArray = String.format($$"__RET=$?;echo %1$s;echo %1$s >&2;echo $__RET;unset __RET\n", END_UUID) .toByteArray( StandardCharsets.UTF_8 ) //private static final byte[] END_CMD = String // .format("__RET=$?;echo %1$s;echo %1$s >&2;echo $__RET;unset __RET\n", END_UUID) // .getBytes(UTF_8); } } ================================================ FILE: suCore/src/main/java/com/valhalla/superuser/internal/MainShell.kt ================================================ package com.valhalla.superuser.internal import androidx.annotation.GuardedBy import androidx.annotation.RestrictTo import com.valhalla.superuser.NoShellException import com.valhalla.superuser.Shell import com.valhalla.superuser.Shell.GetShellCallback import java.io.InputStream import java.util.concurrent.Executor @RestrictTo(RestrictTo.Scope.LIBRARY) object MainShell { @GuardedBy("self") private val mainShell = arrayOfNulls(1) @GuardedBy("class") private var isInitMain = false @GuardedBy("class") private var mainBuilder: BuilderImpl? = null @JvmStatic @Synchronized fun get(): ShellImpl { var shell: ShellImpl? = cached if (shell == null) { if (isInitMain) { throw NoShellException("The main shell died during initialization") } isInitMain = true if (mainBuilder == null) mainBuilder = BuilderImpl() shell = mainBuilder!!.build() isInitMain = false } return shell } private fun returnShell(s: Shell, e: Executor?, cb: GetShellCallback) { if (e == null) cb.onShell(s) else e.execute { cb.onShell(s) } } @JvmStatic fun get(executor: Executor?, callback: GetShellCallback) { val shell: Shell? = cached if (shell != null) { returnShell(shell, executor, callback) } else { // Else we get shell in worker thread and call the callback when we get a Shell Shell.EXECUTOR.execute { try { returnShell(get(), executor, callback) } catch (e: NoShellException) { Utils.ex(e) } } } } @set:Synchronized var cached: ShellImpl? get() { synchronized(mainShell) { var s = mainShell[0] if (s != null && s.status < 0) { s = null mainShell[0] = null } return s } } set(shell) { if (isInitMain) { synchronized(mainShell) { mainShell[0] = shell } } } @Synchronized fun setBuilder(builder: Shell.Builder?) { check(!(isInitMain || cached != null)) { "The main shell was already created" } mainBuilder = builder as BuilderImpl? } fun newJob(`in`: InputStream): Shell.Job { return PendingJob().add(`in`) } fun newJob(vararg commands: String?): Shell.Job { return PendingJob().apply { commands.forEach { cmd -> if (cmd != null) add(cmd) } } } } ================================================ FILE: suCore/src/main/java/com/valhalla/superuser/internal/PendingJob.kt ================================================ package com.valhalla.superuser.internal import com.valhalla.superuser.NoShellException import com.valhalla.superuser.Shell import com.valhalla.superuser.Shell.GetShellCallback import com.valhalla.superuser.internal.MainShell.get import java.io.IOException import java.util.concurrent.Executor import java.util.concurrent.Future internal class PendingJob : JobTask() { private var retryTask: Runnable? = null init { to(UNSET_LIST) } override fun shellDied() { if (retryTask != null) { val r = retryTask retryTask = null r!!.run() } else { super.shellDied() } } private fun exec0() { val shell: ShellImpl? try { shell = get() } catch (_: NoShellException) { super.shellDied() return } try { shell.execTask(this) } catch (_: IOException) { /* JobTask does not throw */ } } override fun exec(): Shell.Result { retryTask = Runnable { this.exec0() } val holder = ResultHolder() callback = holder callbackExecutor = null exec0() return holder.result } private fun submit0() { get(null, object : GetShellCallback { override fun onShell(shell: Shell) { (shell as ShellImpl).submitTask(this@PendingJob) } }) } override fun enqueue(): Future { retryTask = Runnable { this.submit0() } val future = ResultFuture() callback = future callbackExecutor = null submit0() return future } override fun submit(executor: Executor?, cb: Shell.ResultCallback?) { retryTask = Runnable { this.submit0() } callbackExecutor = executor callback = cb submit0() } } ================================================ FILE: suCore/src/main/java/com/valhalla/superuser/internal/ResultFuture.kt ================================================ package com.valhalla.superuser.internal import com.valhalla.superuser.Shell import java.util.concurrent.CountDownLatch import java.util.concurrent.Future import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException internal class ResultFuture : ResultHolder(), Future { private val latch = CountDownLatch(1) override fun onResult(out: Shell.Result) { super.onResult(out) latch.countDown() } override fun cancel(mayInterruptIfRunning: Boolean): Boolean { return latch.count != 0L } override fun isCancelled(): Boolean { return false } override fun isDone(): Boolean { return latch.count == 0L } @Throws(InterruptedException::class) override fun get(): Shell.Result { latch.await() return result } @Throws(InterruptedException::class, TimeoutException::class) override fun get(timeout: Long, unit: TimeUnit?): Shell.Result { if (!latch.await(timeout, unit)) { throw TimeoutException() } return result } } ================================================ FILE: suCore/src/main/java/com/valhalla/superuser/internal/ResultHolder.kt ================================================ package com.valhalla.superuser.internal import com.valhalla.superuser.Shell internal open class ResultHolder : Shell.ResultCallback { var result: Shell.Result = ResultImpl() override fun onResult(out: Shell.Result) { result = out } } ================================================ FILE: suCore/src/main/java/com/valhalla/superuser/internal/ResultImpl.kt ================================================ package com.valhalla.superuser.internal import com.valhalla.superuser.Shell internal class ResultImpl : Shell.Result() { override var out: MutableList = mutableListOf() override var err: MutableList = mutableListOf() override var code: Int = JOB_NOT_EXECUTED } ================================================ FILE: suCore/src/main/java/com/valhalla/superuser/internal/ShellImpl.kt ================================================ package com.valhalla.superuser.internal import android.text.TextUtils import com.valhalla.superuser.Shell import com.valhalla.superuser.ShellUtils.cleanInputStream import com.valhalla.superuser.ShellUtils.escapedString import java.io.BufferedOutputStream import java.io.BufferedReader import java.io.FilterInputStream import java.io.FilterOutputStream import java.io.IOException import java.io.InputStream import java.io.InputStreamReader import java.io.OutputStream import java.nio.charset.StandardCharsets import java.util.ArrayDeque import java.util.concurrent.ExecutionException import java.util.concurrent.FutureTask import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException import java.util.concurrent.locks.Condition import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.Volatile class ShellImpl(builder: BuilderImpl, private val process: Process) : Shell() { @Volatile override var status: Int private set private val stdIn: NoCloseOutputStream private val stdOut: NoCloseInputStream private val stdErr: NoCloseInputStream // Guarded by scheduleLock private val scheduleLock = ReentrantLock() private val idle: Condition = scheduleLock.newCondition() private val tasks = ArrayDeque() private var isRunningTask = false private class SyncTask(private val condition: Condition) : Task { private var set = false fun signal() { set = true condition.signal() } fun await() { while (!set) { try { condition.await() } catch (_: InterruptedException) { } } } override fun run(stdin: OutputStream, stdout: InputStream, stderr: InputStream) {} } private class NoCloseInputStream(`in`: InputStream?) : FilterInputStream(`in`) { override fun close() {} @Throws(IOException::class) fun close0() { `in`.close() } } private class NoCloseOutputStream(out: OutputStream) : FilterOutputStream(out as? BufferedOutputStream ?: BufferedOutputStream(out)) { @Throws(IOException::class) override fun write(b: ByteArray, off: Int, len: Int) { out.write(b, off, len) } @Throws(IOException::class) override fun close() { out.flush() } @Throws(IOException::class) fun close0() { super.close() } } init { status = UNKNOWN stdIn = NoCloseOutputStream(process.outputStream) stdOut = NoCloseInputStream(process.inputStream) stdErr = NoCloseInputStream(process.errorStream) // Shell checks might get stuck indefinitely val check = FutureTask { this.shellCheck() } EXECUTOR.execute(check) try { try { status = check.get(builder.timeout, TimeUnit.SECONDS)!! } catch (e: ExecutionException) { val cause = e.cause if (cause is IOException) { throw cause } else { throw IOException("Unknown ExecutionException", cause) } } catch (e: TimeoutException) { throw IOException("Shell check timeout", e) } catch (e: InterruptedException) { throw IOException("Shell check interrupted", e) } } catch (e: IOException) { release() throw e } } @Throws(IOException::class) private fun shellCheck(): Int { try { process.exitValue() throw IOException("Created process has terminated") } catch (_: IllegalThreadStateException) { // Process is alive } // Clean up potential garbage from InputStreams cleanInputStream(stdOut) cleanInputStream(stdErr) var status = NON_ROOT_SHELL BufferedReader(InputStreamReader(stdOut)).use { br -> stdIn.write(("echo SHELL_TEST\n").toByteArray(StandardCharsets.UTF_8)) stdIn.flush() var s = br.readLine() if (TextUtils.isEmpty(s) || !s!!.contains("SHELL_TEST")) throw IOException("Created process is not a shell") stdIn.write(("id\n").toByteArray(StandardCharsets.UTF_8)) stdIn.flush() s = br.readLine() if (!TextUtils.isEmpty(s) && s.contains("uid=0")) { status = ROOT_SHELL Utils.setConfirmedRootState(true) // noinspection ConstantConditions val cwd = escapedString(System.getProperty("user.dir") ?: "/") stdIn.write(("cd $cwd\n").toByteArray(StandardCharsets.UTF_8)) stdIn.flush() } } return status } private fun release() { status = UNKNOWN try { stdIn.close0() } catch (_: IOException) { } try { stdErr.close0() } catch (_: IOException) { } try { stdOut.close0() } catch (_: IOException) { } process.destroy() } @Throws(InterruptedException::class) override fun waitAndClose(timeout: Long, unit: TimeUnit): Boolean { if (status < 0) return true scheduleLock.lock() try { if (isRunningTask && !idle.await(timeout, unit)) return false close() } finally { scheduleLock.unlock() } return true } override fun close() { if (status < 0) return release() } override val isAlive: Boolean get() { // If status is unknown, it is not alive if (status < 0) return false try { process.exitValue() // Process is dead, shell is not alive release() return false } catch (_: IllegalThreadStateException) { // Process is still running return true } } @Synchronized @Throws(IOException::class) private fun exec0(task: Task) { if (status < 0) { task.shellDied() return } cleanInputStream(stdOut) cleanInputStream(stdErr) try { stdIn.write('\n'.code) stdIn.flush() } catch (_: IOException) { release() task.shellDied() return } task.run(stdIn, stdOut, stdErr) } private fun processTasks() { var task: Task? while ((processNextTask(false).also { task = it }) != null) { try { exec0(task!!) } catch (_: IOException) { } } } private fun processNextTask(fromExec: Boolean): Task? { scheduleLock.lock() try { val task = tasks.poll() if (task == null) { isRunningTask = false idle.signalAll() return null } if (task is SyncTask) { task.signal() return null } if (fromExec) { // Put the task back in front of the queue tasks.offerFirst(task) } else { return task } } finally { scheduleLock.unlock() } EXECUTOR.execute { this.processTasks() } return null } override fun submitTask(task: Task) { scheduleLock.lock() try { tasks.offer(task) if (!isRunningTask) { isRunningTask = true EXECUTOR.execute { this.processTasks() } } } finally { scheduleLock.unlock() } } @Throws(IOException::class) override fun execTask(task: Task) { scheduleLock.lock() try { if (isRunningTask) { val sync = SyncTask(scheduleLock.newCondition()) tasks.offer(sync) // Wait until it's our turn sync.await() } isRunningTask = true } finally { scheduleLock.unlock() } exec0(task) processNextTask(true) } override fun newJob(): Job { return ShellJob(this) } } ================================================ FILE: suCore/src/main/java/com/valhalla/superuser/internal/ShellInputSource.kt ================================================ package com.valhalla.superuser.internal import java.io.Closeable import java.io.IOException import java.io.InputStream import java.io.OutputStream import java.nio.charset.StandardCharsets internal interface ShellInputSource : Closeable { @Throws(IOException::class) fun serve(out: OutputStream) override fun close() {} companion object { const val TAG: String = "SHELL_IN" } } internal class InputStreamSource(private val `in`: InputStream) : ShellInputSource { @Throws(IOException::class) override fun serve(out: OutputStream) { Utils.pump(`in`, out) `in`.close() out.write('\n'.code) Utils.log(ShellInputSource.TAG, "") } override fun close() { try { `in`.close() } catch (_: IOException) { } } } internal class CommandSource(private val cmd: Array) : ShellInputSource { @Throws(IOException::class) override fun serve(out: OutputStream) { for (command in cmd) { out.write(command.toByteArray(StandardCharsets.UTF_8)) out.write('\n'.code) Utils.log(ShellInputSource.TAG, command) } } } ================================================ FILE: suCore/src/main/java/com/valhalla/superuser/internal/ShellJob.kt ================================================ package com.valhalla.superuser.internal import com.valhalla.superuser.Shell import java.io.IOException import java.util.concurrent.Executor import java.util.concurrent.Future internal class ShellJob(private val shell: ShellImpl) : JobTask() { override fun exec(): Shell.Result { val holder = ResultHolder() callback = holder callbackExecutor = null try { shell.execTask(this) } catch (_: IOException) { /* JobTask does not throw */ } return holder.result } override fun submit(executor: Executor?, cb: Shell.ResultCallback?) { callbackExecutor = executor callback = cb shell.submitTask(this) } override fun enqueue(): Future { val future = ResultFuture() callback = future callbackExecutor = null shell.submitTask(this) return future } } ================================================ FILE: suCore/src/main/java/com/valhalla/superuser/internal/StreamGobbler.kt ================================================ package com.valhalla.superuser.internal import java.io.BufferedReader import java.io.IOException import java.io.InputStream import java.io.InputStreamReader import java.nio.charset.StandardCharsets import java.util.concurrent.Callable internal abstract class StreamGobbler( protected val `in`: InputStream?, protected val list: MutableList? ) : Callable { private fun outputAndCheck(line: String?): Boolean { var line = line ?: return false val len = line.length val end = line.startsWith(JobTask.END_UUID, len - JobTask.UUID_LEN) if (end) { if (len == JobTask.UUID_LEN) return false line = line.take(len - JobTask.UUID_LEN) } if (list != null) { list.add(line) Utils.log(TAG, line) } return !end } @Throws(IOException::class) protected fun process(res: Boolean): String? { val br = BufferedReader(InputStreamReader(`in`, StandardCharsets.UTF_8)) var line: String? do { line = br.readLine() } while (outputAndCheck(line)) return if (res) br.readLine() else null } internal class OUT(`in`: InputStream?, list: MutableList?) : StreamGobbler(`in`, list) { @Throws(Exception::class) override fun call(): Int { val codeStr = process(true) try { val code = codeStr?.toInt() ?: NO_RESULT_CODE Utils.log(TAG, "(exit code: $code)") return code } catch (_: NumberFormatException) { return NO_RESULT_CODE } } companion object { private const val NO_RESULT_CODE = 1 } } internal class ERR(`in`: InputStream?, list: MutableList?) : StreamGobbler(`in`, list) { @Throws(Exception::class) override fun call(): Void? { process(false) return null } } companion object { private const val TAG = "SHELL_OUT" } } ================================================ FILE: suCore/src/main/java/com/valhalla/superuser/internal/UiThreadHandler.kt ================================================ package com.valhalla.superuser.internal import android.os.Handler import android.os.Looper import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import java.util.concurrent.Executor /** * A modern replacement for the legacy Handler wrapper. * Uses Coroutines to dispatch to the Main thread. */ @Suppress("unused") object UiThreadHandler { // We keep the Handler for edge cases where strict Looper access is needed, // but prefer Dispatchers.Main val handler: Handler by lazy { Handler(Looper.getMainLooper()) } private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) /** * An executor that dispatches to the Main Thread via Coroutines. * Used by [com.valhalla.superuser.CallbackList]. */ val executor: Executor = Executor { command -> if (Looper.myLooper() == Looper.getMainLooper()) { command.run() } else { mainScope.launch { command.run() } } } /** * Runs the runnable on the UI thread asynchronously. */ fun run(r: Runnable) { if (Looper.myLooper() == Looper.getMainLooper()) { r.run() } else { mainScope.launch { r.run() } } } /** * Runs the runnable on the UI thread and BLOCKS the caller until it is finished. * Replaces the archaic WaitRunnable. */ fun runAndWait(r: Runnable) { if (Looper.myLooper() == Looper.getMainLooper()) { r.run() } else { // Bridge blocking code to Coroutines runBlocking { withContext(Dispatchers.Main) { r.run() } } } } } ================================================ FILE: suCore/src/main/java/com/valhalla/superuser/internal/Utils.kt ================================================ package com.valhalla.superuser.internal import android.annotation.SuppressLint import android.content.Context import android.os.Build import android.os.Process import android.util.ArraySet import androidx.annotation.RestrictTo import com.valhalla.superuser.Shell import com.valhalla.superuser.internal.MainShell.get import com.valhalla.superuser.utils.Logger import java.io.File import java.io.IOException import java.io.InputStream import java.io.OutputStream import java.util.Collections @Suppress("unused") @RestrictTo(RestrictTo.Scope.LIBRARY) object Utils { private const val TAG = "LIB_SU" private var synchronizedCollectionClass: Class<*>? = null // -1: uninitialized // 0: checked, no root // 1: checked, undetermined // 2: checked, root access private var currentRootState = -1 fun log(log: Any) { log(TAG, log) } fun log(tag: String?, log: Any) { if (vLog()) Logger.d(tag, log.toString()) } fun ex(t: Throwable?) { if (vLog()) Logger.e(TAG, "", t) } fun err(t: Throwable?) { err(TAG, t) } fun err(tag: String?, t: Throwable?) { Logger.e(tag, "", t) } fun vLog(): Boolean { return Shell.enableVerboseLogging } fun hasStartupAgents(context: Context): Boolean { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return false val agents = File(context.codeCacheDir, "startup_agents") return agents.isDirectory() } fun isSynchronized(collection: MutableCollection<*>?): Boolean { if (synchronizedCollectionClass == null) { synchronizedCollectionClass = Collections.synchronizedCollection(mutableListOf()).javaClass } return synchronizedCollectionClass!!.isInstance(collection) } @Throws(IOException::class) fun pump(`in`: InputStream, out: OutputStream): Long { var read: Int var total: Long = 0 val buf = ByteArray(64 * 1024) /* 64K buffer */ while ((`in`.read(buf).also { read = it }) > 0) { out.write(buf, 0, read) total += read.toLong() } return total } fun newArraySet(): MutableSet { return ArraySet() } @get:Synchronized val isAppGrantedRoot: Boolean? get() { if (currentRootState < 0) { if (Process.myUid() == 0) { // The current process is a root service currentRootState = 2 return true } // noinspection ConstantConditions for (path in System.getenv("PATH")!!.split(":".toRegex()) .dropLastWhile { it.isEmpty() }.toTypedArray()) { val su = File(path, "su") if (su.canExecute()) { // We don't actually know whether the app has been granted root access. // Do NOT set the value as a confirmed state. currentRootState = 1 return null } } currentRootState = 0 return false } return when (currentRootState) { 0 -> false 2 -> true else -> null } } @Synchronized fun setConfirmedRootState(value: Boolean) { currentRootState = if (value) 2 else 0 } val isRootImpossible: Boolean get() = isAppGrantedRoot == java.lang.Boolean.FALSE val isMainShellRoot: Boolean get() = get().isRoot @get:SuppressLint("DiscouragedPrivateApi") val isProcess64Bit: Boolean get() { return Process.is64Bit() } } ================================================ FILE: suCore/src/main/java/com/valhalla/superuser/ktx/ShellExtensions.kt ================================================ package com.valhalla.superuser.ktx import com.valhalla.superuser.Shell import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume /** * Suspends until the Shell Job is complete and returns the result. * Replaces the archaic [Shell.Job.submit] callback hell. */ suspend fun Shell.Job.await(): Shell.Result = suspendCancellableCoroutine { cont -> // Fix: ResultCallback is not a 'fun interface', so we must use an object expression. submit(object : Shell.ResultCallback { override fun onResult(out: Shell.Result) { if (cont.isActive) { cont.resume(out) } } }) } /** * Converts a Shell Job's output into a reactive Flow. * Replaces the "CallbackList". * * Usage: * Shell.cmd("logcat").asFlow().collect { line -> ... } */ fun Shell.Job.asFlow(): Flow = callbackFlow { // We create a custom list that emits to the flow when items are added. val flowList = object : java.util.ArrayList() { override fun add(element: String?): Boolean { element?.let { trySend(it) } return super.add(element) } } // Direct output to our flow-emitting list to(flowList) // Execute asynchronously using object expression for the callback submit(object : Shell.ResultCallback { override fun onResult(out: Shell.Result) { close() // Close the flow when the job is done } }) awaitClose { // Handle cancellation if necessary, though libsu jobs might not support explicit cancel } } /** * Gets the main shell instance purely via Coroutines, removing the need for * blocking [Shell.getShell] calls on the main thread. */ suspend fun getShellAwait(): Shell = suspendCancellableCoroutine { cont -> Shell.getShell(object : Shell.GetShellCallback { override fun onShell(shell: Shell) { if (cont.isActive) { cont.resume(shell) } } }) } ================================================ FILE: suCore/src/main/java/com/valhalla/superuser/ktx/ShellRepository.kt ================================================ package com.valhalla.superuser.ktx import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.IOException /** * Interface for interacting with the Root Shell. * Inject this into your ViewModels via Koin. * DO NOT use static Shell.shell calls in your UI layer. */ interface ShellRepository { suspend fun isRootGranted(): Boolean suspend fun runCommand(command: String): Result> suspend fun runCommands(vararg commands: String): Result> } class RealShellRepository : ShellRepository { // Lazy check for root status that doesn't block the UI thread override suspend fun isRootGranted(): Boolean = withContext(Dispatchers.IO) { // Ensure we have a shell first getShellAwait().isRoot } override suspend fun runCommand(command: String): Result> { return runInternal(command) } override suspend fun runCommands(vararg commands: String): Result> { return runInternal(*commands) } private suspend fun runInternal(vararg commands: String): Result> = withContext(Dispatchers.IO) { try { val shell = getShellAwait() val jobResult = shell.newJob().add(*commands).to(ArrayList()).await() if (jobResult.isSuccess) { // Filter out nulls which legacy lib-su might produce Result.success(jobResult.out.filterNotNull()) } else { val errorMsg = jobResult.err.filterNotNull().joinToString("\n") Result.failure(IOException("Command failed with code ${jobResult.code}: $errorMsg")) } } catch (e: Exception) { Result.failure(e) } } } ================================================ FILE: suCore/src/main/java/com/valhalla/superuser/utils/Logger.kt ================================================ @file:Suppress("unused") package com.valhalla.superuser.utils import android.util.Log import com.valhalla.superuser.BuildConfig object Logger { fun d(tag: String?, message: String) { if (BuildConfig.DEBUG) { Log.d(tag ?: "", message) } } fun i(tag: String?, message: String) { if (BuildConfig.DEBUG) { Log.i(tag ?: "", message) } } fun w(tag: String?, message: String) { if (BuildConfig.DEBUG) { Log.w(tag ?: "", message) } } fun v(tag: String?, message: String) { if (BuildConfig.DEBUG) { Log.v(tag ?: "", message) } } fun e(tag: String?, message: String, throwable: Throwable? = null) { if (BuildConfig.DEBUG) { Log.e(tag ?: "", message, throwable) } } } ================================================ FILE: suCore/src/main/java/com/valhalla/superuser/utils/ShellUtils.kt ================================================ package com.valhalla.superuser.utils import com.valhalla.superuser.Shell import com.valhalla.superuser.ktx.await /** * Modern replacement for the legacy ShellUtils object. * Uses extension functions for cleaner syntax. */ /** * Checks if the output list contains valid data. */ fun List?.isValidOutput(): Boolean { return this?.any { !it.isNullOrEmpty() } == true } /** * Escapes a string for use in a shell command. * Replaces `ShellUtils.escapedString(str)`. */ fun String.escapeForShell(): String { return "'" + this.replace("'", "'\\''") + "'" } /** * Quickly runs a command and returns the first line of output or empty string. * Now a suspend function to prevent blocking the Main Thread. */ suspend fun Shell.fastCmd(vararg commands: String): String { val result = this.newJob().add(*commands).to(ArrayList()).await() val out = result.out return if (out.isValidOutput()) out.lastOrNull() ?: "" else "" } /** * Quickly checks if a command succeeds (exit code 0). */ suspend fun Shell.fastCmdResult(vararg commands: String): Boolean { return this.newJob().add(*commands).to(ArrayList(), null).await().isSuccess } ================================================ FILE: vm-runtime/README.md ================================================ # vm-runtime Compile-only Java stubs that allow `:bypass` to reference hidden Android runtime classes at compile time. ## Purpose The following class is used by `:bypass` but is not present on the standard Android SDK classpath: | Stub class | Real runtime class | Used for | |---------------------------|---------------------------------------|------------------------------------| | `dalvik.system.VMRuntime` | `dalvik.system.VMRuntime` (on-device) | Calling `setHiddenApiExemptions()` | Without these stubs the `:bypass` module would fail to compile. At runtime the stubs are **not present** — the real classes from the device's ART runtime are used instead. ## Important: never add this as a runtime dependency This module is declared `compileOnly` inside `:bypass`: ```kotlin // bypass/build.gradle.kts compileOnly(project(":vm-runtime")) ``` Do **not** add `:vm-runtime` as `implementation` or `api` anywhere. Shipping the stub classes in the APK would shadow the real `dalvik.system.VMRuntime` and break the bypass entirely. ## Why stubs instead of implementation The real classes like `dalvik.system.VMRuntime` live on the device. Placing the stub in a separate module avoids conflicts during compilation. The module is used at compile time only. ## Stub surface **`dalvik.system.VMRuntime`** ```java public static VMRuntime getRuntime(); public void setHiddenApiExemptions(String... signatures); ``` Only the methods actually called by `:bypass` are stubbed. There is no need to mirror the full `VMRuntime` API. ================================================ FILE: vm-runtime/build.gradle.kts ================================================ plugins { `java-library` } java { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } ================================================ FILE: vm-runtime/src/main/java/dalvik/system/VMRuntime.java ================================================ package dalvik.system; public class VMRuntime { public static VMRuntime getRuntime() { throw new RuntimeException("Stub!"); } public void setHiddenApiExemptions(String... signatures) { } }