diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index fa6783826a..b96ff4fad9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,64 +1,63 @@ name: "Bug report" -description: Report an issue to help the project improve. -title: "bug: - " -labels: [ - "bug" -] +description: "Report an issue to help the project improve." +title: "..." +labels: + - "bug" body: - type: textarea id: description attributes: label: "Description" - description: Please enter an explicit description of your issue - placeholder: Short and explicit description of your incident... + description: "Please enter an explicit description of your issue" + placeholder: "Short and explicit description of your incident..." validations: required: true - type: textarea id: reprod attributes: label: "Reproduction steps" - description: Steps to reproduce the behavior + description: "Steps to reproduce the behavior" value: | - 1. Go to '...' - 2. Click on '....' - 3. Scroll down to '....' - 4. See error - render: bash + 1. Go to `...` + 2. Click on `....` + 3. Scroll down to `....` + 4. See error. validations: required: true - - type: textarea - id: screenshot - attributes: - label: "Screenshots" - description: If applicable, add screenshots to help explain your problem - value: | - ![DESCRIPTION](LINK.png) - render: bash - validations: - required: false - type: textarea id: logs attributes: label: "Logs" - description: Please copy and paste any relevant log output if available - render: bash + description: "Please copy and paste any relevant log output if available" + render: markdown validations: required: false - type: input id: snapchat-version attributes: label: "Snapchat Version" - description: On which Snapchat version is this happening? - placeholder: ex. 12.35.0.45 + description: "On which Snapchat version is this happening?" + placeholder: "ex. 12.35.0.45" + validations: + required: true + - type: input + id: snapenhance-version + attributes: + label: "SnapEnhance Version" + description: "On which SnapEnhance version is this happening?" + placeholder: "ex. 1.2.5" validations: required: true - type: checkboxes id: terms attributes: - label: Agreement - description: By creating this issue I made sure that ... + label: "Agreement" + description: "**By creating this issue, I agree to the following terms:**" options: - - label: I am using the latest stable SnapEnhance version. - required: true - - label: There is no issue already describing my problem. - required: true + - label: "This is not a bug regarding Snapchat+." + - label: "I am using a debug version of SnapEnhance." + - label: "I have provided a detailed description of the issue." + - label: "I have attached a log if deemed necessary." + - label: "This issue is not a duplicate." + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_suggestion.yml b/.github/ISSUE_TEMPLATE/feature_suggestion.yml index 3920dc7993..27ce215eb6 100644 --- a/.github/ISSUE_TEMPLATE/feature_suggestion.yml +++ b/.github/ISSUE_TEMPLATE/feature_suggestion.yml @@ -1,6 +1,6 @@ name: "Feature suggestion" description: Suggest a new feature to help the project improve. -title: "feat: - <title>" +title: "..." labels: [ "enhancement" ] diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml deleted file mode 100644 index 9deb486505..0000000000 --- a/.github/workflows/android.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Android CI -on: - push: - branches: [ "main" ] - paths-ignore: - - '**/README.md' - - '.github/**' - pull_request: - branches: [ "main" ] -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: set up JDK 17 - uses: actions/setup-java@v3 - with: - java-version: '17' - distribution: 'temurin' - cache: gradle - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - name: Clean Gradle Cache - run: ./gradlew clean - - name: Build with Gradle - run: ./gradlew assembleDebug - - name: Upload armv8 - uses: actions/upload-artifact@v3.1.2 - with: - name: app-armv8-release - path: app/build/outputs/apk/armv8/debug/*.apk - - name: Upload armv7 - uses: actions/upload-artifact@v3.1.2 - with: - name: app-armv7-release - path: app/build/outputs/apk/armv7/debug/*.apk diff --git a/.github/workflows/debug.yml b/.github/workflows/debug.yml new file mode 100644 index 0000000000..81a797abcf --- /dev/null +++ b/.github/workflows/debug.yml @@ -0,0 +1,186 @@ +name: Debug CI +on: + workflow_dispatch: + inputs: + ci_upload: + description: 'Upload to CI channel' + required: false + type: boolean + +jobs: + job_armv8: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + with: + submodules: 'recursive' + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Setup NPM Dependencies + run: npm install typescript -g + + - name: Add Android targets for Rust + run: rustup target add armv7-linux-androideabi aarch64-linux-android + + - name: Build + run: ./gradlew assembleArmv8Debug + + - name: Determine the latest Build Tools version installed + shell: bash + run: echo "BUILD_TOOL_VERSION=$(ls "$ANDROID_HOME/build-tools/" | tail -n 1)" >> $GITHUB_ENV + + - name: Sign APK + id: sign_app + uses: SnapEnhance/sign-android-release@master + with: + releaseDirectory: app/build/outputs/apk/armv8/debug/ + signingKeyBase64: ${{ secrets.JAVA_KEYSTORE_DATA }} + alias: ${{ secrets.KEY_ALIAS }} + keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }} + keyPassword: ${{ secrets.KEY_PASSWORD }} + env: + BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }} + + - name: Get current build version + id: version-env + run: | + ./gradlew getVersion + echo "version=$(cat app/build/version.txt)" >> $GITHUB_ENV + + - name: Delete unsigned APK file and rename the signed one + run: | + find app/build/outputs/apk/armv8/debug/ -type f ! -name '*-signed*' -delete + mv ${{steps.sign_app.outputs.signedReleaseFile}} app/build/outputs/apk/armv8/debug/snapenhance-${{ env.version }}-armv8-${GITHUB_SHA::7}.apk + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: snapenhance-armv8-debug + path: app/build/outputs/apk/armv8/debug/*.apk + + job_armv7: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + with: + submodules: 'recursive' + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Setup NPM Dependencies + run: npm install typescript -g + + - name: Add Android targets for Rust + run: rustup target add armv7-linux-androideabi aarch64-linux-android + + - name: Build + run: ./gradlew assembleArmv7Debug + + - name: Determine the latest Build Tools version installed + shell: bash + run: echo "BUILD_TOOL_VERSION=$(ls "$ANDROID_HOME/build-tools/" | tail -n 1)" >> $GITHUB_ENV + + - name: Sign APK + id: sign_app + uses: SnapEnhance/sign-android-release@master + with: + releaseDirectory: app/build/outputs/apk/armv7/debug/ + signingKeyBase64: ${{ secrets.JAVA_KEYSTORE_DATA }} + alias: ${{ secrets.KEY_ALIAS }} + keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }} + keyPassword: ${{ secrets.KEY_PASSWORD }} + env: + BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }} + + - name: Get current build version + id: version-env + run: | + ./gradlew getVersion + echo "version=$(cat app/build/version.txt)" >> $GITHUB_ENV + + - name: Delete unsigned APK file and rename the signed one + run: | + find app/build/outputs/apk/armv7/debug/ -type f ! -name '*-signed*' -delete + mv ${{steps.sign_app.outputs.signedReleaseFile}} app/build/outputs/apk/armv7/debug/snapenhance-${{ env.version }}-armv7-${GITHUB_SHA::7}.apk + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: snapenhance-armv7-debug + path: app/build/outputs/apk/armv7/debug/*.apk + + job_manager: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + with: + submodules: 'recursive' + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build + run: ./gradlew manager:assembleDebug + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: manager + path: manager/build/outputs/apk/debug/*.apk + + job_core: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + with: + submodules: 'recursive' + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Add Android targets for Rust + run: rustup target add armv7-linux-androideabi aarch64-linux-android + + - name: Build + run: ./gradlew assembleCoreDebug + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: core + path: app/build/outputs/apk/core/debug/*.apk diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml new file mode 100644 index 0000000000..eca12a5c59 --- /dev/null +++ b/.github/workflows/pull_request.yml @@ -0,0 +1,148 @@ +name: Pull Request CI +on: + pull_request: + branches: ["dev"] + +jobs: + job_armv8: + runs-on: macos-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + with: + submodules: 'recursive' + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Setup NPM Dependencies + run: npm install typescript -g + + - name: Add Android targets for Rust + run: rustup target add armv7-linux-androideabi aarch64-linux-android + + - name: Build + run: ./gradlew assembleArmv8Debug + + - name: Get current build version + id: version-env + run: | + ./gradlew getVersion + echo "version=$(cat app/build/version.txt)" >> $GITHUB_ENV + + - name: Rename APK file + run: | + mv app/build/outputs/apk/armv8/debug/*.apk app/build/outputs/apk/armv8/debug/snapenhance-${{ env.version }}-armv8-${GITHUB_SHA::7}.apk + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: snapenhance-armv8-debug + path: app/build/outputs/apk/armv8/debug/*.apk + + job_armv7: + runs-on: macos-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + with: + submodules: 'recursive' + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Setup NPM Dependencies + run: npm install typescript -g + + - name: Add Android targets for Rust + run: rustup target add armv7-linux-androideabi aarch64-linux-android + + - name: Build + run: ./gradlew assembleArmv7Debug + + - name: Get current build version + id: version-env + run: | + ./gradlew getVersion + echo "version=$(cat app/build/version.txt)" >> $GITHUB_ENV + + - name: Rename APK file + run: | + mv app/build/outputs/apk/armv7/debug/*.apk app/build/outputs/apk/armv7/debug/snapenhance-${{ env.version }}-armv7-${GITHUB_SHA::7}.apk + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: snapenhance-armv7-debug + path: app/build/outputs/apk/armv7/debug/*.apk + + job_manager: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + with: + submodules: 'recursive' + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build + run: ./gradlew manager:assembleDebug + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: manager + path: manager/build/outputs/apk/debug/*.apk + + job_core: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + with: + submodules: 'recursive' + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Add Android targets for Rust + run: rustup target add armv7-linux-androideabi aarch64-linux-android + + - name: Build + run: ./gradlew assembleCoreDebug + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: core + path: app/build/outputs/apk/core/debug/*.apk diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a13d9bacb1..7a9dea731d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,74 +1,194 @@ name: Release CI on: workflow_dispatch: + inputs: + prerelease: + description: 'Mark as Pre-release' + required: false + type: boolean + publish: + description: 'Publish release' + required: false + type: boolean + default: true jobs: - build: - runs-on: ubuntu-latest + job_armv8: + runs-on: macos-latest steps: - - uses: actions/checkout@v3 - - - name: set up JDK 17 - uses: actions/setup-java@v3 + - name: Checkout repo + uses: actions/checkout@v4 with: - java-version: '17' + submodules: 'recursive' + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' distribution: 'temurin' cache: gradle - + - name: Grant execute permission for gradlew run: chmod +x gradlew + + - name: Setup NPM Dependencies + run: npm install typescript -g - - name: Clean Gradle Cache - run: ./gradlew clean + - name: Add Android targets for Rust + run: rustup target add armv7-linux-androideabi aarch64-linux-android + + - name: Build + run: ./gradlew assembleArmv8Release + + - name: Determine the latest Build Tools version installed + shell: bash + run: echo "BUILD_TOOL_VERSION=$(ls "$ANDROID_HOME/build-tools/" | tail -n 1)" >> $GITHUB_ENV - - name: Build Release APK - run: ./gradlew assembleRelease + - name: Sign APK + id: sign_app + uses: SnapEnhance/sign-android-release@master + with: + releaseDirectory: app/build/outputs/apk/armv8/release/ + signingKeyBase64: ${{ secrets.JAVA_KEYSTORE_DATA }} + alias: ${{ secrets.KEY_ALIAS }} + keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }} + keyPassword: ${{ secrets.KEY_PASSWORD }} + env: + BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }} + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: snapenhance-armv8-release + path: ${{steps.sign_app.outputs.signedReleaseFile}} + + job_armv7: + runs-on: macos-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + with: + submodules: 'recursive' + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Setup NPM Dependencies + run: npm install typescript -g + + - name: Add Android targets for Rust + run: rustup target add armv7-linux-androideabi aarch64-linux-android - - name: Sign armv7 APK - id: sign_armv7_app - uses: kevin-david/zipalign-sign-android-release@v1.1 + - name: Build + run: ./gradlew assembleArmv7Release + + - name: Determine the latest Build Tools version installed + shell: bash + run: echo "BUILD_TOOL_VERSION=$(ls "$ANDROID_HOME/build-tools/" | tail -n 1)" >> $GITHUB_ENV + + - name: Sign APK + id: sign_app + uses: SnapEnhance/sign-android-release@master with: releaseDirectory: app/build/outputs/apk/armv7/release/ signingKeyBase64: ${{ secrets.JAVA_KEYSTORE_DATA }} alias: ${{ secrets.KEY_ALIAS }} keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }} - - - name: Upload armv7 artifact - uses: actions/upload-artifact@v3.1.2 + env: + BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }} + + - name: Upload artifact + uses: actions/upload-artifact@v4 with: - name: app-armv7-release - path: ${{ steps.sign_armv7_app.outputs.signedReleaseFile }} + name: snapenhance-armv7-release + path: ${{steps.sign_app.outputs.signedReleaseFile}} - - name: Sign armv8 APK - id: sign_armv8_app - uses: kevin-david/zipalign-sign-android-release@v1.1 + job_manager: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 with: - releaseDirectory: app/build/outputs/apk/armv8/release/ + submodules: 'recursive' + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build + run: ./gradlew manager:assembleRelease + + - name: Determine the latest Build Tools version installed + shell: bash + run: echo "BUILD_TOOL_VERSION=$(ls "$ANDROID_HOME/build-tools/" | tail -n 1)" >> $GITHUB_ENV + + - name: Sign APK + id: sign_app + uses: SnapEnhance/sign-android-release@master + with: + releaseDirectory: manager/build/outputs/apk/release/ signingKeyBase64: ${{ secrets.JAVA_KEYSTORE_DATA }} alias: ${{ secrets.KEY_ALIAS }} keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }} - - - name: Upload armv8 artifact - uses: actions/upload-artifact@v3.1.2 + env: + BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }} + + - name: Upload artifact + uses: actions/upload-artifact@v4 with: - name: app-armv8-release - path: ${{ steps.sign_armv8_app.outputs.signedReleaseFile }} + name: manager + path: ${{steps.sign_app.outputs.signedReleaseFile}} - - name: Generate Version - run: | - ./gradlew getVersion - - - name: Set Version to Environment + job_release: + runs-on: ubuntu-latest + if: ${{ inputs.publish == true }} + needs: [job_armv8, job_armv7, job_universal, job_manager] + steps: + - name: Checkout repo + uses: actions/checkout@v4 + with: + submodules: 'recursive' + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Get current build version id: version-env - run: echo "version=$(cat app/build/version.txt)" >> $GITHUB_ENV + run: | + ./gradlew getVersion + echo "version=$(cat app/build/version.txt)" >> $GITHUB_ENV + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + path: bins/ + merge-multiple: true - name: Publish APK - uses: marvinpinto/action-automatic-releases@latest - with: - repo_token: "${{ secrets.GITHUB_TOKEN }}" - prerelease: false - files: | - ${{ steps.sign_armv7_app.outputs.signedReleaseFile }} - ${{ steps.sign_armv8_app.outputs.signedReleaseFile }} - automatic_release_tag: v${{ env.version }} + uses: softprops/action-gh-release@v2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + prerelease: ${{ inputs.prerelease }} + files: bins/*.apk + tag_name: v${{ env.version }} diff --git a/.github/workflows/upload.js b/.github/workflows/upload.js new file mode 100644 index 0000000000..8d654ac6f0 --- /dev/null +++ b/.github/workflows/upload.js @@ -0,0 +1,16 @@ +const TelegramBot = require('node-telegram-bot-api'); +const program = require('commander'); + +program + .option('-t, --token <token>', 'Telegram bot token') + .option('-f, --file <filePath>', 'File path of the file to send') + .option('-c, --caption <caption>', 'Caption for the file') + .option('--chatid <chatId>', 'Chat ID to send the file to') + .parse(process.argv); + +const { token, chatid, file, caption } = program.opts(); +const bot = new TelegramBot(token); + +bot.sendDocument(chatid, file, { caption }).then(() => { + process.exit(); +}) diff --git a/LICENSE_google-aosp b/LICENSE_google-aosp new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/LICENSE_google-aosp @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index f5fc255e85..c3464f9a09 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,263 @@ <div align="center"> <img src="https://raw.githubusercontent.com/rhunk/SnapEnhance/main/app/src/main/res/mipmap-xxxhdpi/launcher_icon_foreground.png" height="250" /> - -# Snap Enhance -A xposed mod to enhance the Snapchat experience. <br/><br/> -The project is currently in development, so expect bugs and crashes. If you find any bug, please report on [telegram](https://t.me/snapenhance) or make a pull request. + +[![Build](https://img.shields.io/github/actions/workflow/status/rhunk/SnapEnhance/debug.yml?branch=dev&logo=github&label=Build)](https://github.com/rhunk/SnapEnhance/actions/workflows/android.yml?query=branch%3Amain+event%3Apush+is%3Acompleted) [![Total](https://shields.io/github/downloads/rhunk/SnapEnhance/total?logo=Bookmeter&label=Downloads&logoColor=Green&color=Green)](https://github.com/rhunk/snapenhance/releases) [![Translation status](https://hosted.weblate.org/widget/snapenhance/app/svg-badge.svg)](https://hosted.weblate.org/engage/snapenhance/) + +# SnapEnhance +SnapEnhance is an Xposed mod that enhances your Snapchat experience.<br/><br/> +Please note that this project is currently in development, so bugs and crashes may occur. If you encounter any issues, we encourage you to report them [here](https://github.com/rhunk/SnapEnhance/issues). </div> +## Quick Start +Requirements: +- Rooted using `Magisk` or `KernelSU` +- `LSPosed` installed and fully functional + +Although using this in an unrooted enviroment using something like `LSPatch` should be working fine, it is not recommended to do so, use at your own risk! + +1. Install the module APK from either this [Github repo](https://github.com/rhunk/SnapEnhance/releases) or the [LSPosed repo](https://modules.lsposed.org/module/me.rhunk.snapenhance) +2. Turn on the module in `LSPosed` and make sure Snapchat is in scope +3. Force Stop Snapchat +4. Open the menu by clicking the [Settings Gear Icon](https://i.imgur.com/2grm8li.png) or the [Top Title](https://imgur.com/xWFKha7) (v2.1.0 and higher) + +A full installation guide can be found [here](https://github.com/rhunk/SnapEnhance/wiki/Installation-Guide). + +## Download +To download the latest stable release, please visit the [Releases](https://github.com/rhunk/SnapEnhance/releases) page.<br/> +You can also download the latest debug build from the [Actions](https://github.com/rhunk/SnapEnhance/actions) section.<br/> +We no longer offer official `LSPatch` binaries for obvious reasons. However, you're welcome to patch them yourself, as they should theoretically work without any issues. + +> [!Caution] +> Snapchat is actively banning accounts that use SnapEnhance or its related forks due to new detections. It's recommended to use Snapchat [v12.33.1.19](https://www.apkmirror.com/apk/snap-inc/snapchat/snapchat-12-33-1-19-release/) or earlier. Only use signed builds or builds you've modified yourself to avoid compromising the security of your account. + +## Main Features +<details closed> + <summary>Media Downloader</summary> + + - `Auto Download` + - `Prevent Self Auto Download` + - `Merge Overlays` + - `Force Image Format` + - `Force Voice Note Format` + - `Download Profile Pictures` + - `Opera Download Button` + - `Chat Download Context Menu` + - `Logging` + - `Custom Path Format` +</details> + +<details closed> + <summary>User Interface</summary> + + - `Friend Feed Menu Buttons` + - `AMOLED Dark Mode` + - `Friend Feed Message Preview` + - `Snap Preview` + - `Bootstrap Override` (Default Home Tab & Persistent App Appearance) + - `Enhance Friend Map Nametags` + - `Prevent Message List Auto Scroll` + - `Show Streak Expiration Info` + - `Hide Friend Feed Entry` + - `Hide Streak Restore` + - `Hide Quick Add In Friend Feed` + - `Hide Story Section` + - `Hide UI Components` (Voice Record button, Call Buttons, ...) + - `Opera Media Quick Info` + - `Old Bitmoji Selfie` + - `Disable Spotlight` + - `Hide Settings Gear` + - `Vertical Story Viewer` + - `Message Indicators` + - `Stealth Mode Indicator` + - `Edit Text Override` +</details> + +<details closed> + <summary>Messaging</summary> + + - `Bypass Screenshot Detection` + - `Anonymous Story Viewing` + - `Prevent Story Rewatch Indicator` + - `Hide Peek-a-Peek` + - `Hide Bitmoji Presence` + - `Hide Typing Notifications` + - `Unlimited Snap View Time` + - `Auto Mark As Read` + - `Loop Media PlayBack` + - `Disable Replay In FF` + - `Half Swipe Notifier` + - `Message Preview` + - `Call Start Confirmation` + - `Auto Save Messages` + - `Prevent Message Sending` + - `Friend Mutation Notifier` + - `Better Notifications` + - `Notifications Blacklist` + - `Message Logger` + - `Gallery Media Send Override` + - `Strip Media Metadata` + - `Bypass Message Retention Policy` + - `Bypass Message Action Restrictions` + - `Remove Groups Locked Status` + </details> + +<details closed> + <summary>Global</summary> + + - `Better Location` + - `Suspend Location Updates` + - `Snapchat Plus` + - `Disable Confirmation Dialogs` + - `Disable Metrics` + - `Disable Story Sections` + - `Block Ads` + - `Disable Permission Request` + - `Disable Memories Snap Feed` + - `Spotlight Comments Username` + - `Bypass Video Length Restriction` + - `Default Video Playback Rate` + - `Video Playback Rate Slider` + - `Disable Google Play Services Dialogs` + - `Force Upload Source Quality` + - `Default Volume Controls` + - `Hide Active Music` + - `Disable Snap Splitting` +</details> + +<details closed> + <summary>Camera</summary> + + - `Disable Camera` + - `Immersive Preview` + - `Black Photos` + - `Custom Frame Rate` (Front & Back) + - `HEVC Recording` + - `Force Camera Source Encoding` + - `Override Resolution` (Front & Back) +</details> + +<details closed> + <summary>Experimental</summary> + + - `Session Events` + - `Device Spoof` + - `Convert Message Locally` + - `New Chat Action Menu` + - `Media File Picker` + - `Story Logger` + - `Call Recorder` + - `Account Switcher` + - `Edit Messages` + - `App Passcode` + - `Infinite Story Boost` + - `My Eyes Only Passcode Bypass` + - `No Friend Score Delay` + - `End-to-End Encryption` + - `Enable Hidden Snapchat Plus Features` + - `Custom Streaks Expiration Format` + - `Add Friend Source Spoof` + - `Prevent Forced Logout` +</details> + +## FAQ +<details> + <summary>How to report a bug?</summary> -### About the license -The GNU GPL v3 license is a free, open source software license that gives users the right to modify, share, or redistribute the software.<br/> -The user agrees to make the source code of the software freely available, in addition to any modifications, additions, or derivatives made. <br/> -If the software is redistributed, it must remain under the same GPLv3 license and any modifications must be clearly marked as such.<br/> + - Check that the bug has not already been reported in [Issues](https://github.com/rhunk/SnapEnhance/issues?q=). + - Make sure the bug is not occurring when you use Snapchat without SnapEnhance. + - Make sure you have logs before reporting (go to the SnapEnhance application -> click on the debug icon at the top right and then on the 3 vertical dots -> export logs). +</details> -## Features -<details open> - <summary>Media downloader</summary> +<details> + <summary>My Snapchat keeps crashing/doesn't want to open after installing</summary> - - Download any message in chat (snap, external medias, voice notes, ...) - - Download any story (private, public or professional) - - Anti auto download (prevent auto downloading on specific conversations) + - In some cases, because of Android's signature verification, you **must install Snapchat before SnapEnhance**, so that the two can communicate with each other. + - You may use [LSPatch](https://github.com/LSPosed/LSPatch/forks) or [LSPosed](https://github.com/LSPosed/LSPosed/forks) forks to ensure compatibility with new Android versions, as original projects are no longer updated. </details> -<details open> - <summary>Privacy</summary> +<details> + <summary>AI wallpapers and the Snapchat+ badge aren't working!</summary> + + - Yeah, they're server-sided and will probably never work. +</details> - - Disable metrics (snapchat analytics) - - Prevent sending screenshot and screen recording messages - - Prevent sending typing notifications - - Ad blocker (remove ads from stories and discover) +<details> + <summary>Can you add this feature, please?</summary> + + - Open an issue on our Github repo. </details> -<details open> - <summary>Spying</summary> +<details> + <summary>Can I PM the developers?</summary> + + - No. +</details> + +<details> + <summary>My phone isn't rooted; how do I use this?</summary> + + - You can use `LSPatch` in combination with `SnapEnhance` to run this on an unrooted device, however this is unrecommended and not considered safe. +</details> + +<details> + <summary>Can I use HideMyApplist with this?</summary> + + - No, this will cause some severe issues, and the mod will not be able to inject. +</details> - - Anonymous story viewing (other users won't know you viewed their story) - - Message logger (log all messages sent and received, you will be able to see deleted messages) - - Prevent read receipts (other users won't know you read their messages or viewed their snaps) - - Stealth mode (prevent read receipts on a specific conversation) - - Conversation preview (show messages of a conversation without opening it) +<details> + <summary>How can I translate SnapEnhance into my language?</summary> + + - We have a [Weblate](https://hosted.weblate.org/projects/snapenhance/app/) hosted repo, feel free to submit your translations there. </details> -<details open> - <summary>Extras</summary> +## Privacy +We do not collect any user information. However, please be aware that third-party libraries may collect data as described in their respective privacy policies. +<details> + <summary>Permissions</summary> + + - [android.permission.INTERNET](https://developer.android.com/reference/android/Manifest.permission#INTERNET) + - [android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS](https://developer.android.com/reference/android/Manifest.permission.html#REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) + - [android.permission.POST_NOTIFICATIONS](https://developer.android.com/reference/android/Manifest.permission.html#POST_NOTIFICATIONS) + - [android.permission.SYSTEM_ALERT_WINDOW](https://developer.android.com/reference/android/Manifest.permission#SYSTEM_ALERT_WINDOW) + - [android.permission.USE_BIOMETRIC](https://developer.android.com/reference/android/Manifest.permission#USE_BIOMETRIC) +</details> - - Better notifications (show message content in notification) - - Friend info (username, mutable username, birthday, added date) - - Ui tweaks (remove specific buttons in the user interface) - - External media as snap (send a gallery media to snap) - - Snapchat Plus +<details> + <summary>Third-party libraries used</summary> + + - [libxposed](https://github.com/libxposed/api) + - [ffmpeg-kit-full-gpl](https://github.com/arthenica/ffmpeg-kit) + - [osmdroid](https://github.com/osmdroid/osmdroid) + - [coil](https://github.com/coil-kt/coil) + - [Dobby](https://github.com/jmpews/Dobby) + - [rhino](https://github.com/mozilla/rhino) + - [rhino-android](https://github.com/F43nd1r/rhino-android) + - [libsu](https://github.com/topjohnwu/libsu) + - [colorpicker-compose](https://github.com/skydoves/colorpicker-compose) </details> -## Download -To download the latest stable version, go to the [releases](https://github.com/rhunk/SnapEnhance/releases)<br/> -You can also download the latest build from the [actions](https://github.com/rhunk/SnapEnhance/actions) +## Contributors +Thanks to everyone involved including the [third-party libraries](https://github.com/rhunk/SnapEnhance?tab=readme-ov-file#privacy) used! +- [rathmerdominik](https://github.com/rathmerdominik) +- [Flole998](https://github.com/Flole998) +- [authorisation](https://github.com/authorisation/) +- [RevealedSoulEven](https://github.com/revealedsouleven) +- [iBasim](https://github.com/ibasim) +- [xerta555](https://github.com/xerta555) +- [ptraced](https://github.com/ptraced) +- [CanerKaraca23](https://github.com/CanerKaraca23) +- [bocajthomas](https://github.com/bocajthomas) +- [w451](https://github.com/w451) +- [sn-o-w](https://github.com/sn-o-w) ## Donate +**@rhunk** - LTC: LbBnT9GxgnFhwy891EdDKqGmpn7XtduBdE - BCH: qpu57a05kqljjadvpgjc6t894apprvth9slvlj4vpj -- Bitcoin: bc1qaqnfn6mauzhmx0e6kkenh2wh4r6js0vh5vel92 +- BTC: bc1qaqnfn6mauzhmx0e6kkenh2wh4r6js0vh5vel92 - ETH: 0x0760987491e9de53A73fd87F092Bd432a227Ee92 +- XMR: 49W4Xp7QKdUdVw4otEctWZDC8gnW9nnhoiqMJzY6sKuZBD9xrh9BG4sjdE3r1Jt78j27R6x7522YUA9JVvh892CAJLeKaMu -## Contributors -- [rathmerdominik](https://github.com/rathmerdominik) -- [Flole998](https://github.com/Flole998) -- [authorisation](https://github.com/authorisation/) \ No newline at end of file +**@authorisation** +- XMR: 82kPkAgG2zxQYnSdoFSWzvbSEtEP63NBDh9hgLqp6LgBhPNhZ4dDGv8gVFUEuUhDoi1U14ZgE71teJXo2eBe8iERRRmhcUW diff --git a/app/build.gradle b/app/build.gradle deleted file mode 100644 index 4cce932271..0000000000 --- a/app/build.gradle +++ /dev/null @@ -1,104 +0,0 @@ -plugins { - id 'com.android.application' - id 'org.jetbrains.kotlin.android' -} - -def appVersionName = "1.0.2" -def appVersionCode = 6 - -android { - compileSdk 33 - buildToolsVersion = "33.0.2" - - defaultConfig { - applicationId "me.rhunk.snapenhance" - minSdk 28 - targetSdk 33 - versionCode appVersionCode - versionName appVersionName - multiDexEnabled true - } - - buildTypes { - release { - minifyEnabled false - shrinkResources false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - applicationVariants.configureEach { variant -> - variant.outputs.configureEach { - outputFileName = "app-${appVersionName}-${variant.flavorName}.apk" - } - } - - flavorDimensions "release" - - productFlavors { - armv8 { - getIsDefault().set(true) - ndk { - abiFilters "arm64-v8a" - } - dimension "release" - } - armv7 { - ndk { - abiFilters "armeabi-v7a" - } - dimension "release" - } - } - - kotlinOptions { - jvmTarget = '1.8' - } - namespace 'me.rhunk.snapenhance' -} - -afterEvaluate { - //auto install for debug purpose - getTasks().getByPath(":app:assembleArmv8Debug").doLast { - def apkDebugFile = android.applicationVariants.find { it.buildType.name == "debug" && it.flavorName == "armv8" }.outputs[0].outputFile - try { - println "Killing Snapchat" - exec { - commandLine "adb", "shell", "am", "force-stop", "com.snapchat.android" - } - println "Installing debug build" - exec() { - commandLine "adb", "install", "-r", "-d", apkDebugFile.absolutePath - } - println "Starting Snapchat" - exec { - commandLine "adb", "shell", "am", "start", "com.snapchat.android" - } - } catch (Throwable t) { - println "Failed to install debug build" - t.printStackTrace() - } - } -} - -task getVersion { - doLast { - def version = new File('app/build/version.txt') - version.text = android.defaultConfig.versionName - } -} - -dependencies { - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1' - implementation 'org.jetbrains.kotlin:kotlin-reflect:1.8.21' - - compileOnly files('libs/LSPosed-api-1.0-SNAPSHOT.jar') - implementation 'com.google.code.gson:gson:2.10.1' - implementation 'com.arthenica:ffmpeg-kit-full-gpl:5.1.LTS' - implementation 'org.osmdroid:osmdroid-android:6.1.16' - implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11' -} diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000000..ced10bf84f --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,189 @@ +import com.android.build.gradle.internal.api.BaseVariantOutputImpl +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.gradle.configurationcache.extensions.capitalized +import java.io.ByteArrayOutputStream + +plugins { + alias(libs.plugins.androidApplication) + alias(libs.plugins.kotlinAndroid) + alias(libs.plugins.compose.compiler) + id("kotlin-parcelize") +} + +android { + namespace = rootProject.ext["applicationId"].toString() + compileSdk = 34 + + buildFeatures { + aidl = true + compose = true + } + + defaultConfig { + applicationId = rootProject.ext["applicationId"].toString() + versionCode = rootProject.ext["appVersionCode"].toString().toInt() + versionName = rootProject.ext["appVersionName"].toString() + minSdk = 28 + targetSdk = 34 + multiDexEnabled = true + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles += file("proguard-rules.pro") + } + debug { + (properties["debug_flavor"] == null).also { + isDebuggable = !it + isMinifyEnabled = it + isShrinkResources = it + } + proguardFiles += file("proguard-rules.pro") + } + } + + flavorDimensions += "abi" + + //noinspection ChromeOsAbiSupport + productFlavors { + packaging { + jniLibs { + excludes += "**/*_neon.so" + } + resources { + excludes += "DebugProbesKt.bin" + excludes += "okhttp3/internal/publicsuffix/**" + excludes += "META-INF/*.version" + excludes += "META-INF/services/**" + excludes += "META-INF/*.kotlin_builtins" + excludes += "META-INF/*.kotlin_module" + } + } + + create("core") { + dimension = "abi" + } + + create("armv8") { + ndk { + abiFilters += "arm64-v8a" + } + dimension = "abi" + } + + create("armv7") { + ndk { + abiFilters += "armeabi-v7a" + } + dimension = "abi" + } + + create("all") { + ndk { + abiFilters += listOf("arm64-v8a", "armeabi-v7a") + } + dimension = "abi" + } + } + + properties["debug_flavor"]?.let { + android.productFlavors.find { it.name == it.toString()}?.setIsDefault(true) + } + + applicationVariants.all { + outputs.map { it as BaseVariantOutputImpl }.forEach { outputVariant -> + outputVariant.outputFileName = when { + name.startsWith("core") -> "core.apk" + else -> "snapenhance_${rootProject.ext["appVersionName"]}-${outputVariant.name}.apk" + } + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + kotlinOptions { + jvmTarget = "21" + } +} + +androidComponents { + onVariants(selector().withFlavor("abi", "core")) { + it.packaging.jniLibs.apply { + pickFirsts.set(listOf("**/lib${rootProject.ext["buildHash"]}.so")) + excludes.set(listOf("**/*.so")) + } + } +} + +dependencies { + fun fullImplementation(dependencyNotation: Any) { + compileOnly(dependencyNotation) + for (flavorName in listOf("armv8", "armv7", "all")) { + dependencies.add("${flavorName}Implementation", dependencyNotation) + } + } + + implementation(project(":core")) + implementation(project(":common")) + implementation(libs.androidx.documentfile) + implementation(libs.gson) + implementation(libs.smart.exception.java) + implementation(files("libs/ffmpeg-kit-full-gpl-6.0-2.LTS.aar")) + implementation(libs.osmdroid.android) + implementation(libs.rhino) + implementation(libs.androidx.activity.ktx) + fullImplementation(platform(libs.androidx.compose.bom)) + fullImplementation(libs.bcprov.jdk18on) + fullImplementation(libs.androidx.navigation.compose) + fullImplementation(libs.androidx.material.icons.core) + fullImplementation(libs.androidx.material.ripple) + fullImplementation(libs.androidx.material.icons.extended) + fullImplementation(libs.androidx.material3) + fullImplementation(libs.coil.compose) + fullImplementation(libs.coil.video) + fullImplementation(libs.colorpicker.compose) + fullImplementation(libs.androidx.ui.tooling.preview) + properties["debug_flavor"]?.let { + debugImplementation(libs.androidx.ui.tooling) + } +} + +afterEvaluate { + properties["debug_flavor"]?.toString()?.let { tasks.findByName("install${it.capitalized()}Debug") }?.doLast { + runCatching { + val devices = ByteArrayOutputStream().also { + exec { + commandLine("adb", "devices") + standardOutput = it + } + }.toString().lines().drop(1).mapNotNull { + line -> line.split("\t").firstOrNull()?.takeIf { it.isNotEmpty() } + } + + runBlocking { + devices.forEach { device -> + launch { + exec { + commandLine("adb", "-s", device, "shell", "am", "force-stop", properties["debug_package_name"]) + } + delay(500) + exec { + commandLine("adb", "-s", device, "shell", "am", "start", properties["debug_package_name"]) + } + } + } + } + } + } +} +properties["debug_flavor"]?.let { + configurations.all { + exclude(group = "androidx.profileinstaller", "profileinstaller") + } +} diff --git a/app/libs/ffmpeg-kit-full-gpl-6.0-2.LTS.aar b/app/libs/ffmpeg-kit-full-gpl-6.0-2.LTS.aar new file mode 100644 index 0000000000..97c88c48e5 Binary files /dev/null and b/app/libs/ffmpeg-kit-full-gpl-6.0-2.LTS.aar differ diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 481bb43481..b52bc96e2d 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,21 +1,17 @@ -# 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 +-dontwarn de.robv.android.xposed.** +-dontwarn org.mozilla.javascript.** -# 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 *; -#} +-keep enum * { *; } -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable +-keep class com.android.tools.smali.dexlib2.** { *; } +-keep class org.mozilla.javascript.** { *; } +-keep class androidx.compose.material.icons.** { *; } +-keep class androidx.compose.material3.R$* { *; } +-keep class androidx.compose.ui.R$* { *; } +-keep class androidx.navigation.** { *; } +-keep class me.rhunk.snapenhance.** { *; } +-keep class androidx.core.content.res.ResourcesCompat { *; } -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +-keepclassmembers class * implements android.os.Parcelable { + public static final ** CREATOR; +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ddd607ea9b..20c2f40092 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,49 +2,84 @@ <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> - <uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" /> + <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> + <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <uses-permission android:name="android.permission.INTERNET" /> + <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" /> + <uses-permission android:name="android.permission.USE_BIOMETRIC" /> + + <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:node="remove" tools:ignore="all" /> + <uses-permission android:name="android.permission.READ_PHONE_STATE" tools:node="remove" /> + <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" tools:node="remove" tools:ignore="all" /> + + <queries> + <package android:name="com.snapchat.android" /> + </queries> - <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> - <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" - tools:ignore="ScopedStorage" /> <application android:usesCleartextTraffic="true" android:label="@string/app_name" - tools:targetApi="31" + tools:targetApi="34" + android:allowBackup="true" + android:hasFragileUserData="true" + android:enableOnBackInvokedCallback="true" android:icon="@mipmap/launcher_icon"> <meta-data android:name="xposedmodule" android:value="true" /> <meta-data android:name="xposeddescription" - android:value="Enhanced Snapchat" /> + android:value="SnapEnhance by rhunk" /> <meta-data android:name="xposedminversion" - android:value="53" /> + android:value="93" /> <meta-data android:name="xposedscope" - android:resource="@array/sc_scope" /> + android:value="com.snapchat.android" /> <service - android:name=".bridge.service.BridgeService" - android:exported="true"> + android:name=".bridge.BridgeService" + android:exported="true" + android:permission="com.snapchat.android.permission.UPDATE_STICKER_INDEX"> </service> <activity - android:theme="@android:style/Theme.NoDisplay" - android:name=".bridge.service.MainActivity" - android:exported="true" - android:excludeFromRecents="true"> + android:name=".ui.manager.MainActivity" + android:theme="@style/AppTheme" + android:launchMode="singleTask" + android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> + <category android:name="android.intent.category.DEFAULT" /> </intent-filter> </activity> <activity - android:name=".features.impl.ui.menus.MapActivity" + android:name=".ui.setup.SetupActivity" + android:launchMode="singleTask" android:exported="true" + android:theme="@style/AppTheme" android:excludeFromRecents="true" /> + <activity android:name=".bridge.ForceStartActivity" + android:theme="@android:style/Theme.NoDisplay" + android:excludeFromRecents="true" + android:exported="true" /> + <activity android:name=".bridge.BiometricPromptActivity" + android:theme="@style/BiometricPromptTheme" + android:excludeFromRecents="true" + android:exported="true" /> + + <receiver android:name=".StreaksReminder" /> + + <provider + android:name="androidx.core.content.FileProvider" + android:authorities="me.rhunk.snapenhance.fileprovider" + android:exported="false" + android:grantUriPermissions="true"> + <meta-data + android:name="android.support.FILE_PROVIDER_PATHS" + android:resource="@xml/provider_paths" /> + </provider> </application> </manifest> \ No newline at end of file diff --git a/app/src/main/assets/lang/de_DE.json b/app/src/main/assets/lang/de_DE.json deleted file mode 100644 index 77fcd808ec..0000000000 --- a/app/src/main/assets/lang/de_DE.json +++ /dev/null @@ -1,81 +0,0 @@ -{ - "category": { - "general": "Allgemein", - "spy": "Spy", - "media_download": "Media Downloader", - "privacy": "Privatsphäre", - "ui": "UI", - "extras": "Extras", - "tweaks": "Tweaks", - "experimental": "Experimentell" - }, - - "property": { - "save_folder": "Speicherverzeichnis", - "prevent_read_receipts": "Lesebestätigung verhindern", - "hide_bitmoji_presence": "Bitmoji Präsenz verhindern", - "show_message_content": "Nachrichteninhalt anzeigen", - "message_logger": "Message Logger", - "auto_download_snaps": "Auto Download Snaps", - "auto_download_stories": "Auto Download Stories", - "auto_download_public_stories": "Auto Download Öffentliche Stories", - "auto_download_spotlight": "Auto Download Spotlight", - "overlay_merge": "Zusammenfügen von Snap und Overlay Content", - "download_inchat_snaps": "Download Snaps im Chat", - "anti_auto_download_button": "Anti Auto Download Button", - "disable_metrics": "Metriken deaktivieren", - "prevent_screenshot_notifications": "Screenshot Benachrichtigungen verhindern", - "prevent_status_notifications": "Status Notifications verhindern (Gespeichert in die Kamerarolle, Verpasste Anrufe)", - "anonymous_story_view": "Anonymes anschauen von Stories", - "hide_typing_notification": "Schreiben Benachrichtigung verstecken", - "menu_slot_id": "Freundes Menü Slot ID", - "message_preview_length": "Nachrichtenvorschau Länge", - "external_media_as_snap": "Externe Medien, als Snap", - "auto_save": "Automatisch speichern", - "snapchat_plus": "Snapchat Plus", - "remove_voice_record_button": "Entfernen des Voice Record Button", - "remove_stickers_button": "Entfernen des Sticker Button", - "remove_cognac_button": "Entfernen des Cognac Button", - "remove_call_buttons": "Entfernen des Anruf Button", - "long_snap_sending": "Lange Snaps senden", - "block_ads": "Werbung blockieren", - "streak_expiration_info": "Anzeigen, wann der Streak ausläuft", - "new_map_ui": "Neue Map UI", - "use_download_manager": "Android Download Manager benutzen" - }, - - - "friend_menu_option": { - "preview": "Vorschau", - "stealth_mode": "Tarnmodus", - "anti_auto_download": "Anti Auto Download" - }, - - "message_context_menu_option": { - "download": "Download", - "preview": "Vorschau" - }, - - "opera_context_menu": { - "download": "Medien Download" - }, - - "modal_option": { - "profile_info": "Profilinformationen", - "close": "Close" - }, - - "conversation_preview": { - "streak_expiration": "läuft aus in %s Tagen, %s Stunden und %s minutes", - "title": "Vorschau", - "unknown_user": "Unbekannter Nutzer" - }, - - "profile_info": { - "title": "Profilinformationen", - "username": "Nutzername", - "display_name": "Anzeigename", - "added_date": "Hinzugefügt Datum", - "birthday": "Geburtstag : {day} {month} " - } -} \ No newline at end of file diff --git a/app/src/main/assets/lang/en_US.json b/app/src/main/assets/lang/en_US.json deleted file mode 100644 index f381440b07..0000000000 --- a/app/src/main/assets/lang/en_US.json +++ /dev/null @@ -1,189 +0,0 @@ -{ - "category": { - "spying_privacy": "Spying & Privacy", - "media_manager": "Media Manager", - "ui_tweaks": "UI & Tweaks", - "camera": "Camera", - "updates": "Updates", - "experimental_debugging": "Experimental" - }, - - "action": { - "clean_cache": "Clean Cache", - "clear_message_logger": "Clear Message Logger", - "refresh_mappings": "Refresh Mappings", - "open_map": "Choose location on map", - "check_for_updates": "Check for updates" - }, - - "property": { - "save_folder": "Save Folder", - "prevent_read_receipts": "Prevent Read Receipts", - "hide_bitmoji_presence": "Hide Bitmoji Presence", - "show_message_content_in_notifications": "Show Message Content In Notifications", - "better_notifications": "Better Notifications", - "notification_blacklist": "Notification Blacklist", - "message_logger": "Message Logger", - "unlimited_snap_view_time": "Unlimited Snap View Time", - "auto_download_options": "Auto Download Options", - "download_options": "Download Options", - "chat_download_context_menu": "Enable Chat Download Context Menu", - "auto_download_blacklist": "Auto Download Blacklist", - "disable_metrics": "Disable Metrics", - "prevent_screenshot_notifications": "Prevent Screenshot Notifications", - "prevent_status_notifications": "Prevent Status Notifications (Save to camera roll, missed calls)", - "anonymous_story_view": "Anonymous Story View", - "hide_typing_notification": "Hide Typing Notification", - "menu_slot_id": "Friend Menu Slot ID", - "message_preview_length": "Message Preview Length", - "gallery_media_send_override": "Gallery Media Send Override", - "auto_save_messages": "Auto Save Messages", - "anti_auto_save": "Anti Auto Save Button", - "snapchat_plus": "Snapchat Plus", - "disable_snap_splitting": "Disable Snap Splitting", - "disable_video_length_restriction": "Disable Video Length Restriction", - "force_media_source_quality": "Force Media Source Quality", - "media_quality_level": "Media Quality Level", - "remove_voice_record_button": "Remove Voice Record Button", - "remove_stickers_button": "Remove Stickers Button", - "remove_cognac_button": "Remove Cognac Button", - "remove_call_buttons": "Remove Call Buttons", - "block_ads": "Block Ads", - "streak_expiration_info": "Show Streak Expiration Info", - "new_map_ui": "New Map UI", - "use_download_manager": "Use Android Download Manager", - "app_passcode": "Set App Passcode", - "app_lock_on_resume": "App Lock On Resume", - "meo_passcode_bypass": "My Eyes Only Passcode Bypass", - "location_spoof": "Snapmap Location Spoofer", - "latitude_value": "Latitude", - "longitude_value": "Longitude", - "hide_ui_elements": "Hide UI Elements", - "auto_updater": "Auto Updater", - "disable_camera": "Disable Camera", - "infinite_story_boost": "Infinite Story Boost", - "enable_app_appearance": "Enable App Appearance Settings", - "disable_spotlight": "Disable Spotlight", - "preview_resolution": "Override Preview Resolution", - "picture_resolution": "Override Picture Resolution", - "force_highest_frame_rate": "Force Highest Frame Rate", - "force_camera_source_encoding": "Force Camera Source Encoding", - "amoled_dark_mode": "AMOLED Dark Mode" - }, - - "option": { - "property": { - "better_notifications": { - "chat": "Show chat messages", - "snap": "Show medias", - "reply_button": "Add reply button" - }, - "download_options": { - "format_user_folder": "Create folder for each user", - "format_hash": "Add a unique hash to the file path", - "format_username": "Add the username to the file path", - "format_date_time": "Add the date and time to the file path", - "merge_overlay": "Merge Snap Image Overlays" - }, - "auto_download_options": { - "friend_snaps": "Friend Snaps", - "friend_stories": "Friend Stories", - "public_stories": "Public Stories", - "spotlight": "Spotlight" - }, - "auto_save_messages": { - "NOTE": "Audio Note", - "CHAT": "Chat", - "EXTERNAL_MEDIA": "External Media", - "SNAP": "Snap", - "STICKER": "Sticker" - }, - "notification_blacklist": { - "chat": "Chat", - "snap": "Snap", - "typing": "Typing" - }, - "gallery_media_send_override": { - "OFF": "Off", - "NOTE": "Audio Note", - "SNAP": "Snap", - "LIVE_SNAP": "Snap with audio" - }, - "media_quality_level": { - "LEVEL_NONE": "Level None", - "LEVEL_1": "Level 1", - "LEVEL_2": "Level 2", - "LEVEL_3": "Level 3", - "LEVEL_4": "Level 4", - "LEVEL_5": "Level 5", - "LEVEL_6": "Level 6", - "LEVEL_7": "Level 7", - "LEVEL_MAX": "Level Max" - }, - "hide_ui_elements": { - "remove_call_buttons": "Remove Call Buttons", - "remove_cognac_button": "Remove Cognac Button", - "remove_stickers_button": "Remove Stickers Button", - "remove_voice_record_button": "Remove Voice Record Button", - "remove_camera_borders": "Remove Camera Borders" - }, - "auto_updater": { - "DISABLED": "Disabled", - "EVERY_LAUNCH": "Every Launch", - "DAILY": "Daily", - "WEEKLY": "Weekly" - } - } - }, - - "friend_menu_option": { - "preview": "Preview", - "stealth_mode": "Stealth Mode", - "anti_auto_download": "Anti Auto Download", - "anti_auto_save": "Anti Auto Save" - }, - - "message_context_menu_option": { - "download": "Download", - "preview": "Preview" - }, - - "chat_action_menu": { - "preview_button": "Preview", - "download_button": "Download", - "delete_logged_message_button": "Delete Logged Message" - }, - - "opera_context_menu": { - "download": "Download Media" - }, - - "modal_option": { - "profile_info": "Profile Info", - "close": "Close" - }, - - "conversation_preview": { - "streak_expiration": "expires in %s days %s hours %s minutes", - "title": "Preview", - "unknown_user": "Unknown User" - }, - - "profile_info": { - "title": "Profile Info", - "username": "Username", - "display_name": "Display Name", - "added_date": "Added Date", - "birthday": "Birthday : {month} {day}" - }, - - "auto_updater": { - "no_update_available": "No Update available!", - "dialog_title": "New Update available!", - "dialog_message": "There is a new Update for SnapEnhance available! ({version})\n\n{body}", - "dialog_positive_button": "Download and Install", - "dialog_negative_button": "Cancel", - "downloading_toast": "Downloading Update...", - "download_manager_notification_title": "Downloading SnapEnhance APK..." - } -} \ No newline at end of file diff --git a/app/src/main/assets/xposed_init b/app/src/main/assets/xposed_init deleted file mode 100644 index 6f814117b3..0000000000 --- a/app/src/main/assets/xposed_init +++ /dev/null @@ -1 +0,0 @@ -me.rhunk.snapenhance.XposedLoader \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/Constants.kt b/app/src/main/kotlin/me/rhunk/snapenhance/Constants.kt deleted file mode 100644 index 2799bb9b9c..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/Constants.kt +++ /dev/null @@ -1,22 +0,0 @@ -package me.rhunk.snapenhance - -object Constants { - const val TAG = "SnapEnhance" - const val SNAPCHAT_PACKAGE_NAME = "com.snapchat.android" - - const val VIEW_INJECTED_CODE = 0x7FFFFF02 - const val VIEW_DRAWER = 0x7FFFFF03 - - val ARROYO_NOTE_ENCRYPTION_PROTO_PATH = intArrayOf(4, 4, 6, 1, 1) - val ARROYO_SNAP_ENCRYPTION_PROTO_PATH = intArrayOf(4, 4, 11, 5, 1, 1) - val MESSAGE_SNAP_ENCRYPTION_PROTO_PATH = intArrayOf(11, 5, 1, 1) - val MESSAGE_EXTERNAL_MEDIA_ENCRYPTION_PROTO_PATH = intArrayOf(3, 3, 5, 1, 1) - val ARROYO_EXTERNAL_MEDIA_ENCRYPTION_PROTO_PATH = intArrayOf(4, 4, 3, 3, 5, 1, 1) - val ARROYO_STRING_CHAT_MESSAGE_PROTO = intArrayOf(4, 4, 2, 1) - val ARROYO_URL_KEY_PROTO_PATH = intArrayOf(4, 5, 1, 3) - - const val ARROYO_ENCRYPTION_PROTO_INDEX = 19 - const val ARROYO_ENCRYPTION_PROTO_INDEX_V2 = 4 - - const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt new file mode 100644 index 0000000000..4d90126507 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt @@ -0,0 +1,264 @@ +package me.rhunk.snapenhance + +import android.util.Log +import com.google.gson.GsonBuilder +import me.rhunk.snapenhance.common.data.FileType +import me.rhunk.snapenhance.common.logger.AbstractLogger +import me.rhunk.snapenhance.common.logger.LogChannel +import me.rhunk.snapenhance.common.logger.LogLevel +import java.io.File +import java.io.OutputStream +import java.io.RandomAccessFile +import java.time.format.DateTimeFormatter +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import kotlin.time.Duration.Companion.hours + +class LogLine( + val logLevel: LogLevel, + val dateTime: String, + val tag: String, + val message: String +) { + companion object { + fun fromString(line: String) = runCatching { + val parts = line.trimEnd().split("/") + if (parts.size != 4) return@runCatching null + LogLine( + LogLevel.fromLetter(parts[0]) ?: return@runCatching null, + parts[1], + parts[2], + parts[3] + ) + }.getOrNull() + } + + override fun toString(): String { + return "${logLevel.letter}/$dateTime/$tag/$message" + } +} + + +class LogReader( + logFile: File +) { + private val randomAccessFile = RandomAccessFile(logFile, "r") + private var startLineIndexes = mutableListOf<Long>() + var lineCount = queryLineCount() + + private fun readLogLine(): LogLine? { + val lines = StringBuilder() + val lastPointer = randomAccessFile.filePointer + var lastChar: Int = -1 + var bufferLength = 0 + while (true) { + val char = randomAccessFile.read() + if (char == -1) { + randomAccessFile.seek(lastPointer) + return null + } + if ((char == '|'.code && lastChar == '\n'.code) || bufferLength > 4096) { + break + } + lines.append(char.toChar()) + bufferLength++ + lastChar = char + } + + return LogLine.fromString(lines.trimEnd().toString()) + ?: LogLine(LogLevel.ERROR, "1970-01-01 00:00:00", "LogReader", "Failed to parse log line: $lines") + } + + fun incrementLineCount() { + synchronized(randomAccessFile) { + randomAccessFile.seek(randomAccessFile.length()) + startLineIndexes.add(randomAccessFile.filePointer + 1) + lineCount++ + } + } + + private fun queryLineCount(): Int { + val buffer = ByteArray(1024 * 1024) + + synchronized(randomAccessFile) { + randomAccessFile.seek(0) + var lineCount = 0 + var read: Int + var lastPointer: Long = 0 + var line: StringBuilder? = null + + while (randomAccessFile.read(buffer).also { read = it } != -1) { + for (i in 0 until read) { + val char = buffer[i].toInt().toChar() + if (line == null) { + line = StringBuilder() + lastPointer = randomAccessFile.filePointer - read + i + } + line.append(char) + if (char == '\n') { + if (line.startsWith('|')) { + lineCount++ + startLineIndexes.add(lastPointer + 1) + } + line = null + } + } + } + + return lineCount + } + } + + private fun getLine(index: Int): String? { + if (index <= 0 || index > lineCount) return null + synchronized(randomAccessFile) { + randomAccessFile.seek(startLineIndexes.getOrNull(index) ?: return null) + return readLogLine()?.toString() + } + } + + fun getLogLine(index: Int): LogLine? { + return getLine(index)?.let { LogLine.fromString(it) } + } +} + + +class LogManager( + private val remoteSideContext: RemoteSideContext +): AbstractLogger(LogChannel.MANAGER) { + companion object { + private val LOG_LIFETIME = 24.hours + } + + private val printLogLock = Any() + private val anonymizeLogs by lazy { !remoteSideContext.config.root.scripting.disableLogAnonymization.get() } + + var lineAddListener = { _: LogLine -> } + + private val logFolder = File(remoteSideContext.androidContext.cacheDir, "logs") + private var logFile: File? = null + + private val uuidRegex by lazy { Regex("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", RegexOption.MULTILINE) } + private val contentUriRegex by lazy { Regex("content://[a-zA-Z0-9_\\-./]+") } + private val filePathRegex by lazy { Regex("([a-zA-Z0-9_\\-./]+)\\.(${FileType.entries.joinToString("|") { file -> file.fileExtension.toString() }})") } + + fun init() { + if (!logFolder.exists()) { + logFolder.mkdirs() + } + logFile = remoteSideContext.sharedPreferences.getString("log_file", null)?.let { File(it) }?.takeIf { it.exists() } ?: run { + newLogFile() + logFile + } + + if (System.currentTimeMillis() - remoteSideContext.sharedPreferences.getLong("last_created", 0) > LOG_LIFETIME.inWholeMilliseconds) { + newLogFile() + } + } + + fun internalLog(tag: String, logLevel: LogLevel, message: Any?) { + synchronized(printLogLock) { + runCatching { + val anonymizedMessage = message.toString().let { + if (remoteSideContext.config.isInitialized() && anonymizeLogs) + it.replace(uuidRegex, "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx") + .replace(contentUriRegex, "content://xxx") + .replace(filePathRegex, "xxxxxxxx.$2") + else it + } + val line = LogLine( + logLevel = logLevel, + dateTime = getCurrentDateTime(), + tag = tag, + message = anonymizedMessage + ) + logFile?.appendText("|$line\n", Charsets.UTF_8) + lineAddListener(line) + Log.println(logLevel.priority, tag, anonymizedMessage) + }.onFailure { + Log.println(Log.ERROR, tag, "Failed to log message: $message") + Log.println(Log.ERROR, tag, it.stackTraceToString()) + } + } + } + + private fun getCurrentDateTime(pathSafe: Boolean = false): String { + return DateTimeFormatter.ofPattern(if (pathSafe) "yyyy-MM-dd_HH-mm-ss" else "yyyy-MM-dd HH:mm:ss").format( + java.time.LocalDateTime.now() + ) + } + + private fun newLogFile() { + val currentTime = System.currentTimeMillis() + logFile = File(logFolder, "snapenhance_${getCurrentDateTime(pathSafe = true)}.log").also { + it.createNewFile() + remoteSideContext.sharedPreferences.edit().putString("log_file", it.absolutePath).putLong("last_created", currentTime).apply() + } + } + + fun clearLogs() { + logFolder.listFiles()?.forEach { it.delete() } + newLogFile() + } + + fun exportLogsToZip(outputStream: OutputStream) { + val zipOutputStream = ZipOutputStream(outputStream).apply { + setMethod(ZipOutputStream.DEFLATED) + } + + // add device info to zip + zipOutputStream.putNextEntry(ZipEntry("device_info.json")) + val gson = GsonBuilder().setPrettyPrinting().create() + zipOutputStream.write(gson.toJson(remoteSideContext.installationSummary).toByteArray()) + zipOutputStream.closeEntry() + + // add config + zipOutputStream.putNextEntry(ZipEntry("config.json")) + zipOutputStream.write(remoteSideContext.config.exportToString(exportSensitiveData = false).toByteArray()) + zipOutputStream.closeEntry() + + //add logFolder to zip + logFolder.walk().forEach { + if (it.isFile) { + zipOutputStream.putNextEntry(ZipEntry(it.name)) + it.inputStream().copyTo(zipOutputStream) + zipOutputStream.closeEntry() + } + } + + zipOutputStream.close() + } + + fun newReader(onAddLine: (LogLine) -> Unit) = LogReader(logFile!!).also { + lineAddListener = { line -> it.incrementLineCount(); onAddLine(line) } + } + + override fun debug(message: Any?, tag: String) { + internalLog(tag, LogLevel.DEBUG, message) + } + + override fun error(message: Any?, tag: String) { + internalLog(tag, LogLevel.ERROR, message) + } + + override fun error(message: Any?, throwable: Throwable, tag: String) { + internalLog(tag, LogLevel.ERROR, message) + internalLog(tag, LogLevel.ERROR, throwable.stackTraceToString()) + } + + override fun info(message: Any?, tag: String) { + internalLog(tag, LogLevel.INFO, message) + } + + override fun verbose(message: Any?, tag: String) { + internalLog(tag, LogLevel.VERBOSE, message) + } + + override fun warn(message: Any?, tag: String) { + internalLog(tag, LogLevel.WARN, message) + } + + override fun assert(message: Any?, tag: String) { + internalLog(tag, LogLevel.ASSERT, message) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/Logger.kt b/app/src/main/kotlin/me/rhunk/snapenhance/Logger.kt deleted file mode 100644 index 9cc4b89de0..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/Logger.kt +++ /dev/null @@ -1,42 +0,0 @@ -package me.rhunk.snapenhance - -import android.util.Log -import de.robv.android.xposed.XposedBridge - -object Logger { - private const val TAG = "SnapEnhance" - - fun log(message: Any?) { - Log.i(TAG, message.toString()) - } - - fun debug(message: Any?) { - if (!BuildConfig.DEBUG) return - Log.d(TAG, message.toString()) - } - - fun error(throwable: Throwable) { - Log.e(TAG, "",throwable) - } - - fun error(message: Any?) { - Log.e(TAG, message.toString()) - } - - fun error(message: Any?, throwable: Throwable) { - Log.e(TAG, message.toString(), throwable) - } - - fun xposedLog(message: Any?) { - XposedBridge.log(message.toString()) - } - - fun xposedLog(message: Any?, throwable: Throwable?) { - XposedBridge.log(message.toString()) - XposedBridge.log(throwable) - } - - fun xposedLog(throwable: Throwable) { - XposedBridge.log(throwable) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt deleted file mode 100644 index d2a15b6358..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt +++ /dev/null @@ -1,111 +0,0 @@ -package me.rhunk.snapenhance - -import android.app.Activity -import android.content.Context -import android.content.Intent -import android.content.res.Resources -import android.os.Handler -import android.os.Looper -import android.os.Process -import android.widget.Toast -import com.google.gson.Gson -import com.google.gson.GsonBuilder -import me.rhunk.snapenhance.bridge.AbstractBridgeClient -import me.rhunk.snapenhance.data.MessageSender -import me.rhunk.snapenhance.database.DatabaseAccess -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.manager.impl.ActionManager -import me.rhunk.snapenhance.manager.impl.ConfigManager -import me.rhunk.snapenhance.manager.impl.FeatureManager -import me.rhunk.snapenhance.manager.impl.MappingManager -import me.rhunk.snapenhance.manager.impl.TranslationManager -import me.rhunk.snapenhance.util.download.DownloadServer -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors -import kotlin.reflect.KClass -import kotlin.system.exitProcess - -class ModContext { - private val executorService: ExecutorService = Executors.newCachedThreadPool() - - lateinit var androidContext: Context - var mainActivity: Activity? = null - lateinit var bridgeClient: AbstractBridgeClient - - val gson: Gson = GsonBuilder().create() - - val translation = TranslationManager(this) - val features = FeatureManager(this) - val mappings = MappingManager(this) - val config = ConfigManager(this) - val actionManager = ActionManager(this) - val database = DatabaseAccess(this) - val downloadServer = DownloadServer(this) - val messageSender = MessageSender(this) - val classCache get() = SnapEnhance.classCache - val resources: Resources get() = androidContext.resources - - fun <T : Feature> feature(featureClass: KClass<T>): T { - return features.get(featureClass)!! - } - - fun runOnUiThread(runnable: () -> Unit) { - Handler(Looper.getMainLooper()).post { - runCatching(runnable).onFailure { - Logger.xposedLog("UI thread runnable failed", it) - } - } - } - - fun executeAsync(runnable: () -> Unit) { - executorService.submit { - runCatching { - runnable() - }.onFailure { - longToast("Async task failed " + it.message) - Logger.xposedLog("Async task failed", it) - } - } - } - - fun shortToast(message: Any) { - runOnUiThread { - Toast.makeText(androidContext, message.toString(), Toast.LENGTH_SHORT).show() - } - } - - fun longToast(message: Any) { - runOnUiThread { - Toast.makeText(androidContext, message.toString(), Toast.LENGTH_LONG).show() - } - } - - fun softRestartApp(saveSettings: Boolean = false) { - if (saveSettings) { - config.writeConfig() - } - val intent: Intent? = androidContext.packageManager.getLaunchIntentForPackage( - Constants.SNAPCHAT_PACKAGE_NAME - ) - intent?.let { - val mainIntent = Intent.makeRestartActivityTask(intent.component) - androidContext.startActivity(mainIntent) - } - exitProcess(1) - } - - fun crash(message: String, throwable: Throwable? = null) { - Logger.xposedLog(message, throwable) - longToast(message) - delayForceCloseApp(100) - } - - fun delayForceCloseApp(delay: Long) = Handler(Looper.getMainLooper()).postDelayed({ - forceCloseApp() - }, delay) - - fun forceCloseApp() { - Process.killProcess(Process.myPid()) - exitProcess(1) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteAccountStorage.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteAccountStorage.kt new file mode 100644 index 0000000000..a5ad1b474e --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteAccountStorage.kt @@ -0,0 +1,52 @@ +package me.rhunk.snapenhance + +import android.os.ParcelFileDescriptor +import me.rhunk.snapenhance.bridge.AccountStorage +import me.rhunk.snapenhance.common.util.ktx.toParcelFileDescriptor + +class RemoteAccountStorage( + private val context: RemoteSideContext +): AccountStorage.Stub() { + private val accountFolder by lazy { + context.androidContext.filesDir.resolve("accounts").also { + if (!it.exists()) it.mkdirs() + } + } + + override fun getAccounts(): Map<String, String> { + return accountFolder.listFiles()?.sortedByDescending { it.lastModified() }?.mapNotNull { file -> + if (!file.name.endsWith(".zip") || !file.name.contains("|")) return@mapNotNull null + file.nameWithoutExtension.split('|').let { it[0] to it[1] } + }?.toMap() ?: emptyMap() + } + + override fun addAccount(userId: String, username: String, pfd: ParcelFileDescriptor) { + removeAccount(userId) + accountFolder.resolve("$userId|$username.zip").outputStream().use { fileOutputStream -> + ParcelFileDescriptor.AutoCloseInputStream(pfd).use { + it.copyTo(fileOutputStream) + } + } + } + + override fun removeAccount(userId: String) { + accountFolder.listFiles()?.firstOrNull { + it.nameWithoutExtension.startsWith(userId) + }?.also { + context.log.verbose("Removing account file: ${it.name}") + it.delete() + } + } + + override fun isAccountExists(userId: String): Boolean { + return accountFolder.listFiles()?.any { + it.nameWithoutExtension.startsWith(userId) + } ?: false + } + + override fun getAccountData(userId: String): ParcelFileDescriptor? { + return accountFolder.listFiles()?.firstOrNull { + it.nameWithoutExtension.startsWith(userId) + }?.inputStream()?.toParcelFileDescriptor(context.coroutineScope) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteFileHandleManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteFileHandleManager.kt new file mode 100644 index 0000000000..b98b703432 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteFileHandleManager.kt @@ -0,0 +1,153 @@ +package me.rhunk.snapenhance + +import android.os.ParcelFileDescriptor +import me.rhunk.snapenhance.bridge.storage.FileHandle +import me.rhunk.snapenhance.bridge.storage.FileHandleManager +import me.rhunk.snapenhance.common.bridge.FileHandleScope +import me.rhunk.snapenhance.common.bridge.InternalFileHandleType +import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper +import me.rhunk.snapenhance.common.logger.AbstractLogger +import me.rhunk.snapenhance.common.util.ktx.toParcelFileDescriptor +import java.io.File +import java.io.OutputStream + + +class ByteArrayFileHandle( + private val context: RemoteSideContext, + private val data: ByteArray +): FileHandle.Stub() { + override fun exists() = true + override fun create() = false + override fun delete() = false + + override fun open(mode: Int): ParcelFileDescriptor? { + return runCatching { + data.inputStream().toParcelFileDescriptor(context.coroutineScope) + }.onFailure { + context.log.error("Failed to open byte array file handle: ${it.message}", it) + }.getOrNull() + } +} + +class LocalFileHandle( + private val file: File +): FileHandle.Stub() { + override fun exists() = file.exists() + override fun create() = file.createNewFile() + override fun delete() = file.delete() + + override fun open(mode: Int): ParcelFileDescriptor? { + return runCatching { + ParcelFileDescriptor.open(file, mode) + }.onFailure { + AbstractLogger.directError("Failed to open file handle: ${it.message}", it) + }.getOrNull() + } +} + +class AssetFileHandle( + private val context: RemoteSideContext, + private val assetPath: String +): FileHandle.Stub() { + override fun exists() = true + override fun create() = false + override fun delete() = false + + override fun open(mode: Int): ParcelFileDescriptor? { + return runCatching { + context.androidContext.assets.open(assetPath).toParcelFileDescriptor(context.coroutineScope) + }.onFailure { + context.log.error("Failed to open asset handle: ${it.message}", it) + }.getOrNull() + } +} + + +class RemoteFileHandleManager( + private val context: RemoteSideContext +): FileHandleManager.Stub() { + private val userImportFolder = File(context.androidContext.filesDir, "user_imports").apply { + mkdirs() + } + + override fun getFileHandle(scope: String, name: String): FileHandle? { + val fileHandleScope = FileHandleScope.fromValue(scope) ?: run { + context.log.error("invalid file handle scope: $scope", "FileHandleManager") + return null + } + when (fileHandleScope) { + FileHandleScope.INTERNAL -> { + val fileHandleType = InternalFileHandleType.fromValue(name) ?: run { + context.log.error("invalid file handle name: $name", "FileHandleManager") + return null + } + + return LocalFileHandle( + fileHandleType.resolve(context.androidContext) + ) + } + FileHandleScope.LOCALE -> { + val foundLocale = context.androidContext.resources.assets.list("lang")?.firstOrNull { + it.startsWith(name) + }?.substringBefore(".") ?: return null + + if (name == LocaleWrapper.DEFAULT_LOCALE) { + return AssetFileHandle( + context, + "lang/${LocaleWrapper.DEFAULT_LOCALE}.json" + ) + } + + return AssetFileHandle( + context, + "lang/$foundLocale.json" + ) + } + FileHandleScope.USER_IMPORT -> { + return LocalFileHandle( + File(userImportFolder, name.substringAfterLast("/")) + ) + } + FileHandleScope.COMPOSER -> { + return AssetFileHandle( + context, + "composer/${name.substringAfterLast("/")}" + ) + } + else -> return null + } + } + + fun getStoredFiles(filter: ((File) -> Boolean)? = null): List<File> { + return userImportFolder.listFiles()?.toList()?.let { files -> + filter?.let { files.filter(it) } ?: files + }?.sortedBy { -it.lastModified() } ?: emptyList() + } + + fun getFileInfo(name: String): Pair<Long, Long>? { + return runCatching { + val file = File(userImportFolder, name) + file.length() to file.lastModified() + }.onFailure { + context.log.error("Failed to get file info: ${it.message}", it) + }.getOrNull() + } + + fun importFile(name: String, block: OutputStream.() -> Unit): Boolean { + return runCatching { + val file = File(userImportFolder, name) + file.outputStream().use(block) + true + }.onFailure { + context.log.error("Failed to import file: ${it.message}", it) + }.getOrDefault(false) + } + + fun deleteFile(name: String): Boolean { + return runCatching { + File(userImportFolder, name).delete() + }.onFailure { + context.log.error("Failed to delete file: ${it.message}", it) + }.isSuccess + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteLocationManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteLocationManager.kt new file mode 100644 index 0000000000..269e80d50b --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteLocationManager.kt @@ -0,0 +1,15 @@ +package me.rhunk.snapenhance + +import me.rhunk.snapenhance.bridge.location.FriendLocation +import me.rhunk.snapenhance.bridge.location.LocationManager + +class RemoteLocationManager( + private val remoteSideContext: RemoteSideContext +): LocationManager.Stub() { + var friendsLocation = listOf<FriendLocation>() + private set + + override fun provideFriendsLocation(friendsLocation: List<FriendLocation>) { + this.friendsLocation = friendsLocation.sortedBy { -it.lastUpdated } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt new file mode 100644 index 0000000000..391344145f --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt @@ -0,0 +1,240 @@ +package me.rhunk.snapenhance + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.core.app.CoreComponentFactory +import androidx.documentfile.provider.DocumentFile +import coil.ImageLoader +import coil.decode.VideoFrameDecoder +import coil.disk.DiskCache +import coil.memory.MemoryCache +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import me.rhunk.snapenhance.bridge.BridgeService +import me.rhunk.snapenhance.common.BuildConfig +import me.rhunk.snapenhance.common.Constants +import me.rhunk.snapenhance.common.action.EnumAction +import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper +import me.rhunk.snapenhance.common.bridge.wrapper.LoggerWrapper +import me.rhunk.snapenhance.common.bridge.wrapper.MappingsWrapper +import me.rhunk.snapenhance.common.config.ModConfig +import me.rhunk.snapenhance.common.logger.fatalCrash +import me.rhunk.snapenhance.common.util.constantLazyBridge +import me.rhunk.snapenhance.common.util.getPurgeTime +import me.rhunk.snapenhance.e2ee.E2EEImplementation +import me.rhunk.snapenhance.scripting.RemoteScriptManager +import me.rhunk.snapenhance.storage.AppDatabase +import me.rhunk.snapenhance.task.TaskManager +import me.rhunk.snapenhance.ui.manager.MainActivity +import me.rhunk.snapenhance.ui.manager.data.InstallationSummary +import me.rhunk.snapenhance.ui.manager.data.ModInfo +import me.rhunk.snapenhance.ui.manager.data.PlatformInfo +import me.rhunk.snapenhance.ui.manager.data.SnapchatAppInfo +import me.rhunk.snapenhance.ui.overlay.RemoteOverlay +import me.rhunk.snapenhance.ui.setup.Requirements +import me.rhunk.snapenhance.ui.setup.SetupActivity +import java.io.ByteArrayInputStream +import java.lang.ref.WeakReference +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate + + +class RemoteSideContext( + val androidContext: Context +) { + val coroutineScope = CoroutineScope(Dispatchers.IO) + + private var _activity: WeakReference<ComponentActivity>? = null + var bridgeService: BridgeService? = null + + var activity: ComponentActivity? + get() = _activity?.get() + set(value) { _activity?.clear(); _activity = WeakReference(value) } + + val sharedPreferences: SharedPreferences get() = androidContext.getSharedPreferences("prefs", 0) + val fileHandleManager = RemoteFileHandleManager(this) + val config = ModConfig(androidContext, constantLazyBridge { fileHandleManager }) + val translation = LocaleWrapper(constantLazyBridge { fileHandleManager }) + val mappings = MappingsWrapper(constantLazyBridge { fileHandleManager }) + val taskManager = TaskManager(this) + val database = AppDatabase(this) + val streaksReminder = StreaksReminder(this) + val log = LogManager(this) + val scriptManager = RemoteScriptManager(this) + val remoteOverlay = RemoteOverlay(this) + val e2eeImplementation = E2EEImplementation(this) + val messageLogger by lazy { LoggerWrapper(androidContext) } + val tracker = RemoteTracker(this) + val accountStorage = RemoteAccountStorage(this) + val locationManager = RemoteLocationManager(this) + + //used to load bitmoji selfies and download previews + val imageLoader by lazy { + ImageLoader.Builder(androidContext) + .dispatcher(Dispatchers.IO) + .memoryCache { + MemoryCache.Builder(androidContext) + .maxSizePercent(0.25) + .build() + } + .diskCache { + DiskCache.Builder() + .directory(androidContext.cacheDir.resolve("coil-disk-cache")) + .maxSizeBytes(1024 * 1024 * 100) // 100MB + .build() + } + .components { add(VideoFrameDecoder.Factory()) }.build() + } + + val gson: Gson by lazy { GsonBuilder().setPrettyPrinting().create() } + + fun reload() { + runCatching { + runBlocking(Dispatchers.IO) { + log.init() + log.verbose("Loading RemoteSideContext") + config.load() + launch { + mappings.apply { + init(androidContext) + } + } + translation.apply { + userLocale = config.locale + load() + } + database.init() + streaksReminder.init() + scriptManager.init() + launch { + taskManager.init() + config.root.messaging.messageLogger.takeIf { + it.globalState == true + }?.autoPurge?.let { getPurgeTime(it.getNullable()) }?.let { + messageLogger.purgeAll(it) + } + + config.root.friendTracker.takeIf { + it.globalState == true + }?.autoPurge?.let { getPurgeTime(it.getNullable()) }?.let { + messageLogger.purgeTrackerLogs(it) + } + } + } + }.onFailure { + log.error("Failed to load RemoteSideContext", it) + androidContext.fatalCrash(it) + } + + scriptManager.runtime.eachModule { + callFunction("module.onSnapEnhanceLoad", androidContext) + } + } + + val installationSummary by lazy { + InstallationSummary( + snapchatInfo = mappings.getSnapchatPackageInfo()?.let { + SnapchatAppInfo( + packageName = it.packageName, + version = it.versionName, + versionCode = it.longVersionCode, + isLSPatched = it.applicationInfo.appComponentFactory != CoreComponentFactory::class.java.name, + isSplitApk = it.splitNames?.isNotEmpty() ?: false + ) + }, + modInfo = ModInfo( + loaderPackageName = MainActivity::class.java.`package`?.name, + buildPackageName = androidContext.packageName, + buildVersion = BuildConfig.VERSION_NAME, + buildVersionCode = BuildConfig.VERSION_CODE.toLong(), + buildIssuer = androidContext.packageManager.getPackageInfo(androidContext.packageName, PackageManager.GET_SIGNING_CERTIFICATES) + ?.signingInfo?.apkContentsSigners?.firstOrNull()?.let { + val certFactory = CertificateFactory.getInstance("X509") + val cert = certFactory.generateCertificate(ByteArrayInputStream(it.toByteArray())) as X509Certificate + cert.issuerDN.toString() + } ?: throw Exception("Failed to get certificate info"), + gitHash = BuildConfig.GIT_HASH, + isDebugBuild = BuildConfig.DEBUG, + mappingVersion = mappings.getGeneratedBuildNumber(), + mappingsOutdated = mappings.isMappingsOutdated() + ), + platformInfo = PlatformInfo( + device = Build.DEVICE, + androidVersion = Build.VERSION.RELEASE, + systemAbi = Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown" + ) + ) + } + + fun longToast(message: Any) { + androidContext.mainExecutor.execute { + Toast.makeText(androidContext, message.toString(), Toast.LENGTH_LONG).show() + } + log.debug(message.toString()) + } + + fun shortToast(message: Any) { + androidContext.mainExecutor.execute { + Toast.makeText(androidContext, message.toString(), Toast.LENGTH_SHORT).show() + } + log.debug(message.toString()) + } + + fun hasMessagingBridge() = bridgeService != null && bridgeService?.messagingBridge != null && bridgeService?.messagingBridge?.asBinder()?.pingBinder() == true + + fun checkForRequirements(overrideRequirements: Int? = null): Boolean { + var requirements = overrideRequirements ?: 0 + if (!config.wasPresent) { + requirements = requirements or Requirements.FIRST_RUN + } + + config.root.downloader.saveFolder.get().let { + if (it.isEmpty() || run { + val documentFile = runCatching { DocumentFile.fromTreeUri(androidContext, Uri.parse(it)) }.getOrNull() + documentFile == null || !documentFile.exists() || !documentFile.canWrite() + }) { + requirements = requirements or Requirements.SAVE_FOLDER + } + } + + if (!sharedPreferences.getBoolean("debug_disable_mapper", false) && mappings.getSnapchatPackageInfo() != null && mappings.isMappingsOutdated()) { + requirements = requirements or Requirements.MAPPINGS + } + + if (requirements == 0) return false + + val currentContext = activity ?: androidContext + + Intent(currentContext, SetupActivity::class.java).apply { + putExtra("requirements", requirements) + if (currentContext !is Activity) { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + currentContext.startActivity(this) + return true + } + } + + fun launchActionIntent(action: EnumAction) { + val intent = androidContext.packageManager.getLaunchIntentForPackage( + Constants.SNAPCHAT_PACKAGE_NAME + ) + if (intent == null) { + shortToast("Can't execute action: Snapchat is not installed") + return + } + intent.putExtra(EnumAction.ACTION_PARAMETER, action.key) + androidContext.startActivity(intent) + } +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteTracker.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteTracker.kt new file mode 100644 index 0000000000..cb5bc0a67e --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteTracker.kt @@ -0,0 +1,34 @@ +package me.rhunk.snapenhance + +import me.rhunk.snapenhance.bridge.logger.TrackerInterface +import me.rhunk.snapenhance.common.data.ScopedTrackerRule +import me.rhunk.snapenhance.common.data.TrackerEventsResult +import me.rhunk.snapenhance.common.data.TrackerRule +import me.rhunk.snapenhance.common.data.TrackerRuleEvent +import me.rhunk.snapenhance.common.util.toSerialized +import me.rhunk.snapenhance.storage.getRuleTrackerScopes +import me.rhunk.snapenhance.storage.getTrackerEvents +import me.rhunk.snapenhance.storage.updateFriendScore + + +class RemoteTracker( + private val context: RemoteSideContext +): TrackerInterface.Stub() { + fun init() {} + + override fun getTrackedEvents(eventType: String): String? { + val events = mutableMapOf<TrackerRule, MutableList<TrackerRuleEvent>>() + + context.database.getTrackerEvents(eventType).forEach { (event, rule) -> + events.getOrPut(rule) { mutableListOf() }.add(event) + } + + return TrackerEventsResult(events.mapKeys { + ScopedTrackerRule(it.key, context.database.getRuleTrackerScopes(it.key.id)) + }).toSerialized() + } + + override fun updateFriendScore(userId: String, score: Long): Long { + return context.database.updateFriendScore(userId, score) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/SharedContextHolder.kt b/app/src/main/kotlin/me/rhunk/snapenhance/SharedContextHolder.kt new file mode 100644 index 0000000000..af6bf3a8c4 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/SharedContextHolder.kt @@ -0,0 +1,20 @@ +package me.rhunk.snapenhance + +import android.app.Activity +import android.content.Context +import java.lang.ref.WeakReference + +object SharedContextHolder { + private lateinit var _remoteSideContext: WeakReference<RemoteSideContext> + + fun remote(context: Context): RemoteSideContext { + if (!::_remoteSideContext.isInitialized || _remoteSideContext.get() == null) { + _remoteSideContext = WeakReference(RemoteSideContext(context.let { + if (it is Activity) it.applicationContext else it + })) + _remoteSideContext.get()?.reload() + } + + return _remoteSideContext.get()!! + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/SnapEnhance.kt b/app/src/main/kotlin/me/rhunk/snapenhance/SnapEnhance.kt deleted file mode 100644 index 1ce7a76953..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/SnapEnhance.kt +++ /dev/null @@ -1,91 +0,0 @@ -package me.rhunk.snapenhance - -import android.annotation.SuppressLint -import android.app.Activity -import android.app.Application -import android.content.Context -import android.os.Build -import me.rhunk.snapenhance.bridge.AbstractBridgeClient -import me.rhunk.snapenhance.bridge.client.RootBridgeClient -import me.rhunk.snapenhance.bridge.client.ServiceBridgeClient -import me.rhunk.snapenhance.data.SnapClassCache -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.Hooker -import kotlin.time.ExperimentalTime -import kotlin.time.measureTime - -class SnapEnhance { - companion object { - lateinit var classLoader: ClassLoader - val classCache: SnapClassCache by lazy { - SnapClassCache(classLoader) - } - } - private val appContext = ModContext() - - init { - Hooker.hook(Application::class.java, "attach", HookStage.BEFORE) { param -> - appContext.androidContext = param.arg<Context>(0).also { - classLoader = it.classLoader - } - appContext.bridgeClient = provideBridgeClient() - - appContext.bridgeClient.apply { - this.context = appContext - start { bridgeResult -> - if (!bridgeResult) { - Logger.xposedLog("Cannot connect to bridge service") - appContext.softRestartApp() - return@start - } - runCatching { - init() - }.onFailure { - Logger.xposedLog("Failed to initialize", it) - } - } - } - } - - Hooker.hook(Activity::class.java, "onCreate", HookStage.AFTER) { - val activity = it.thisObject() as Activity - if (!activity.packageName.equals(Constants.SNAPCHAT_PACKAGE_NAME)) return@hook - val isMainActivityNotNull = appContext.mainActivity != null - appContext.mainActivity = activity - if (isMainActivityNotNull || !appContext.mappings.areMappingsLoaded) return@hook - onActivityCreate() - } - } - - @SuppressLint("ObsoleteSdkInt") - private fun provideBridgeClient(): AbstractBridgeClient { - //unsafe way for Android 9 devices - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { - return RootBridgeClient() - } - return ServiceBridgeClient() - } - - @OptIn(ExperimentalTime::class) - private fun init() { - measureTime { - with(appContext) { - translation.init() - config.init() - mappings.init() - //if mappings aren't loaded, we can't initialize features - if (!mappings.areMappingsLoaded) return - features.init() - } - }.also { time -> - Logger.debug("initialized in $time") - } - } - - private fun onActivityCreate() { - with(appContext) { - features.onActivityCreate() - actionManager.init() - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/StreaksReminder.kt b/app/src/main/kotlin/me/rhunk/snapenhance/StreaksReminder.kt new file mode 100644 index 0000000000..4ba956bab6 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/StreaksReminder.kt @@ -0,0 +1,125 @@ +package me.rhunk.snapenhance + +import android.app.AlarmManager +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.graphics.drawable.toBitmap +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.bridge.ForceStartActivity +import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie +import me.rhunk.snapenhance.storage.getFriendStreaks +import me.rhunk.snapenhance.storage.getFriends +import me.rhunk.snapenhance.ui.util.coil.ImageRequestHelper +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes + +class StreaksReminder( + private val remoteSideContext: RemoteSideContext? = null +): BroadcastReceiver() { + companion object { + private const val NOTIFICATION_CHANNEL_ID = "streaks" + } + + private fun getNotificationManager(context: Context) = context.getSystemService(NotificationManager::class.java).apply { + createNotificationChannel( + NotificationChannel( + NOTIFICATION_CHANNEL_ID, + "Streaks", + NotificationManager.IMPORTANCE_HIGH + ) + ) + } + + override fun onReceive(ctx: Context, intent: Intent) { + val remoteSideContext = this.remoteSideContext ?: SharedContextHolder.remote(ctx) + val streaksReminderConfig = remoteSideContext.config.root.streaksReminder + val sharedPreferences = remoteSideContext.sharedPreferences + + if (streaksReminderConfig.globalState != true) return + + val interval = streaksReminderConfig.interval.get().hours + val remainingHours = streaksReminderConfig.remainingHours.get() + + if (sharedPreferences.getLong("lastStreaksReminder", 0).milliseconds + interval - 10.minutes > System.currentTimeMillis().milliseconds) return + sharedPreferences.edit().putLong("lastStreaksReminder", System.currentTimeMillis()).apply() + + remoteSideContext.androidContext.getSystemService(AlarmManager::class.java).setRepeating( + AlarmManager.RTC_WAKEUP, 5000, interval.inWholeMilliseconds, + PendingIntent.getBroadcast(remoteSideContext.androidContext, 0, Intent(remoteSideContext.androidContext, StreaksReminder::class.java), + PendingIntent.FLAG_IMMUTABLE) + ) + + val notifyFriendList = remoteSideContext.database.getFriends() + .associateBy { remoteSideContext.database.getFriendStreaks(it.userId) } + .filter { (streaks, _) -> streaks != null && streaks.notify && streaks.isAboutToExpire(remainingHours) } + + val notificationManager = getNotificationManager(ctx) + val streaksReminderTranslation = remoteSideContext.translation.getCategory("streaks_reminder") + + if (streaksReminderConfig.groupNotifications.get() && notifyFriendList.isNotEmpty()) { + notificationManager.notify(0, NotificationCompat.Builder(ctx, NOTIFICATION_CHANNEL_ID) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + .setGroup("streaks") + .setGroupSummary(true) + .setSmallIcon(R.drawable.streak_icon) + .build()) + } + + notifyFriendList.forEach { (streaks, friend) -> + remoteSideContext.coroutineScope.launch { + val bitmojiUrl = BitmojiSelfie.getBitmojiSelfie(friend.selfieId, friend.bitmojiId, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D) + val bitmojiImage = remoteSideContext.imageLoader.execute( + ImageRequestHelper.newBitmojiImageRequest(ctx, bitmojiUrl) + ) + + val notificationBuilder = NotificationCompat.Builder(ctx, NOTIFICATION_CHANNEL_ID) + .setContentTitle(streaksReminderTranslation["notification_title"]) + .setContentText(streaksReminderTranslation.format("notification_text", + "friend" to (friend.displayName ?: friend.mutableUsername), + "hoursLeft" to (streaks?.hoursLeft() ?: 0).toString() + )) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setAutoCancel(true) + .setGroup("streaks") + .setContentIntent(PendingIntent.getActivity( + ctx, + 0, + Intent(ctx, ForceStartActivity::class.java).apply { + putExtra("streaks_notification_action", true) + }, + PendingIntent.FLAG_IMMUTABLE + )) + .apply { + setSmallIcon(R.drawable.streak_icon) + bitmojiImage.drawable?.let { + setLargeIcon(it.toBitmap()) + } + } + + if (streaksReminderConfig.groupNotifications.get()) { + notificationBuilder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) + } + + notificationManager.notify(friend.userId.hashCode(), notificationBuilder.build().apply { + flags = NotificationCompat.FLAG_ONLY_ALERT_ONCE + }) + } + } + } + + fun init() { + if (remoteSideContext == null) throw IllegalStateException("RemoteSideContext is null") + if (remoteSideContext.config.root.streaksReminder.globalState != true) return + + onReceive(remoteSideContext.androidContext, Intent()) + } + + fun dismissAllNotifications() = getNotificationManager(remoteSideContext!!.androidContext).cancelAll() +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/XposedLoader.kt b/app/src/main/kotlin/me/rhunk/snapenhance/XposedLoader.kt deleted file mode 100644 index e2058e8d66..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/XposedLoader.kt +++ /dev/null @@ -1,11 +0,0 @@ -package me.rhunk.snapenhance - -import de.robv.android.xposed.IXposedHookLoadPackage -import de.robv.android.xposed.callbacks.XC_LoadPackage - -class XposedLoader : IXposedHookLoadPackage { - override fun handleLoadPackage(p0: XC_LoadPackage.LoadPackageParam) { - if (p0.packageName != Constants.SNAPCHAT_PACKAGE_NAME) return - SnapEnhance() - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/action/EnumQuickActions.kt b/app/src/main/kotlin/me/rhunk/snapenhance/action/EnumQuickActions.kt new file mode 100644 index 0000000000..ad8e2b7b1d --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/action/EnumQuickActions.kt @@ -0,0 +1,24 @@ +package me.rhunk.snapenhance.action + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.PersonSearch +import androidx.compose.ui.graphics.vector.ImageVector +import me.rhunk.snapenhance.ui.manager.Routes + +enum class EnumQuickActions( + val key: String, + val icon: ImageVector, + val action: Routes.() -> Unit +) { + FILE_IMPORTS("file_imports", Icons.Default.FolderOpen, { + fileImports.navigateReset() + }), + FRIEND_TRACKER("friend_tracker", Icons.Default.PersonSearch, { + friendTracker.navigateReset() + }), + LOGGER_HISTORY("logger_history", Icons.Default.History, { + loggerHistory.navigateReset() + }), +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/action/impl/CheckForUpdates.kt b/app/src/main/kotlin/me/rhunk/snapenhance/action/impl/CheckForUpdates.kt deleted file mode 100644 index ac724ce616..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/action/impl/CheckForUpdates.kt +++ /dev/null @@ -1,20 +0,0 @@ -package me.rhunk.snapenhance.action.impl - -import me.rhunk.snapenhance.action.AbstractAction -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.features.impl.AutoUpdater - -class CheckForUpdates : AbstractAction("action.check_for_updates", dependsOnProperty = ConfigProperty.AUTO_UPDATER) { - override fun run() { - context.executeAsync { - runCatching { - val latestVersion = context.feature(AutoUpdater::class).checkForUpdates() - if (latestVersion == null) { - context.longToast(context.translation.get("auto_updater.no_update_available")) - } - }.onFailure { - context.longToast(it.message ?: "Failed to check for updates") - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/action/impl/ClearMessageLogger.kt b/app/src/main/kotlin/me/rhunk/snapenhance/action/impl/ClearMessageLogger.kt deleted file mode 100644 index b31853cf1d..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/action/impl/ClearMessageLogger.kt +++ /dev/null @@ -1,10 +0,0 @@ -package me.rhunk.snapenhance.action.impl - -import me.rhunk.snapenhance.action.AbstractAction - -class ClearMessageLogger : AbstractAction("action.clear_message_logger") { - override fun run() { - context.bridgeClient.clearMessageLogger() - context.shortToast("Message logger cleared") - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/action/impl/OpenMap.kt b/app/src/main/kotlin/me/rhunk/snapenhance/action/impl/OpenMap.kt deleted file mode 100644 index 5a89fdafdf..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/action/impl/OpenMap.kt +++ /dev/null @@ -1,23 +0,0 @@ -package me.rhunk.snapenhance.action.impl - -import android.content.Intent -import android.os.Bundle -import me.rhunk.snapenhance.BuildConfig -import me.rhunk.snapenhance.action.AbstractAction -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.features.impl.ui.menus.MapActivity - -class OpenMap: AbstractAction("action.open_map", dependsOnProperty = ConfigProperty.LOCATION_SPOOF) { - override fun run() { - context.runOnUiThread { - val mapActivityIntent = Intent() - mapActivityIntent.setClassName(BuildConfig.APPLICATION_ID, MapActivity::class.java.name) - mapActivityIntent.putExtra("location", Bundle().apply { - putDouble("latitude", context.config.string(ConfigProperty.LATITUDE).toDouble()) - putDouble("longitude", context.config.string(ConfigProperty.LONGITUDE).toDouble()) - }) - - context.mainActivity!!.startActivityForResult(mapActivityIntent, 0x1337) - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/action/impl/RefreshMappings.kt b/app/src/main/kotlin/me/rhunk/snapenhance/action/impl/RefreshMappings.kt deleted file mode 100644 index 798a4521a4..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/action/impl/RefreshMappings.kt +++ /dev/null @@ -1,11 +0,0 @@ -package me.rhunk.snapenhance.action.impl - -import me.rhunk.snapenhance.action.AbstractAction -import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType - -class RefreshMappings : AbstractAction("action.refresh_mappings") { - override fun run() { - context.bridgeClient.deleteFile(BridgeFileType.MAPPINGS) - context.softRestartApp() - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/AbstractBridgeClient.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/AbstractBridgeClient.kt deleted file mode 100644 index 5b0691b52c..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/AbstractBridgeClient.kt +++ /dev/null @@ -1,122 +0,0 @@ -package me.rhunk.snapenhance.bridge - -import me.rhunk.snapenhance.ModContext -import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType -import me.rhunk.snapenhance.bridge.common.impl.locale.LocaleResult - -abstract class AbstractBridgeClient { - lateinit var context: ModContext - - /** - * Start the bridge client - * - * @param callback the callback to call when the initialization is done - */ - abstract fun start(callback: (Boolean) -> Unit = {}) - - /** - * Create a file if it doesn't exist, and read it - * - * @param fileType the type of file to create and read - * @param defaultContent the default content to write to the file if it doesn't exist - * @return the content of the file - */ - abstract fun createAndReadFile(fileType: BridgeFileType, defaultContent: ByteArray): ByteArray - - /** - * Read a file - * - * @param fileType the type of file to read - * @return the content of the file - */ - abstract fun readFile(fileType: BridgeFileType): ByteArray - - /** - * Write a file - * - * @param fileType the type of file to write - * @param content the content to write to the file - * @return true if the file was written successfully - */ - abstract fun writeFile(fileType: BridgeFileType, content: ByteArray?): Boolean - - /** - * Delete a file - * - * @param fileType the type of file to delete - * @return true if the file was deleted successfully - */ - abstract fun deleteFile(fileType: BridgeFileType): Boolean - - /** - * Check if a file exists - * - * @param fileType the type of file to check - * @return true if the file exists - */ - abstract fun isFileExists(fileType: BridgeFileType): Boolean - - /** - * Download content from a URL and save it to a file - * - * @param url the URL to download content from - * @param path the path to save the content to - * @return true if the content was downloaded successfully - */ - abstract fun downloadContent(url: String, path: String): Boolean - - /** - * Get the content of a logged message from the database - * - * @param conversationId the ID of the conversation - * @return the content of the message - */ - abstract fun getLoggedMessageIds(conversationId: String, limit: Int): List<Long> - - /** - * Get the content of a logged message from the database - * - * @param id the ID of the message logger message - * @return the content of the message - */ - abstract fun getMessageLoggerMessage(conversationId: String, id: Long): ByteArray? - - /** - * Add a message to the message logger database - * - * @param id the ID of the message logger message - * @param message the content of the message - */ - abstract fun addMessageLoggerMessage(conversationId: String, id: Long, message: ByteArray) - - /** - * Delete a message from the message logger database - * - * @param id the ID of the message logger message - */ - abstract fun deleteMessageLoggerMessage(conversationId: String, id: Long) - - /** - * Clear the message logger database - */ - abstract fun clearMessageLogger() - - /** - * Fetch the translations - * - * @return the translations result - */ - abstract fun fetchTranslations(): LocaleResult - - /** - * Get check for updates last time - * @return the last time check for updates was done - */ - abstract fun getAutoUpdaterTime(): Long - - /** - * Set check for updates last time - * @param time the time to set - */ - abstract fun setAutoUpdaterTime(time: Long) -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BiometricPromptActivity.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BiometricPromptActivity.kt new file mode 100644 index 0000000000..bfa6626455 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BiometricPromptActivity.kt @@ -0,0 +1,57 @@ +package me.rhunk.snapenhance.bridge + +import android.content.Intent +import android.hardware.biometrics.BiometricManager +import android.hardware.biometrics.BiometricPrompt +import android.os.Build +import android.os.Bundle +import android.os.CancellationSignal +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import me.rhunk.snapenhance.SharedContextHolder +import java.util.concurrent.Executors + +class BiometricPromptActivity: ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + fun cancel() { + setResult(RESULT_CANCELED, Intent()) + finish() + } + + val remoteSideContext = SharedContextHolder.remote(this) + + BiometricPrompt.Builder(this@BiometricPromptActivity) + .setTitle(remoteSideContext.translation["biometric_auth.title"]) + .setSubtitle(remoteSideContext.translation["biometric_auth.subtitle"]) + .apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_WEAK or BiometricManager.Authenticators.DEVICE_CREDENTIAL) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + @Suppress("DEPRECATION") + setDeviceCredentialAllowed(true) + } + } + .build().authenticate( + CancellationSignal().apply { + setOnCancelListener { + cancel() + } + }, + Executors.newSingleThreadExecutor(), + object: BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence?) { + cancel() + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult?) { + setResult(RESULT_OK, Intent()) + finish() + } + } + ) + + setContent {} + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt new file mode 100644 index 0000000000..d548bf2f6a --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt @@ -0,0 +1,249 @@ +package me.rhunk.snapenhance.bridge + +import android.app.Service +import android.content.Intent +import android.os.IBinder +import android.os.ParcelFileDescriptor +import kotlinx.coroutines.runBlocking +import me.rhunk.snapenhance.RemoteSideContext +import me.rhunk.snapenhance.SharedContextHolder +import me.rhunk.snapenhance.bridge.snapclient.MessagingBridge +import me.rhunk.snapenhance.common.data.MessagingFriendInfo +import me.rhunk.snapenhance.common.data.MessagingGroupInfo +import me.rhunk.snapenhance.common.data.SocialScope +import me.rhunk.snapenhance.common.logger.LogLevel +import me.rhunk.snapenhance.common.ui.OverlayType +import me.rhunk.snapenhance.common.util.toParcelable +import me.rhunk.snapenhance.download.DownloadProcessor +import me.rhunk.snapenhance.download.FFMpegProcessor +import me.rhunk.snapenhance.storage.* +import me.rhunk.snapenhance.task.Task +import me.rhunk.snapenhance.task.TaskType +import java.io.File +import java.util.UUID +import kotlin.system.measureTimeMillis + +class BridgeService : Service() { + private lateinit var remoteSideContext: RemoteSideContext + lateinit var syncCallback: SyncCallback + var messagingBridge: MessagingBridge? = null + + override fun onDestroy() { + if (::remoteSideContext.isInitialized) { + remoteSideContext.bridgeService = null + } + } + + override fun onBind(intent: Intent): IBinder? { + remoteSideContext = SharedContextHolder.remote(this).apply { + if (checkForRequirements()) return null + } + remoteSideContext.apply { + bridgeService = this@BridgeService + } + return BridgeBinder() + } + + fun triggerScopeSync(scope: SocialScope, id: String, updateOnly: Boolean = false) { + runCatching { + if (!syncCallback.asBinder().pingBinder()) { + remoteSideContext.log.warn("Failed to sync $scope $id: Callback is dead") + return + } + val modDatabase = remoteSideContext.database + val syncedObject = when (scope) { + SocialScope.FRIEND -> { + if (updateOnly && modDatabase.getFriendInfo(id) == null) return + syncCallback.syncFriend(id) + } + SocialScope.GROUP -> { + if (updateOnly && modDatabase.getGroupInfo(id) == null) return + syncCallback.syncGroup(id) + } + else -> null + } + + if (syncedObject == null) { + remoteSideContext.log.warn("Failed to sync $scope $id") + return + } + + when (scope) { + SocialScope.FRIEND -> { + toParcelable<MessagingFriendInfo>(syncedObject)?.let { + modDatabase.syncFriend(it) + } + } + SocialScope.GROUP -> { + toParcelable<MessagingGroupInfo>(syncedObject)?.let { + modDatabase.syncGroupInfo(it) + } + } + } + }.onFailure { + remoteSideContext.log.error("Failed to sync $scope $id", it) + } + } + + inner class BridgeBinder : BridgeInterface.Stub() { + override fun getApplicationApkPath(): String = applicationInfo.publicSourceDir + + override fun broadcastLog(tag: String, level: String, message: String) { + remoteSideContext.log.internalLog(tag, LogLevel.fromShortName(level) ?: LogLevel.INFO, message) + } + override fun enqueueDownload(intent: Intent, callback: DownloadCallback) { + DownloadProcessor( + remoteSideContext = remoteSideContext, + callback = callback + ).onReceive(intent) + } + + override fun convertMedia( + input: ParcelFileDescriptor?, + inputExtension: String, + outputExtension: String, + audioCodec: String?, + videoCodec: String? + ): ParcelFileDescriptor? { + return runBlocking { + val taskId = UUID.randomUUID().toString() + val inputFile = File.createTempFile(taskId, ".$inputExtension", remoteSideContext.androidContext.cacheDir) + + runCatching { + ParcelFileDescriptor.AutoCloseInputStream(input).use { inputStream -> + inputFile.outputStream().use { outputStream -> + inputStream.copyTo(outputStream) + } + } + }.onFailure { + remoteSideContext.log.error("Failed to copy input file", it) + inputFile.delete() + return@runBlocking null + } + val cachedFile = File.createTempFile(taskId, ".$outputExtension", remoteSideContext.androidContext.cacheDir) + + val pendingTask = remoteSideContext.taskManager.createPendingTask( + Task( + type = TaskType.DOWNLOAD, + title = "Media conversion", + author = null, + hash = taskId + ) + ) + runCatching { + FFMpegProcessor.newFFMpegProcessor(remoteSideContext, pendingTask).execute( + FFMpegProcessor.Request( + action = FFMpegProcessor.Action.CONVERSION, + inputs = listOf(inputFile.absolutePath), + output = cachedFile, + videoCodec = videoCodec, + audioCodec = audioCodec + ) + ) + pendingTask.success() + return@runBlocking ParcelFileDescriptor.open(cachedFile, ParcelFileDescriptor.MODE_READ_ONLY) + }.onFailure { + pendingTask.fail(it.message ?: "Failed to convert video") + remoteSideContext.log.error("Failed to convert video", it) + } + + inputFile.delete() + cachedFile.delete() + null + } + } + + override fun getRules(uuid: String): List<String> { + return remoteSideContext.database.getRules(uuid).map { it.key } + } + + override fun getRuleIds(type: String): MutableList<String> { + return remoteSideContext.database.getRuleIds(type) + } + + override fun setRule(uuid: String, rule: String, state: Boolean) { + remoteSideContext.database.setRule(uuid, rule, state) + } + + override fun sync(callback: SyncCallback) { + syncCallback = callback + measureTimeMillis { + remoteSideContext.database.getFriends().map { it.userId } .forEach { friendId -> + triggerScopeSync(SocialScope.FRIEND, friendId, true) + } + remoteSideContext.database.getGroups().map { it.conversationId }.forEach { groupId -> + triggerScopeSync(SocialScope.GROUP, groupId, true) + } + }.also { + remoteSideContext.log.verbose("Syncing remote took $it ms") + } + } + + override fun triggerSync(scope: String, id: String) { + remoteSideContext.log.verbose("trigger sync for $scope $id") + triggerScopeSync(SocialScope.getByName(scope), id, true) + } + + override fun passGroupsAndFriends( + groups: List<String>, + friends: List<String> + ) { + remoteSideContext.log.verbose("Received ${groups.size} groups and ${friends.size} friends") + remoteSideContext.database.receiveMessagingDataCallback( + friends.mapNotNull { toParcelable<MessagingFriendInfo>(it) }, + groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) } + ) + } + + override fun getScopeNotes(id: String): String? { + return remoteSideContext.database.getScopeNotes(id) + } + + override fun setScopeNotes(id: String, content: String?) { + remoteSideContext.database.setScopeNotes(id, content) + } + + override fun getScriptingInterface() = remoteSideContext.scriptManager + + override fun getE2eeInterface() = remoteSideContext.e2eeImplementation + override fun getLogger() = remoteSideContext.messageLogger + override fun getTracker() = remoteSideContext.tracker + override fun getAccountStorage() = remoteSideContext.accountStorage + override fun getFileHandleManager() = remoteSideContext.fileHandleManager + override fun getLocationManager() = remoteSideContext.locationManager + + override fun registerMessagingBridge(bridge: MessagingBridge) { + messagingBridge = bridge + } + + override fun openOverlay(type: String) { + runCatching { + val overlayType = OverlayType.fromKey(type) ?: throw IllegalArgumentException("Unknown overlay type: $type") + remoteSideContext.remoteOverlay.show { routes -> + when (overlayType) { + OverlayType.SETTINGS -> routes.features + OverlayType.BETTER_LOCATION -> routes.betterLocation + } + } + }.onFailure { + remoteSideContext.log.error("Failed to open $type overlay", it) + } + } + + override fun closeOverlay() { + runCatching { + remoteSideContext.remoteOverlay.close() + }.onFailure { + remoteSideContext.log.error("Failed to close overlay", it) + } + } + + override fun registerConfigStateListener(listener: ConfigStateListener) { + remoteSideContext.config.configStateListener = listener + } + + override fun getDebugProp(key: String, defaultValue: String?): String? { + return remoteSideContext.sharedPreferences.all["debug_$key"]?.toString() ?: defaultValue + } + } +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/ForceStartActivity.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/ForceStartActivity.kt new file mode 100644 index 0000000000..1aa4d3f03c --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/ForceStartActivity.kt @@ -0,0 +1,21 @@ +package me.rhunk.snapenhance.bridge + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import me.rhunk.snapenhance.SharedContextHolder +import me.rhunk.snapenhance.common.Constants + +class ForceStartActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (intent.getBooleanExtra("streaks_notification_action", false)) { + packageManager.getLaunchIntentForPackage(Constants.SNAPCHAT_PACKAGE_NAME)?.apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(this) + } + SharedContextHolder.remote(this).streaksReminder.dismissAllNotifications() + } + finish() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/MessageLoggerWrapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/MessageLoggerWrapper.kt deleted file mode 100644 index 70cb34c2ea..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/MessageLoggerWrapper.kt +++ /dev/null @@ -1,62 +0,0 @@ -package me.rhunk.snapenhance.bridge - -import android.content.ContentValues -import android.database.sqlite.SQLiteDatabase -import java.io.File - -class MessageLoggerWrapper( - private val databaseFile: File -) { - - lateinit var database: SQLiteDatabase - - fun init() { - database = SQLiteDatabase.openDatabase(databaseFile.absolutePath, null, SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.OPEN_READWRITE) - database.execSQL("CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY, conversation_id VARCHAR, message_id BIGINT, message_data BLOB)") - } - - fun deleteMessage(conversationId: String, messageId: Long) { - database.execSQL("DELETE FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())) - } - - fun addMessage(conversationId: String, messageId: Long, serializedMessage: ByteArray): Boolean { - val cursor = database.rawQuery("SELECT message_id FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())) - val state = cursor.moveToFirst() - cursor.close() - if (state) { - return false - } - database.insert("messages", null, ContentValues().apply { - put("conversation_id", conversationId) - put("message_id", messageId) - put("message_data", serializedMessage) - }) - return true - } - - fun getMessage(conversationId: String, messageId: Long): Pair<Boolean, ByteArray?> { - val cursor = database.rawQuery("SELECT message_data FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())) - val state = cursor.moveToFirst() - val message: ByteArray? = if (state) { - cursor.getBlob(0) - } else { - null - } - cursor.close() - return Pair(state, message) - } - - fun getMessageIds(conversationId: String, limit: Int): List<Long> { - val cursor = database.rawQuery("SELECT message_id FROM messages WHERE conversation_id = ? ORDER BY message_id DESC LIMIT ?", arrayOf(conversationId, limit.toString())) - val messageIds = mutableListOf<Long>() - while (cursor.moveToNext()) { - messageIds.add(cursor.getLong(0)) - } - cursor.close() - return messageIds - } - - fun clearMessages() { - database.execSQL("DELETE FROM messages") - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/client/RootBridgeClient.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/client/RootBridgeClient.kt deleted file mode 100644 index bf700ec37c..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/client/RootBridgeClient.kt +++ /dev/null @@ -1,155 +0,0 @@ -package me.rhunk.snapenhance.bridge.client - -import android.os.Environment -import me.rhunk.snapenhance.Logger -import me.rhunk.snapenhance.bridge.AbstractBridgeClient -import me.rhunk.snapenhance.bridge.MessageLoggerWrapper -import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType -import me.rhunk.snapenhance.bridge.common.impl.locale.LocaleResult -import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream -import java.io.OutputStream -import java.util.zip.ZipInputStream - -class RootBridgeClient : AbstractBridgeClient() { - private lateinit var messageLoggerWrapper: MessageLoggerWrapper - companion object { - private val MOD_FOLDER = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS),"SnapEnhance") - } - - override fun start(callback: (Boolean) -> Unit) { - if (!MOD_FOLDER.exists()) { - MOD_FOLDER.mkdirs() - } - messageLoggerWrapper = MessageLoggerWrapper(File(MOD_FOLDER, BridgeFileType.MESSAGE_LOGGER_DATABASE.fileName)).also { it.init() } - callback(true) - } - - override fun createAndReadFile(fileType: BridgeFileType, defaultContent: ByteArray): ByteArray { - val file = File(MOD_FOLDER, fileType.fileName) - if (file.exists()) { - return readFile(fileType) - } - val outputStream = openFileWritable(file) - outputStream.write(defaultContent) - outputStream.close() - return defaultContent - } - - override fun readFile(fileType: BridgeFileType): ByteArray { - return File(MOD_FOLDER, fileType.fileName).readBytes() - } - - override fun writeFile(fileType: BridgeFileType, content: ByteArray?): Boolean { - val outputStream = openFileWritable(File(MOD_FOLDER, fileType.fileName)) - outputStream.write(content) - outputStream.close() - return true - } - - override fun deleteFile(fileType: BridgeFileType): Boolean { - val file = File(MOD_FOLDER, fileType.fileName) - val exists = file.exists() - if (exists) { - rootOperation("rm ${file.absolutePath}") - } - return exists - } - - override fun isFileExists(fileType: BridgeFileType): Boolean { - return File(MOD_FOLDER, fileType.fileName).exists() - } - - override fun downloadContent(url: String, path: String): Boolean { - return true - } - - override fun getLoggedMessageIds(conversationId: String, limit: Int): List<Long> { - return messageLoggerWrapper.getMessageIds(conversationId, limit) - } - - override fun getMessageLoggerMessage(conversationId: String, id: Long): ByteArray? { - val (state, messageData) = messageLoggerWrapper.getMessage(conversationId, id) - if (state) { - return messageData - } - return null - } - - override fun addMessageLoggerMessage(conversationId: String, id: Long, message: ByteArray) { - messageLoggerWrapper.addMessage(conversationId, id, message) - } - - override fun deleteMessageLoggerMessage(conversationId: String, id: Long) { - messageLoggerWrapper.deleteMessage(conversationId, id) - } - - override fun clearMessageLogger() { - messageLoggerWrapper.clearMessages() - } - - override fun fetchTranslations(): LocaleResult { - val locale = "en_US"//Locale.getDefault().toString() - - //https://github.com/LSPosed/LSPosed/blob/master/core/src/main/java/org/lsposed/lspd/util/LspModuleClassLoader.java#L36 - val moduleApk = javaClass.classLoader.javaClass.declaredFields.first { it.type == String::class.java }.let { - it.isAccessible = true - it.get(javaClass.classLoader) as String - } - - val langJsonData: ByteArray? = ZipInputStream(FileInputStream(moduleApk)).let { zip -> - while (true) { - val entry = zip.nextEntry ?: break - if (entry.name == "assets/lang/$locale.json") { - return@let zip.readBytes() - } - } - return@let null - } - - if (langJsonData != null) { - Logger.debug("Fetched translations for $locale") - return LocaleResult(locale, langJsonData) - } - - throw Throwable("Failed to fetch translations for $locale") - } - - override fun getAutoUpdaterTime(): Long { - readFile(BridgeFileType.AUTO_UPDATER_TIMESTAMP).run { - return if (isEmpty()) { - 0 - } else { - String(this).toLong() - } - } - } - - override fun setAutoUpdaterTime(time: Long) { - writeFile(BridgeFileType.AUTO_UPDATER_TIMESTAMP, time.toString().toByteArray(Charsets.UTF_8)) - } - - private fun rootOperation(command: String): String { - val process = Runtime.getRuntime().exec("su -c $command") - process.waitFor() - process.errorStream?.bufferedReader()?.let { - val error = it.readText() - if (error.isNotEmpty()) { - throw Throwable("Failed to execute root operation: $error") - } - } - Logger.debug("Root operation executed: $command") - return process.inputStream.bufferedReader().readText() - } - - private fun openFileWritable(file: File): OutputStream { - runCatching { - if (!file.exists()) rootOperation("touch ${file.absolutePath}") - }.onFailure { - Logger.error("Failed to set file permissions: ${it.message}") - } - - return FileOutputStream(file) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/client/ServiceBridgeClient.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/client/ServiceBridgeClient.kt deleted file mode 100644 index ee29081a74..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/client/ServiceBridgeClient.kt +++ /dev/null @@ -1,267 +0,0 @@ -package me.rhunk.snapenhance.bridge.client - - -import android.annotation.TargetApi -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.ServiceConnection -import android.os.Build -import android.os.Bundle -import android.os.Handler -import android.os.HandlerThread -import android.os.IBinder -import android.os.Message -import android.os.Messenger -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.suspendCancellableCoroutine -import me.rhunk.snapenhance.BuildConfig -import me.rhunk.snapenhance.Logger.xposedLog -import me.rhunk.snapenhance.bridge.AbstractBridgeClient -import me.rhunk.snapenhance.bridge.common.BridgeMessage -import me.rhunk.snapenhance.bridge.common.BridgeMessageType -import me.rhunk.snapenhance.bridge.common.impl.download.DownloadContentRequest -import me.rhunk.snapenhance.bridge.common.impl.download.DownloadContentResult -import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType -import me.rhunk.snapenhance.bridge.common.impl.file.FileAccessRequest -import me.rhunk.snapenhance.bridge.common.impl.file.FileAccessResult -import me.rhunk.snapenhance.bridge.common.impl.locale.LocaleRequest -import me.rhunk.snapenhance.bridge.common.impl.locale.LocaleResult -import me.rhunk.snapenhance.bridge.common.impl.messagelogger.MessageLoggerListResult -import me.rhunk.snapenhance.bridge.common.impl.messagelogger.MessageLoggerRequest -import me.rhunk.snapenhance.bridge.common.impl.messagelogger.MessageLoggerResult -import me.rhunk.snapenhance.bridge.service.BridgeService -import java.util.concurrent.CompletableFuture -import java.util.concurrent.Executors -import kotlin.coroutines.resume -import kotlin.reflect.KClass -import kotlin.system.exitProcess - - -class ServiceBridgeClient: AbstractBridgeClient(), ServiceConnection { - private val handlerThread = HandlerThread("BridgeClient") - - private lateinit var messenger: Messenger - private lateinit var future: CompletableFuture<Boolean> - - @TargetApi(Build.VERSION_CODES.Q) - override fun start(callback: (Boolean) -> Unit) { - this.future = CompletableFuture() - this.handlerThread.start() - - with(context.androidContext) { - val intent = Intent() - .setClassName(BuildConfig.APPLICATION_ID, BridgeService::class.java.name) - bindService( - intent, - Context.BIND_AUTO_CREATE, - Executors.newSingleThreadExecutor(), - this@ServiceBridgeClient - ) - } - callback(future.get()) - } - - private fun handleResponseMessage( - msg: Message - ): BridgeMessage { - val message: BridgeMessage = when (BridgeMessageType.fromValue(msg.what)) { - BridgeMessageType.FILE_ACCESS_RESULT -> FileAccessResult() - BridgeMessageType.DOWNLOAD_CONTENT_RESULT -> DownloadContentResult() - BridgeMessageType.MESSAGE_LOGGER_RESULT -> MessageLoggerResult() - BridgeMessageType.MESSAGE_LOGGER_LIST_RESULT -> MessageLoggerListResult() - BridgeMessageType.LOCALE_RESULT -> LocaleResult() - else -> throw IllegalStateException("Unknown message type: ${msg.what}") - } - - with(message) { - read(msg.data) - return this - } - } - - @Suppress("UNCHECKED_CAST", "UNUSED_PARAMETER") - private fun <T : BridgeMessage> sendMessage( - messageType: BridgeMessageType, - message: BridgeMessage, - resultType: KClass<T>? = null - ) = runBlocking { - suspendCancellableCoroutine { cancelableContinuation -> - val replyMessenger = Messenger(object : Handler(handlerThread.looper) { - override fun handleMessage(msg: Message) { - if (cancelableContinuation.isCancelled) return - runCatching { - cancelableContinuation.resume(handleResponseMessage(msg) as T) - }.onFailure { - cancelableContinuation.cancel(it) - } - } - }) - - runCatching { - with(Message.obtain()) { - what = messageType.value - replyTo = replyMessenger - data = Bundle() - message.write(data) - messenger.send(this) - } - }.onFailure { - cancelableContinuation.cancel(it) - } - } - } - - override fun createAndReadFile( - fileType: BridgeFileType, - defaultContent: ByteArray - ): ByteArray { - sendMessage( - BridgeMessageType.FILE_ACCESS_REQUEST, - FileAccessRequest(FileAccessRequest.FileAccessAction.EXISTS, fileType, null), - FileAccessResult::class - ).run { - if (state!!) { - return readFile(fileType) - } - writeFile(fileType, defaultContent) - return defaultContent - } - } - - override fun readFile(fileType: BridgeFileType): ByteArray { - sendMessage( - BridgeMessageType.FILE_ACCESS_REQUEST, - FileAccessRequest(FileAccessRequest.FileAccessAction.READ, fileType, null), - FileAccessResult::class - ).run { - return content!! - } - } - - override fun writeFile( - fileType: BridgeFileType, - content: ByteArray? - ): Boolean { - sendMessage( - BridgeMessageType.FILE_ACCESS_REQUEST, - FileAccessRequest(FileAccessRequest.FileAccessAction.WRITE, fileType, content), - FileAccessResult::class - ).run { - return state!! - } - } - - override fun deleteFile(fileType: BridgeFileType): Boolean { - sendMessage( - BridgeMessageType.FILE_ACCESS_REQUEST, - FileAccessRequest(FileAccessRequest.FileAccessAction.DELETE, fileType, null), - FileAccessResult::class - ).run { - return state!! - } - } - - - override fun isFileExists(fileType: BridgeFileType): Boolean { - sendMessage( - BridgeMessageType.FILE_ACCESS_REQUEST, - FileAccessRequest(FileAccessRequest.FileAccessAction.EXISTS, fileType, null), - FileAccessResult::class - ).run { - return state!! - } - } - - override fun downloadContent(url: String, path: String): Boolean { - sendMessage( - BridgeMessageType.DOWNLOAD_CONTENT_REQUEST, - DownloadContentRequest(url, path), - DownloadContentResult::class - ).run { - return state!! - } - } - - override fun getLoggedMessageIds(conversationId: String, limit: Int): List<Long> { - sendMessage( - BridgeMessageType.MESSAGE_LOGGER_REQUEST, - MessageLoggerRequest(MessageLoggerRequest.Action.LIST_IDS, conversationId, limit.toLong()), - MessageLoggerListResult::class - ).run { - return messages!! - } - } - - override fun getMessageLoggerMessage(conversationId: String, id: Long): ByteArray? { - sendMessage( - BridgeMessageType.MESSAGE_LOGGER_REQUEST, - MessageLoggerRequest(MessageLoggerRequest.Action.GET, conversationId, id), - MessageLoggerResult::class - ).run { - return message - } - } - - override fun addMessageLoggerMessage(conversationId: String,id: Long, message: ByteArray) { - sendMessage( - BridgeMessageType.MESSAGE_LOGGER_REQUEST, - MessageLoggerRequest(MessageLoggerRequest.Action.ADD, conversationId, id, message), - MessageLoggerResult::class - ) - } - - override fun deleteMessageLoggerMessage(conversationId: String,id: Long) { - sendMessage( - BridgeMessageType.MESSAGE_LOGGER_REQUEST, - MessageLoggerRequest(MessageLoggerRequest.Action.DELETE, conversationId, id), - MessageLoggerResult::class - ) - } - - override fun clearMessageLogger() { - sendMessage( - BridgeMessageType.MESSAGE_LOGGER_REQUEST, - MessageLoggerRequest(MessageLoggerRequest.Action.CLEAR), - MessageLoggerResult::class - ) - } - - override fun fetchTranslations(): LocaleResult { - sendMessage( - BridgeMessageType.LOCALE_REQUEST, - LocaleRequest(), - LocaleResult::class - ).run { - return this - } - } - - override fun getAutoUpdaterTime(): Long { - createAndReadFile(BridgeFileType.AUTO_UPDATER_TIMESTAMP, "0".toByteArray()).run { - return if (isEmpty()) { - 0 - } else { - String(this).toLong() - } - } - } - - override fun setAutoUpdaterTime(time: Long) { - writeFile(BridgeFileType.AUTO_UPDATER_TIMESTAMP, time.toString().toByteArray()) - } - - override fun onServiceConnected(name: ComponentName, service: IBinder) { - messenger = Messenger(service) - future.complete(true) - } - - override fun onNullBinding(name: ComponentName) { - xposedLog("failed to connect to bridge service") - future.complete(false) - } - - override fun onServiceDisconnected(name: ComponentName) { - exitProcess(0) - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/BridgeMessage.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/BridgeMessage.kt deleted file mode 100644 index e12e59d79d..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/BridgeMessage.kt +++ /dev/null @@ -1,16 +0,0 @@ -package me.rhunk.snapenhance.bridge.common - -import android.os.Bundle -import android.os.Message - -abstract class BridgeMessage { - abstract fun write(bundle: Bundle) - abstract fun read(bundle: Bundle) - - fun toMessage(what: Int): Message { - val message = Message.obtain(null, what) - message.data = Bundle() - write(message.data) - return message - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/BridgeMessageType.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/BridgeMessageType.kt deleted file mode 100644 index 62a8432d53..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/BridgeMessageType.kt +++ /dev/null @@ -1,23 +0,0 @@ -package me.rhunk.snapenhance.bridge.common - - -enum class BridgeMessageType( - val value: Int = 0 -) { - UNKNOWN(-1), - FILE_ACCESS_REQUEST(0), - FILE_ACCESS_RESULT(1), - DOWNLOAD_CONTENT_REQUEST(2), - DOWNLOAD_CONTENT_RESULT(3), - LOCALE_REQUEST(4), - LOCALE_RESULT(5), - MESSAGE_LOGGER_REQUEST(6), - MESSAGE_LOGGER_RESULT(7), - MESSAGE_LOGGER_LIST_RESULT(8); - - companion object { - fun fromValue(value: Int): BridgeMessageType { - return values().firstOrNull { it.value == value } ?: UNKNOWN - } - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/download/DownloadContentRequest.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/download/DownloadContentRequest.kt deleted file mode 100644 index e1854a0d14..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/download/DownloadContentRequest.kt +++ /dev/null @@ -1,20 +0,0 @@ -package me.rhunk.snapenhance.bridge.common.impl.download - -import android.os.Bundle -import me.rhunk.snapenhance.bridge.common.BridgeMessage - -class DownloadContentRequest( - var url: String? = null, - var path: String? = null -) : BridgeMessage() { - - override fun write(bundle: Bundle) { - bundle.putString("url", url) - bundle.putString("path", path) - } - - override fun read(bundle: Bundle) { - url = bundle.getString("url") - path = bundle.getString("path") - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/download/DownloadContentResult.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/download/DownloadContentResult.kt deleted file mode 100644 index 4c664f9033..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/download/DownloadContentResult.kt +++ /dev/null @@ -1,17 +0,0 @@ -package me.rhunk.snapenhance.bridge.common.impl.download - -import android.os.Bundle -import me.rhunk.snapenhance.bridge.common.BridgeMessage - -class DownloadContentResult( - var state: Boolean? = null -) : BridgeMessage() { - - override fun write(bundle: Bundle) { - bundle.putBoolean("state", state!!) - } - - override fun read(bundle: Bundle) { - state = bundle.getBoolean("state") - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/file/BridgeFileType.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/file/BridgeFileType.kt deleted file mode 100644 index 54f3437d43..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/file/BridgeFileType.kt +++ /dev/null @@ -1,18 +0,0 @@ -package me.rhunk.snapenhance.bridge.common.impl.file - - -enum class BridgeFileType(val value: Int, val fileName: String, val isDatabase: Boolean = false) { - CONFIG(0, "config.json"), - MAPPINGS(1, "mappings.json"), - MESSAGE_LOGGER_DATABASE(2, "message_logger.db", true), - STEALTH(3, "stealth.txt"), - ANTI_AUTO_DOWNLOAD(4, "anti_auto_download.txt"), - ANTI_AUTO_SAVE(5, "anti_auto_save.txt"), - AUTO_UPDATER_TIMESTAMP(6, "auto_updater_timestamp.txt"); - - companion object { - fun fromValue(value: Int): BridgeFileType? { - return values().firstOrNull { it.value == value } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/file/FileAccessRequest.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/file/FileAccessRequest.kt deleted file mode 100644 index 33f81ef438..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/file/FileAccessRequest.kt +++ /dev/null @@ -1,33 +0,0 @@ -package me.rhunk.snapenhance.bridge.common.impl.file - -import android.os.Bundle -import me.rhunk.snapenhance.bridge.common.BridgeMessage - -class FileAccessRequest( - var action: FileAccessAction? = null, - var fileType: BridgeFileType? = null, - var content: ByteArray? = null -) : BridgeMessage() { - - override fun write(bundle: Bundle) { - bundle.putInt("action", action!!.value) - bundle.putInt("fileType", fileType!!.value) - bundle.putByteArray("content", content) - } - - override fun read(bundle: Bundle) { - action = FileAccessAction.fromValue(bundle.getInt("action")) - fileType = BridgeFileType.fromValue(bundle.getInt("fileType")) - content = bundle.getByteArray("content") - } - - enum class FileAccessAction(val value: Int) { - READ(0), WRITE(1), DELETE(2), EXISTS(3); - - companion object { - fun fromValue(value: Int): FileAccessAction? { - return values().firstOrNull { it.value == value } - } - } - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/file/FileAccessResult.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/file/FileAccessResult.kt deleted file mode 100644 index fa1d910489..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/file/FileAccessResult.kt +++ /dev/null @@ -1,20 +0,0 @@ -package me.rhunk.snapenhance.bridge.common.impl.file - -import android.os.Bundle -import me.rhunk.snapenhance.bridge.common.BridgeMessage - -class FileAccessResult( - var state: Boolean? = null, - var content: ByteArray? = null -) : BridgeMessage() { - - override fun write(bundle: Bundle) { - bundle.putBoolean("state", state!!) - bundle.putByteArray("content", content) - } - - override fun read(bundle: Bundle) { - state = bundle.getBoolean("state") - content = bundle.getByteArray("content") - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/locale/LocaleRequest.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/locale/LocaleRequest.kt deleted file mode 100644 index cfeb4c1f0d..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/locale/LocaleRequest.kt +++ /dev/null @@ -1,12 +0,0 @@ -package me.rhunk.snapenhance.bridge.common.impl.locale - -import android.os.Bundle -import me.rhunk.snapenhance.bridge.common.BridgeMessage - -class LocaleRequest() : BridgeMessage() { - override fun write(bundle: Bundle) { - } - - override fun read(bundle: Bundle) { - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/locale/LocaleResult.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/locale/LocaleResult.kt deleted file mode 100644 index ba587e319c..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/locale/LocaleResult.kt +++ /dev/null @@ -1,19 +0,0 @@ -package me.rhunk.snapenhance.bridge.common.impl.locale - -import android.os.Bundle -import me.rhunk.snapenhance.bridge.common.BridgeMessage - -class LocaleResult( - var locale: String? = null, - var content: ByteArray? = null -) : BridgeMessage(){ - override fun write(bundle: Bundle) { - bundle.putString("locale", locale) - bundle.putByteArray("content", content) - } - - override fun read(bundle: Bundle) { - locale = bundle.getString("locale") - content = bundle.getByteArray("content") - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/messagelogger/MessageLoggerListResult.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/messagelogger/MessageLoggerListResult.kt deleted file mode 100644 index 1c879fee66..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/messagelogger/MessageLoggerListResult.kt +++ /dev/null @@ -1,18 +0,0 @@ -package me.rhunk.snapenhance.bridge.common.impl.messagelogger - -import android.os.Bundle -import me.rhunk.snapenhance.bridge.common.BridgeMessage - - -class MessageLoggerListResult( - var messages: List<Long>? = null -) : BridgeMessage() { - - override fun write(bundle: Bundle) { - bundle.putLongArray("messages", messages!!.map { it }.toLongArray()) - } - - override fun read(bundle: Bundle) { - messages = bundle.getLongArray("messages")?.toList() ?: emptyList() - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/messagelogger/MessageLoggerRequest.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/messagelogger/MessageLoggerRequest.kt deleted file mode 100644 index 0125307146..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/messagelogger/MessageLoggerRequest.kt +++ /dev/null @@ -1,34 +0,0 @@ -package me.rhunk.snapenhance.bridge.common.impl.messagelogger - -import android.os.Bundle -import me.rhunk.snapenhance.bridge.common.BridgeMessage - -class MessageLoggerRequest( - var action: Action? = null, - var conversationId: String? = null, - var index: Long? = null, - var message: ByteArray? = null -) : BridgeMessage(){ - - override fun write(bundle: Bundle) { - bundle.putString("action", action!!.name) - bundle.putString("conversationId", conversationId) - bundle.putLong("messageId", index ?: 0) - bundle.putByteArray("message", message) - } - - override fun read(bundle: Bundle) { - action = Action.valueOf(bundle.getString("action")!!) - conversationId = bundle.getString("conversationId") - index = bundle.getLong("messageId") - message = bundle.getByteArray("message") - } - - enum class Action { - ADD, - GET, - CLEAR, - DELETE, - LIST_IDS - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/messagelogger/MessageLoggerResult.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/messagelogger/MessageLoggerResult.kt deleted file mode 100644 index f88e2b5ddd..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/messagelogger/MessageLoggerResult.kt +++ /dev/null @@ -1,20 +0,0 @@ -package me.rhunk.snapenhance.bridge.common.impl.messagelogger - -import android.os.Bundle -import me.rhunk.snapenhance.bridge.common.BridgeMessage - -class MessageLoggerResult( - var state: Boolean? = null, - var message: ByteArray? = null -) : BridgeMessage() { - - override fun write(bundle: Bundle) { - bundle.putBoolean("state", state!!) - bundle.putByteArray("message", message) - } - - override fun read(bundle: Bundle) { - state = bundle.getBoolean("state") - message = bundle.getByteArray("message") - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/service/BridgeService.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/service/BridgeService.kt deleted file mode 100644 index f72272e1fc..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/service/BridgeService.kt +++ /dev/null @@ -1,184 +0,0 @@ -package me.rhunk.snapenhance.bridge.service - -import android.annotation.SuppressLint -import android.app.DownloadManager -import android.app.Service -import android.content.* -import android.net.Uri -import android.os.* -import me.rhunk.snapenhance.Logger -import me.rhunk.snapenhance.bridge.MessageLoggerWrapper -import me.rhunk.snapenhance.bridge.common.BridgeMessageType -import me.rhunk.snapenhance.bridge.common.impl.* -import me.rhunk.snapenhance.bridge.common.impl.download.DownloadContentRequest -import me.rhunk.snapenhance.bridge.common.impl.download.DownloadContentResult -import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType -import me.rhunk.snapenhance.bridge.common.impl.file.FileAccessRequest -import me.rhunk.snapenhance.bridge.common.impl.file.FileAccessResult -import me.rhunk.snapenhance.bridge.common.impl.locale.LocaleRequest -import me.rhunk.snapenhance.bridge.common.impl.locale.LocaleResult -import me.rhunk.snapenhance.bridge.common.impl.messagelogger.MessageLoggerListResult -import me.rhunk.snapenhance.bridge.common.impl.messagelogger.MessageLoggerRequest -import me.rhunk.snapenhance.bridge.common.impl.messagelogger.MessageLoggerResult -import java.io.File -import java.util.* - -class BridgeService : Service() { - private lateinit var messageLoggerWrapper: MessageLoggerWrapper - - override fun onBind(intent: Intent): IBinder { - messageLoggerWrapper = MessageLoggerWrapper(getDatabasePath(BridgeFileType.MESSAGE_LOGGER_DATABASE.fileName)).also { it.init() } - - return Messenger(object : Handler(Looper.getMainLooper()) { - override fun handleMessage(msg: Message) { - runCatching { - this@BridgeService.handleMessage(msg) - }.onFailure { - Logger.error("Failed to handle message", it) - } - } - }).binder - } - - private fun handleMessage(msg: Message) { - val replyMessenger = msg.replyTo - when (BridgeMessageType.fromValue(msg.what)) { - BridgeMessageType.FILE_ACCESS_REQUEST -> { - with(FileAccessRequest()) { - read(msg.data) - handleFileAccess(this) { message -> - replyMessenger.send(message) - } - } - } - BridgeMessageType.DOWNLOAD_CONTENT_REQUEST -> { - with(DownloadContentRequest()) { - read(msg.data) - handleDownloadContent(this) { message -> - replyMessenger.send(message) - } - } - } - BridgeMessageType.LOCALE_REQUEST -> { - with(LocaleRequest()) { - read(msg.data) - handleLocaleRequest { message -> - replyMessenger.send(message) - } - } - } - BridgeMessageType.MESSAGE_LOGGER_REQUEST -> { - with(MessageLoggerRequest()) { - read(msg.data) - handleMessageLoggerRequest(this) { message -> - replyMessenger.send(message) - } - } - } - - else -> Logger.log("Unknown message type: " + msg.what) - } - } - - private fun handleMessageLoggerRequest(msg: MessageLoggerRequest, reply: (Message) -> Unit) { - when (msg.action) { - MessageLoggerRequest.Action.ADD -> { - val isSuccess = messageLoggerWrapper.addMessage(msg.conversationId!!, msg.index!!, msg.message!!) - reply(MessageLoggerResult(isSuccess).toMessage(BridgeMessageType.MESSAGE_LOGGER_RESULT.value)) - return - } - MessageLoggerRequest.Action.CLEAR -> { - messageLoggerWrapper.clearMessages() - } - MessageLoggerRequest.Action.DELETE -> { - messageLoggerWrapper.deleteMessage(msg.conversationId!!, msg.index!!) - } - MessageLoggerRequest.Action.GET -> { - val (state, messageData) = messageLoggerWrapper.getMessage(msg.conversationId!!, msg.index!!) - reply(MessageLoggerResult(state, messageData).toMessage(BridgeMessageType.MESSAGE_LOGGER_RESULT.value)) - } - MessageLoggerRequest.Action.LIST_IDS -> { - val messageIds = messageLoggerWrapper.getMessageIds(msg.conversationId!!, msg.index!!.toInt()) - reply(MessageLoggerListResult(messageIds).toMessage(BridgeMessageType.MESSAGE_LOGGER_LIST_RESULT.value)) - return - } - else -> { - Logger.log(Exception("Unknown message logger action: ${msg.action}")) - } - } - - reply(MessageLoggerResult(true).toMessage(BridgeMessageType.MESSAGE_LOGGER_RESULT.value)) - } - - private fun handleLocaleRequest(reply: (Message) -> Unit) { - val deviceLocale = Locale.getDefault().toString() - val compatibleLocale = resources.assets.list("lang")?.find { it.startsWith(deviceLocale) }?.substring(0, 5) ?: "en_US" - - resources.assets.open("lang/$compatibleLocale.json").use { inputStream -> - reply(LocaleResult(compatibleLocale, inputStream.readBytes()).toMessage(BridgeMessageType.LOCALE_RESULT.value)) - } - } - - @SuppressLint("UnspecifiedRegisterReceiverFlag") - private fun handleDownloadContent(msg: DownloadContentRequest, reply: (Message) -> Unit) { - if (!msg.url!!.startsWith("http://127.0.0.1:")) return - - val outputFile = File(msg.path!!) - outputFile.parentFile?.let { - if (!it.exists()) it.mkdirs() - } - val downloadManager = getSystemService(DOWNLOAD_SERVICE) as DownloadManager - val request = DownloadManager.Request(Uri.parse(msg.url)) - .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE) - .setAllowedOverMetered(true) - .setAllowedOverRoaming(true) - .setDestinationUri(Uri.fromFile(outputFile)) - val downloadId = downloadManager.enqueue(request) - registerReceiver(object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) != downloadId) return - unregisterReceiver(this) - reply(DownloadContentResult(true).toMessage(BridgeMessageType.DOWNLOAD_CONTENT_RESULT.value)) - } - }, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)) - } - - private fun handleFileAccess(msg: FileAccessRequest, reply: (Message) -> Unit) { - val fileFolder = if (msg.fileType!!.isDatabase) { - File(dataDir, "databases") - } else { - File(filesDir.absolutePath) - } - val requestFile = File(fileFolder, msg.fileType!!.fileName) - - val result: FileAccessResult = when (msg.action) { - FileAccessRequest.FileAccessAction.READ -> { - if (!requestFile.exists()) { - FileAccessResult(false, null) - } else { - FileAccessResult(true, requestFile.readBytes()) - } - } - FileAccessRequest.FileAccessAction.WRITE -> { - if (!requestFile.exists()) { - requestFile.createNewFile() - } - requestFile.writeBytes(msg.content!!) - FileAccessResult(true, null) - } - FileAccessRequest.FileAccessAction.DELETE -> { - if (!requestFile.exists()) { - FileAccessResult(false, null) - } else { - requestFile.delete() - FileAccessResult(true, null) - } - } - FileAccessRequest.FileAccessAction.EXISTS -> FileAccessResult(requestFile.exists(), null) - else -> throw Exception("Unknown action: " + msg.action) - } - - reply(result.toMessage(BridgeMessageType.FILE_ACCESS_RESULT.value)) - } - -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/service/MainActivity.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/service/MainActivity.kt deleted file mode 100644 index b5a0c2d01b..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/service/MainActivity.kt +++ /dev/null @@ -1,17 +0,0 @@ -package me.rhunk.snapenhance.bridge.service - -import android.app.Activity -import android.content.Intent -import android.os.Bundle -import me.rhunk.snapenhance.Constants - -class MainActivity : Activity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - packageManager.getLaunchIntentForPackage(Constants.SNAPCHAT_PACKAGE_NAME)?.apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - startActivity(this) - } - finish() - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/config/ConfigAccessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/config/ConfigAccessor.kt deleted file mode 100644 index 2361bab598..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/config/ConfigAccessor.kt +++ /dev/null @@ -1,62 +0,0 @@ -package me.rhunk.snapenhance.config - -open class ConfigAccessor( - private val configMap: MutableMap<ConfigProperty, ConfigValue<*>> = mutableMapOf() -) { - fun bool(key: ConfigProperty): Boolean { - return get(key).value() as Boolean - } - - fun int(key: ConfigProperty): Int { - return get(key).value() as Int - } - - fun string(key: ConfigProperty): String { - return get(key).value() as String - } - - fun double(key: ConfigProperty): Double { - return get(key).value() as Double - } - - fun float(key: ConfigProperty): Float { - return get(key).value() as Float - } - - fun long(key: ConfigProperty): Long { - return get(key).value() as Long - } - - fun short(key: ConfigProperty): Short { - return get(key).value() as Short - } - - fun byte(key: ConfigProperty): Byte { - return get(key).value() as Byte - } - - fun char(key: ConfigProperty): Char { - return get(key).value() as Char - } - - @Suppress("UNCHECKED_CAST") - fun options(key: ConfigProperty): Map<String, Boolean> { - return get(key).value() as Map<String, Boolean> - } - - fun state(key: ConfigProperty): String { - return get(key).value() as String - } - - fun get(key: ConfigProperty): ConfigValue<*> { - return configMap[key]!! - } - - fun set(key: ConfigProperty, value: ConfigValue<*>) { - configMap[key] = value - } - - fun entries(): Set<Map.Entry<ConfigProperty, ConfigValue<*>>> { - return configMap.entries - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/config/ConfigCategory.kt b/app/src/main/kotlin/me/rhunk/snapenhance/config/ConfigCategory.kt deleted file mode 100644 index f435bdb537..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/config/ConfigCategory.kt +++ /dev/null @@ -1,12 +0,0 @@ -package me.rhunk.snapenhance.config - -enum class ConfigCategory( - val key: String -) { - SPYING_PRIVACY("category.spying_privacy"), - MEDIA_MANAGEMENT("category.media_manager"), - UI_TWEAKS("category.ui_tweaks"), - UPDATES("category.updates"), - CAMERA("category.camera"), - EXPERIMENTAL_DEBUGGING("category.experimental_debugging"); -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/config/ConfigProperty.kt b/app/src/main/kotlin/me/rhunk/snapenhance/config/ConfigProperty.kt deleted file mode 100644 index cb1440800d..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/config/ConfigProperty.kt +++ /dev/null @@ -1,362 +0,0 @@ -package me.rhunk.snapenhance.config - -import android.os.Environment -import me.rhunk.snapenhance.config.impl.ConfigIntegerValue -import me.rhunk.snapenhance.config.impl.ConfigStateListValue -import me.rhunk.snapenhance.config.impl.ConfigStateSelection -import me.rhunk.snapenhance.config.impl.ConfigStateValue -import me.rhunk.snapenhance.config.impl.ConfigStringValue -import me.rhunk.snapenhance.features.impl.tweaks.CameraTweaks -import java.io.File - -enum class ConfigProperty( - val nameKey: String, - val descriptionKey: String, - val category: ConfigCategory, - val valueContainer: ConfigValue<*>, - val shouldAppearInSettings: Boolean = true, - val disableValueLocalization: Boolean = false -) { - - //SPYING AND PRIVACY - MESSAGE_LOGGER("property.message_logger", - "description.message_logger", - ConfigCategory.SPYING_PRIVACY, - ConfigStateValue(false) - ), - PREVENT_READ_RECEIPTS( - "property.prevent_read_receipts", - "description.prevent_read_receipts", - ConfigCategory.SPYING_PRIVACY, - ConfigStateValue(false) - ), - HIDE_BITMOJI_PRESENCE( - "property.hide_bitmoji_presence", - "description.hide_bitmoji_presence", - ConfigCategory.SPYING_PRIVACY, - ConfigStateValue(false) - ), - BETTER_NOTIFICATIONS( - "property.better_notifications", - "description.better_notifications", - ConfigCategory.SPYING_PRIVACY, - ConfigStateListValue( - listOf("snap", "chat", "reply_button"), - mutableMapOf( - "snap" to false, - "chat" to false, - "reply_button" to false - ) - ) - ), - NOTIFICATION_BLACKLIST( - "property.notification_blacklist", - "description.notification_blacklist", - ConfigCategory.SPYING_PRIVACY, - ConfigStateListValue( - listOf("snap", "chat", "typing"), - mutableMapOf( - "snap" to false, - "chat" to false, - "typing" to false - ) - ) - ), - DISABLE_METRICS("property.disable_metrics", - "description.disable_metrics", - ConfigCategory.SPYING_PRIVACY, - ConfigStateValue(false) - ), - BLOCK_ADS("property.block_ads", - "description.block_ads", - ConfigCategory.SPYING_PRIVACY, - ConfigStateValue(false) - ), - UNLIMITED_SNAP_VIEW_TIME("property.unlimited_snap_view_time", - "description.unlimited_snap_view_time", - ConfigCategory.SPYING_PRIVACY, - ConfigStateValue(false) - ), - PREVENT_SCREENSHOT_NOTIFICATIONS( - "property.prevent_screenshot_notifications", - "description.prevent_screenshot_notifications", - ConfigCategory.SPYING_PRIVACY, - ConfigStateValue(false) - ), - PREVENT_STATUS_NOTIFICATIONS( - "property.prevent_status_notifications", - "description.prevent_status_notifications", - ConfigCategory.SPYING_PRIVACY, - ConfigStateValue(false) - ), - ANONYMOUS_STORY_VIEW( - "property.anonymous_story_view", - "description.anonymous_story_view", - ConfigCategory.SPYING_PRIVACY, - ConfigStateValue(false) - ), - HIDE_TYPING_NOTIFICATION( - "property.hide_typing_notification", - "description.hide_typing_notification", - ConfigCategory.SPYING_PRIVACY, - ConfigStateValue(false) - ), - - //MEDIA MANAGEMENT - SAVE_FOLDER( - "property.save_folder", "description.save_folder", ConfigCategory.MEDIA_MANAGEMENT, - ConfigStringValue(File( - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).absolutePath + "/Snapchat", - "SnapEnhance" - ).absolutePath) - ), - AUTO_DOWNLOAD_OPTIONS( - "property.auto_download_options", "description.auto_download_options", ConfigCategory.MEDIA_MANAGEMENT, - ConfigStateListValue( - listOf("friend_snaps", "friend_stories", "public_stories", "spotlight"), - mutableMapOf( - "friend_snaps" to false, - "friend_stories" to false, - "public_stories" to false, - "spotlight" to false - ) - ) - ), - DOWNLOAD_OPTIONS( - "property.download_options", "description.download_options", ConfigCategory.MEDIA_MANAGEMENT, - ConfigStateListValue( - listOf("format_user_folder", "format_hash", "format_date_time", "format_username", "merge_overlay"), - mutableMapOf( - "format_user_folder" to true, - "format_hash" to true, - "format_date_time" to true, - "format_username" to false, - "merge_overlay" to false, - ) - ) - ), - CHAT_DOWNLOAD_CONTEXT_MENU( - "property.chat_download_context_menu", - "description.chat_download_context_menu", - ConfigCategory.MEDIA_MANAGEMENT, - ConfigStateValue(false) - ), - DOWNLOAD_BLACKLIST( - "property.auto_download_blacklist", - "description.auto_download_blacklist", - ConfigCategory.MEDIA_MANAGEMENT, - ConfigStateValue(false) - ), - GALLERY_MEDIA_SEND_OVERRIDE( - "property.gallery_media_send_override", - "description.gallery_media_send_override", - ConfigCategory.MEDIA_MANAGEMENT, - ConfigStateSelection( - listOf("OFF", "NOTE", "SNAP", "LIVE_SNAP"), - "OFF" - ) - ), - AUTO_SAVE_MESSAGES("property.auto_save_messages", - "description.auto_save_messages", - ConfigCategory.MEDIA_MANAGEMENT, - ConfigStateListValue( - listOf("CHAT", "SNAP", "NOTE", "EXTERNAL_MEDIA", "STICKER") - ) - ), - ANTI_AUTO_SAVE("property.anti_auto_save", - "description.anti_auto_save", - ConfigCategory.MEDIA_MANAGEMENT, - ConfigStateValue(false) - ), - - FORCE_MEDIA_SOURCE_QUALITY( - "property.force_media_source_quality", - "description.force_media_source_quality", - ConfigCategory.MEDIA_MANAGEMENT, - ConfigStateValue(false) - ), - - //UI AND TWEAKS - CAMERA_DISABLE( - "property.disable_camera", - "description.disable_camera", - ConfigCategory.UI_TWEAKS, - ConfigStateValue(false) - ), - HIDE_UI_ELEMENTS( - "property.hide_ui_elements", - "description.hide_ui_elements", - ConfigCategory.UI_TWEAKS, - ConfigStateListValue( - listOf("remove_voice_record_button", "remove_stickers_button", "remove_cognac_button", "remove_call_buttons", "remove_camera_borders"), - mutableMapOf( - "remove_voice_record_button" to false, - "remove_stickers_button" to false, - "remove_cognac_button" to false, - "remove_call_buttons" to false, - "remove_camera_borders" to false - ) - ) - ), - STREAK_EXPIRATION_INFO( - "property.streak_expiration_info", - "description.streakexpirationinfo", - ConfigCategory.UI_TWEAKS, - ConfigStateValue(false) - ), - DISABLE_SNAP_SPLITTING( - "property.disable_snap_splitting", - "description.disable_snap_splitting", - ConfigCategory.UI_TWEAKS, - ConfigStateValue(false) - ), - DISABLE_VIDEO_LENGTH_RESTRICTION( - "property.disable_video_length_restriction", - "description.disable_video_length_restriction", - ConfigCategory.UI_TWEAKS, - ConfigStateValue(false) - ), - SNAPCHAT_PLUS("property.snapchat_plus", - "description.snapchat_plus", - ConfigCategory.UI_TWEAKS, - ConfigStateValue(false) - ), - NEW_MAP_UI("property.new_map_ui", - "description.new_map_ui", - ConfigCategory.UI_TWEAKS, - ConfigStateValue(false) - ), - LOCATION_SPOOF( - "property.location_spoof", - "description.location_spoof", - ConfigCategory.UI_TWEAKS, - ConfigStateValue(false) - ), - LATITUDE( - "property.latitude_value", - "description.latitude_value", - ConfigCategory.UI_TWEAKS, - ConfigStringValue("0.0000"), - shouldAppearInSettings = false - ), - LONGITUDE( - "property.longitude_value", - "description.longitude_value", - ConfigCategory.UI_TWEAKS, - ConfigStringValue("0.0000"), - shouldAppearInSettings = false - ), - MENU_SLOT_ID("property.menu_slot_id", - "description.menu_slot_id", - ConfigCategory.UI_TWEAKS, - ConfigIntegerValue(1) - ), - MESSAGE_PREVIEW_LENGTH( - "property.message_preview_length", - "description.message_preview_length", - ConfigCategory.UI_TWEAKS, - ConfigIntegerValue(20) - ), - DISABLE_SPOTLIGHT( - "property.disable_spotlight", - "description.disable_spotlight", - ConfigCategory.UI_TWEAKS, - ConfigStateValue(false) - ), - ENABLE_APP_APPEARANCE( - "property.enable_app_appearance", - "description.enable_app_appearance", - ConfigCategory.UI_TWEAKS, - ConfigStateValue(false) - ), - - - //CAMERA - OVERRIDE_PREVIEW_RESOLUTION( - "property.preview_resolution", - "description.preview_resolution", - ConfigCategory.CAMERA, - ConfigStateSelection( - CameraTweaks.resolutions, - "OFF" - ), - disableValueLocalization = true - ), - OVERRIDE_PICTURE_RESOLUTION( - "property.picture_resolution", - "description.picture_resolution", - ConfigCategory.CAMERA, - ConfigStateSelection( - CameraTweaks.resolutions, - "OFF" - ), - disableValueLocalization = true - ), - FORCE_HIGHEST_FRAME_RATE( - "property.force_highest_frame_rate", - "description.force_highest_frame_rate", - ConfigCategory.CAMERA, - ConfigStateValue(false) - ), - FORCE_CAMERA_SOURCE_ENCODING( - "property.force_camera_source_encoding", - "description.force_camera_source_encoding", - ConfigCategory.CAMERA, - ConfigStateValue(false) - ), - - // UPDATES - AUTO_UPDATER( - "property.auto_updater", - "description.auto_updater", - ConfigCategory.UPDATES, - ConfigStateSelection( - listOf("DISABLED", "EVERY_LAUNCH", "DAILY", "WEEKLY"), - "DAILY" - ) - ), - - // EXPERIMENTAL DEBUGGING - USE_DOWNLOAD_MANAGER( - "property.use_download_manager", - "description.use_download_manager", - ConfigCategory.EXPERIMENTAL_DEBUGGING, - ConfigStateValue(false) - ), - APP_PASSCODE( - "property.app_passcode", - "description.app_passcode", - ConfigCategory.EXPERIMENTAL_DEBUGGING, - ConfigStringValue("", isHidden = true) - ), - APP_LOCK_ON_RESUME( - "property.app_lock_on_resume", - "description.app_lock_on_resume", - ConfigCategory.EXPERIMENTAL_DEBUGGING, - ConfigStateValue(false) - ), - INFINITE_STORY_BOOST( - "property.infinite_story_boost", - "description.infinite_story_boost", - ConfigCategory.EXPERIMENTAL_DEBUGGING, - ConfigStateValue(false) - ), - MEO_PASSCODE_BYPASS( - "property.meo_passcode_bypass", - "description.meo_passcode_bypass", - ConfigCategory.EXPERIMENTAL_DEBUGGING, - ConfigStateValue(false) - ), - AMOLED_DARK_MODE( - "property.amoled_dark_mode", - "description.amoled_dark_mode", - ConfigCategory.EXPERIMENTAL_DEBUGGING, - ConfigStateValue(false) - ); - - companion object { - fun sortedByCategory(): List<ConfigProperty> { - return values().sortedBy { it.category.ordinal } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/config/ConfigValue.kt b/app/src/main/kotlin/me/rhunk/snapenhance/config/ConfigValue.kt deleted file mode 100644 index e12c8f6cac..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/config/ConfigValue.kt +++ /dev/null @@ -1,22 +0,0 @@ -package me.rhunk.snapenhance.config - -abstract class ConfigValue<T> { - private val propertyChangeListeners = mutableListOf<(T) -> Unit>() - - fun addPropertyChangeListener(listener: (T) -> Unit) = propertyChangeListeners.add(listener) - fun removePropertyChangeListener(listener: (T) -> Unit) = propertyChangeListeners.remove(listener) - - abstract fun value(): T - abstract fun read(): String - protected abstract fun write(value: String) - - protected fun onValueChanged() { - propertyChangeListeners.forEach { it(value()) } - } - - fun writeFrom(value: String) { - val oldValue = read() - write(value) - if (oldValue != value) onValueChanged() - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/config/impl/ConfigIntegerValue.kt b/app/src/main/kotlin/me/rhunk/snapenhance/config/impl/ConfigIntegerValue.kt deleted file mode 100644 index e02cac045d..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/config/impl/ConfigIntegerValue.kt +++ /dev/null @@ -1,17 +0,0 @@ -package me.rhunk.snapenhance.config.impl - -import me.rhunk.snapenhance.config.ConfigValue - -class ConfigIntegerValue( - private var value: Int -) : ConfigValue<Int>() { - override fun value() = value - - override fun read(): String { - return value.toString() - } - - override fun write(value: String) { - this.value = value.toInt() - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/config/impl/ConfigStateListValue.kt b/app/src/main/kotlin/me/rhunk/snapenhance/config/impl/ConfigStateListValue.kt deleted file mode 100644 index 005d9aa639..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/config/impl/ConfigStateListValue.kt +++ /dev/null @@ -1,32 +0,0 @@ -package me.rhunk.snapenhance.config.impl - -import me.rhunk.snapenhance.config.ConfigValue - -class ConfigStateListValue( - private val keys: List<String>, - private var states: MutableMap<String, Boolean> = mutableMapOf() -) : ConfigValue<Map<String, Boolean>>() { - override fun value() = states - - fun setKey(key: String, state: Boolean) { - states[key] = state - onValueChanged() - } - - operator fun get(key: String) = states[key] ?: false - - override fun read(): String { - return keys.joinToString("|") { "$it:${states[it]}" } - } - - override fun write(value: String) { - value.split("|").forEach { - val (key, state) = it.split(":") - states[key] = state.toBoolean() - } - } - - override fun toString(): String { - return states.filter { it.value }.keys.joinToString(", ") { it } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/config/impl/ConfigStateSelection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/config/impl/ConfigStateSelection.kt deleted file mode 100644 index 4f9d3f86dc..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/config/impl/ConfigStateSelection.kt +++ /dev/null @@ -1,22 +0,0 @@ -package me.rhunk.snapenhance.config.impl - -import me.rhunk.snapenhance.config.ConfigValue - -class ConfigStateSelection( - private val keys: List<String>, - private var state: String = "" -) : ConfigValue<String>() { - fun keys(): List<String> { - return keys - } - - override fun value() = state - - override fun read(): String { - return state - } - - override fun write(value: String) { - state = value - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/config/impl/ConfigStateValue.kt b/app/src/main/kotlin/me/rhunk/snapenhance/config/impl/ConfigStateValue.kt deleted file mode 100644 index ef8dfaea2b..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/config/impl/ConfigStateValue.kt +++ /dev/null @@ -1,17 +0,0 @@ -package me.rhunk.snapenhance.config.impl - -import me.rhunk.snapenhance.config.ConfigValue - -class ConfigStateValue( - private var value: Boolean -) : ConfigValue<Boolean>() { - override fun value() = value - - override fun read(): String { - return value.toString() - } - - override fun write(value: String) { - this.value = value.toBoolean() - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/config/impl/ConfigStringValue.kt b/app/src/main/kotlin/me/rhunk/snapenhance/config/impl/ConfigStringValue.kt deleted file mode 100644 index 359c564851..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/config/impl/ConfigStringValue.kt +++ /dev/null @@ -1,20 +0,0 @@ -package me.rhunk.snapenhance.config.impl - -import me.rhunk.snapenhance.config.ConfigValue - -class ConfigStringValue( - private var value: String = "", - val isHidden: Boolean = false -) : ConfigValue<String>() { - override fun value() = value - - fun hiddenValue() = if (isHidden) value.map { '*' }.joinToString("") else value - - override fun read(): String { - return value - } - - override fun write(value: String) { - this.value = value - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/FileType.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/FileType.kt deleted file mode 100644 index 0b8c506f3f..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/data/FileType.kt +++ /dev/null @@ -1,50 +0,0 @@ -package me.rhunk.snapenhance.data - -enum class FileType( - val fileExtension: String? = null, - val isVideo: Boolean = false, - val isImage: Boolean = false, - val isAudio: Boolean = false -) { - GIF("gif", false, false, false), - PNG("png", false, true, false), - MP4("mp4", true, false, false), - MP3("mp3", false, false, true), - JPG("jpg", false, true, false), - ZIP("zip", false, false, false), - WEBP("webp", false, true, false), - UNKNOWN("dat", false, false, false); - - companion object { - private val fileSignatures = HashMap<String, FileType>() - - init { - fileSignatures["52494646"] = WEBP - fileSignatures["504b0304"] = ZIP - fileSignatures["89504e47"] = PNG - fileSignatures["00000020"] = MP4 - fileSignatures["00000018"] = MP4 - fileSignatures["0000001c"] = MP4 - fileSignatures["ffd8ffe0"] = JPG - } - - fun fromString(string: String?): FileType { - return values().firstOrNull { it.fileExtension.equals(string, ignoreCase = true) } ?: UNKNOWN - } - - private fun bytesToHex(bytes: ByteArray): String { - val result = StringBuilder() - for (b in bytes) { - result.append(String.format("%02x", b)) - } - return result.toString() - } - - fun fromByteArray(array: ByteArray): FileType { - val headerBytes = ByteArray(16) - System.arraycopy(array, 0, headerBytes, 0, 16) - val hex = bytesToHex(headerBytes) - return fileSignatures.entries.firstOrNull { hex.startsWith(it.key) }?.value ?: UNKNOWN - } - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/MessageSender.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/MessageSender.kt deleted file mode 100644 index 4061461005..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/data/MessageSender.kt +++ /dev/null @@ -1,164 +0,0 @@ -package me.rhunk.snapenhance.data - -import me.rhunk.snapenhance.ModContext -import me.rhunk.snapenhance.data.wrapper.AbstractWrapper -import me.rhunk.snapenhance.data.wrapper.impl.MessageDestinations -import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID -import me.rhunk.snapenhance.features.impl.Messaging -import me.rhunk.snapenhance.util.CallbackBuilder -import me.rhunk.snapenhance.util.protobuf.ProtoWriter - -class MessageSender( - private val context: ModContext, -) { - companion object { - val redSnapProto: (Boolean) -> ByteArray = {hasAudio -> - ProtoWriter().apply { - write(11, 5) { - write(1) { - write(1) { - writeConstant(2, 0) - writeConstant(12, 0) - writeConstant(15, 0) - } - writeConstant(6, 0) - } - write(2) { - writeConstant(5, if (hasAudio) 1 else 0) - writeBuffer(6, byteArrayOf()) - } - } - }.toByteArray() - } - - val audioNoteProto: (Int) -> ByteArray = { duration -> - ProtoWriter().apply { - write(6, 1) { - write(1) { - writeConstant(2, 4) - write(5) { - writeConstant(1, 0) - writeConstant(2, 0) - } - writeConstant(7, 0) - writeConstant(13, duration) - } - } - }.toByteArray() - } - - } - - private val sendMessageCallback by lazy { context.mappings.getMappedClass("callbacks", "SendMessageCallback") } - - private val platformAnalyticsCreatorClass by lazy { - context.mappings.getMappedClass("PlatformAnalyticsCreator") - } - - private fun defaultPlatformAnalytics(): ByteArray { - val analyticsSource = platformAnalyticsCreatorClass.constructors[0].parameterTypes[0] - val chatAnalyticsSource = analyticsSource.enumConstants.first { it.toString() == "CHAT" } - - val platformAnalyticsDefaultArgs = arrayOf(chatAnalyticsSource, null, null, null, null, null, null, null, null, null, 0L, 0L, - null, null, false, null, null, 0L, null, null, false, null, null, - null, null, null, null, null, null, null, null, null, null, null, - null, null, null, null, null, null, false, null, null, false, 0L, -2, 8191) - - val platformAnalyticsInstance = platformAnalyticsCreatorClass.constructors[0].newInstance( - *platformAnalyticsDefaultArgs - ) ?: throw Exception("Failed to create platform analytics instance") - - return platformAnalyticsInstance.javaClass.declaredMethods.first { it.returnType == ByteArray::class.java } - .invoke(platformAnalyticsInstance) as ByteArray? - ?: throw Exception("Failed to get platform analytics content") - } - - private fun createLocalMessageContentTemplate( - contentType: ContentType, - messageContent: ByteArray, - localMediaReference: ByteArray? = null, - metricMessageMediaType: MetricsMessageMediaType = MetricsMessageMediaType.DERIVED_FROM_MESSAGE_TYPE, - metricsMediaType: MetricsMessageType = MetricsMessageType.TEXT, - savePolicy: String = "PROHIBITED", - ): String { - return """ - { - "mAllowsTranscription": false, - "mBotMention": false, - "mContent": [${messageContent.joinToString(",")}], - "mContentType": "${contentType.name}", - "mIncidentalAttachments": [], - "mLocalMediaReferences": [${ - if (localMediaReference != null) { - "{\"mId\": [${localMediaReference.joinToString(",")}]}" - } else { - "" - } - }], - "mPlatformAnalytics": { - "mAttemptId": { - "mId": [${(1..16).map { (-127 ..127).random() }.joinToString(",")}] - }, - "mContent": [${defaultPlatformAnalytics().joinToString(",")}], - "mMetricsMessageMediaType": "${metricMessageMediaType.name}", - "mMetricsMessageType": "${metricsMediaType.name}", - "mReactionSource": "NONE" - }, - "mSavePolicy": "$savePolicy" - } - """.trimIndent() - } - - private fun internalSendMessage(conversations: List<SnapUUID>, localMessageContentTemplate: String, callback: Any) { - val sendMessageWithContentMethod = context.classCache.conversationManager.declaredMethods.first { it.name == "sendMessageWithContent" } - - val localMessageContent = context.gson.fromJson(localMessageContentTemplate, context.classCache.localMessageContent) - val messageDestinations = MessageDestinations(AbstractWrapper.newEmptyInstance(context.classCache.messageDestinations)).also { - it.conversations = conversations - it.mPhoneNumbers = arrayListOf() - it.stories = arrayListOf() - } - - sendMessageWithContentMethod.invoke(context.feature(Messaging::class).conversationManager, messageDestinations.instanceNonNull(), localMessageContent, callback) - } - - //TODO: implement sendSnapMessage - /* - fun sendSnapMessage(conversations: List<SnapUUID>, chatMediaType: ChatMediaType, uri: Uri, onError: (Any) -> Unit = {}, onSuccess: () -> Unit = {}) { - val mediaReferenceBuffer = FlatBufferBuilder(0).apply { - val uriOffset = createString(uri.toString()) - forceDefaults(true) - startTable(2) - addOffset(1, uriOffset, 0) - addInt(0, chatMediaType.value, 0) - finish(endTable()) - finished() - }.sizedByteArray() - - internalSendMessage(conversations, createLocalMessageContentTemplate( - contentType = ContentType.SNAP, - messageContent = redSnapProto(chatMediaType == ChatMediaType.AUDIO || chatMediaType == ChatMediaType.VIDEO), - localMediaReference = mediaReferenceBuffer, - metricMessageMediaType = MetricsMessageMediaType.IMAGE, - metricsMediaType = MetricsMessageType.SNAP - ), CallbackBuilder(sendMessageCallback) - .override("onSuccess") { - onSuccess() - } - .override("onError") { - onError(it.arg(0)) - } - .build()) - }*/ - - fun sendChatMessage(conversations: List<SnapUUID>, message: String, onError: (Any) -> Unit = {}, onSuccess: () -> Unit = {}) { - internalSendMessage(conversations, createLocalMessageContentTemplate(ContentType.CHAT, ProtoWriter().apply { - write(2) { - writeString(1, message) - } - }.toByteArray(), savePolicy = "LIFETIME"), CallbackBuilder(sendMessageCallback) - .override("onSuccess", callback = { onSuccess() }) - .override("onError", callback = { onError(it.arg(0)) }) - .build()) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/SnapEnums.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/SnapEnums.kt deleted file mode 100644 index 3b3b02ff6b..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/data/SnapEnums.kt +++ /dev/null @@ -1,134 +0,0 @@ -package me.rhunk.snapenhance.data - -enum class MessageState { - PREPARING, SENDING, COMMITTED, FAILED, CANCELING -} - -enum class ChatMediaType ( - val value: Int -) { - IMAGE(0), - VIDEO(1), - VIDEO_NO_SOUND(2), - FRIEND_DEPRECATED(3), - BLOB(4), - LAGUNA_SOUND(5), - LAGUNA_NO_SOUND(6), - GIF(7), - FINGERPRINT_HEADER_SIZE(8), - AUDIO_STITCH(9), - PSYCHOMANTIS(10), - SCREAMINGMANTIS(11), - MALIBU_SOUND(12), - MALIBU_NO_SOUND(13), - LAGUNAHD_SOUND(14), - LAGUNAHD_NO_SOUND(15), - GHOSTMANTIS(16), - NEWPORT_SOUND(17), - NEWPORT_NO_SOUND(18), - AUDIO(19), - BLOOP(20), - SPECTACLES_IMAGE(21), - SPECTACLES_VIDEO(22), - SPECTACLES_VIDEO_NO_SOUND(23), - CHEERIOS_IMAGE(24), - CHEERIOS_VIDEO_SOUND(25), - CHEERIOS_VIDEO_NO_SOUND(26), - WEB(27), - UNRECOGNIZED_VALUE(-9999); -} -enum class ContentType(val id: Int) { - UNKNOWN(-1), - SNAP(0), - CHAT(1), - EXTERNAL_MEDIA(2), - SHARE(3), - NOTE(4), - STICKER(5), - STATUS(6), - LOCATION(7), - STATUS_SAVE_TO_CAMERA_ROLL(8), - STATUS_CONVERSATION_CAPTURE_SCREENSHOT(9), - STATUS_CONVERSATION_CAPTURE_RECORD(10), - STATUS_CALL_MISSED_VIDEO(11), - STATUS_CALL_MISSED_AUDIO(12), - LIVE_LOCATION_SHARE(13), - CREATIVE_TOOL_ITEM(14), - FAMILY_CENTER_INVITE(15), - FAMILY_CENTER_ACCEPT(16), - FAMILY_CENTER_LEAVE(17); - - companion object { - fun fromId(i: Int): ContentType { - return values().firstOrNull { it.id == i } ?: UNKNOWN - } - } -} - -enum class PlayableSnapState { - NOTDOWNLOADED, DOWNLOADING, DOWNLOADFAILED, PLAYABLE, VIEWEDREPLAYABLE, PLAYING, VIEWEDNOTREPLAYABLE -} - -enum class MetricsMessageMediaType { - NO_MEDIA, - IMAGE, - VIDEO, - VIDEO_NO_SOUND, - GIF, - DERIVED_FROM_MESSAGE_TYPE, - REACTION -} - -enum class MetricsMessageType { - TEXT, - STICKER, - CUSTOM_STICKER, - SNAP, - AUDIO_NOTE, - MEDIA, - BATCHED_MEDIA, - MISSED_AUDIO_CALL, - MISSED_VIDEO_CALL, - JOINED_CALL, - LEFT_CALL, - SNAPCHATTER, - LOCATION_SHARE, - LOCATION_REQUEST, - SCREENSHOT, - SCREEN_RECORDING, - GAME_CLOSED, - STORY_SHARE, - MAP_DROP_SHARE, - MAP_STORY_SHARE, - MAP_STORY_SNAP_SHARE, - MAP_HEAT_SNAP_SHARE, - MAP_SCREENSHOT_SHARE, - MEMORIES_STORY, - SEARCH_STORY_SHARE, - SEARCH_STORY_SNAP_SHARE, - DISCOVER_SHARE, - SHAZAM_SHARE, - SAVE_TO_CAMERA_ROLL, - GAME_SCORE_SHARE, - SNAP_PRO_PROFILE_SHARE, - SNAP_PRO_SNAP_SHARE, - CANVAS_APP_SHARE, - AD_SHARE, - STORY_REPLY, - SPOTLIGHT_STORY_SHARE, - CAMEO, - MEMOJI, - BITMOJI_OUTFIT_SHARE, - LIVE_LOCATION_SHARE, - CREATIVE_TOOL_ITEM, - SNAP_KIT_INVITE_SHARE, - QUOTE_REPLY_SHARE, - BLOOPS_STORY_SHARE, - SNAP_PRO_SAVED_STORY_SHARE, - PLACE_PROFILE_SHARE, - PLACE_STORY_SHARE, - SAVED_STORY_SHARE -} -enum class MediaReferenceType { - UNASSIGNED, OVERLAY, IMAGE, VIDEO, ASSET_BUNDLE, AUDIO, ANIMATED_IMAGE, FONT, WEB_VIEW_CONTENT, VIDEO_NO_AUDIO -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/AbstractWrapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/AbstractWrapper.kt deleted file mode 100644 index 541a0cb049..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/AbstractWrapper.kt +++ /dev/null @@ -1,36 +0,0 @@ -package me.rhunk.snapenhance.data.wrapper - -import de.robv.android.xposed.XposedHelpers -import me.rhunk.snapenhance.util.CallbackBuilder - -abstract class AbstractWrapper( - protected var instance: Any? -) { - companion object { - fun newEmptyInstance(clazz: Class<*>): Any { - return CallbackBuilder.createEmptyObject(clazz.constructors[0]) ?: throw NullPointerException() - } - } - - fun instanceNonNull(): Any = instance!! - fun isPresent(): Boolean = instance != null - - override fun hashCode(): Int { - return instance.hashCode() - } - - override fun toString(): String { - return instance.toString() - } - - fun <T : Enum<*>> getEnumValue(fieldName: String, defaultValue: T): T { - val mContentType = XposedHelpers.getObjectField(instance, fieldName) as Enum<*> - return java.lang.Enum.valueOf(defaultValue::class.java, mContentType.name) as T - } - - @Suppress("UNCHECKED_CAST") - fun setEnumValue(fieldName: String, value: Enum<*>) { - val type = instance!!.javaClass.declaredFields.find { it.name == fieldName }?.type as Class<out Enum<*>> - XposedHelpers.setObjectField(instance, fieldName, java.lang.Enum.valueOf(type, value.name)) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/Message.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/Message.kt deleted file mode 100644 index 87be84ed2f..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/Message.kt +++ /dev/null @@ -1,15 +0,0 @@ -package me.rhunk.snapenhance.data.wrapper.impl - -import me.rhunk.snapenhance.data.MessageState -import me.rhunk.snapenhance.data.wrapper.AbstractWrapper -import me.rhunk.snapenhance.util.getObjectField - -class Message(obj: Any?) : AbstractWrapper(obj) { - val orderKey get() = instanceNonNull().getObjectField("mOrderKey") as Long - val senderId get() = SnapUUID(instanceNonNull().getObjectField("mSenderId")) - val messageContent get() = MessageContent(instanceNonNull().getObjectField("mMessageContent")) - val messageDescriptor get() = MessageDescriptor(instanceNonNull().getObjectField("mDescriptor")) - val messageMetadata get() = MessageMetadata(instanceNonNull().getObjectField("mMetadata")) - var messageState get() = getEnumValue("mState", MessageState.COMMITTED) - set(value) = setEnumValue("mState", value) -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageContent.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageContent.kt deleted file mode 100644 index e0bf5a61c7..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageContent.kt +++ /dev/null @@ -1,15 +0,0 @@ -package me.rhunk.snapenhance.data.wrapper.impl - -import me.rhunk.snapenhance.data.ContentType -import me.rhunk.snapenhance.data.wrapper.AbstractWrapper -import me.rhunk.snapenhance.util.getObjectField -import me.rhunk.snapenhance.util.setObjectField - -class MessageContent(obj: Any?) : AbstractWrapper(obj) { - var content - get() = instanceNonNull().getObjectField("mContent") as ByteArray - set(value) = instanceNonNull().setObjectField("mContent", value) - var contentType - get() = getEnumValue("mContentType", ContentType.UNKNOWN) - set(value) = setEnumValue("mContentType", value) -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageDescriptor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageDescriptor.kt deleted file mode 100644 index 0c9a4e2bc1..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageDescriptor.kt +++ /dev/null @@ -1,9 +0,0 @@ -package me.rhunk.snapenhance.data.wrapper.impl - -import me.rhunk.snapenhance.data.wrapper.AbstractWrapper -import me.rhunk.snapenhance.util.getObjectField - -class MessageDescriptor(obj: Any?) : AbstractWrapper(obj) { - val messageId: Long get() = instanceNonNull().getObjectField("mMessageId") as Long - val conversationId: SnapUUID get() = SnapUUID(instanceNonNull().getObjectField("mConversationId")!!) -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageDestinations.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageDestinations.kt deleted file mode 100644 index 6fd58f74af..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageDestinations.kt +++ /dev/null @@ -1,14 +0,0 @@ -package me.rhunk.snapenhance.data.wrapper.impl - -import me.rhunk.snapenhance.data.wrapper.AbstractWrapper -import me.rhunk.snapenhance.util.getObjectField -import me.rhunk.snapenhance.util.setObjectField - -class MessageDestinations(obj: Any) : AbstractWrapper(obj){ - var conversations get() = (instanceNonNull().getObjectField("mConversations") as ArrayList<*>).map { SnapUUID(it) } - set(value) = instanceNonNull().setObjectField("mConversations", value.map { it.instanceNonNull() }.toCollection(ArrayList())) - var stories get() = instanceNonNull().getObjectField("mStories") as ArrayList<Any> - set(value) = instanceNonNull().setObjectField("mStories", value) - var mPhoneNumbers get() = instanceNonNull().getObjectField("mPhoneNumbers") as ArrayList<Any> - set(value) = instanceNonNull().setObjectField("mPhoneNumbers", value) -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageMetadata.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageMetadata.kt deleted file mode 100644 index ce939badc8..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageMetadata.kt +++ /dev/null @@ -1,16 +0,0 @@ -package me.rhunk.snapenhance.data.wrapper.impl - -import me.rhunk.snapenhance.data.PlayableSnapState -import me.rhunk.snapenhance.data.wrapper.AbstractWrapper -import me.rhunk.snapenhance.util.getObjectField - -class MessageMetadata(obj: Any?) : AbstractWrapper(obj){ - val createdAt: Long get() = instanceNonNull().getObjectField("mCreatedAt") as Long - val readAt: Long get() = instanceNonNull().getObjectField("mReadAt") as Long - var playableSnapState: PlayableSnapState - get() = getEnumValue("mPlayableSnapState", PlayableSnapState.PLAYABLE) - set(value) { - setEnumValue("mPlayableSnapState", value) - } - val savedBy: List<SnapUUID> = (instanceNonNull().getObjectField("mSavedBy") as List<*>).map { SnapUUID(it!!) } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/SnapUUID.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/SnapUUID.kt deleted file mode 100644 index 2270e062e6..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/SnapUUID.kt +++ /dev/null @@ -1,38 +0,0 @@ -package me.rhunk.snapenhance.data.wrapper.impl - -import me.rhunk.snapenhance.SnapEnhance -import me.rhunk.snapenhance.data.wrapper.AbstractWrapper -import me.rhunk.snapenhance.util.getObjectField -import java.nio.ByteBuffer -import java.util.UUID - -class SnapUUID(obj: Any?) : AbstractWrapper(obj) { - private val uuidString by lazy { toUUID().toString() } - - val bytes: ByteArray get() = instanceNonNull().getObjectField("mId") as ByteArray - - private fun toUUID(): UUID { - val buffer = ByteBuffer.wrap(bytes) - return UUID(buffer.long, buffer.long) - } - - override fun toString(): String { - return uuidString - } - - companion object { - fun fromString(uuid: String): SnapUUID { - return fromUUID(UUID.fromString(uuid)) - } - fun fromBytes(bytes: ByteArray): SnapUUID { - val constructor = SnapEnhance.classCache.snapUUID.getConstructor(ByteArray::class.java) - return SnapUUID(constructor.newInstance(bytes)) - } - fun fromUUID(uuid: UUID): SnapUUID { - val buffer = ByteBuffer.allocate(16) - buffer.putLong(uuid.mostSignificantBits) - buffer.putLong(uuid.leastSignificantBits) - return fromBytes(buffer.array()) - } - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/Layer.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/Layer.kt deleted file mode 100644 index 4ac1fd9e7d..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/Layer.kt +++ /dev/null @@ -1,21 +0,0 @@ -package me.rhunk.snapenhance.data.wrapper.impl.media.opera - -import me.rhunk.snapenhance.data.wrapper.AbstractWrapper -import me.rhunk.snapenhance.util.ReflectionHelper - -class Layer(obj: Any?) : AbstractWrapper(obj) { - val paramMap: ParamMap - get() { - val layerControllerField = ReflectionHelper.searchFieldContainsToString( - instanceNonNull()::class.java, - instance, - "OperaPageModel" - )!! - - val paramsMapHashMap = ReflectionHelper.searchFieldStartsWithToString( - layerControllerField.type, - layerControllerField[instance] as Any, "OperaPageModel" - )!! - return ParamMap(paramsMapHashMap[layerControllerField[instance]]!!) - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/LayerController.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/LayerController.kt deleted file mode 100644 index 2dccd48d37..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/LayerController.kt +++ /dev/null @@ -1,18 +0,0 @@ -package me.rhunk.snapenhance.data.wrapper.impl.media.opera - -import de.robv.android.xposed.XposedHelpers -import me.rhunk.snapenhance.data.wrapper.AbstractWrapper -import me.rhunk.snapenhance.util.ReflectionHelper -import java.lang.reflect.Field -import java.util.concurrent.ConcurrentHashMap - -class LayerController(obj: Any?) : AbstractWrapper(obj) { - val paramMap: ParamMap - get() { - val paramMapField: Field = ReflectionHelper.searchFieldTypeInSuperClasses( - instanceNonNull()::class.java, - ConcurrentHashMap::class.java - ) ?: throw RuntimeException("Could not find paramMap field") - return ParamMap(XposedHelpers.getObjectField(instance, paramMapField.name)) - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/database/DatabaseAccess.kt b/app/src/main/kotlin/me/rhunk/snapenhance/database/DatabaseAccess.kt deleted file mode 100644 index 4a7bc9b0af..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/database/DatabaseAccess.kt +++ /dev/null @@ -1,221 +0,0 @@ -package me.rhunk.snapenhance.database - -import android.annotation.SuppressLint -import android.database.sqlite.SQLiteDatabase -import me.rhunk.snapenhance.Logger -import me.rhunk.snapenhance.ModContext -import me.rhunk.snapenhance.database.objects.* -import me.rhunk.snapenhance.manager.Manager -import java.io.File - -@SuppressLint("Range") -class DatabaseAccess(private val context: ModContext) : Manager { - private val databaseLock = Any() - - private val arroyoDatabase: File by lazy { - context.androidContext.getDatabasePath("arroyo.db") - } - - private val mainDatabase: File by lazy { - context.androidContext.getDatabasePath("main.db") - } - - private fun openMain(): SQLiteDatabase { - return SQLiteDatabase.openDatabase( - mainDatabase.absolutePath, - null, - SQLiteDatabase.OPEN_READONLY - )!! - } - - private fun openArroyo(): SQLiteDatabase { - return SQLiteDatabase.openDatabase( - arroyoDatabase.absolutePath, - null, - SQLiteDatabase.OPEN_READONLY - )!! - } - - fun hasArroyo(): Boolean { - return arroyoDatabase.exists() - } - - private fun <T> safeDatabaseOperation( - database: SQLiteDatabase, - query: (SQLiteDatabase) -> T? - ): T? { - synchronized(databaseLock) { - return runCatching { - query(database) - }.onFailure { - Logger.xposedLog("Database operation failed", it) - }.getOrNull() - } - } - - private fun <T : DatabaseObject> readDatabaseObject( - obj: T, - database: SQLiteDatabase, - table: String, - where: String, - args: Array<String> - ): T? { - val cursor = database.rawQuery("SELECT * FROM $table WHERE $where", args) - if (!cursor.moveToFirst()) { - cursor.close() - return null - } - try { - obj.write(cursor) - } catch (e: Throwable) { - Logger.xposedLog(e) - } - cursor.close() - return obj - } - - fun getFriendFeedInfoByUserId(userId: String): FriendFeedInfo? { - return safeDatabaseOperation(openMain()) { database -> - readDatabaseObject( - FriendFeedInfo(), - database, - "FriendsFeedView", - "friendUserId = ?", - arrayOf(userId) - ) - } - } - - fun getFriendFeedInfoByConversationId(conversationId: String): FriendFeedInfo? { - return safeDatabaseOperation(openMain()) { - readDatabaseObject( - FriendFeedInfo(), - it, - "FriendsFeedView", - "key = ?", - arrayOf(conversationId) - ) - } - } - - fun getFriendInfo(userId: String): FriendInfo? { - return safeDatabaseOperation(openMain()) { - readDatabaseObject( - FriendInfo(), - it, - "FriendWithUsername", - "userId = ?", - arrayOf(userId) - ) - } - } - - fun getFriendFeed(limit: Int): List<FriendFeedInfo> { - return safeDatabaseOperation(openMain()) { database -> - val cursor = database.rawQuery( - "SELECT * FROM FriendsFeedView ORDER BY _id LIMIT ?", - arrayOf(limit.toString()) - ) - val list = mutableListOf<FriendFeedInfo>() - while (cursor.moveToNext()) { - val friendFeedInfo = FriendFeedInfo() - try { - friendFeedInfo.write(cursor) - } catch (_: Throwable) {} - list.add(friendFeedInfo) - } - cursor.close() - list - } ?: emptyList() - } - - fun getConversationMessageFromId(clientMessageId: Long): ConversationMessage? { - return safeDatabaseOperation(openArroyo()) { - readDatabaseObject( - ConversationMessage(), - it, - "conversation_message", - "client_message_id = ?", - arrayOf(clientMessageId.toString()) - ) - } - } - - fun getDMConversationIdFromUserId(userId: String): UserConversationLink? { - return safeDatabaseOperation(openArroyo()) { - readDatabaseObject( - UserConversationLink(), - it, - "user_conversation", - "user_id = ? AND conversation_type = 0", - arrayOf(userId) - ) - } - } - - fun getStoryEntryFromId(storyId: String): StoryEntry? { - return safeDatabaseOperation(openMain()) { - readDatabaseObject(StoryEntry(), it, "Story", "storyId = ?", arrayOf(storyId)) - } - } - - fun getConversationParticipants(conversationId: String): List<String>? { - return safeDatabaseOperation(openArroyo()) { arroyoDatabase: SQLiteDatabase -> - val cursor = arroyoDatabase.rawQuery( - "SELECT * FROM user_conversation WHERE client_conversation_id = ?", - arrayOf(conversationId) - ) - if (!cursor.moveToFirst()) { - cursor.close() - return@safeDatabaseOperation emptyList() - } - val participants = mutableListOf<String>() - do { - participants.add(cursor.getString(cursor.getColumnIndex("user_id"))) - } while (cursor.moveToNext()) - cursor.close() - participants - } - } - - fun getMyUserId(): String? { - return safeDatabaseOperation(openArroyo()) { arroyoDatabase: SQLiteDatabase -> - val cursor = arroyoDatabase.rawQuery(buildString { - append("SELECT * FROM required_values WHERE key = 'USERID'") - }, null) - - if (!cursor.moveToFirst()) { - cursor.close() - return@safeDatabaseOperation null - } - - val userId = cursor.getString(cursor.getColumnIndex("value")) - cursor.close() - userId - } - } - - fun getMessagesFromConversationId( - conversationId: String, - limit: Int - ): List<ConversationMessage>? { - return safeDatabaseOperation(openArroyo()) { arroyoDatabase: SQLiteDatabase -> - val cursor = arroyoDatabase.rawQuery( - "SELECT * FROM conversation_message WHERE client_conversation_id = ? ORDER BY creation_timestamp DESC LIMIT ?", - arrayOf(conversationId, limit.toString()) - ) - if (!cursor.moveToFirst()) { - cursor.close() - return@safeDatabaseOperation emptyList() - } - val messages = mutableListOf<ConversationMessage>() - do { - val message = ConversationMessage() - message.write(cursor) - messages.add(message) - } while (cursor.moveToNext()) - cursor.close() - messages - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/database/objects/ConversationMessage.kt b/app/src/main/kotlin/me/rhunk/snapenhance/database/objects/ConversationMessage.kt deleted file mode 100644 index 0e97373d9a..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/database/objects/ConversationMessage.kt +++ /dev/null @@ -1,44 +0,0 @@ -package me.rhunk.snapenhance.database.objects - -import android.annotation.SuppressLint -import android.database.Cursor -import me.rhunk.snapenhance.Constants -import me.rhunk.snapenhance.data.ContentType -import me.rhunk.snapenhance.database.DatabaseObject -import me.rhunk.snapenhance.util.protobuf.ProtoReader - -@Suppress("ArrayInDataClass") -data class ConversationMessage( - var client_conversation_id: String? = null, - var client_message_id: Int = 0, - var server_message_id: Int = 0, - var message_content: ByteArray? = null, - var is_saved: Int = 0, - var is_viewed_by_user: Int = 0, - var content_type: Int = 0, - var creation_timestamp: Long = 0, - var read_timestamp: Long = 0, - var sender_id: String? = null -) : DatabaseObject { - - @SuppressLint("Range") - override fun write(cursor: Cursor) { - client_conversation_id = cursor.getString(cursor.getColumnIndex("client_conversation_id")) - client_message_id = cursor.getInt(cursor.getColumnIndex("client_message_id")) - server_message_id = cursor.getInt(cursor.getColumnIndex("server_message_id")) - message_content = cursor.getBlob(cursor.getColumnIndex("message_content")) - is_saved = cursor.getInt(cursor.getColumnIndex("is_saved")) - is_viewed_by_user = cursor.getInt(cursor.getColumnIndex("is_viewed_by_user")) - content_type = cursor.getInt(cursor.getColumnIndex("content_type")) - creation_timestamp = cursor.getLong(cursor.getColumnIndex("creation_timestamp")) - read_timestamp = cursor.getLong(cursor.getColumnIndex("read_timestamp")) - sender_id = cursor.getString(cursor.getColumnIndex("sender_id")) - } - - fun getMessageAsString(): String? { - return when (ContentType.fromId(content_type)) { - ContentType.CHAT -> message_content?.let { ProtoReader(it).getString(*Constants.ARROYO_STRING_CHAT_MESSAGE_PROTO) } - else -> null - } - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/database/objects/FriendFeedInfo.kt b/app/src/main/kotlin/me/rhunk/snapenhance/database/objects/FriendFeedInfo.kt deleted file mode 100644 index 03ad557453..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/database/objects/FriendFeedInfo.kt +++ /dev/null @@ -1,33 +0,0 @@ -package me.rhunk.snapenhance.database.objects - -import android.annotation.SuppressLint -import android.database.Cursor -import me.rhunk.snapenhance.database.DatabaseObject - -data class FriendFeedInfo( - var id: Int = 0, - var feedDisplayName: String? = null, - var participantsSize: Int = 0, - var lastInteractionTimestamp: Long = 0, - var displayTimestamp: Long = 0, - var displayInteractionType: String? = null, - var lastInteractionUserId: Int = 0, - var key: String? = null, - var friendUserId: String? = null, - var friendDisplayName: String? = null, -) : DatabaseObject { - - @SuppressLint("Range") - override fun write(cursor: Cursor) { - id = cursor.getInt(cursor.getColumnIndex("_id")) - feedDisplayName = cursor.getString(cursor.getColumnIndex("feedDisplayName")) - participantsSize = cursor.getInt(cursor.getColumnIndex("participantsSize")) - lastInteractionTimestamp = cursor.getLong(cursor.getColumnIndex("lastInteractionTimestamp")) - displayTimestamp = cursor.getLong(cursor.getColumnIndex("displayTimestamp")) - displayInteractionType = cursor.getString(cursor.getColumnIndex("displayInteractionType")) - lastInteractionUserId = cursor.getInt(cursor.getColumnIndex("lastInteractionUserId")) - key = cursor.getString(cursor.getColumnIndex("key")) - friendUserId = cursor.getString(cursor.getColumnIndex("friendUserId")) - friendDisplayName = cursor.getString(cursor.getColumnIndex("friendDisplayUsername")) - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/database/objects/FriendInfo.kt b/app/src/main/kotlin/me/rhunk/snapenhance/database/objects/FriendInfo.kt deleted file mode 100644 index ac1b29c7bf..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/database/objects/FriendInfo.kt +++ /dev/null @@ -1,58 +0,0 @@ -package me.rhunk.snapenhance.database.objects - -import android.annotation.SuppressLint -import android.database.Cursor -import me.rhunk.snapenhance.database.DatabaseObject - -data class FriendInfo( - var id: Int = 0, - var lastModifiedTimestamp: Long = 0, - var username: String? = null, - var userId: String? = null, - var displayName: String? = null, - var bitmojiAvatarId: String? = null, - var bitmojiSelfieId: String? = null, - var bitmojiSceneId: String? = null, - var bitmojiBackgroundId: String? = null, - var friendmojis: String? = null, - var friendmojiCategories: String? = null, - var snapScore: Int = 0, - var birthday: Long = 0, - var addedTimestamp: Long = 0, - var reverseAddedTimestamp: Long = 0, - var serverDisplayName: String? = null, - var streakLength: Int = 0, - var streakExpirationTimestamp: Long = 0, - var reverseBestFriendRanking: Int = 0, - var isPinnedBestFriend: Int = 0, - var plusBadgeVisibility: Int = 0, - var usernameForSorting: String? = null -) : DatabaseObject { - @SuppressLint("Range") - override fun write(cursor: Cursor) { - id = cursor.getInt(cursor.getColumnIndex("_id")) - lastModifiedTimestamp = cursor.getLong(cursor.getColumnIndex("_lastModifiedTimestamp")) - username = cursor.getString(cursor.getColumnIndex("username")) - userId = cursor.getString(cursor.getColumnIndex("userId")) - displayName = cursor.getString(cursor.getColumnIndex("displayName")) - bitmojiAvatarId = cursor.getString(cursor.getColumnIndex("bitmojiAvatarId")) - bitmojiSelfieId = cursor.getString(cursor.getColumnIndex("bitmojiSelfieId")) - bitmojiSceneId = cursor.getString(cursor.getColumnIndex("bitmojiSceneId")) - bitmojiBackgroundId = cursor.getString(cursor.getColumnIndex("bitmojiBackgroundId")) - friendmojis = cursor.getString(cursor.getColumnIndex("friendmojis")) - friendmojiCategories = cursor.getString(cursor.getColumnIndex("friendmojiCategories")) - snapScore = cursor.getInt(cursor.getColumnIndex("score")) - birthday = cursor.getLong(cursor.getColumnIndex("birthday")) - addedTimestamp = cursor.getLong(cursor.getColumnIndex("addedTimestamp")) - reverseAddedTimestamp = cursor.getLong(cursor.getColumnIndex("reverseAddedTimestamp")) - serverDisplayName = cursor.getString(cursor.getColumnIndex("serverDisplayName")) - streakLength = cursor.getInt(cursor.getColumnIndex("streakLength")) - streakExpirationTimestamp = cursor.getLong(cursor.getColumnIndex("streakExpiration")) - reverseBestFriendRanking = cursor.getInt(cursor.getColumnIndex("reverseBestFriendRanking")) - usernameForSorting = cursor.getString(cursor.getColumnIndex("usernameForSorting")) - if (cursor.getColumnIndex("isPinnedBestFriend") != -1) isPinnedBestFriend = - cursor.getInt(cursor.getColumnIndex("isPinnedBestFriend")) - if (cursor.getColumnIndex("plusBadgeVisibility") != -1) plusBadgeVisibility = - cursor.getInt(cursor.getColumnIndex("plusBadgeVisibility")) - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/database/objects/StoryEntry.kt b/app/src/main/kotlin/me/rhunk/snapenhance/database/objects/StoryEntry.kt deleted file mode 100644 index f0001ca579..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/database/objects/StoryEntry.kt +++ /dev/null @@ -1,23 +0,0 @@ -package me.rhunk.snapenhance.database.objects - -import android.annotation.SuppressLint -import android.database.Cursor -import me.rhunk.snapenhance.database.DatabaseObject - -data class StoryEntry( - var id: Int = 0, - var storyId: String? = null, - var displayName: String? = null, - var isLocal: Boolean? = null, - var userId: String? = null -) : DatabaseObject { - - @SuppressLint("Range") - override fun write(cursor: Cursor) { - id = cursor.getInt(cursor.getColumnIndex("_id")) - storyId = cursor.getString(cursor.getColumnIndex("storyId")) - displayName = cursor.getString(cursor.getColumnIndex("displayName")) - isLocal = cursor.getInt(cursor.getColumnIndex("isLocal")) == 1 - userId = cursor.getString(cursor.getColumnIndex("userId")) - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/database/objects/UserConversationLink.kt b/app/src/main/kotlin/me/rhunk/snapenhance/database/objects/UserConversationLink.kt deleted file mode 100644 index e44c019ee0..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/database/objects/UserConversationLink.kt +++ /dev/null @@ -1,19 +0,0 @@ -package me.rhunk.snapenhance.database.objects - -import android.annotation.SuppressLint -import android.database.Cursor -import me.rhunk.snapenhance.database.DatabaseObject - -class UserConversationLink( - var user_id: String? = null, - var client_conversation_id: String? = null, - var conversation_type: Int = 0 -) : DatabaseObject { - - @SuppressLint("Range") - override fun write(cursor: Cursor) { - user_id = cursor.getString(cursor.getColumnIndex("user_id")) - client_conversation_id = cursor.getString(cursor.getColumnIndex("client_conversation_id")) - conversation_type = cursor.getInt(cursor.getColumnIndex("conversation_type")) - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt new file mode 100644 index 0000000000..fcb0595f5f --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt @@ -0,0 +1,489 @@ +package me.rhunk.snapenhance.download + +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.widget.Toast +import androidx.documentfile.provider.DocumentFile +import com.google.gson.GsonBuilder +import kotlinx.coroutines.Job +import kotlinx.coroutines.job +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import me.rhunk.snapenhance.RemoteSideContext +import me.rhunk.snapenhance.bridge.DownloadCallback +import me.rhunk.snapenhance.common.Constants +import me.rhunk.snapenhance.common.ReceiversConfig +import me.rhunk.snapenhance.common.data.FileType +import me.rhunk.snapenhance.common.data.download.DownloadMediaType +import me.rhunk.snapenhance.common.data.download.DownloadMetadata +import me.rhunk.snapenhance.common.data.download.DownloadRequest +import me.rhunk.snapenhance.common.data.download.InputMedia +import me.rhunk.snapenhance.common.data.download.SplitMediaAssetType +import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper +import me.rhunk.snapenhance.common.util.snap.RemoteMediaResolver +import me.rhunk.snapenhance.core.features.impl.downloader.decoder.AttachmentType +import me.rhunk.snapenhance.task.PendingTask +import me.rhunk.snapenhance.task.PendingTaskListener +import me.rhunk.snapenhance.task.Task +import me.rhunk.snapenhance.task.TaskStatus +import me.rhunk.snapenhance.task.TaskType +import java.io.File +import java.io.InputStream +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.ConcurrentHashMap +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult +import kotlin.coroutines.coroutineContext +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +data class DownloadedFile( + val file: File, + val fileType: FileType +) + +/** + * DownloadProcessor handles the download requests of the user + */ +@OptIn(ExperimentalEncodingApi::class) +class DownloadProcessor ( + private val remoteSideContext: RemoteSideContext, + private val callback: DownloadCallback +) { + + private val translation by lazy { + remoteSideContext.translation.getCategory("download_processor") + } + + private val gson by lazy { GsonBuilder().setPrettyPrinting().create() } + + private fun fallbackToast(message: Any) { + android.os.Handler(remoteSideContext.androidContext.mainLooper).post { + Toast.makeText(remoteSideContext.androidContext, message.toString(), Toast.LENGTH_SHORT).show() + } + } + + private fun callbackOnSuccess(path: String) = runCatching { + callback.onSuccess(path) + }.onFailure { + fallbackToast(it) + } + + private fun callbackOnFailure(message: String, throwable: String? = null) = runCatching { + callback.onFailure(message, throwable) + }.onFailure { + fallbackToast("$message\n$throwable") + } + + private fun callbackOnProgress(message: String) = runCatching { + callback.onProgress(message) + }.onFailure { + fallbackToast(it) + } + + private fun newFFMpegProcessor(pendingTask: PendingTask) = FFMpegProcessor.newFFMpegProcessor(remoteSideContext, pendingTask) + + suspend fun saveMediaToGallery(pendingTask: PendingTask, inputFile: File, metadata: DownloadMetadata) { + if (coroutineContext.job.isCancelled) return + + runCatching { + var fileType = FileType.fromFile(inputFile) + + if (fileType.isImage) { + remoteSideContext.config.root.downloader.forceImageFormat.getNullable()?.let { format -> + val bitmap = BitmapFactory.decodeFile(inputFile.absolutePath) ?: throw Exception("Failed to decode bitmap") + @Suppress("DEPRECATION") val compressFormat = when (format) { + "png" -> Bitmap.CompressFormat.PNG + "jpg" -> Bitmap.CompressFormat.JPEG + "webp" -> Bitmap.CompressFormat.WEBP + else -> throw Exception("Invalid image format") + } + + pendingTask.updateProgress("Converting image to $format") + inputFile.outputStream().use { + bitmap.compress(compressFormat, 100, it) + } + fileType = FileType.fromFile(inputFile) + } + } + + val fileName = metadata.outputPath.substringAfterLast("/") + "." + fileType.fileExtension + + val outputFolder = DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(remoteSideContext.config.root.downloader.saveFolder.get())) + ?: throw Exception("Failed to open output folder") + + val outputFileFolder = metadata.outputPath.let { + if (it.contains("/")) { + it.substringBeforeLast("/").split("/").fold(outputFolder) { folder, name -> + folder.findFile(name) ?: folder.createDirectory(name)!! + } + } else { + outputFolder + } + } + + // checks if the file already exists and if it does, compares its contents with the input file, if contents differ, deletes existing file. + outputFileFolder.findFile(fileName)?.let { existingFile -> + pendingTask.updateProgress("Comparing existing media") + if (existingFile.length() != inputFile.length()) { + existingFile.delete() + return@let + } + + remoteSideContext.androidContext.contentResolver.openInputStream(existingFile.uri)?.use { existingInputStream -> + val buffer1 = ByteArray(1024 * 1024) + val buffer2 = ByteArray(1024 * 1024) + var read1: Int + var read2: Int + + inputFile.inputStream().use { inputStream -> + while (true) { + read1 = inputStream.read(buffer1) + read2 = existingInputStream.read(buffer2) + if (read1 != read2 || !buffer1.contentEquals(buffer2)) { + existingFile.delete() + return@let + } + if (read1 == -1) break + } + } + } + + pendingTask.task.extra = existingFile.uri.toString() + pendingTask.success() + callbackOnFailure(translation["already_downloaded_toast"]) + return + } + + val outputFile = outputFileFolder.createFile(fileType.mimeType, fileName)!! + + pendingTask.updateProgress("Saving media to gallery") + remoteSideContext.androidContext.contentResolver.openOutputStream(outputFile.uri)!!.use { outputStream -> + inputFile.inputStream().use { inputStream -> + inputStream.copyTo(outputStream) + } + } + + pendingTask.task.extra = outputFile.uri.toString() + pendingTask.success() + + runCatching { + remoteSideContext.androidContext.sendBroadcast(Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE").apply { + data = outputFile.uri + }) + }.onFailure { + remoteSideContext.log.error("Failed to scan media file", it) + callbackOnFailure(translation.format("failed_gallery_toast", "error" to it.toString()), it.message) + } + + remoteSideContext.log.verbose("download complete") + callbackOnSuccess(fileName) + }.onFailure { exception -> + remoteSideContext.log.error("Failed to save media to gallery", exception) + callbackOnFailure(translation.format("failed_gallery_toast", "error" to exception.toString()), exception.message) + pendingTask.fail("Failed to save media to gallery") + } + } + + private fun createMediaTempFile(): File { + return File.createTempFile("media", ".tmp") + } + + private fun downloadInputMedias(pendingTask: PendingTask, downloadRequest: DownloadRequest) = runBlocking { + val jobs = mutableListOf<Job>() + val downloadedMedias = mutableMapOf<InputMedia, File>() + var totalSize = 1L + val inputMediaDownloadedBytes = mutableMapOf<InputMedia, Long>() + val inputMediaProgress = ConcurrentHashMap<InputMedia, String>() + + fun updateDownloadProgress() { + pendingTask.updateProgress( + inputMediaProgress.values.joinToString("\n"), + progress = (inputMediaDownloadedBytes.values.sum() * 100 / totalSize.coerceAtLeast(1)).toInt().coerceIn(0, 100) + ) + } + + downloadRequest.inputMedias.forEach { inputMedia -> + fun setProgress(progress: String) { + inputMediaProgress[inputMedia] = progress + updateDownloadProgress() + } + + fun handleInputStream(inputStream: InputStream, estimatedSize: Long = 0L) { + createMediaTempFile().apply { + val decryptedInputStream = (inputMedia.encryption?.decryptInputStream(inputStream) ?: inputStream).buffered() + val buffer = ByteArray(1024 * 1024 * 2) // 2MB + var read: Int + var totalRead = 0L + + outputStream().use { outputStream -> + while (decryptedInputStream.read(buffer).also { read = it } != -1) { + outputStream.write(buffer, 0, read) + totalRead += read + inputMediaDownloadedBytes[inputMedia] = totalRead + setProgress("${totalRead / 1024}KB/${estimatedSize / 1024}KB") + } + } + }.also { downloadedMedias[inputMedia] = it } + } + + launch { + when (inputMedia.type) { + DownloadMediaType.PROTO_MEDIA -> { + RemoteMediaResolver.downloadBoltMedia(Base64.UrlSafe.decode(inputMedia.content), decryptionCallback = { it }, resultCallback = { inputStream, length -> + totalSize += length + inputStream.use { + handleInputStream(it, estimatedSize = length) + } + }) + } + DownloadMediaType.REMOTE_MEDIA -> { + with(URL(inputMedia.content).openConnection() as HttpURLConnection) { + requestMethod = "GET" + setRequestProperty("User-Agent", Constants.USER_AGENT) + connect() + totalSize += contentLength.toLong() + inputStream.use { + handleInputStream(it, estimatedSize = contentLength.toLong()) + } + } + } + DownloadMediaType.DIRECT_MEDIA -> { + val decoded = Base64.UrlSafe.decode(inputMedia.content) + totalSize += decoded.size.toLong() + handleInputStream(decoded.inputStream(), estimatedSize = decoded.size.toLong()) + } + else -> { + File(inputMedia.content).inputStream().use { + totalSize += it.available().toLong() + handleInputStream(it, estimatedSize = it.available().toLong()) + } + } + } + }.also { jobs.add(it) } + } + + jobs.joinAll() + downloadedMedias + } + + private suspend fun downloadRemoteMedia(pendingTask: PendingTask, metadata: DownloadMetadata, downloadedMedias: Map<InputMedia, DownloadedFile>, downloadRequest: DownloadRequest) { + downloadRequest.inputMedias.first().let { inputMedia -> + val mediaType = inputMedia.type + val media = downloadedMedias[inputMedia]!! + + if (!downloadRequest.isDashPlaylist) { + if (inputMedia.attachmentType == AttachmentType.NOTE.key) { + remoteSideContext.config.root.downloader.forceVoiceNoteFormat.getNullable()?.let { format -> + val outputFile = File.createTempFile("voice_note", ".$format") + newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request( + action = FFMpegProcessor.Action.CONVERSION, + inputs = listOf(media.file.absolutePath), + output = outputFile + )) + media.file.delete() + saveMediaToGallery(pendingTask, outputFile, metadata) + outputFile.delete() + return + } + } + + saveMediaToGallery(pendingTask, media.file, metadata) + media.file.delete() + return + } + + assert(mediaType == DownloadMediaType.REMOTE_MEDIA) + + val playlistXml = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(media.file) + val baseUrlNodeList = playlistXml.getElementsByTagName("BaseURL") + for (i in 0 until baseUrlNodeList.length) { + val baseUrlNode = baseUrlNodeList.item(i) + val baseUrl = baseUrlNode.textContent + baseUrlNode.textContent = "${RemoteMediaResolver.CF_ST_CDN_D}$baseUrl" + } + + val dashOptions = downloadRequest.dashOptions!! + + val dashPlaylistFile = renameFromFileType(media.file, FileType.MPD) + dashPlaylistFile.outputStream().use { + TransformerFactory.newInstance().newTransformer().transform(DOMSource(playlistXml), StreamResult(it)) + } + + callbackOnProgress(translation.format("download_toast", "path" to dashPlaylistFile.nameWithoutExtension)) + val outputFile = File.createTempFile("dash", ".mp4") + runCatching { + newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request( + action = FFMpegProcessor.Action.DOWNLOAD_DASH, + inputs = listOf(dashPlaylistFile.absolutePath), + output = outputFile, + startTime = dashOptions.offsetTime, + duration = dashOptions.duration + )) + saveMediaToGallery(pendingTask, outputFile, metadata) + }.onFailure { exception -> + if (coroutineContext.job.isCancelled) return@onFailure + remoteSideContext.log.error("Failed to download dash media", exception) + callbackOnFailure(translation.format("failed_processing_toast", "error" to exception.toString()), exception.message) + pendingTask.fail("Failed to download dash media") + } + + dashPlaylistFile.delete() + outputFile.delete() + media.file.delete() + } + } + + private fun renameFromFileType(file: File, fileType: FileType): File { + val newFile = File(file.parentFile, file.nameWithoutExtension + "." + fileType.fileExtension) + file.renameTo(newFile) + return newFile + } + + fun enqueue(downloadRequest: DownloadRequest, downloadMetadata: DownloadMetadata) { + remoteSideContext.coroutineScope.launch { + remoteSideContext.taskManager.getTaskByHash(downloadMetadata.mediaIdentifier)?.let { task -> + remoteSideContext.log.debug("already queued or downloaded") + + if (task.status.isFinalStage()) { + if (task.status != TaskStatus.SUCCESS) return@let + // check if the media file has been deleted + if (task.type == TaskType.DOWNLOAD) { + val outputFile = runCatching { + DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(task.extra)) + }.getOrNull() + + if (outputFile != null && !outputFile.exists()) { + return@let + } + } + callbackOnFailure(translation["already_downloaded_toast"], null) + } else { + callbackOnFailure(translation["already_queued_toast"], null) + } + return@launch + } + + remoteSideContext.log.debug("downloading media") + val pendingTask = remoteSideContext.taskManager.createPendingTask( + Task( + type = TaskType.DOWNLOAD, + title = downloadMetadata.downloadSource, + author = downloadMetadata.mediaAuthor, + hash = downloadMetadata.mediaIdentifier + ) + ).apply { + status = TaskStatus.RUNNING + addListener(PendingTaskListener(onCancel = { + coroutineContext.job.cancel() + })) + updateProgress("Downloading...") + } + + runCatching { + if (downloadRequest.isAudioStream) { + val streamUrl = downloadRequest.inputMedias.first().content + val outputFile = File.createTempFile("audio_stream", ".mp3") + + callbackOnProgress("Downloading audio stream") + pendingTask.updateProgress("Downloading audio stream") + newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request( + action = FFMpegProcessor.Action.DOWNLOAD_AUDIO_STREAM, + inputs = listOf(streamUrl), + output = outputFile, + audioStreamFormat = downloadRequest.audioStreamFormat + )) + saveMediaToGallery(pendingTask, outputFile, downloadMetadata) + return@launch + } + + //first download all input medias into cache + val downloadedMedias = downloadInputMedias(pendingTask, downloadRequest).map { + it.key to DownloadedFile(it.value, FileType.fromFile(it.value)) + }.toMap().toMutableMap() + remoteSideContext.log.verbose("downloaded ${downloadedMedias.size} medias") + + var shouldMergeOverlay = downloadRequest.shouldMergeOverlay + + //if there is a zip file, extract it and replace the downloaded media with the extracted ones + downloadedMedias.values.find { it.fileType == FileType.ZIP }?.let { zipFile -> + val oldDownloadedMedias = downloadedMedias.toMap() + downloadedMedias.clear() + + zipFile.file.inputStream().use { zipFileInputStream -> + MediaDownloaderHelper.getSplitElements(zipFileInputStream) { type, inputStream -> + createMediaTempFile().apply { + outputStream().use { + inputStream.copyTo(it) + } + }.also { + downloadedMedias[InputMedia( + type = DownloadMediaType.LOCAL_MEDIA, + content = it.absolutePath, + isOverlay = type == SplitMediaAssetType.OVERLAY + )] = DownloadedFile(it, FileType.fromFile(it)) + } + } + } + + oldDownloadedMedias.forEach { (_, value) -> + value.file.delete() + } + + shouldMergeOverlay = true + } + + if (shouldMergeOverlay) { + assert(downloadedMedias.size == 2) + val media = downloadedMedias.entries.first { !it.key.isOverlay }.value + val overlayMedia = downloadedMedias.entries.first { it.key.isOverlay }.value + + val renamedMedia = renameFromFileType(media.file, media.fileType) + val renamedOverlayMedia = renameFromFileType(overlayMedia.file, overlayMedia.fileType) + val mergedOverlay: File = File.createTempFile("merged", ".mp4") + runCatching { + callbackOnProgress(translation.format("processing_toast", "path" to media.file.nameWithoutExtension)) + + newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request( + action = FFMpegProcessor.Action.MERGE_OVERLAY, + inputs = listOf(renamedMedia.absolutePath), + output = mergedOverlay, + overlay = renamedOverlayMedia + )) + + saveMediaToGallery(pendingTask, mergedOverlay, downloadMetadata) + }.onFailure { exception -> + if (coroutineContext.job.isCancelled) return@onFailure + remoteSideContext.log.error("Failed to merge overlay", exception) + callbackOnFailure(translation.format("failed_processing_toast", "error" to exception.toString()), exception.message) + pendingTask.fail("Failed to merge overlay") + } + + mergedOverlay.delete() + renamedOverlayMedia.delete() + renamedMedia.delete() + return@launch + } + + downloadRemoteMedia(pendingTask, downloadMetadata, downloadedMedias, downloadRequest) + }.onFailure { exception -> + pendingTask.fail("Failed to download media") + remoteSideContext.log.error("Failed to download media", exception) + callbackOnFailure(translation["failed_generic_toast"], exception.message) + } + } + } + + fun onReceive(intent: Intent) { + val downloadMetadata = gson.fromJson(intent.getStringExtra(ReceiversConfig.DOWNLOAD_METADATA_EXTRA)!!, DownloadMetadata::class.java) + val downloadRequest = gson.fromJson(intent.getStringExtra(ReceiversConfig.DOWNLOAD_REQUEST_EXTRA)!!, DownloadRequest::class.java) + + enqueue(downloadRequest, downloadMetadata) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt new file mode 100644 index 0000000000..ee5f81d67e --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt @@ -0,0 +1,227 @@ +package me.rhunk.snapenhance.download + +import android.media.AudioFormat +import android.media.MediaMetadataRetriever +import com.arthenica.ffmpegkit.FFmpegKit +import com.arthenica.ffmpegkit.FFmpegSession +import com.arthenica.ffmpegkit.Level +import com.arthenica.ffmpegkit.Statistics +import kotlinx.coroutines.suspendCancellableCoroutine +import me.rhunk.snapenhance.LogManager +import me.rhunk.snapenhance.RemoteSideContext +import me.rhunk.snapenhance.common.config.impl.DownloaderConfig +import me.rhunk.snapenhance.common.data.download.AudioStreamFormat +import me.rhunk.snapenhance.common.logger.LogLevel +import me.rhunk.snapenhance.task.PendingTask +import java.io.File +import java.util.concurrent.Executors + + +class ArgumentList { + private val arguments = mutableListOf<Pair<String, String>>() + + operator fun plusAssign(stringPair: Pair<String, String>) { + arguments += stringPair + } + + operator fun plusAssign(key: String) { + arguments += key to "" + } + + operator fun minusAssign(key: String) { + arguments.removeIf { it.first == key } + } + + operator fun get(key: String) = arguments.find { it.first == key }?.second + + fun forEach(action: (Pair<String, String>) -> Unit) { + arguments.forEach(action) + } + + fun clear() { + arguments.clear() + } +} + + +class FFMpegProcessor( + private val logManager: LogManager, + private val ffmpegOptions: DownloaderConfig.FFMpegOptions, + private val onStatistics: (Statistics) -> Unit = {} +) { + companion object { + private const val TAG = "ffmpeg-processor" + + fun newFFMpegProcessor(context: RemoteSideContext, pendingTask: PendingTask) = FFMpegProcessor( + logManager = context.log, + ffmpegOptions = context.config.root.downloader.ffmpegOptions, + onStatistics = { + pendingTask.updateProgress("Processing (frames=${it.videoFrameNumber}, fps=${it.videoFps}, time=${it.time}, bitrate=${it.bitrate}, speed=${it.speed})") + } + ) + } + enum class Action { + DOWNLOAD_DASH, + MERGE_OVERLAY, + CONVERSION, + MERGE_MEDIA, + DOWNLOAD_AUDIO_STREAM, + } + + data class Request( + val action: Action, + val inputs: List<String>, + val output: File, + val overlay: File? = null, //only for MERGE_OVERLAY + val startTime: Long? = null, //only for DOWNLOAD_DASH + val duration: Long? = null, //only for DOWNLOAD_DASH + val audioStreamFormat: AudioStreamFormat? = null, //only for DOWNLOAD_AUDIO_STREAM + + var videoCodec: String? = null, + var audioCodec: String? = null, + ) + + + private suspend fun newFFMpegTask(globalArguments: ArgumentList, inputArguments: ArgumentList, outputArguments: ArgumentList) = suspendCancellableCoroutine<FFmpegSession> { + val stringBuilder = StringBuilder() + arrayOf(globalArguments, inputArguments, outputArguments).forEach { argumentList -> + argumentList.forEach { (key, value) -> + stringBuilder.append("$key ${value.takeIf { it.isNotEmpty() }?.plus(" ") ?: ""}") + } + } + + logManager.debug("arguments: $stringBuilder", "FFMpegProcessor") + + FFmpegKit.executeAsync(stringBuilder.toString(), + { session -> + it.resumeWith( + if (session.returnCode.isValueSuccess) { + Result.success(session) + } else { + Result.failure(Exception(session.output)) + } + ) + }, logFunction@{ log -> + logManager.internalLog(TAG, when (log.level) { + Level.AV_LOG_ERROR, Level.AV_LOG_FATAL -> LogLevel.ERROR + Level.AV_LOG_WARNING -> LogLevel.WARN + Level.AV_LOG_VERBOSE -> LogLevel.VERBOSE + else -> return@logFunction + }, log.message) + }, { onStatistics(it) }, Executors.newSingleThreadExecutor()) + } + + suspend fun execute(args: Request) { + // load ffmpeg native sync to avoid native crash + synchronized(this) { FFmpegKit.listSessions() } + val globalArguments = ArgumentList().apply { + this += "-y" + this += "-threads" to ffmpegOptions.threads.get().toString() + } + + val inputArguments = ArgumentList().apply { + args.inputs.forEach { path -> + this += "-i" to path + } + } + + val outputArguments = ArgumentList().apply { + this += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast") + this += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() } ?: "h264_mediacodec") + this += "-c:a" to (ffmpegOptions.customAudioCodec.get().takeIf { it.isNotEmpty() } ?: "copy") + this += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" } + this += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K" + this += "-b:a" to ffmpegOptions.audioBitrate.get().toString() + "K" + } + + when (args.action) { + Action.DOWNLOAD_DASH -> { + outputArguments += "-ss" to "'${args.startTime}ms'" + if (args.duration != null) { + outputArguments += "-t" to "'${args.duration}ms'" + } + } + Action.MERGE_OVERLAY -> { + inputArguments += "-i" to args.overlay!!.absolutePath + outputArguments += "-filter_complex" to "\"[0]scale2ref[img][vid];[img]setsar=1[img];[vid]nullsink;[img][1]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw*sar/2):2*trunc(ih/2)\"" + } + Action.CONVERSION -> { + if (ffmpegOptions.customAudioCodec.isEmpty()) { + outputArguments -= "-c:a" + } + outputArguments -= "-c:v" + args.videoCodec?.let { + outputArguments += "-c:v" to it + } ?: run { + outputArguments += "-vn" + } + args.audioCodec?.let { + outputArguments -= "-c:a" + outputArguments += "-c:a" to it + } + } + Action.MERGE_MEDIA -> { + inputArguments.clear() + val filesInfo = args.inputs.mapNotNull { file -> + runCatching { + MediaMetadataRetriever().apply { setDataSource(file) } + }.getOrNull()?.let { file to it } + } + + val (maxWidth, maxHeight) = filesInfo.maxByOrNull { (_, r) -> + r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0 + }?.let { (_, r) -> + r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() to + r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() + } ?: throw Exception("Failed to get video size") + + val filterFirstPart = StringBuilder() + val filterSecondPart = StringBuilder() + var containsNoSound = false + + filesInfo.forEachIndexed { index, (file, retriever) -> + filterFirstPart.append("[$index:v]scale=$maxWidth:$maxHeight,setsar=1[v$index];") + if (retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO) == "yes") { + filterSecondPart.append("[v$index][$index:a]") + } else { + containsNoSound = true + filterSecondPart.append("[v$index][${filesInfo.size}]") + } + inputArguments += "-i" to file + } + + if (containsNoSound) { + inputArguments += "-f" to "lavfi" + inputArguments += "-t" to "0.1" + inputArguments += "-i" to "anullsrc=channel_layout=stereo:sample_rate=44100" + } + + if (outputArguments["-c:a"] == "copy") { + outputArguments -= "-c:a" + } + + outputArguments += "-fps_mode" to "vfr" + + outputArguments += "-filter_complex" to "\"$filterFirstPart ${filterSecondPart}concat=n=${args.inputs.size}:v=1:a=1[vout][aout]\"" + outputArguments += "-map" to "\"[aout]\"" + outputArguments += "-map" to "\"[vout]\"" + + filesInfo.forEach { it.second.close() } + } + Action.DOWNLOAD_AUDIO_STREAM -> { + outputArguments.clear() + globalArguments += "-f" to when (args.audioStreamFormat!!.encoding) { + AudioFormat.ENCODING_PCM_8BIT -> "u8" + AudioFormat.ENCODING_PCM_16BIT -> "s16le" + AudioFormat.ENCODING_PCM_FLOAT -> "f32le" + AudioFormat.ENCODING_PCM_32BIT -> "s32le" + else -> throw IllegalArgumentException("Unsupported audio encoding") + } + globalArguments += "-ar" to args.audioStreamFormat.sampleRate.toString() + globalArguments += "-ac" to args.audioStreamFormat.channels.toString() + } + } + outputArguments += args.output.absolutePath + newFFMpegTask(globalArguments, inputArguments, outputArguments) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/e2ee/E2EEImplementation.kt b/app/src/main/kotlin/me/rhunk/snapenhance/e2ee/E2EEImplementation.kt new file mode 100644 index 0000000000..f796176599 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/e2ee/E2EEImplementation.kt @@ -0,0 +1,166 @@ +package me.rhunk.snapenhance.e2ee + +import me.rhunk.snapenhance.RemoteSideContext +import me.rhunk.snapenhance.bridge.e2ee.E2eeInterface +import me.rhunk.snapenhance.bridge.e2ee.EncryptionResult +import me.rhunk.snapenhance.core.util.EvictingMap +import org.bouncycastle.pqc.crypto.crystals.kyber.* +import java.io.File +import java.security.MessageDigest +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + + +class E2EEImplementation ( + private val context: RemoteSideContext +) : E2eeInterface.Stub() { + private val kyberDefaultParameters = KyberParameters.kyber1024 + private val secureRandom = SecureRandom() + + private val e2eeFolder by lazy { File(context.androidContext.filesDir, "e2ee").also { + if (!it.exists()) it.mkdirs() + }} + private val pairingFolder by lazy { File(context.androidContext.cacheDir, "e2ee-pairing").also { + if (!it.exists()) it.mkdirs() + else { + it.deleteRecursively() + it.mkdirs() + } + } } + + private val sharedSecretKeyCache = EvictingMap<String, ByteArray?>(100) + + fun storeSharedSecretKey(friendId: String, key: ByteArray) { + File(e2eeFolder, "$friendId.key").writeBytes(key) + sharedSecretKeyCache[friendId] = key + } + + fun getSharedSecretKey(friendId: String): ByteArray? { + return sharedSecretKeyCache.getOrPut(friendId) { + runCatching { + File(e2eeFolder, "$friendId.key").readBytes() + }.onFailure { + context.log.warn("Failed to read shared secret key: ${it.message}") + }.getOrNull() + } + } + + fun deleteSharedSecretKey(friendId: String) { + File(e2eeFolder, "$friendId.key").delete() + } + + override fun createKeyExchange(friendId: String): ByteArray? { + val keyPairGenerator = KyberKeyPairGenerator() + keyPairGenerator.init( + KyberKeyGenerationParameters(secureRandom, kyberDefaultParameters) + ) + val keyPair = keyPairGenerator.generateKeyPair() + val publicKey = keyPair.public as KyberPublicKeyParameters + val privateKey = keyPair.private as KyberPrivateKeyParameters + runCatching { + File(pairingFolder, "$friendId.private").writeBytes(privateKey.encoded) + File(pairingFolder, "$friendId.public").writeBytes(publicKey.encoded) + }.onFailure { + context.log.error("Failed to write private key to file", it) + return null + } + return publicKey.encoded + } + + override fun acceptPairingRequest(friendId: String, publicKey: ByteArray): ByteArray? { + val kemGen = KyberKEMGenerator(secureRandom) + val encapsulatedSecret = runCatching { + kemGen.generateEncapsulated( + KyberPublicKeyParameters( + kyberDefaultParameters, + publicKey + ) + ) + }.onFailure { + context.log.error("Failed to generate encapsulated secret", it) + return null + }.getOrThrow() + + runCatching { + storeSharedSecretKey(friendId, encapsulatedSecret.secret) + }.onFailure { + context.log.error("Failed to store shared secret key", it) + return null + } + return encapsulatedSecret.encapsulation + } + + override fun acceptPairingResponse(friendId: String, encapsulatedSecret: ByteArray): Boolean { + val privateKey = runCatching { + val secretKey = File(pairingFolder, "$friendId.private").readBytes() + object: KyberPrivateKeyParameters(kyberDefaultParameters, null, null, null, null, null) { + override fun getEncoded() = secretKey + } + }.onFailure { + context.log.error("Failed to read private key from file", it) + return false + }.getOrThrow() + + val kemExtractor = KyberKEMExtractor(privateKey) + val sharedSecret = runCatching { + kemExtractor.extractSecret(encapsulatedSecret) + }.onFailure { + context.log.error("Failed to extract shared secret", it) + return false + }.getOrThrow() + + runCatching { + storeSharedSecretKey(friendId, sharedSecret) + }.onFailure { + context.log.error("Failed to store shared secret key", it) + return false + } + + return true + } + + override fun friendKeyExists(friendId: String): Boolean { + return File(e2eeFolder, "$friendId.key").exists() + } + + override fun getSecretFingerprint(friendId: String): String? { + val sharedSecretKey = getSharedSecretKey(friendId) ?: return null + + return MessageDigest.getInstance("SHA-256") + .digest(sharedSecretKey) + .joinToString("") { "%02x".format(it) } + .chunked(5) + .joinToString(" ") + } + + override fun encryptMessage(friendId: String, message: ByteArray): EncryptionResult? { + val encryptionKey = getSharedSecretKey(friendId) ?: return null + + return runCatching { + val iv = ByteArray(16).apply { secureRandom.nextBytes(this) } + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(encryptionKey, "AES"), IvParameterSpec(iv)) + EncryptionResult().apply { + this.iv = iv + this.ciphertext = cipher.doFinal(message) + } + }.onFailure { + context.log.error("Failed to encrypt message for $friendId", it) + }.getOrNull() + } + + override fun decryptMessage(friendId: String, message: ByteArray, iv: ByteArray): ByteArray? { + val encryptionKey = getSharedSecretKey(friendId) ?: return null + + return runCatching { + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(encryptionKey, "AES"), IvParameterSpec(iv)) + cipher.doFinal(message) + }.onFailure { + context.log.warn("Failed to decrypt message for $friendId") + return null + }.getOrNull() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/event/EventBus.kt b/app/src/main/kotlin/me/rhunk/snapenhance/event/EventBus.kt deleted file mode 100644 index dca07ff979..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/event/EventBus.kt +++ /dev/null @@ -1,62 +0,0 @@ -package me.rhunk.snapenhance.event - -import me.rhunk.snapenhance.ModContext -import kotlin.reflect.KClass - -abstract class Event { - lateinit var context: ModContext -} - -interface IListener<T> { - fun handle(event: T) -} - -class EventBus( - private val context: ModContext -) { - private val subscribers = mutableMapOf<KClass<out Event>, MutableList<IListener<out Event>>>() - - fun <T : Event> subscribe(event: KClass<T>, listener: IListener<T>) { - if (!subscribers.containsKey(event)) { - subscribers[event] = mutableListOf() - } - subscribers[event]!!.add(listener) - } - - fun <T : Event> subscribe(event: KClass<T>, listener: (T) -> Unit) { - subscribe(event, object : IListener<T> { - override fun handle(event: T) { - listener(event) - } - }) - } - - fun <T : Event> unsubscribe(event: KClass<T>, listener: IListener<T>) { - if (!subscribers.containsKey(event)) { - return - } - subscribers[event]!!.remove(listener) - } - - fun <T : Event> post(event: T) { - if (!subscribers.containsKey(event::class)) { - return - } - - event.context = context - - subscribers[event::class]!!.forEach { listener -> - @Suppress("UNCHECKED_CAST") - try { - (listener as IListener<T>).handle(event) - } catch (t: Throwable) { - println("Error while handling event ${event::class.simpleName} by ${listener::class.simpleName}") - t.printStackTrace() - } - } - } - - fun clear() { - subscribers.clear() - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/event/Events.kt b/app/src/main/kotlin/me/rhunk/snapenhance/event/Events.kt deleted file mode 100644 index aae56c2d44..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/event/Events.kt +++ /dev/null @@ -1,3 +0,0 @@ -package me.rhunk.snapenhance.event - -//TODO: addView event \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/BridgeFileFeature.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/BridgeFileFeature.kt deleted file mode 100644 index 3603ff2b15..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/BridgeFileFeature.kt +++ /dev/null @@ -1,54 +0,0 @@ -package me.rhunk.snapenhance.features - -import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType -import java.io.BufferedReader -import java.io.ByteArrayInputStream -import java.io.InputStreamReader -import java.nio.charset.StandardCharsets - -abstract class BridgeFileFeature(name: String, private val bridgeFileType: BridgeFileType, loadParams: Int) : Feature(name, loadParams) { - private val fileLines = mutableListOf<String>() - - protected fun readFile() { - val temporaryLines = mutableListOf<String>() - val fileData: ByteArray = context.bridgeClient.createAndReadFile(bridgeFileType, ByteArray(0)) - with(BufferedReader(InputStreamReader(ByteArrayInputStream(fileData), StandardCharsets.UTF_8))) { - var line = "" - while (readLine()?.also { line = it } != null) temporaryLines.add(line) - close() - } - fileLines.clear() - fileLines.addAll(temporaryLines) - } - - private fun updateFile() { - val sb = StringBuilder() - fileLines.forEach { - sb.append(it).append("\n") - } - context.bridgeClient.writeFile(bridgeFileType, sb.toString().toByteArray(Charsets.UTF_8)) - } - - protected fun exists(line: String) = fileLines.contains(line) - - protected fun toggle(line: String) { - if (exists(line)) fileLines.remove(line) else fileLines.add(line) - updateFile() - } - - protected fun setState(line: String, state: Boolean) { - if (state) { - if (!exists(line)) fileLines.add(line) - } else { - if (exists(line)) fileLines.remove(line) - } - updateFile() - } - - protected fun reload() = readFile() - - protected fun put(line: String) { - fileLines.add(line) - updateFile() - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/Feature.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/Feature.kt deleted file mode 100644 index ab632492cc..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/Feature.kt +++ /dev/null @@ -1,31 +0,0 @@ -package me.rhunk.snapenhance.features - -import me.rhunk.snapenhance.ModContext - -abstract class Feature( - val nameKey: String, - val loadParams: Int = FeatureLoadParams.INIT_SYNC -) { - lateinit var context: ModContext - - /** - * called on the main thread when the mod initialize - */ - open fun init() {} - - /** - * called on a dedicated thread when the mod initialize - */ - open fun asyncInit() {} - - /** - * called when the Snapchat Activity is created - */ - open fun onActivityCreate() {} - - - /** - * called on a dedicated thread when the Snapchat Activity is created - */ - open fun asyncOnActivityCreate() {} -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/FeatureLoadParams.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/FeatureLoadParams.kt deleted file mode 100644 index fbbbc2f4b7..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/FeatureLoadParams.kt +++ /dev/null @@ -1,11 +0,0 @@ -package me.rhunk.snapenhance.features - -object FeatureLoadParams { - const val NO_INIT = 0 - - const val INIT_SYNC = 1 - const val ACTIVITY_CREATE_SYNC = 2 - - const val INIT_ASYNC = 3 - const val ACTIVITY_CREATE_ASYNC = 4 -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/AutoUpdater.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/AutoUpdater.kt deleted file mode 100644 index e9c5df19ae..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/AutoUpdater.kt +++ /dev/null @@ -1,109 +0,0 @@ -package me.rhunk.snapenhance.features.impl - -import android.annotation.SuppressLint -import android.app.AlertDialog -import android.app.DownloadManager -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.net.Uri -import android.os.Environment -import me.rhunk.snapenhance.BuildConfig -import me.rhunk.snapenhance.Logger -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import okhttp3.OkHttpClient -import okhttp3.Request -import org.json.JSONArray - -class AutoUpdater : Feature("AutoUpdater", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { - override fun asyncOnActivityCreate() { - val checkForUpdateMode = context.config.state(ConfigProperty.AUTO_UPDATER) - val currentTimeMillis = System.currentTimeMillis() - val checkForUpdatesTimestamp = context.bridgeClient.getAutoUpdaterTime() - - val delayTimestamp = when (checkForUpdateMode) { - "EVERY_LAUNCH" -> currentTimeMillis - checkForUpdatesTimestamp - "DAILY" -> 86400000L - "WEEKLY" -> 604800000L - else -> return - } - - if (checkForUpdatesTimestamp + delayTimestamp > currentTimeMillis) return - - runCatching { - checkForUpdates() - }.onFailure { - Logger.error("Failed to check for updates: ${it.message}", it) - }.onSuccess { - context.bridgeClient.setAutoUpdaterTime(currentTimeMillis) - } - } - - @SuppressLint("UnspecifiedRegisterReceiverFlag") - fun checkForUpdates(): String? { - val endpoint = Request.Builder().url("https://api.github.com/repos/rhunk/SnapEnhance/releases").build() - val response = OkHttpClient().newCall(endpoint).execute() - - if (!response.isSuccessful) throw Throwable("Failed to fetch releases: ${response.code}") - - val releases = JSONArray(response.body.string()).also { - if (it.length() == 0) throw Throwable("No releases found") - } - - val latestRelease = releases.getJSONObject(0) - val latestVersion = latestRelease.getString("tag_name") - if (latestVersion.removePrefix("v") == BuildConfig.VERSION_NAME) return null - - val releaseContentBody = latestRelease.getString("body") - val downloadEndpoint = latestRelease.getJSONArray("assets").getJSONObject(0).getString("browser_download_url") - - context.runOnUiThread { - AlertDialog.Builder(context.mainActivity) - .setTitle(context.translation.get("auto_updater.dialog_title")) - .setMessage( - context.translation.get("auto_updater.dialog_message") - .replace("{version}", latestVersion) - .replace("{body}", releaseContentBody) - ) - .setNegativeButton(context.translation.get("auto_updater.dialog_negative_button")) { dialog, _ -> - dialog.dismiss() - } - .setPositiveButton(context.translation.get("auto_updater.dialog_positive_button")) { dialog, _ -> - dialog.dismiss() - context.longToast(context.translation.get("auto_updater.downloading_toast")) - - val request = DownloadManager.Request(Uri.parse(downloadEndpoint)) - .setTitle(context.translation.get("auto_updater.download_manager_notification_title")) - .setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, "latest-snapenhance.apk") - .setMimeType("application/vnd.android.package-archive") - .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE) - - val downloadManager = context.androidContext.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager - val downloadId = downloadManager.enqueue(request) - - val onCompleteReceiver = object: BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) - if (id != downloadId) return - context.unregisterReceiver(this) - context.startActivity( - Intent(Intent.ACTION_VIEW).apply { - setDataAndType(downloadManager.getUriForDownloadedFile(downloadId), "application/vnd.android.package-archive") - flags = Intent.FLAG_ACTIVITY_NEW_TASK - } - ) - } - } - - context.mainActivity?.registerReceiver(onCompleteReceiver, IntentFilter( - DownloadManager.ACTION_DOWNLOAD_COMPLETE - )) - }.show() - } - - return latestVersion - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ConfigEnumKeys.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ConfigEnumKeys.kt deleted file mode 100644 index 04f413770b..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ConfigEnumKeys.kt +++ /dev/null @@ -1,100 +0,0 @@ -package me.rhunk.snapenhance.features.impl - -import android.annotation.SuppressLint -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.hook -import me.rhunk.snapenhance.util.getObjectField -import me.rhunk.snapenhance.util.setObjectField -import java.lang.reflect.Field -import java.lang.reflect.Modifier -import java.lang.reflect.Type - -class ConfigEnumKeys : Feature("Config enum keys", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { - - data class HookEnumContext( - val key: String, - val type: Type?, - val value: Any?, - val set: (Any) -> Unit - ) - - companion object { - fun hookAllEnums(enumClass: Class<*>, callback: HookEnumContext.() -> Unit) { - //Enum(String, int, ?) - //or Enum(?) - val enumDataClass = enumClass.constructors[0].parameterTypes.first { clazz: Class<*> -> clazz != String::class.java && !clazz.isPrimitive } - - //get the field which contains the enum data class - val enumDataField = enumClass.declaredFields.first { field: Field -> field.type == enumDataClass } - - val typeField = enumDataClass.declaredFields.first { field: Field -> field.type == Type::class.java } - - //get the field value of the enum data class (the first field of the class with the desc Object) - val objectDataField = enumDataField.type.fields.first { field: Field -> - field.type == Any::class.java && Modifier.isPublic( - field.modifiers - ) && Modifier.isFinal(field.modifiers) - } - - enumClass.enumConstants.forEach { enum -> - enumDataField.get(enum)?.let { enumData -> - val key = enum.toString() - val type = typeField.get(enumData) as Type? - val value = enumData.getObjectField(objectDataField.name) - val set = { newValue: Any -> - enumData.setObjectField(objectDataField.name, newValue) - } - callback(HookEnumContext(key, type, value, set)) - } - } - } - } - - @SuppressLint("PrivateApi") - override fun onActivityCreate() { - if (context.config.bool(ConfigProperty.NEW_MAP_UI)) { - hookAllEnums(context.mappings.getMappedClass("enums", "PLUS")) { - if (key == "REDUCE_MY_PROFILE_UI_COMPLEXITY") set(true) - } - } - - hookAllEnums(context.mappings.getMappedClass("enums", "ARROYO")) { - if (key == "ENABLE_LONG_SNAP_SENDING") { - if (context.config.bool(ConfigProperty.DISABLE_SNAP_SPLITTING)) set(true) - } - } - - if (context.config.bool(ConfigProperty.STREAK_EXPIRATION_INFO)) { - hookAllEnums(context.mappings.getMappedClass("enums", "FRIENDS_FEED")) { - if (key == "STREAK_EXPIRATION_INFO") set(true) - } - } - - if (context.config.bool(ConfigProperty.BLOCK_ADS)) { - hookAllEnums(context.mappings.getMappedClass("enums", "SNAPADS")) { - if (key == "BYPASS_AD_FEATURE_GATE") { - set(true) - } - if (key == "CUSTOM_AD_SERVER_URL" || key == "CUSTOM_AD_INIT_SERVER_URL" || key == "CUSTOM_AD_TRACKER_URL") { - set("http://127.0.0.1") - } - } - } - - ConfigProperty.ENABLE_APP_APPEARANCE.valueContainer.addPropertyChangeListener { - context.softRestartApp(true) - } - - val sharedPreferencesImpl = context.androidContext.classLoader.loadClass("android.app.SharedPreferencesImpl") - - sharedPreferencesImpl.methods.first { it.name == "getBoolean" }.hook(HookStage.BEFORE) { param -> - when (param.arg<String>(0)) { - "SIG_APP_APPEARANCE_SETTING" -> if (context.config.bool(ConfigProperty.ENABLE_APP_APPEARANCE)) param.setResult(true) - "SPOTLIGHT_5TH_TAB_ENABLED" -> if (context.config.bool(ConfigProperty.DISABLE_SPOTLIGHT)) param.setResult(false) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/Messaging.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/Messaging.kt deleted file mode 100644 index fd0f4b9fa8..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/Messaging.kt +++ /dev/null @@ -1,71 +0,0 @@ -package me.rhunk.snapenhance.features.impl - -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.Hooker - -class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC or FeatureLoadParams.INIT_ASYNC or FeatureLoadParams.INIT_SYNC) { - lateinit var conversationManager: Any - - var lastOpenedConversationUUID: SnapUUID? = null - var lastFetchConversationUserUUID: SnapUUID? = null - var lastFetchConversationUUID: SnapUUID? = null - var lastFocusedMessageId: Long = -1 - - override fun init() { - Hooker.hookConstructor(context.classCache.conversationManager, HookStage.BEFORE) { - conversationManager = it.thisObject() - } - } - - override fun onActivityCreate() { - with(context.classCache.conversationManager) { - Hooker.hook(this, "enterConversation", HookStage.BEFORE) { - lastOpenedConversationUUID = SnapUUID(it.arg(0)) - } - - Hooker.hook(this, "getOneOnOneConversationIds", HookStage.BEFORE) { param -> - val conversationIds: List<Any> = param.arg(0) - if (conversationIds.isNotEmpty()) { - lastFetchConversationUserUUID = SnapUUID(conversationIds[0]) - } - } - - Hooker.hook(this, "exitConversation", HookStage.BEFORE) { - lastOpenedConversationUUID = null - } - - Hooker.hook(this, "fetchConversation", HookStage.BEFORE) { - lastFetchConversationUUID = SnapUUID(it.arg(0)) - } - } - - } - - override fun asyncInit() { - arrayOf("activate", "deactivate", "processTypingActivity").forEach { hook -> - Hooker.hook(context.classCache.presenceSession, hook, HookStage.BEFORE, { context.config.bool(ConfigProperty.HIDE_BITMOJI_PRESENCE) }) { - it.setResult(null) - } - } - - //get last opened snap for media downloader - Hooker.hook(context.classCache.snapManager, "onSnapInteraction", HookStage.BEFORE) { param -> - lastOpenedConversationUUID = SnapUUID(param.arg(1)) - lastFocusedMessageId = param.arg(2) - } - - Hooker.hook(context.classCache.conversationManager, "fetchMessage", HookStage.BEFORE) { param -> - lastFetchConversationUserUUID = SnapUUID((param.arg(0) as Any)) - lastFocusedMessageId = param.arg(1) - } - - Hooker.hook(context.classCache.conversationManager, "sendTypingNotification", HookStage.BEFORE, - {context.config.bool(ConfigProperty.HIDE_TYPING_NOTIFICATION)}) { - it.setResult(null) - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/AntiAutoDownload.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/AntiAutoDownload.kt deleted file mode 100644 index 8cff13f093..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/AntiAutoDownload.kt +++ /dev/null @@ -1,19 +0,0 @@ -package me.rhunk.snapenhance.features.impl.downloader - -import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType -import me.rhunk.snapenhance.features.BridgeFileFeature -import me.rhunk.snapenhance.features.FeatureLoadParams - -class AntiAutoDownload : BridgeFileFeature("AntiAutoDownload", BridgeFileType.ANTI_AUTO_DOWNLOAD, loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { - override fun onActivityCreate() { - readFile() - } - - fun setUserIgnored(userId: String, state: Boolean) { - setState(userId.hashCode().toLong().toString(16), state) - } - - fun isUserIgnored(userId: String): Boolean { - return exists(userId.hashCode().toLong().toString(16)) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt deleted file mode 100644 index 43326d8a7c..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt +++ /dev/null @@ -1,442 +0,0 @@ -package me.rhunk.snapenhance.features.impl.downloader - -import android.app.AlertDialog -import android.content.DialogInterface -import android.graphics.Bitmap -import android.media.MediaScannerConnection -import android.net.Uri -import android.widget.ImageView -import com.arthenica.ffmpegkit.FFmpegKit -import me.rhunk.snapenhance.Constants -import me.rhunk.snapenhance.Constants.ARROYO_URL_KEY_PROTO_PATH -import me.rhunk.snapenhance.Logger.xposedLog -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.data.ContentType -import me.rhunk.snapenhance.data.FileType -import me.rhunk.snapenhance.data.wrapper.impl.media.MediaInfo -import me.rhunk.snapenhance.data.wrapper.impl.media.dash.LongformVideoPlaylistItem -import me.rhunk.snapenhance.data.wrapper.impl.media.dash.SnapPlaylistItem -import me.rhunk.snapenhance.data.wrapper.impl.media.opera.Layer -import me.rhunk.snapenhance.data.wrapper.impl.media.opera.ParamMap -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.features.impl.Messaging -import me.rhunk.snapenhance.features.impl.spying.MessageLogger -import me.rhunk.snapenhance.hook.HookAdapter -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.Hooker -import me.rhunk.snapenhance.util.EncryptionUtils -import me.rhunk.snapenhance.util.MediaDownloaderHelper -import me.rhunk.snapenhance.util.MediaType -import me.rhunk.snapenhance.util.PreviewUtils -import me.rhunk.snapenhance.util.download.RemoteMediaResolver -import me.rhunk.snapenhance.util.getObjectField -import me.rhunk.snapenhance.util.protobuf.ProtoReader -import java.io.ByteArrayOutputStream -import java.io.File -import java.io.FileOutputStream -import java.io.InputStream -import java.net.HttpURLConnection -import java.net.URL -import java.nio.file.Paths -import java.text.SimpleDateFormat -import java.util.Arrays -import java.util.Locale -import java.util.concurrent.atomic.AtomicReference -import javax.crypto.Cipher -import javax.crypto.CipherInputStream -import javax.xml.parsers.DocumentBuilderFactory -import javax.xml.transform.TransformerFactory -import javax.xml.transform.dom.DOMSource -import javax.xml.transform.stream.StreamResult -import kotlin.io.path.inputStream - - -class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { - private var lastSeenMediaInfoMap: MutableMap<MediaType, MediaInfo>? = null - private var lastSeenMapParams: ParamMap? = null - private val isFFmpegPresent by lazy { - runCatching { FFmpegKit.execute("-version") }.isSuccess - } - - private fun canMergeOverlay(): Boolean { - if (context.config.options(ConfigProperty.DOWNLOAD_OPTIONS)["merge_overlay"] == false) return false - return isFFmpegPresent - } - - private fun createNewFilePath(hash: Int, author: String, fileType: FileType): String { - val hexHash = Integer.toHexString(hash) - val downloadOptions = context.config.options(ConfigProperty.DOWNLOAD_OPTIONS) - - val currentDateTime = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.ENGLISH).format(System.currentTimeMillis()) - - val finalPath = StringBuilder() - - fun appendFileName(string: String) { - if (finalPath.isEmpty() || finalPath.endsWith("/")) { - finalPath.append(string) - } else { - finalPath.append("_").append(string) - } - } - - if (downloadOptions["format_user_folder"] == true) { - finalPath.append(author).append("/") - } - if (downloadOptions["format_hash"] == true) { - appendFileName(hexHash) - } - if (downloadOptions["format_username"] == true) { - appendFileName(author) - } - if (downloadOptions["format_date_time"] == true) { - appendFileName(currentDateTime) - } - - if (finalPath.isEmpty()) finalPath.append(hexHash) - - return finalPath.toString() + "." + fileType.fileExtension - } - - private fun downloadFile(outputFile: File, content: ByteArray): Boolean { - val onDownloadComplete = { - context.shortToast( - "Saved to " + outputFile.absolutePath.replace(context.config.string(ConfigProperty.SAVE_FOLDER), "") - .substring(1) - ) - } - if (!context.config.bool(ConfigProperty.USE_DOWNLOAD_MANAGER)) { - try { - val fos = FileOutputStream(outputFile) - fos.write(content) - fos.close() - MediaScannerConnection.scanFile( - context.androidContext, - arrayOf(outputFile.absolutePath), - null, - null - ) - onDownloadComplete() - } catch (e: Throwable) { - xposedLog(e) - context.longToast("Failed to save file: " + e.message) - return false - } - return true - } - context.downloadServer.startFileDownload(outputFile, content) { result -> - if (result) { - onDownloadComplete() - return@startFileDownload - } - context.longToast("Failed to save file. Check logs for more info.") - } - return true - } - private fun queryMediaData(mediaInfo: MediaInfo): ByteArray { - val mediaUri = Uri.parse(mediaInfo.uri) - val mediaInputStream = AtomicReference<InputStream>() - if (mediaUri.scheme == "file") { - mediaInputStream.set(Paths.get(mediaUri.path).inputStream()) - } else { - val url = URL(mediaUri.toString()) - val connection = url.openConnection() as HttpURLConnection - connection.requestMethod = "GET" - connection.setRequestProperty("User-Agent", Constants.USER_AGENT) - connection.connect() - mediaInputStream.set(connection.inputStream) - } - mediaInfo.encryption?.let { encryption -> - mediaInputStream.set(CipherInputStream(mediaInputStream.get(), encryption.newCipher(Cipher.DECRYPT_MODE))) - } - return mediaInputStream.get().readBytes() - } - - private fun createNeededDirectories(file: File): File { - val directory = file.parentFile ?: return file - if (!directory.exists()) { - directory.mkdirs() - } - return file - } - - private fun isFileExists(hash: Int, author: String, fileType: FileType): Boolean { - val fileName: String = createNewFilePath(hash, author, fileType) - val outputFile: File = - createNeededDirectories(File(context.config.string(ConfigProperty.SAVE_FOLDER), fileName)) - return outputFile.exists() - } - - - /* - * Download the last seen media - */ - fun downloadLastOperaMediaAsync() { - if (lastSeenMapParams == null || lastSeenMediaInfoMap == null) return - context.executeAsync { - handleOperaMedia(lastSeenMapParams!!, lastSeenMediaInfoMap!!, true) - } - } - - private fun downloadOperaMedia(mediaInfoMap: Map<MediaType, MediaInfo>, author: String) { - if (mediaInfoMap.isEmpty()) return - val originalMediaInfo = mediaInfoMap[MediaType.ORIGINAL]!! - if (mediaInfoMap.containsKey(MediaType.OVERLAY)) { - context.shortToast("Downloading split snap") - } - var mediaContent: ByteArray? = queryMediaData(originalMediaInfo) - val hash = Arrays.hashCode(mediaContent) - if (mediaInfoMap.containsKey(MediaType.OVERLAY)) { - //prevent converting the same media twice - if (isFileExists(hash, author, FileType.fromByteArray(mediaContent!!))) { - context.shortToast("Media already exists") - return - } - val overlayMediaInfo = mediaInfoMap[MediaType.OVERLAY]!! - val overlayContent: ByteArray = queryMediaData(overlayMediaInfo) - mediaContent = MediaDownloaderHelper.mergeOverlay(mediaContent, overlayContent, false) - } - val fileType = FileType.fromByteArray(mediaContent!!) - downloadMediaContent(mediaContent, hash, author, fileType) - } - - private fun downloadMediaContent( - data: ByteArray, - hash: Int, - messageAuthor: String, - fileType: FileType - ): Boolean { - val fileName: String = createNewFilePath(hash, messageAuthor, fileType) ?: return false - val outputFile: File = createNeededDirectories(File(context.config.string(ConfigProperty.SAVE_FOLDER), fileName)) - if (outputFile.exists()) { - context.shortToast("Media already exists") - return false - } - return downloadFile(outputFile, data) - } - - /** - * Handles the media from the opera viewer - * - * @param paramMap the parameters from the opera viewer - * @param mediaInfoMap the media info map - * @param forceDownload if the media should be downloaded - */ - private fun handleOperaMedia( - paramMap: ParamMap, - mediaInfoMap: Map<MediaType, MediaInfo>, - forceDownload: Boolean - ) { - //messages - paramMap["MESSAGE_ID"]?.toString()?.takeIf { forceDownload || canAutoDownload("friend_snaps") }?.let { id -> - val messageId = id.substring(id.lastIndexOf(":") + 1).toLong() - val senderId: String = context.database.getConversationMessageFromId(messageId)!!.sender_id!! - - if (!forceDownload && context.feature(AntiAutoDownload::class).isUserIgnored(senderId)) { - return - } - - val author = context.database.getFriendInfo(senderId)!!.usernameForSorting!! - downloadOperaMedia(mediaInfoMap, author) - return - } - - //private stories - paramMap["PLAYLIST_V2_GROUP"]?.toString()?.takeIf { - it.contains("storyUserId=") && (forceDownload || canAutoDownload("friend_stories")) - }?.let { playlistGroup -> - val storyIdStartIndex = playlistGroup.indexOf("storyUserId=") + 12 - val storyUserId = playlistGroup.substring( - storyIdStartIndex, - playlistGroup.indexOf(",", storyIdStartIndex) - ) - val author = context.database.getFriendInfo(if (storyUserId == "null") context.database.getMyUserId()!! else storyUserId) - - downloadOperaMedia(mediaInfoMap, author!!.usernameForSorting!!) - return - } - - val snapSource = paramMap["SNAP_SOURCE"].toString() - - //public stories - if ((snapSource == "PUBLIC_USER" || snapSource == "SAVED_STORY") && - (forceDownload || canAutoDownload("public_stories"))) { - val userDisplayName = (if (paramMap.containsKey("USER_DISPLAY_NAME")) paramMap["USER_DISPLAY_NAME"].toString() else "").replace( - "[^\\x00-\\x7F]".toRegex(), - "") - downloadOperaMedia(mediaInfoMap, "Public-Stories/$userDisplayName") - return - } - - //spotlight - if (snapSource == "SINGLE_SNAP_STORY" && (forceDownload || canAutoDownload("spotlight"))) { - downloadOperaMedia(mediaInfoMap, "Spotlight") - return - } - - //stories with mpeg dash media - //TODO: option to download multiple chapters - if (paramMap.containsKey("LONGFORM_VIDEO_PLAYLIST_ITEM") && forceDownload) { - if (!isFFmpegPresent) { - context.shortToast("Can't download media. ffmpeg was not found") - return - } - - val storyName = paramMap["STORY_NAME"].toString().replace( - "[^\\x00-\\x7F]".toRegex(), - "") - - //get the position of the media in the playlist and the duration - val snapItem = SnapPlaylistItem(paramMap["SNAP_PLAYLIST_ITEM"]!!) - val snapChapterList = LongformVideoPlaylistItem(paramMap["LONGFORM_VIDEO_PLAYLIST_ITEM"]!!).chapters - if (snapChapterList.isEmpty()) { - context.shortToast("No chapters found") - return - } - val snapChapter = snapChapterList.first { it.snapId == snapItem.snapId } - val nextChapter = snapChapterList.getOrNull(snapChapterList.indexOf(snapChapter) + 1) - - //add 100ms to the start time to prevent the video from starting too early - val snapChapterTimestamp = snapChapter.startTimeMs.plus(100) - val duration = nextChapter?.startTimeMs?.minus(snapChapterTimestamp) ?: 0 - - //get the mpd playlist and append the cdn url to baseurl nodes - val playlistUrl = paramMap["MEDIA_ID"].toString().let { it.substring(it.indexOf("https://cf-st.sc-cdn.net")) } - val playlistXml = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(URL(playlistUrl).openStream()) - val baseUrlNodeList = playlistXml.getElementsByTagName("BaseURL") - for (i in 0 until baseUrlNodeList.length) { - val baseUrlNode = baseUrlNodeList.item(i) - val baseUrl = baseUrlNode.textContent - baseUrlNode.textContent = "${RemoteMediaResolver.CF_ST_CDN_D}$baseUrl" - } - - val xmlData = ByteArrayOutputStream() - TransformerFactory.newInstance().newTransformer().transform(DOMSource(playlistXml), StreamResult(xmlData)) - runCatching { - context.shortToast("Downloading dash media. This might take a while...") - val downloadedMedia = MediaDownloaderHelper.downloadDashChapter(xmlData.toByteArray().toString(Charsets.UTF_8), snapChapterTimestamp, duration) - downloadMediaContent(downloadedMedia, downloadedMedia.contentHashCode(), "Pro-Stories/${storyName}", FileType.fromByteArray(downloadedMedia)) - }.onFailure { - context.longToast("Failed to download media: ${it.message}") - xposedLog(it) - } - } - } - - private fun canAutoDownload(keyFilter: String? = null): Boolean { - val options = context.config.options(ConfigProperty.AUTO_DOWNLOAD_OPTIONS) - return options.filter { it.value }.any { keyFilter == null || it.key.contains(keyFilter, true) } - } - - override fun asyncOnActivityCreate() { - val operaViewerControllerClass: Class<*> = context.mappings.getMappedClass("OperaPageViewController", "Class") - - val onOperaViewStateCallback: (HookAdapter) -> Unit = onOperaViewStateCallback@{ param -> - - val viewState = (param.thisObject() as Any).getObjectField(context.mappings.getMappedValue("OperaPageViewController", "viewStateField")).toString() - if (viewState != "FULLY_DISPLAYED") { - return@onOperaViewStateCallback - } - val operaLayerList = (param.thisObject() as Any).getObjectField(context.mappings.getMappedValue("OperaPageViewController", "layerListField")) as ArrayList<*> - val mediaParamMap: ParamMap = operaLayerList.map { Layer(it) }.first().paramMap - - if (!mediaParamMap.containsKey("image_media_info") && !mediaParamMap.containsKey("video_media_info_list")) - return@onOperaViewStateCallback - - val mediaInfoMap = mutableMapOf<MediaType, MediaInfo>() - val isVideo = mediaParamMap.containsKey("video_media_info_list") - mediaInfoMap[MediaType.ORIGINAL] = MediaInfo( - (if (isVideo) mediaParamMap["video_media_info_list"] else mediaParamMap["image_media_info"])!! - ) - if (canMergeOverlay() && mediaParamMap.containsKey("overlay_image_media_info")) { - mediaInfoMap[MediaType.OVERLAY] = - MediaInfo(mediaParamMap["overlay_image_media_info"]!!) - } - lastSeenMapParams = mediaParamMap - lastSeenMediaInfoMap = mediaInfoMap - - if (!canAutoDownload()) return@onOperaViewStateCallback - - context.executeAsync { - try { - handleOperaMedia(mediaParamMap, mediaInfoMap, false) - } catch (e: Throwable) { - xposedLog(e) - context.longToast(e.message!!) - } - } - } - - arrayOf("onDisplayStateChange", "onDisplayStateChange2").forEach { methodName -> - Hooker.hook( - operaViewerControllerClass, - context.mappings.getMappedValue("OperaPageViewController", methodName), - HookStage.AFTER, onOperaViewStateCallback - ) - } - } - - /** - * Called when a message is focused in chat - */ - //TODO: use snapchat classes instead of database (when content is deleted) - fun onMessageActionMenu(isPreviewMode: Boolean) { - //check if the message was focused in a conversation - val messaging = context.feature(Messaging::class) - if (messaging.lastOpenedConversationUUID == null) return - val message = context.database.getConversationMessageFromId(messaging.lastFocusedMessageId) ?: return - - //get the message author - val messageAuthor: String = context.database.getFriendInfo(message.sender_id!!)!!.usernameForSorting!! - - //check if the messageId - val contentType: ContentType = ContentType.fromId(message.content_type) - if (context.feature(MessageLogger::class).isMessageRemoved(message.client_message_id.toLong())) { - context.shortToast("Preview/Download are not yet available for deleted messages") - return - } - if (contentType != ContentType.NOTE && - contentType != ContentType.SNAP && - contentType != ContentType.EXTERNAL_MEDIA) { - context.shortToast("Unsupported content type $contentType") - return - } - val messageReader = ProtoReader(message.message_content!!) - val urlProto: ByteArray = messageReader.getByteArray(*ARROYO_URL_KEY_PROTO_PATH)!! - - //download the message content - try { - val downloadedMedia = MediaDownloaderHelper.downloadMediaFromReference(urlProto, canMergeOverlay(), isPreviewMode) { - EncryptionUtils.decryptInputStreamFromArroyo(it, contentType, messageReader) - }[MediaType.ORIGINAL] ?: throw Exception("Failed to download media") - val fileType = FileType.fromByteArray(downloadedMedia) - - if (isPreviewMode) { - runCatching { - val bitmap: Bitmap? = PreviewUtils.createPreview(downloadedMedia, fileType.isVideo) - if (bitmap == null) { - context.shortToast("Failed to create preview") - return - } - val builder = AlertDialog.Builder(context.mainActivity) - builder.setTitle("Preview") - val imageView = ImageView(builder.context) - imageView.setImageBitmap(bitmap) - builder.setView(imageView) - builder.setPositiveButton( - "Close" - ) { dialog: DialogInterface, _: Int -> dialog.dismiss() } - context.runOnUiThread { builder.show() } - }.onFailure { - context.shortToast("Failed to create preview: $it") - xposedLog(it) - } - return - } - downloadMediaContent(downloadedMedia, downloadedMedia.contentHashCode(), messageAuthor, fileType) - } catch (e: Throwable) { - context.longToast("Failed to download " + e.message) - xposedLog(e) - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/AmoledDarkMode.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/AmoledDarkMode.kt deleted file mode 100644 index f0fedce209..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/AmoledDarkMode.kt +++ /dev/null @@ -1,49 +0,0 @@ -package me.rhunk.snapenhance.features.impl.experiments - -import android.annotation.SuppressLint -import android.content.res.TypedArray -import android.graphics.drawable.ColorDrawable -import me.rhunk.snapenhance.Constants -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.Hooker -import me.rhunk.snapenhance.hook.hook - -class AmoledDarkMode : Feature("Amoled Dark Mode", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { - @SuppressLint("DiscouragedApi") - override fun onActivityCreate() { - if (!context.config.bool(ConfigProperty.AMOLED_DARK_MODE)) return - val attributeCache = mutableMapOf<String, Int>() - - fun getAttribute(name: String): Int { - if (attributeCache.containsKey(name)) return attributeCache[name]!! - return context.resources.getIdentifier(name, "attr", Constants.SNAPCHAT_PACKAGE_NAME).also { attributeCache[name] = it } - } - - context.androidContext.theme.javaClass.getMethod("obtainStyledAttributes", IntArray::class.java).hook(HookStage.AFTER) { param -> - val array = param.arg<IntArray>(0) - val result = param.getResult() as TypedArray - - fun ephemeralHook(methodName: String, content: Any) { - Hooker.ephemeralHookObjectMethod(result::class.java, result, methodName, HookStage.BEFORE) { - it.setResult(content) - } - } - - when (array[0]) { - getAttribute("sigColorTextPrimary") -> { - ephemeralHook("getColor", 0xFFFFFFFF.toInt()) - } - getAttribute("sigColorBackgroundMain") -> { - ephemeralHook("getColor", 0xFF000000.toInt()) - } - getAttribute("actionSheetBackgroundDrawable"), - getAttribute("actionSheetRoundedBackgroundDrawable") -> { - ephemeralHook("getDrawable", ColorDrawable(0xFF000000.toInt())) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/AppPasscode.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/AppPasscode.kt deleted file mode 100644 index bded13b78a..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/AppPasscode.kt +++ /dev/null @@ -1,105 +0,0 @@ -package me.rhunk.snapenhance.features.impl.experiments - -import android.annotation.SuppressLint -import android.app.AlertDialog -import android.content.Context -import android.os.Build -import android.text.Editable -import android.text.InputType -import android.text.TextWatcher -import android.view.inputmethod.InputMethodManager -import android.widget.EditText -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams - -//TODO: fingerprint unlock -class AppPasscode : Feature("App Passcode", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { - private var isLocked = false - - private fun setActivityVisibility(isVisible: Boolean) { - context.mainActivity?.let { - it.window.attributes = it.window.attributes.apply { alpha = if (isVisible) 1.0F else 0.0F } - } - } - - fun lock() { - if (isLocked) return - isLocked = true - val passcode = context.config.string(ConfigProperty.APP_PASSCODE).also { if (it.isEmpty()) return } - val isDigitPasscode = passcode.all { it.isDigit() } - - val mainActivity = context.mainActivity!! - setActivityVisibility(false) - - val prompt = AlertDialog.Builder(mainActivity) - val createPrompt = { - val alertDialog = prompt.create() - val textView = EditText(mainActivity) - textView.setSingleLine() - textView.inputType = if (isDigitPasscode) { - (InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD) - } else { - (InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD) - } - textView.hint = "Code :" - textView.setPadding(100, 100, 100, 100) - - textView.addTextChangedListener(object: TextWatcher { - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { - if (s.contentEquals(passcode)) { - alertDialog.dismiss() - isLocked = false - setActivityVisibility(true) - } - } - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} - override fun afterTextChanged(s: Editable?) {} - }) - - alertDialog.setView(textView) - - textView.viewTreeObserver.addOnWindowFocusChangeListener { hasFocus -> - if (!hasFocus) return@addOnWindowFocusChangeListener - val imm = mainActivity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.showSoftInput(textView, InputMethodManager.SHOW_IMPLICIT) - } - - alertDialog.window?.let { - it.attributes.verticalMargin = -0.18F - } - - alertDialog.show() - textView.requestFocus() - } - - prompt.setOnCancelListener { - createPrompt() - } - - createPrompt() - } - - @SuppressLint("MissingPermission") - override fun onActivityCreate() { - if (!context.database.hasArroyo()) return - - context.runOnUiThread { - lock() - } - - if (!context.config.bool(ConfigProperty.APP_LOCK_ON_RESUME)) return - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - context.mainActivity?.registerActivityLifecycleCallbacks(object: android.app.Application.ActivityLifecycleCallbacks { - override fun onActivityPaused(activity: android.app.Activity) { lock() } - override fun onActivityResumed(activity: android.app.Activity) {} - override fun onActivityStarted(activity: android.app.Activity) {} - override fun onActivityDestroyed(activity: android.app.Activity) {} - override fun onActivitySaveInstanceState(activity: android.app.Activity, outState: android.os.Bundle) {} - override fun onActivityStopped(activity: android.app.Activity) {} - override fun onActivityCreated(activity: android.app.Activity, savedInstanceState: android.os.Bundle?) {} - }) - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/InfiniteStoryBoost.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/InfiniteStoryBoost.kt deleted file mode 100644 index 1e5be29ffe..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/InfiniteStoryBoost.kt +++ /dev/null @@ -1,24 +0,0 @@ -package me.rhunk.snapenhance.features.impl.experiments - -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.hookConstructor - -class InfiniteStoryBoost : Feature("InfiniteStoryBoost", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { - override fun asyncOnActivityCreate() { - val storyBoostStateClass = context.mappings.getMappedClass("StoryBoostStateClass") - - storyBoostStateClass.hookConstructor(HookStage.BEFORE, { - context.config.bool(ConfigProperty.INFINITE_STORY_BOOST) - }) { param -> - val startTimeMillis = param.arg<Long>(1) - //reset timestamp if it's more than 24 hours - if (System.currentTimeMillis() - startTimeMillis > 86400000) { - param.setArg(1, 0) - param.setArg(2, 0) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/MeoPasscodeBypass.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/MeoPasscodeBypass.kt deleted file mode 100644 index cc22887bbd..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/MeoPasscodeBypass.kt +++ /dev/null @@ -1,21 +0,0 @@ -package me.rhunk.snapenhance.features.impl.experiments - -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.Hooker - -class MeoPasscodeBypass : Feature("Meo Passcode Bypass", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { - override fun asyncOnActivityCreate() { - Hooker.hook( - context.mappings.getMappedClass("BCryptClass"), - context.mappings.getMappedValue("BCryptClassHashMethod"), - HookStage.BEFORE, - { context.config.bool(ConfigProperty.MEO_PASSCODE_BYPASS) }, - ) { param -> - //set the hash to the result of the method - param.setResult(param.arg(1)) - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/privacy/DisableMetrics.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/privacy/DisableMetrics.kt deleted file mode 100644 index f9b9a7a88d..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/privacy/DisableMetrics.kt +++ /dev/null @@ -1,42 +0,0 @@ -package me.rhunk.snapenhance.features.impl.privacy - -import de.robv.android.xposed.XposedHelpers -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.hook.HookAdapter -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.Hooker - -class DisableMetrics : Feature("DisableMetrics", loadParams = FeatureLoadParams.INIT_SYNC) { - override fun init() { - val disableMetricsFilter: (HookAdapter) -> Boolean = { - context.config.bool(ConfigProperty.DISABLE_METRICS) - } - - Hooker.hook(context.classCache.unifiedGrpcService, "unaryCall", HookStage.BEFORE, disableMetricsFilter) { param -> - val url: String = param.arg(0) - if (url.endsWith("snapchat.valis.Valis/SendClientUpdate") || - url.endsWith("targetingQuery") - ) { - param.setResult(null) - } - } - - Hooker.hook(context.classCache.networkApi, "submit", HookStage.BEFORE, disableMetricsFilter) { param -> - val httpRequest: Any = param.arg(0) - val url = XposedHelpers.getObjectField(httpRequest, "mUrl").toString() - /*if (url.contains("resolve?co=")) { - val index = url.indexOf("co=") - val end = url.lastIndexOf("&") - val co = url.substring(index + 3, end) - val decoded = Base64.getDecoder().decode(co.toByteArray(StandardCharsets.UTF_8)) - debug("decoded : " + decoded.toString(Charsets.UTF_8)) - debug("content: $co") - }*/ - if (url.contains("app-analytics") || url.endsWith("v1/metrics")) { - param.setResult(null) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/privacy/PreventMessageSending.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/privacy/PreventMessageSending.kt deleted file mode 100644 index 8c35d003a0..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/privacy/PreventMessageSending.kt +++ /dev/null @@ -1,37 +0,0 @@ -package me.rhunk.snapenhance.features.impl.privacy - -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.data.ContentType -import me.rhunk.snapenhance.data.wrapper.impl.MessageContent -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.Hooker - -class PreventMessageSending : Feature("Send message override", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { - override fun asyncOnActivityCreate() { - Hooker.hook( - context.classCache.conversationManager, - "sendMessageWithContent", - HookStage.BEFORE - ) { param -> - val message = MessageContent(param.arg(1)) - val contentType = message.contentType - - if (context.config.bool(ConfigProperty.PREVENT_STATUS_NOTIFICATIONS)) { - if (contentType == ContentType.STATUS_SAVE_TO_CAMERA_ROLL || - contentType == ContentType.STATUS_CALL_MISSED_AUDIO || - contentType == ContentType.STATUS_CALL_MISSED_VIDEO) { - param.setResult(null) - } - } - - if (context.config.bool(ConfigProperty.PREVENT_SCREENSHOT_NOTIFICATIONS)) { - if (contentType == ContentType.STATUS_CONVERSATION_CAPTURE_SCREENSHOT || - contentType == ContentType.STATUS_CONVERSATION_CAPTURE_RECORD) { - param.setResult(null) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/AnonymousStoryViewing.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/AnonymousStoryViewing.kt deleted file mode 100644 index b50daeb5cc..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/AnonymousStoryViewing.kt +++ /dev/null @@ -1,20 +0,0 @@ -package me.rhunk.snapenhance.features.impl.spying - -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.Hooker -import me.rhunk.snapenhance.util.getObjectField - -class AnonymousStoryViewing : Feature("Anonymous Story Viewing", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { - override fun asyncOnActivityCreate() { - Hooker.hook(context.classCache.networkApi,"submit", HookStage.BEFORE, { context.config.bool(ConfigProperty.ANONYMOUS_STORY_VIEW) }) { - val httpRequest: Any = it.arg(0) - val url = httpRequest.getObjectField("mUrl") as String - if (url.endsWith("readreceipt-indexer/batchuploadreadreceipts") || url.endsWith("v2/batch_cta")) { - it.setResult(null) - } - } - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/MessageLogger.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/MessageLogger.kt deleted file mode 100644 index 385c743b47..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/MessageLogger.kt +++ /dev/null @@ -1,124 +0,0 @@ -package me.rhunk.snapenhance.features.impl.spying - -import com.google.gson.JsonObject -import com.google.gson.JsonParser -import me.rhunk.snapenhance.Logger -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.data.ContentType -import me.rhunk.snapenhance.data.MessageState -import me.rhunk.snapenhance.data.wrapper.impl.Message -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.Hooker -import kotlin.time.ExperimentalTime -import kotlin.time.measureTime - -class MessageLogger : Feature("MessageLogger", - loadParams = FeatureLoadParams.INIT_SYNC or - FeatureLoadParams.ACTIVITY_CREATE_ASYNC -) { - companion object { - const val PREFETCH_MESSAGE_COUNT = 20 - const val PREFETCH_FEED_COUNT = 20 - } - - //two level of cache to avoid querying the database - private val fetchedMessages = mutableListOf<Long>() - private val deletedMessageCache = mutableMapOf<Long, JsonObject>() - - private val myUserId by lazy { context.database.getMyUserId() } - - fun isMessageRemoved(messageId: Long) = deletedMessageCache.containsKey(messageId) - - fun deleteMessage(conversationId: String, messageId: Long) { - fetchedMessages.remove(messageId) - deletedMessageCache.remove(messageId) - context.bridgeClient.deleteMessageLoggerMessage(conversationId, messageId) - } - - @OptIn(ExperimentalTime::class) - override fun asyncOnActivityCreate() { - ConfigProperty.MESSAGE_LOGGER.valueContainer.addPropertyChangeListener { - context.config.writeConfig() - context.softRestartApp() - } - - if (!context.database.hasArroyo()) { - return - } - - measureTime { - context.database.getFriendFeed(PREFETCH_FEED_COUNT).forEach { friendFeedInfo -> - fetchedMessages.addAll(context.bridgeClient.getLoggedMessageIds(friendFeedInfo.key!!, PREFETCH_MESSAGE_COUNT)) - } - }.also { Logger.debug("Loaded ${fetchedMessages.size} cached messages in $it") } - } - - private fun processSnapMessage(messageInstance: Any) { - val message = Message(messageInstance) - - if (message.messageState != MessageState.COMMITTED) return - - //exclude messages sent by me - if (message.senderId.toString() == myUserId) return - - val messageId = message.messageDescriptor.messageId - val conversationId = message.messageDescriptor.conversationId.toString() - - if (message.messageContent.contentType != ContentType.STATUS) { - if (fetchedMessages.contains(messageId)) return - fetchedMessages.add(messageId) - - context.executeAsync { - context.bridgeClient.getMessageLoggerMessage(conversationId, messageId)?.let { - return@executeAsync - } - context.bridgeClient.addMessageLoggerMessage(conversationId, messageId, context.gson.toJson(messageInstance).toByteArray(Charsets.UTF_8)) - } - - return - } - - //query the deleted message - val deletedMessageObject: JsonObject = if (deletedMessageCache.containsKey(messageId)) - deletedMessageCache[messageId] - else { - context.bridgeClient.getMessageLoggerMessage(conversationId, messageId)?.let { - JsonParser.parseString(it.toString(Charsets.UTF_8)).asJsonObject - } - } ?: return - - val messageJsonObject = deletedMessageObject.asJsonObject - - //if the message is a snap make it playable - if (messageJsonObject["mMessageContent"]?.asJsonObject?.get("mContentType")?.asString == "SNAP") { - messageJsonObject["mMetadata"].asJsonObject.addProperty("mPlayableSnapState", "PLAYABLE") - } - - //serialize all properties of messageJsonObject and put in the message object - messageInstance.javaClass.declaredFields.forEach { field -> - field.isAccessible = true - messageJsonObject[field.name]?.let { fieldValue -> - field.set(messageInstance, context.gson.fromJson(fieldValue, field.type)) - } - } - - //set the message state to PREPARING for visibility - with(message.messageContent.contentType) { - if (this != ContentType.SNAP && this != ContentType.EXTERNAL_MEDIA) { - message.messageState = MessageState.PREPARING - } - } - - deletedMessageCache[messageId] = deletedMessageObject - } - - override fun init() { - Hooker.hookConstructor(context.classCache.message, HookStage.AFTER, { - context.config.bool(ConfigProperty.MESSAGE_LOGGER) - }) { param -> - processSnapMessage(param.thisObject()) - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/PreventReadReceipts.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/PreventReadReceipts.kt deleted file mode 100644 index 414d84c2bb..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/PreventReadReceipts.kt +++ /dev/null @@ -1,28 +0,0 @@ -package me.rhunk.snapenhance.features.impl.spying - -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.Hooker - -class PreventReadReceipts : Feature("PreventReadReceipts", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { - override fun onActivityCreate() { - val isConversationInStealthMode: (SnapUUID) -> Boolean = hook@{ - if (context.config.bool(ConfigProperty.PREVENT_READ_RECEIPTS)) return@hook true - context.feature(StealthMode::class).isStealth(it.toString()) - } - - arrayOf("mediaMessagesDisplayed", "displayedMessages").forEach { methodName: String -> - Hooker.hook(context.classCache.conversationManager, methodName, HookStage.BEFORE, { isConversationInStealthMode(SnapUUID(it.arg(0))) }) { - it.setResult(null) - } - } - Hooker.hook(context.classCache.snapManager, "onSnapInteraction", HookStage.BEFORE) { - if (isConversationInStealthMode(SnapUUID(it.arg(1) as Any))) { - it.setResult(null) - } - } - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/StealthMode.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/StealthMode.kt deleted file mode 100644 index 14e0250041..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/StealthMode.kt +++ /dev/null @@ -1,20 +0,0 @@ -package me.rhunk.snapenhance.features.impl.spying - -import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType -import me.rhunk.snapenhance.features.BridgeFileFeature -import me.rhunk.snapenhance.features.FeatureLoadParams - - -class StealthMode : BridgeFileFeature("StealthMode", BridgeFileType.STEALTH, loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { - override fun onActivityCreate() { - readFile() - } - - fun setStealth(conversationId: String, stealth: Boolean) { - setState(conversationId.hashCode().toLong().toString(16), stealth) - } - - fun isStealth(conversationId: String): Boolean { - return exists(conversationId.hashCode().toLong().toString(16)) - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AntiAutoSave.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AntiAutoSave.kt deleted file mode 100644 index 6ea7885b11..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AntiAutoSave.kt +++ /dev/null @@ -1,19 +0,0 @@ -package me.rhunk.snapenhance.features.impl.tweaks - -import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType -import me.rhunk.snapenhance.features.BridgeFileFeature -import me.rhunk.snapenhance.features.FeatureLoadParams - -class AntiAutoSave : BridgeFileFeature("AntiAutoSave", BridgeFileType.ANTI_AUTO_SAVE, loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { - override fun onActivityCreate() { - readFile() - } - - fun setConversationIgnored(userId: String, state: Boolean) { - setState(userId.hashCode().toLong().toString(16), state) - } - - fun isConversationIgnored(userId: String): Boolean { - return exists(userId.hashCode().toLong().toString(16)) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AutoSave.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AutoSave.kt deleted file mode 100644 index ff48820bed..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AutoSave.kt +++ /dev/null @@ -1,134 +0,0 @@ -package me.rhunk.snapenhance.features.impl.tweaks - -import me.rhunk.snapenhance.Logger -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.data.MessageState -import me.rhunk.snapenhance.data.wrapper.impl.Message -import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.features.impl.Messaging -import me.rhunk.snapenhance.features.impl.spying.MessageLogger -import me.rhunk.snapenhance.features.impl.spying.StealthMode -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.Hooker -import me.rhunk.snapenhance.util.CallbackBuilder -import me.rhunk.snapenhance.util.getObjectField -import java.util.concurrent.Executors - -class AutoSave : Feature("Auto Save", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { - private val asyncSaveExecutorService = Executors.newSingleThreadExecutor() - - private val messageLogger by lazy { context.feature(MessageLogger::class) } - private val messaging by lazy { context.feature(Messaging::class) } - - private val myUserId by lazy { context.database.getMyUserId() } - - private val fetchConversationWithMessagesCallbackClass by lazy { context.mappings.getMappedClass("callbacks", "FetchConversationWithMessagesCallback") } - private val callbackClass by lazy { context.mappings.getMappedClass("callbacks", "Callback") } - - private val updateMessageMethod by lazy { context.classCache.conversationManager.methods.first { it.name == "updateMessage" } } - private val fetchConversationWithMessagesPaginatedMethod by lazy { - context.classCache.conversationManager.methods.first { it.name == "fetchConversationWithMessagesPaginated" } - } - - private fun saveMessage(conversationId: SnapUUID, message: Message) { - val messageId = message.messageDescriptor.messageId - if (messageLogger.isMessageRemoved(messageId)) return - if (message.messageState != MessageState.COMMITTED) return - - val callback = CallbackBuilder(callbackClass) - .override("onError") { - Logger.xposedLog("Error saving message $messageId") - }.build() - - runCatching { - updateMessageMethod.invoke( - context.feature(Messaging::class).conversationManager, - conversationId.instanceNonNull(), - messageId, - context.classCache.messageUpdateEnum.enumConstants.first { it.toString() == "SAVE" }, - callback - ) - }.onFailure { - Logger.xposedLog("Error saving message $messageId", it) - } - - //delay between saves - Thread.sleep(100L) - } - - private fun canSaveMessage(message: Message): Boolean { - if (message.messageMetadata.savedBy.any { uuid -> uuid.toString() == myUserId }) return false - val contentType = message.messageContent.contentType.toString() - - return context.config.options(ConfigProperty.AUTO_SAVE_MESSAGES).filter { it.value }.any { it.key == contentType } - } - - private fun canSave(): Boolean { - if (context.config.options(ConfigProperty.AUTO_SAVE_MESSAGES).none { it.value }) return false - - with(context.feature(Messaging::class)) { - if (lastOpenedConversationUUID == null) return@canSave false - val conversation = lastOpenedConversationUUID.toString() - if (context.feature(StealthMode::class).isStealth(conversation)) return@canSave false - if (context.feature(AntiAutoSave::class).isConversationIgnored(conversation)) return@canSave false - } - return true - } - - override fun asyncOnActivityCreate() { - //called when enter in a conversation (or when a message is sent) - Hooker.hook( - context.mappings.getMappedClass("callbacks", "FetchConversationWithMessagesCallback"), - "onFetchConversationWithMessagesComplete", - HookStage.BEFORE, - { canSave() } - ) { param -> - val conversationId = SnapUUID(param.arg<Any>(0).getObjectField("mConversationId")!!) - val messages = param.arg<List<Any>>(1).map { Message(it) } - messages.forEach { - if (!canSaveMessage(it)) return@forEach - asyncSaveExecutorService.submit { - saveMessage(conversationId, it) - } - } - } - - //called when a message is received - Hooker.hook( - context.mappings.getMappedClass("callbacks", "FetchMessageCallback"), - "onFetchMessageComplete", - HookStage.BEFORE, - { canSave() } - ) { param -> - val message = Message(param.arg(0)) - if (!canSaveMessage(message)) return@hook - val conversationId = message.messageDescriptor.conversationId - - asyncSaveExecutorService.submit { - saveMessage(conversationId, message) - } - } - - Hooker.hook( - context.mappings.getMappedClass("callbacks", "SendMessageCallback"), - "onSuccess", - HookStage.BEFORE, - { canSave() } - ) { - val callback = CallbackBuilder(fetchConversationWithMessagesCallbackClass).build() - runCatching { - fetchConversationWithMessagesPaginatedMethod.invoke( - messaging.conversationManager, messaging.lastOpenedConversationUUID!!.instanceNonNull(), - Long.MAX_VALUE, - 3, - callback - ) - }.onFailure { - Logger.xposedLog("failed to save message", it) - } - } - - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/CameraTweaks.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/CameraTweaks.kt deleted file mode 100644 index 4f7e3cadf3..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/CameraTweaks.kt +++ /dev/null @@ -1,70 +0,0 @@ -package me.rhunk.snapenhance.features.impl.tweaks - -import android.Manifest -import android.annotation.SuppressLint -import android.app.admin.DevicePolicyManager -import android.content.ContextWrapper -import android.content.pm.PackageManager -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.data.wrapper.impl.ScSize -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.features.impl.ConfigEnumKeys -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.hook -import me.rhunk.snapenhance.hook.hookConstructor - -class CameraTweaks : Feature("Camera Tweaks", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { - companion object { - val resolutions = listOf("OFF", "3264x2448", "3264x1840", "3264x1504", "2688x1512", "2560x1920", "2448x2448", "2340x1080", "2160x1080", "1920x1440", "1920x1080", "1600x1200", "1600x960", "1600x900", "1600x736", "1600x720", "1560x720", "1520x720", "1440x1080", "1440x720", "1280x720", "1080x1080", "1080x720", "960x720", "720x720", "720x480", "640x480", "352x288", "320x240", "176x144") - } - - private fun parseResolution(resolution: String): IntArray? { - return resolution.takeIf { resolution != "OFF" }?.split("x")?.map { it.toInt() }?.toIntArray() - } - - @SuppressLint("MissingPermission", "DiscouragedApi") - override fun onActivityCreate() { - if (context.config.bool(ConfigProperty.CAMERA_DISABLE)) { - ContextWrapper::class.java.hook("checkPermission", HookStage.BEFORE) { param -> - val permission = param.arg<String>(0) - if (permission == Manifest.permission.CAMERA) { - param.setResult(PackageManager.PERMISSION_GRANTED) - } - } - - DevicePolicyManager::class.java.hook("getCameraDisabled", HookStage.BEFORE) { param -> - param.setResult(true) - } - } - - ConfigEnumKeys.hookAllEnums(context.mappings.getMappedClass("enums", "CAMERA")) { - if (key == "FORCE_CAMERA_HIGHEST_FPS" && context.config.bool(ConfigProperty.FORCE_HIGHEST_FRAME_RATE)) { - set(true) - } - if (key == "MEDIA_RECORDER_MAX_QUALITY_LEVEL" && context.config.bool(ConfigProperty.FORCE_CAMERA_SOURCE_ENCODING)) { - value!!.javaClass.enumConstants?.let { enumData -> set(enumData.filter { it.toString() == "LEVEL_MAX" }) } - } - } - - val previewResolutionConfig = parseResolution(context.config.state(ConfigProperty.OVERRIDE_PREVIEW_RESOLUTION)) - val captureResolutionConfig = parseResolution(context.config.state(ConfigProperty.OVERRIDE_PICTURE_RESOLUTION)) - - context.mappings.getMappedClass("ScCameraSettings").hookConstructor(HookStage.BEFORE) { param -> - val previewResolution = ScSize(param.argNullable(2)) - val captureResolution = ScSize(param.argNullable(3)) - - if (previewResolution.isPresent() && captureResolution.isPresent()) { - previewResolutionConfig?.let { - previewResolution.first = it[0] - previewResolution.second = it[1] - } - - captureResolutionConfig?.let { - captureResolution.first = it[0] - captureResolution.second = it[1] - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/DisableVideoLengthRestriction.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/DisableVideoLengthRestriction.kt deleted file mode 100644 index 9c63cef59e..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/DisableVideoLengthRestriction.kt +++ /dev/null @@ -1,20 +0,0 @@ -package me.rhunk.snapenhance.features.impl.tweaks - -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.Hooker - -class DisableVideoLengthRestriction : Feature("DisableVideoLengthRestriction", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { - override fun asyncOnActivityCreate() { - val defaultMediaItem = context.mappings.getMappedClass("DefaultMediaItem") - - Hooker.hookConstructor(defaultMediaItem, HookStage.BEFORE, { - context.config.bool(ConfigProperty.DISABLE_VIDEO_LENGTH_RESTRICTION) - }) { param -> - //set the video length argument - param.setArg(5, -1L) - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/GalleryMediaSendOverride.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/GalleryMediaSendOverride.kt deleted file mode 100644 index 62f79b188d..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/GalleryMediaSendOverride.kt +++ /dev/null @@ -1,49 +0,0 @@ -package me.rhunk.snapenhance.features.impl.tweaks - -import android.app.AlertDialog -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.data.ContentType -import me.rhunk.snapenhance.data.MessageSender -import me.rhunk.snapenhance.data.wrapper.impl.MessageContent -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.Hooker -import me.rhunk.snapenhance.util.protobuf.ProtoReader - -class GalleryMediaSendOverride : Feature("Gallery Media Send Override", loadParams = FeatureLoadParams.INIT_SYNC) { - override fun init() { - Hooker.hook(context.classCache.conversationManager, "sendMessageWithContent", HookStage.BEFORE) { param -> - val overrideType = context.config.state(ConfigProperty.GALLERY_MEDIA_SEND_OVERRIDE).also { if (it == "OFF") return@hook } - - val localMessageContent = MessageContent(param.arg(1)) - if (localMessageContent.contentType != ContentType.EXTERNAL_MEDIA) return@hook - //story replies - val messageProtoReader = ProtoReader(localMessageContent.content) - if (messageProtoReader.exists(7)) return@hook - - if (messageProtoReader.readPath(3)?.getCount(3) != 1) { - context.runOnUiThread { - AlertDialog.Builder(context.mainActivity!!) - .setMessage("You can only send one media at a time") - .setPositiveButton("OK", null) - .show() - } - param.setResult(null) - return@hook - } - - when (overrideType) { - "SNAP", "LIVE_SNAP" -> { - localMessageContent.contentType = ContentType.SNAP - localMessageContent.content = MessageSender.redSnapProto(overrideType == "LIVE_SNAP") - } - "NOTE" -> { - localMessageContent.contentType = ContentType.NOTE - val mediaDuration = messageProtoReader.getInt(3, 3, 5, 1, 1, 15) ?: 0 - localMessageContent.content = MessageSender.audioNoteProto(mediaDuration) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/LocationSpoofer.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/LocationSpoofer.kt deleted file mode 100644 index 550934c3b2..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/LocationSpoofer.kt +++ /dev/null @@ -1,64 +0,0 @@ -package me.rhunk.snapenhance.features.impl.tweaks - -import android.content.Intent -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.Hooker - -class LocationSpoofer: Feature("LocationSpoof", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { - override fun asyncOnActivityCreate() { - Hooker.hook(context.mainActivity!!.javaClass, "onActivityResult", HookStage.BEFORE) { param -> - val intent = param.argNullable<Intent>(2) ?: return@hook - val bundle = intent.getBundleExtra("location") ?: return@hook - param.setResult(null) - val latitude = bundle.getFloat("latitude") - val longitude = bundle.getFloat("longitude") - - with(context.config) { - get(ConfigProperty.LATITUDE).writeFrom(latitude.toString()) - get(ConfigProperty.LONGITUDE).writeFrom(longitude.toString()) - writeConfig() - } - context.longToast("Location set to $latitude, $longitude") - } - - if (!context.config.bool(ConfigProperty.LOCATION_SPOOF)) return - val locationClass = android.location.Location::class.java - val locationManagerClass = android.location.LocationManager::class.java - - Hooker.hook(locationClass, "getLatitude", HookStage.BEFORE) { hookAdapter -> - hookAdapter.setResult(getLatitude()) - } - - Hooker.hook(locationClass, "getLongitude", HookStage.BEFORE) { hookAdapter -> - hookAdapter.setResult(getLongitude()) - } - - Hooker.hook(locationClass, "getAccuracy", HookStage.BEFORE) { hookAdapter -> - hookAdapter.setResult(getAccuracy()) - } - - //Might be redundant because it calls isProviderEnabledForUser which we also hook, meaning if isProviderEnabledForUser returns true this will also return true - Hooker.hook(locationManagerClass, "isProviderEnabled", HookStage.BEFORE) { hookAdapter -> - hookAdapter.setResult(true) - } - - Hooker.hook(locationManagerClass, "isProviderEnabledForUser", HookStage.BEFORE) {hookAdapter -> - hookAdapter.setResult(true) - } - } - - private fun getLatitude():Double { - return context.config.string(ConfigProperty.LATITUDE).toDouble() - } - - private fun getLongitude():Double { - return context.config.string(ConfigProperty.LONGITUDE).toDouble() - } - - private fun getAccuracy():Float { - return 0.0f - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/MediaQualityLevelOverride.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/MediaQualityLevelOverride.kt deleted file mode 100644 index bd98fa34e1..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/MediaQualityLevelOverride.kt +++ /dev/null @@ -1,21 +0,0 @@ -package me.rhunk.snapenhance.features.impl.tweaks - -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.Hooker - -class MediaQualityLevelOverride : Feature("MediaQualityLevelOverride", loadParams = FeatureLoadParams.INIT_SYNC) { - override fun init() { - val enumQualityLevel = context.mappings.getMappedClass("enums", "QualityLevel") - - Hooker.hook(context.mappings.getMappedClass("MediaQualityLevelProvider"), - context.mappings.getMappedValue("MediaQualityLevelProviderMethod"), - HookStage.BEFORE, - { context.config.bool(ConfigProperty.FORCE_MEDIA_SOURCE_QUALITY) } - ) { param -> - param.setResult(enumQualityLevel.enumConstants.firstOrNull { it.toString() == "LEVEL_MAX" } ) - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/Notifications.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/Notifications.kt deleted file mode 100644 index 2ba9cd6ff6..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/Notifications.kt +++ /dev/null @@ -1,300 +0,0 @@ -package me.rhunk.snapenhance.features.impl.tweaks - -import android.app.Notification -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.RemoteInput -import android.content.Context -import android.content.Intent -import android.graphics.Bitmap -import android.os.Bundle -import android.os.UserHandle -import de.robv.android.xposed.XposedBridge -import de.robv.android.xposed.XposedHelpers -import me.rhunk.snapenhance.Constants -import me.rhunk.snapenhance.Logger -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.data.ContentType -import me.rhunk.snapenhance.data.MediaReferenceType -import me.rhunk.snapenhance.data.wrapper.impl.Message -import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.features.impl.Messaging -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.Hooker -import me.rhunk.snapenhance.util.CallbackBuilder -import me.rhunk.snapenhance.util.EncryptionUtils -import me.rhunk.snapenhance.util.MediaDownloaderHelper -import me.rhunk.snapenhance.util.MediaType -import me.rhunk.snapenhance.util.PreviewUtils -import me.rhunk.snapenhance.util.protobuf.ProtoReader - -class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.INIT_SYNC) { - companion object{ - const val ACTION_REPLY = "me.rhunk.snapenhance.action.REPLY" - } - - private val notificationDataQueue = mutableMapOf<Long, NotificationData>() // messageId => notification - private val cachedMessages = mutableMapOf<String, MutableList<String>>() // conversationId => cached messages - private val notificationIdMap = mutableMapOf<Int, String>() // notificationId => conversationId - - private val broadcastReceiverClass by lazy { - context.androidContext.classLoader.loadClass("com.snap.widgets.core.BestFriendsWidgetProvider") - } - - private val notifyAsUserMethod by lazy { - XposedHelpers.findMethodExact( - NotificationManager::class.java, "notifyAsUser", - String::class.java, - Int::class.javaPrimitiveType, - Notification::class.java, - UserHandle::class.java - ) - } - - private val cancelAsUserMethod by lazy { - XposedHelpers.findMethodExact(NotificationManager::class.java, "cancelAsUser", String::class.java, Int::class.javaPrimitiveType, UserHandle::class.java) - } - - private val fetchConversationWithMessagesMethod by lazy { - context.classCache.conversationManager.methods.first { it.name == "fetchConversationWithMessages"} - } - - private val notificationManager by lazy { - context.androidContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - } - - private fun setNotificationText(notification: Notification, text: String) { - with(notification.extras) { - putString("android.text", text) - putString("android.bigText", text) - } - } - - private fun computeNotificationText(conversationId: String): String { - val messageBuilder = StringBuilder() - cachedMessages.computeIfAbsent(conversationId) { mutableListOf() }.forEach { - if (messageBuilder.isNotEmpty()) messageBuilder.append("\n") - messageBuilder.append(it) - } - return messageBuilder.toString() - } - - private fun setupNotificationActionButtons(conversationId: String, notificationData: NotificationData) { - val notificationBuilder = XposedHelpers.newInstance( - Notification.Builder::class.java, - context.androidContext, - notificationData.notification - ) as Notification.Builder - - val chatReplyInput = RemoteInput.Builder("chat_reply_input") - .setLabel("Reply") - .build() - - val replyIntent = Intent() - .setClassName(Constants.SNAPCHAT_PACKAGE_NAME, broadcastReceiverClass.name) - .putExtra("conversation_id", conversationId) - .putExtra("notification_id", notificationData.id) - .setAction(ACTION_REPLY) - - val action = Notification.Action.Builder( - null, - "Reply", - PendingIntent.getBroadcast( - context.androidContext, - System.nanoTime().toInt(), - replyIntent, - PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE - ) - ).addRemoteInput(chatReplyInput).build() - - notificationBuilder.setActions(action) - notificationData.notification = notificationBuilder.build() - } - - private fun setupBroadcastReceiverHook() { - Hooker.hook(broadcastReceiverClass, "onReceive", HookStage.BEFORE) { param -> - val androidContext = param.arg<Context>(0) - val intent = param.arg<Intent>(1) - if (intent.action != ACTION_REPLY) return@hook - param.setResult(null) - - val notificationManager = androidContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val updateNotification: (Int, (Notification) -> Unit) -> Unit = { notificationId, notificationBuilder -> - notificationManager.activeNotifications.firstOrNull { it.id == notificationId }?.let { - notificationBuilder(it.notification) - XposedBridge.invokeOriginalMethod(notifyAsUserMethod, notificationManager, arrayOf( - it.tag, it.id, it.notification, it.user - )) - } - } - - val input = RemoteInput.getResultsFromIntent(intent).getCharSequence("chat_reply_input") - .toString() - val conversationId = intent.getStringExtra("conversation_id")!! - val notificationId = intent.getIntExtra("notification_id", -1) - - context.database.getMyUserId()?.let { context.database.getFriendInfo(it) }?.let { myUser -> - cachedMessages.computeIfAbsent(conversationId) { mutableListOf() }.add("${myUser.displayName}: $input") - - updateNotification(notificationId) { notification -> - setNotificationText(notification, computeNotificationText(conversationId)) - } - - context.messageSender.sendChatMessage(listOf(SnapUUID.fromString(conversationId)), input, onError = { - context.longToast("Failed to send message: $it") - }) - } - } - } - - private fun fetchMessagesResult(conversationId: String, messages: List<Message>) { - val sendNotificationData = { notificationData: NotificationData, forceCreate: Boolean -> - val notificationId = if (forceCreate) System.nanoTime().toInt() else notificationData.id - notificationIdMap.computeIfAbsent(notificationId) { conversationId } - - XposedBridge.invokeOriginalMethod(notifyAsUserMethod, notificationManager, arrayOf( - notificationData.tag, if (forceCreate) System.nanoTime().toInt() else notificationData.id, notificationData.notification, notificationData.userHandle - )) - } - - notificationDataQueue.entries.onEach { (messageId, notificationData) -> - val snapMessage = messages.firstOrNull { message -> message.orderKey == messageId } ?: return - val senderUsername = context.database.getFriendInfo(snapMessage.senderId.toString())?.displayName ?: throw Throwable("Cant find senderId of message $snapMessage") - - val contentType = snapMessage.messageContent.contentType - val contentData = snapMessage.messageContent.content - - val formatUsername: (String) -> String = { "$senderUsername: $it" } - val notificationCache = cachedMessages.let { it.computeIfAbsent(conversationId) { mutableListOf() } } - val appendNotifications: () -> Unit = { setNotificationText(notificationData.notification, computeNotificationText(conversationId))} - - when (contentType) { - ContentType.NOTE -> { - notificationCache.add(formatUsername("sent audio note")) - appendNotifications() - } - ContentType.CHAT -> { - ProtoReader(contentData).getString(2, 1)?.trim()?.let { - notificationCache.add(formatUsername(it)) - } - appendNotifications() - } - ContentType.SNAP, ContentType.EXTERNAL_MEDIA-> { - //serialize the message content into a json object - val serializedMessageContent = context.gson.toJsonTree(snapMessage.messageContent.instanceNonNull()).asJsonObject - val mediaReferences = serializedMessageContent["mRemoteMediaReferences"] - .asJsonArray.map { it.asJsonObject["mMediaReferences"].asJsonArray } - .flatten() - - mediaReferences.forEach { media -> - val protoMediaReference = media.asJsonObject["mContentObject"].asJsonArray.map { it.asByte }.toByteArray() - val mediaType = MediaReferenceType.valueOf(media.asJsonObject["mMediaType"].asString) - runCatching { - //download the media - val mediaInfo = ProtoReader(contentData).let { - if (contentType == ContentType.EXTERNAL_MEDIA) - return@let it.readPath(*Constants.MESSAGE_EXTERNAL_MEDIA_ENCRYPTION_PROTO_PATH) - else - return@let it.readPath(*Constants.MESSAGE_SNAP_ENCRYPTION_PROTO_PATH) - }?: return@runCatching - - val downloadedMedia = MediaDownloaderHelper.downloadMediaFromReference(protoMediaReference, mergeOverlay = false, isPreviewMode = false) { - if (mediaInfo.exists(Constants.ARROYO_ENCRYPTION_PROTO_INDEX)) - EncryptionUtils.decryptInputStream(it, false, mediaInfo, Constants.ARROYO_ENCRYPTION_PROTO_INDEX) - else it - }[MediaType.ORIGINAL] ?: throw Throwable("Failed to download media") - - val bitmapPreview = PreviewUtils.createPreview(downloadedMedia, mediaType.name.contains("VIDEO"))!! - val notificationBuilder = XposedHelpers.newInstance( - Notification.Builder::class.java, - context.androidContext, - notificationData.notification - ) as Notification.Builder - notificationBuilder.setLargeIcon(bitmapPreview) - notificationBuilder.style = Notification.BigPictureStyle().bigPicture(bitmapPreview).bigLargeIcon(null as Bitmap?) - - sendNotificationData(notificationData.copy(notification = notificationBuilder.build()), true) - return@onEach - }.onFailure { - Logger.xposedLog("Failed to send preview notification", it) - } - } - } - else -> { - notificationCache.add(formatUsername("sent $contentType")) - } - } - - if (contentType == ContentType.CHAT && context.config.options(ConfigProperty.BETTER_NOTIFICATIONS)["reply_button"] == true) { - setupNotificationActionButtons(conversationId, notificationData) - } - - sendNotificationData(notificationData, false) - }.clear() - } - - private fun shouldIgnoreNotification(type: String): Boolean { - val states = context.config.options(ConfigProperty.NOTIFICATION_BLACKLIST) - - states["snap"]?.let { if (type.endsWith("SNAP") && it) return true } - states["chat"]?.let { if (type.endsWith("CHAT") && it) return true } - states["typing"]?.let { if (type.endsWith("TYPING") && it) return true } - - return false - } - - override fun init() { - setupBroadcastReceiverHook() - - val fetchConversationWithMessagesCallback = context.mappings.getMappedClass("callbacks", "FetchConversationWithMessagesCallback") - - Hooker.hook(notifyAsUserMethod, HookStage.BEFORE) { param -> - val notificationData = NotificationData(param.argNullable(0), param.arg(1), param.arg(2), param.arg(3)) - - val extras: Bundle = notificationData.notification.extras.getBundle("system_notification_extras")?: return@hook - - val messageId = extras.getString("message_id") ?: return@hook - val notificationType = extras.getString("notification_type") ?: return@hook - val conversationId = extras.getString("conversation_id") ?: return@hook - - if (shouldIgnoreNotification(notificationType)) { - param.setResult(null) - return@hook - } - - if (context.config.options(ConfigProperty.BETTER_NOTIFICATIONS) - .filter { it.value }.none { notificationType.endsWith(it.key.uppercase())}) return@hook - - val conversationManager: Any = context.feature(Messaging::class).conversationManager - notificationDataQueue[messageId.toLong()] = notificationData - - val callback = CallbackBuilder(fetchConversationWithMessagesCallback) - .override("onFetchConversationWithMessagesComplete") { callbackParam -> - val messageList = (callbackParam.arg(1) as List<Any>).map { msg -> Message(msg) } - fetchMessagesResult(conversationId, messageList) - } - .override("onError") { - Logger.xposedLog("Failed to fetch message ${it.arg(0) as Any}") - }.build() - - fetchConversationWithMessagesMethod.invoke(conversationManager, SnapUUID.fromString(conversationId).instanceNonNull(), callback) - param.setResult(null) - } - - Hooker.hook(cancelAsUserMethod, HookStage.BEFORE) { param -> - val notificationId = param.arg<Int>(1) - notificationIdMap[notificationId]?.let { - cachedMessages[it]?.clear() - } - } - } - - data class NotificationData( - val tag: String?, - val id: Int, - var notification: Notification, - val userHandle: UserHandle - ) -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/SnapchatPlus.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/SnapchatPlus.kt deleted file mode 100644 index 944798af65..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/SnapchatPlus.kt +++ /dev/null @@ -1,29 +0,0 @@ -package me.rhunk.snapenhance.features.impl.tweaks - -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.Hooker - -class SnapchatPlus: Feature("SnapchatPlus", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { - private val originalSubscriptionTime = (System.currentTimeMillis() - 7776000000L) - private val expirationTimeMillis = (System.currentTimeMillis() + 15552000000L) - - override fun asyncOnActivityCreate() { - if (!context.config.bool(ConfigProperty.SNAPCHAT_PLUS)) return - - val subscriptionInfoClass = context.mappings.getMappedClass("SubscriptionInfoClass") - - Hooker.hookConstructor(subscriptionInfoClass, HookStage.BEFORE) { param -> - if (param.arg<Int>(0) == 2) return@hookConstructor - //subscription tier - param.setArg(0, 2) - //subscription status - param.setArg(1, 2) - - param.setArg(2, originalSubscriptionTime) - param.setArg(3, expirationTimeMillis) - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/UnlimitedSnapViewTime.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/UnlimitedSnapViewTime.kt deleted file mode 100644 index 50c6862587..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/UnlimitedSnapViewTime.kt +++ /dev/null @@ -1,36 +0,0 @@ -package me.rhunk.snapenhance.features.impl.tweaks - -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.data.ContentType -import me.rhunk.snapenhance.data.MessageState -import me.rhunk.snapenhance.data.wrapper.impl.Message -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.Hooker -import me.rhunk.snapenhance.util.protobuf.ProtoEditor -import me.rhunk.snapenhance.util.protobuf.ProtoReader - -class UnlimitedSnapViewTime : - Feature("UnlimitedSnapViewTime", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { - override fun onActivityCreate() { - Hooker.hookConstructor(context.classCache.message, HookStage.AFTER, { - context.config.bool(ConfigProperty.UNLIMITED_SNAP_VIEW_TIME) - }) { param -> - val message = Message(param.thisObject()) - if (message.messageState != MessageState.COMMITTED) return@hookConstructor - if (message.messageContent.contentType != ContentType.SNAP) return@hookConstructor - - with(message.messageContent) { - val mediaAttributes = ProtoReader(this.content).readPath(11, 5, 2) ?: return@hookConstructor - if (mediaAttributes.exists(6)) return@hookConstructor - this.content = ProtoEditor(this.content).apply { - edit(11, 5, 2) { - mediaAttributes.getInt(5)?.let { writeConstant(5, it) } - writeBuffer(6, byteArrayOf()) - } - }.toByteArray() - } - } - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/UITweaks.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/UITweaks.kt deleted file mode 100644 index 418f69535c..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/UITweaks.kt +++ /dev/null @@ -1,84 +0,0 @@ -package me.rhunk.snapenhance.features.impl.ui - -import android.annotation.SuppressLint -import android.content.res.Resources -import android.view.View -import android.view.ViewGroup -import me.rhunk.snapenhance.Constants -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.Hooker -import me.rhunk.snapenhance.hook.hook - -class UITweaks : Feature("UITweaks", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { - @SuppressLint("DiscouragedApi") - override fun onActivityCreate() { - val resources = context.resources - - val capriViewfinderDefaultCornerRadius = context.resources.getIdentifier("capri_viewfinder_default_corner_radius", "dimen", Constants.SNAPCHAT_PACKAGE_NAME) - val ngsHovaNavLargerCameraButtonSize = context.resources.getIdentifier("ngs_hova_nav_larger_camera_button_size", "dimen", Constants.SNAPCHAT_PACKAGE_NAME) - - val callButtonsStub = resources.getIdentifier("call_buttons_stub", "id", Constants.SNAPCHAT_PACKAGE_NAME) - val callButton1 = resources.getIdentifier("friend_action_button3", "id", Constants.SNAPCHAT_PACKAGE_NAME) - val callButton2 = resources.getIdentifier("friend_action_button4", "id", Constants.SNAPCHAT_PACKAGE_NAME) - - val chatNoteRecordButton = resources.getIdentifier("chat_note_record_button", "id", Constants.SNAPCHAT_PACKAGE_NAME) - val chatInputBarSticker = resources.getIdentifier("chat_input_bar_sticker", "id", Constants.SNAPCHAT_PACKAGE_NAME) - val chatInputBarCognac = resources.getIdentifier("chat_input_bar_cognac", "id", Constants.SNAPCHAT_PACKAGE_NAME) - val hiddenElements = context.config.options(ConfigProperty.HIDE_UI_ELEMENTS) - - Resources::class.java.methods.first { it.name == "getDimensionPixelSize"}.hook(HookStage.AFTER, { - hiddenElements["remove_camera_borders"] == true - }) { param -> - val id = param.arg<Int>(0) - if (id == capriViewfinderDefaultCornerRadius || id == ngsHovaNavLargerCameraButtonSize) { - param.setResult(0) - } - } - - Hooker.hook(View::class.java, "setVisibility", HookStage.BEFORE) { methodParam -> - val viewId = (methodParam.thisObject() as View).id - if (viewId == chatNoteRecordButton && hiddenElements["remove_voice_record_button"] == true) { - methodParam.setArg(0, View.GONE) - } - if (viewId == callButton1 || viewId == callButton2) { - if (hiddenElements["remove_call_buttons"] == false) return@hook - methodParam.setArg(0, View.GONE) - } - } - - //TODO: use the event bus to dispatch a addView event - val addViewMethod = ViewGroup::class.java.getMethod( - "addView", - View::class.java, - Int::class.javaPrimitiveType, - ViewGroup.LayoutParams::class.java - ) - Hooker.hook(addViewMethod, HookStage.BEFORE) { param -> - val view: View = param.arg(0) - val viewId = view.id - - if (viewId == chatNoteRecordButton && hiddenElements["remove_voice_record_button"] == true) { - view.isEnabled = false - view.setWillNotDraw(true) - } - - if (chatInputBarCognac == viewId && hiddenElements["remove_cognac_button"] == true) { - view.visibility = View.GONE - } - if (chatInputBarSticker == viewId && hiddenElements["remove_stickers_button"] == true) { - view.visibility = View.GONE - } - if (viewId == callButton1 || viewId == callButton2) { - if (hiddenElements["remove_call_buttons"] == false) return@hook - if (view.visibility == View.GONE) return@hook - } - if (viewId == callButtonsStub) { - if (hiddenElements["remove_call_buttons"] == false) return@hook - param.setResult(null) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/AbstractMenu.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/AbstractMenu.kt deleted file mode 100644 index a00abe23f8..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/AbstractMenu.kt +++ /dev/null @@ -1,9 +0,0 @@ -package me.rhunk.snapenhance.features.impl.ui.menus - -import me.rhunk.snapenhance.ModContext - -abstract class AbstractMenu() { - lateinit var context: ModContext - - open fun init() {} -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/MapActivity.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/MapActivity.kt deleted file mode 100644 index 938a8dc019..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/MapActivity.kt +++ /dev/null @@ -1,99 +0,0 @@ -package me.rhunk.snapenhance.features.impl.ui.menus - -import android.annotation.SuppressLint -import android.app.Activity -import android.app.AlertDialog -import android.content.Context -import android.os.Bundle -import android.view.MotionEvent -import android.widget.Button -import android.widget.EditText -import me.rhunk.snapenhance.R -import org.osmdroid.config.Configuration -import org.osmdroid.tileprovider.tilesource.TileSourceFactory -import org.osmdroid.util.GeoPoint -import org.osmdroid.views.MapView -import org.osmdroid.views.Projection -import org.osmdroid.views.overlay.Marker -import org.osmdroid.views.overlay.Overlay - - -//TODO: Implement correctly -class MapActivity : Activity() { - - private lateinit var mapView: MapView - - @SuppressLint("MissingInflatedId", "ResourceType") - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - val contextBundle = intent.extras?.getBundle("location") ?: return - val locationLatitude = contextBundle.getDouble("latitude") - val locationLongitude = contextBundle.getDouble("longitude") - - Configuration.getInstance().load(applicationContext, getSharedPreferences("osmdroid", Context.MODE_PRIVATE)) - - setContentView(R.layout.map) - - mapView = findViewById(R.id.mapView) - mapView.setMultiTouchControls(true); - mapView.setTileSource(TileSourceFactory.MAPNIK) - - val startPoint = GeoPoint(locationLatitude, locationLongitude) - mapView.controller.setZoom(10.0) - mapView.controller.setCenter(startPoint) - - val marker = Marker(mapView) - marker.isDraggable = true - marker.position = startPoint - marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) - - mapView.overlays.add(object: Overlay() { - override fun onSingleTapConfirmed(e: MotionEvent?, mapView: MapView?): Boolean { - val proj: Projection = mapView!!.projection - val loc = proj.fromPixels(e!!.x.toInt(), e.y.toInt()) as GeoPoint - marker.position = loc - mapView.invalidate() - return true - } - }) - - mapView.overlays.add(marker) - - val applyButton = findViewById<Button>(R.id.apply_location_button) - applyButton.setOnClickListener { - val bundle = Bundle() - bundle.putFloat("latitude", marker.position.latitude.toFloat()) - bundle.putFloat("longitude", marker.position.longitude.toFloat()) - setResult(RESULT_OK, intent.putExtra("location", bundle)) - finish() - } - - val setPreciseLocationButton = findViewById<Button>(R.id.set_precise_location_button) - - setPreciseLocationButton.setOnClickListener { - val locationDialog = layoutInflater.inflate(R.layout.precise_location_dialog, null) - val dialogLatitude = locationDialog.findViewById<EditText>(R.id.dialog_latitude).also { it.setText(marker.position.latitude.toString()) } - val dialogLongitude = locationDialog.findViewById<EditText>(R.id.dialog_longitude).also { it.setText(marker.position.longitude.toString()) } - - AlertDialog.Builder(this) - .setView(locationDialog) - .setTitle("Set a precise location") - .setPositiveButton("Set") { _, _ -> - val latitude = dialogLatitude.text.toString().toDoubleOrNull() - val longitude = dialogLongitude.text.toString().toDoubleOrNull() - if (latitude != null && longitude != null) { - val preciseLocation = GeoPoint(latitude, longitude) - mapView.controller.setCenter(preciseLocation) - marker.position = preciseLocation - mapView.invalidate() - } - }.setNegativeButton("Cancel") { _, _ -> }.show() - } - } - - override fun onDestroy() { - super.onDestroy() - mapView.onDetach() - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/MenuViewInjector.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/MenuViewInjector.kt deleted file mode 100644 index a8899fe3ff..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/MenuViewInjector.kt +++ /dev/null @@ -1,110 +0,0 @@ -package me.rhunk.snapenhance.features.impl.ui.menus - -import android.annotation.SuppressLint -import android.view.View -import android.view.ViewGroup -import android.widget.FrameLayout -import android.widget.LinearLayout -import de.robv.android.xposed.XposedBridge -import me.rhunk.snapenhance.Constants -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.features.impl.Messaging -import me.rhunk.snapenhance.features.impl.ui.menus.impl.ChatActionMenu -import me.rhunk.snapenhance.features.impl.ui.menus.impl.FriendFeedInfoMenu -import me.rhunk.snapenhance.features.impl.ui.menus.impl.OperaContextActionMenu -import me.rhunk.snapenhance.features.impl.ui.menus.impl.SettingsMenu -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.Hooker -import java.lang.reflect.Modifier - -@SuppressLint("DiscouragedApi") -class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { - private val friendFeedInfoMenu = FriendFeedInfoMenu() - private val operaContextActionMenu = OperaContextActionMenu() - private val chatActionMenu = ChatActionMenu() - private val settingMenu = SettingsMenu() - - private val newChatString by lazy { - context.resources.getString(context.resources.getIdentifier("new_chat", "string", Constants.SNAPCHAT_PACKAGE_NAME)) - } - - @SuppressLint("ResourceType") - override fun asyncOnActivityCreate() { - friendFeedInfoMenu.context = context - operaContextActionMenu.context = context - chatActionMenu.context = context - settingMenu.context = context - - val actionSheetItemsContainerLayoutId = context.resources.getIdentifier("action_sheet_items_container", "id", Constants.SNAPCHAT_PACKAGE_NAME) - val addViewMethod = ViewGroup::class.java.getMethod( - "addView", - View::class.java, - Int::class.javaPrimitiveType, - ViewGroup.LayoutParams::class.java - ) - - Hooker.hook(addViewMethod, HookStage.BEFORE) { param -> - val viewGroup: ViewGroup = param.thisObject() - val originalAddView: (View) -> Unit = { view: View -> - XposedBridge.invokeOriginalMethod( - addViewMethod, - viewGroup, - arrayOf( - view, - -1, - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - ) - ) - } - - val childView: View = param.arg(0) - operaContextActionMenu.inject(viewGroup, childView) - - //download in chat snaps and notes from the chat action menu - if (viewGroup.javaClass.name.endsWith("ActionMenuChatItemContainer")) { - if (viewGroup.parent == null || viewGroup.parent.parent == null) return@hook - chatActionMenu.inject(viewGroup) - return@hook - } - - //TODO : preview group chats - if (viewGroup is LinearLayout && viewGroup.id == actionSheetItemsContainerLayoutId) { - val itemStringInterface by lazy { - childView.javaClass.declaredFields.filter { - !it.type.isPrimitive && Modifier.isAbstract(it.type.modifiers) - }.map { - runCatching { - it.isAccessible = true - it[childView] - }.getOrNull() - }.firstOrNull() - } - - //the 3 dot button shows a menu which contains the first item as a Plain object - if (viewGroup.getChildCount() == 0 && itemStringInterface != null && itemStringInterface.toString().startsWith("Plain(primaryText=$newChatString")) { - - settingMenu.inject(viewGroup, originalAddView) - viewGroup.addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener { - override fun onViewAttachedToWindow(v: View) {} - override fun onViewDetachedFromWindow(v: View) { - context.config.writeConfig() - } - }) - return@hook - } - if (context.feature(Messaging::class).lastFetchConversationUserUUID == null) return@hook - - //filter by the slot index - if (viewGroup.getChildCount() != context.config.int(ConfigProperty.MENU_SLOT_ID)) return@hook - friendFeedInfoMenu.inject(viewGroup, originalAddView) - } - - } - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/ViewAppearanceHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/ViewAppearanceHelper.kt deleted file mode 100644 index da4a0b9fdb..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/ViewAppearanceHelper.kt +++ /dev/null @@ -1,78 +0,0 @@ -package me.rhunk.snapenhance.features.impl.ui.menus - -import android.annotation.SuppressLint -import android.content.res.ColorStateList -import android.graphics.Color -import android.view.Gravity -import android.view.MotionEvent -import android.view.View -import android.widget.Switch -import android.widget.TextView -import me.rhunk.snapenhance.Constants - -object ViewAppearanceHelper { - @SuppressLint("UseSwitchCompatOrMaterialCode", "RtlHardcoded", "DiscouragedApi", - "ClickableViewAccessibility" - ) - fun applyTheme(viewModel: View, view: TextView) { - val sigColorTextPrimary = viewModel.context.theme.obtainStyledAttributes( - intArrayOf(viewModel.resources.getIdentifier("sigColorTextPrimary", "attr", Constants.SNAPCHAT_PACKAGE_NAME)) - ).getColor(0, 0) - - val sigColorBackgroundMain = viewModel.context.theme.obtainStyledAttributes( - intArrayOf(viewModel.resources.getIdentifier("sigColorBackgroundMain", "attr", Constants.SNAPCHAT_PACKAGE_NAME)) - ).getColor(0, 0) - - val snapchatFontResId = view.context.resources.getIdentifier("avenir_next_medium", "font", "com.snapchat.android") - //remove the shadow - view.setBackgroundColor(sigColorBackgroundMain) - view.setTextColor(sigColorTextPrimary) - view.setShadowLayer(0F, 0F, 0F, 0) - view.outlineProvider = null - view.gravity = Gravity.LEFT or Gravity.CENTER_VERTICAL - view.width = viewModel.width - - //DPI Calculator - val scalingFactor = view.context.resources.displayMetrics.densityDpi.toDouble() / 400 - view.height = (150 * scalingFactor).toInt() - view.setPadding((40 * scalingFactor).toInt(), 0, (40 * scalingFactor).toInt(), 0) - view.isAllCaps = false - view.textSize = 16f - view.typeface = view.context.resources.getFont(snapchatFontResId) - - //FIXME: wrong color, shouldn't be that much noticeable though - view.setOnTouchListener { _, event -> - when (event.action) { - MotionEvent.ACTION_DOWN -> { - view.setBackgroundColor(0x5395026) - } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - view.setBackgroundColor(sigColorBackgroundMain) - } - } - false - } - - if (view is Switch) { - with(viewModel.resources) { - view.switchMinWidth = getDimension(getIdentifier("v11_switch_min_width", "dimen", Constants.SNAPCHAT_PACKAGE_NAME)).toInt() - } - val colorStateList = ColorStateList( - arrayOf(intArrayOf(-android.R.attr.state_checked), intArrayOf(android.R.attr.state_checked) - ), intArrayOf( - Color.parseColor("#1d1d1d"), - Color.parseColor("#26bd49") - ) - ) - val thumbStateList = ColorStateList( - arrayOf(intArrayOf(-android.R.attr.state_checked), intArrayOf(android.R.attr.state_checked) - ), intArrayOf( - Color.parseColor("#F5F5F5"), - Color.parseColor("#26bd49") - ) - ) - view.trackTintList = colorStateList - view.thumbTintList = thumbStateList - } - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/ChatActionMenu.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/ChatActionMenu.kt deleted file mode 100644 index 1bee439f19..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/ChatActionMenu.kt +++ /dev/null @@ -1,107 +0,0 @@ -package me.rhunk.snapenhance.features.impl.ui.menus.impl - -import android.annotation.SuppressLint -import android.content.res.Resources -import android.graphics.Color -import android.graphics.drawable.ColorDrawable -import android.os.SystemClock -import android.util.TypedValue -import android.view.MotionEvent -import android.view.View -import android.view.ViewGroup -import android.view.ViewGroup.MarginLayoutParams -import android.widget.Button -import me.rhunk.snapenhance.Constants.VIEW_INJECTED_CODE -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.features.impl.Messaging -import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader -import me.rhunk.snapenhance.features.impl.spying.MessageLogger -import me.rhunk.snapenhance.features.impl.ui.menus.AbstractMenu - - -class ChatActionMenu : AbstractMenu() { - private fun wasInjectedView(view: View): Boolean { - if (view.getTag(VIEW_INJECTED_CODE) != null) return true - view.setTag(VIEW_INJECTED_CODE, true) - return false - } - - private fun applyButtonTheme(parent: View, button: Button) { - button.background = ColorDrawable(Color.WHITE) - button.setTextColor(Color.BLACK) - button.transformationMethod = null - val margin = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, - 20f, - Resources.getSystem().displayMetrics - ).toInt() - val params = MarginLayoutParams(parent.layoutParams) - params.setMargins(margin, 5, margin, 5) - params.marginEnd = margin - params.marginStart = margin - button.layoutParams = params - button.height = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, - 50f, - Resources.getSystem().displayMetrics - ).toInt() - } - - @SuppressLint("SetTextI18n") - fun inject(viewGroup: ViewGroup) { - val parent = viewGroup.parent.parent as ViewGroup - if (wasInjectedView(parent)) return - //close the action menu using a touch event - val closeActionMenu = { - viewGroup.dispatchTouchEvent( - MotionEvent.obtain( - SystemClock.uptimeMillis(), - SystemClock.uptimeMillis(), - MotionEvent.ACTION_DOWN, - 0f, - 0f, - 0 - ) - ) - } - if (context.config.bool(ConfigProperty.CHAT_DOWNLOAD_CONTEXT_MENU)) { - parent.addView(Button(viewGroup.context).apply { - applyButtonTheme(parent, this) - text = this@ChatActionMenu.context.translation.get("chat_action_menu.preview_button") - setOnClickListener { - closeActionMenu() - this@ChatActionMenu.context.executeAsync { this@ChatActionMenu.context.feature(MediaDownloader::class).onMessageActionMenu(true) } - } - }) - - parent.addView(Button(viewGroup.context).apply { - applyButtonTheme(parent, this) - text = this@ChatActionMenu.context.translation.get("chat_action_menu.download_button") - setOnClickListener { - closeActionMenu() - this@ChatActionMenu.context.executeAsync { - this@ChatActionMenu.context.feature( - MediaDownloader::class - ).onMessageActionMenu(false) - } - } - }) - } - - //delete logged message button - if (context.config.bool(ConfigProperty.MESSAGE_LOGGER)) { - val downloadButton = Button(viewGroup.context) - applyButtonTheme(parent, downloadButton) - downloadButton.text = context.translation.get("chat_action_menu.delete_logged_message_button") - downloadButton.setOnClickListener { - closeActionMenu() - context.executeAsync { - with(context.feature(Messaging::class)) { - context.feature(MessageLogger::class).deleteMessage(lastOpenedConversationUUID.toString(), lastFocusedMessageId) - } - } - } - parent.addView(downloadButton) - } - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/FriendFeedInfoMenu.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/FriendFeedInfoMenu.kt deleted file mode 100644 index 9b2893acff..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/FriendFeedInfoMenu.kt +++ /dev/null @@ -1,240 +0,0 @@ -package me.rhunk.snapenhance.features.impl.ui.menus.impl - -import android.annotation.SuppressLint -import android.app.AlertDialog -import android.content.Context -import android.content.DialogInterface -import android.content.res.Resources -import android.graphics.BitmapFactory -import android.graphics.drawable.BitmapDrawable -import android.graphics.drawable.Drawable -import android.view.View -import android.widget.Button -import android.widget.CompoundButton -import android.widget.Switch -import android.widget.Toast -import me.rhunk.snapenhance.Logger -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.data.ContentType -import me.rhunk.snapenhance.database.objects.ConversationMessage -import me.rhunk.snapenhance.database.objects.FriendInfo -import me.rhunk.snapenhance.database.objects.UserConversationLink -import me.rhunk.snapenhance.features.impl.Messaging -import me.rhunk.snapenhance.features.impl.downloader.AntiAutoDownload -import me.rhunk.snapenhance.features.impl.tweaks.AntiAutoSave -import me.rhunk.snapenhance.features.impl.spying.StealthMode -import me.rhunk.snapenhance.features.impl.ui.menus.AbstractMenu -import me.rhunk.snapenhance.features.impl.ui.menus.ViewAppearanceHelper.applyTheme -import java.net.HttpURLConnection -import java.net.URL -import java.text.DateFormat -import java.text.SimpleDateFormat -import java.util.Calendar -import java.util.Date -import java.util.Locale - -class FriendFeedInfoMenu : AbstractMenu() { - private fun getImageDrawable(url: String): Drawable { - val connection = URL(url).openConnection() as HttpURLConnection - connection.connect() - val input = connection.inputStream - return BitmapDrawable(Resources.getSystem(), BitmapFactory.decodeStream(input)) - } - - private fun formatDate(timestamp: Long): String? { - return SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH).format(Date(timestamp)) - } - - private fun showProfileInfo(profile: FriendInfo) { - var icon: Drawable? = null - try { - if (profile.bitmojiSelfieId != null && profile.bitmojiAvatarId != null) { - icon = getImageDrawable( - "https://sdk.bitmoji.com/render/panel/" + profile.bitmojiSelfieId - .toString() + "-" + profile.bitmojiAvatarId - .toString() + "-v1.webp?transparent=1&scale=0" - ) - } - } catch (e: Throwable) { - Logger.xposedLog(e) - } - val finalIcon = icon - context.runOnUiThread { - val addedTimestamp: Long = profile.addedTimestamp.coerceAtLeast(profile.reverseAddedTimestamp) - val builder = AlertDialog.Builder(context.mainActivity) - builder.setIcon(finalIcon) - builder.setTitle(profile.displayName) - - val birthday = Calendar.getInstance() - birthday[Calendar.MONTH] = (profile.birthday shr 32).toInt() - 1 - val message: String = """ - ${context.translation.get("profile_info.username")}: ${profile.username} - ${context.translation.get("profile_info.display_name")}: ${profile.displayName} - ${context.translation.get("profile_info.added_date")}: ${formatDate(addedTimestamp)} - ${birthday.getDisplayName( - Calendar.MONTH, - Calendar.LONG, - context.translation.locale - )?.let { - context.translation.get("profile_info.birthday") - .replace("{month}", it) - .replace("{day}", profile.birthday.toInt().toString()) - } - } - """.trimIndent() - builder.setMessage(message) - builder.setPositiveButton( - "OK" - ) { dialog: DialogInterface, _: Int -> dialog.dismiss() } - builder.show() - } - } - - private fun showPreview(userId: String?, conversationId: String, androidCtx: Context?) { - //query message - val messages: List<ConversationMessage>? = context.database.getMessagesFromConversationId( - conversationId, - context.config.int(ConfigProperty.MESSAGE_PREVIEW_LENGTH) - )?.reversed() - - if (messages.isNullOrEmpty()) { - Toast.makeText(androidCtx, "No messages found", Toast.LENGTH_SHORT).show() - return - } - val participants: Map<String, FriendInfo> = context.database.getConversationParticipants(conversationId)!! - .map { context.database.getFriendInfo(it)!! } - .associateBy { it.userId!! } - - val messageBuilder = StringBuilder() - - messages.forEach{ message: ConversationMessage -> - val sender: FriendInfo? = participants[message.sender_id] - - var messageString: String = message.getMessageAsString() ?: ContentType.fromId(message.content_type).name - - if (message.content_type == ContentType.SNAP.id) { - val readTimeStamp: Long = message.read_timestamp - messageString = "\uD83D\uDFE5" //red square - if (readTimeStamp > 0) { - messageString += " \uD83D\uDC40 " //eyes - messageString += DateFormat.getDateTimeInstance( - DateFormat.SHORT, - DateFormat.SHORT - ).format(Date(readTimeStamp)) - } - } - - var displayUsername = sender?.displayName ?: sender?.usernameForSorting?: context.translation.get("conversation_preview.unknown_user") - - if (displayUsername.length > 12) { - displayUsername = displayUsername.substring(0, 13) + "... " - } - - messageBuilder.append(displayUsername).append(": ").append(messageString).append("\n") - } - - val targetPerson: FriendInfo? = - if (userId == null) null else participants[userId] - - targetPerson?.streakExpirationTimestamp?.takeIf { it > 0 }?.let { - val timeSecondDiff = ((it - System.currentTimeMillis()) / 1000 / 60).toInt() - messageBuilder.append("\n\n") - .append("\uD83D\uDD25 ") //fire emoji - .append(context.translation.get("conversation_preview.streak_expiration").format( - timeSecondDiff / 60 / 24, - timeSecondDiff / 60 % 24, - timeSecondDiff % 60 - )) - } - - //alert dialog - val builder = AlertDialog.Builder(context.mainActivity) - builder.setTitle(context.translation.get("conversation_preview.title")) - builder.setMessage(messageBuilder.toString()) - builder.setPositiveButton( - "OK" - ) { dialog: DialogInterface, _: Int -> dialog.dismiss() } - targetPerson?.let { - builder.setNegativeButton(context.translation.get("modal_option.profile_info")) {_, _ -> - context.executeAsync { - showProfileInfo(it) - } - } - } - builder.show() - } - - private fun createToggleFeature(viewModel: View, viewConsumer: ((View) -> Unit), text: String, isChecked: () -> Boolean, toggle: (Boolean) -> Unit) { - val switch = Switch(viewModel.context) - switch.text = context.translation.get(text) - switch.isChecked = isChecked() - applyTheme(viewModel, switch) - switch.setOnCheckedChangeListener { _: CompoundButton?, checked: Boolean -> - toggle(checked) - } - viewConsumer(switch) - } - - @SuppressLint("SetTextI18n", "UseSwitchCompatOrMaterialCode", "DefaultLocale") - fun inject(viewModel: View, viewConsumer: ((View) -> Unit)) { - val messaging = context.feature(Messaging::class) - var focusedConversationTargetUser: String? = null - val conversationId: String - if (messaging.lastFetchConversationUserUUID != null) { - focusedConversationTargetUser = messaging.lastFetchConversationUserUUID.toString() - val conversation: UserConversationLink = context.database.getDMConversationIdFromUserId(focusedConversationTargetUser) ?: return - conversationId = conversation.client_conversation_id!!.trim().lowercase() - } else { - conversationId = messaging.lastFetchConversationUUID.toString() - } - - //preview button - val previewButton = Button(viewModel.context) - previewButton.text = context.translation.get("friend_menu_option.preview") - applyTheme(viewModel, previewButton) - val finalFocusedConversationTargetUser = focusedConversationTargetUser - previewButton.setOnClickListener { - showPreview( - finalFocusedConversationTargetUser, - conversationId, - previewButton.context - ) - } - - //stealth switch - val stealthSwitch = Switch(viewModel.context) - stealthSwitch.text = context.translation.get("friend_menu_option.stealth_mode") - stealthSwitch.isChecked = context.feature(StealthMode::class).isStealth(conversationId) - applyTheme(viewModel, stealthSwitch) - stealthSwitch.setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean -> - context.feature(StealthMode::class).setStealth( - conversationId, - isChecked - ) - } - - run { - val userId = context.database.getFriendFeedInfoByConversationId(conversationId)?.friendUserId ?: return@run - if (context.config.bool(ConfigProperty.DOWNLOAD_BLACKLIST)) { - createToggleFeature(viewModel, - viewConsumer, - "friend_menu_option.anti_auto_download", - { context.feature(AntiAutoDownload::class).isUserIgnored(userId) }, - { context.feature(AntiAutoDownload::class).setUserIgnored(userId, it) } - ) - } - - if (context.config.bool(ConfigProperty.ANTI_AUTO_SAVE)) { - createToggleFeature(viewModel, - viewConsumer, - "friend_menu_option.anti_auto_save", - { context.feature(AntiAutoSave::class).isConversationIgnored(conversationId) }, - { context.feature(AntiAutoSave::class).setConversationIgnored(conversationId, it) } - ) - } - } - - viewConsumer(stealthSwitch) - viewConsumer(previewButton) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/OperaContextActionMenu.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/OperaContextActionMenu.kt deleted file mode 100644 index 288bb1967e..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/OperaContextActionMenu.kt +++ /dev/null @@ -1,82 +0,0 @@ -package me.rhunk.snapenhance.features.impl.ui.menus.impl - -import android.annotation.SuppressLint -import android.view.Gravity -import android.view.View -import android.view.ViewGroup -import android.widget.Button -import android.widget.LinearLayout -import android.widget.ScrollView -import me.rhunk.snapenhance.Constants -import me.rhunk.snapenhance.Logger -import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader -import me.rhunk.snapenhance.features.impl.ui.menus.AbstractMenu -import me.rhunk.snapenhance.features.impl.ui.menus.ViewAppearanceHelper.applyTheme - -@SuppressLint("DiscouragedApi") -class OperaContextActionMenu : AbstractMenu() { - private val contextCardsScrollView by lazy { - context.resources.getIdentifier("context_cards_scroll_view", "id", Constants.SNAPCHAT_PACKAGE_NAME) - } - - /* - LinearLayout : - - LinearLayout: - - SnapFontTextView - - ImageView - - LinearLayout: - - SnapFontTextView - - ImageView - - LinearLayout: - - SnapFontTextView - - ImageView - */ - private fun isViewGroupButtonMenuContainer(viewGroup: ViewGroup): Boolean { - if (viewGroup !is LinearLayout) return false - val children = ArrayList<View>() - for (i in 0 until viewGroup.getChildCount()) - children.add(viewGroup.getChildAt(i)) - return if (children.any { view: View? -> view !is LinearLayout }) - false - else children.map { view: View -> view as LinearLayout } - .any { linearLayout: LinearLayout -> - val viewChildren = ArrayList<View>() - for (i in 0 until linearLayout.childCount) viewChildren.add( - linearLayout.getChildAt( - i - ) - ) - viewChildren.any { viewChild: View -> - viewChild.javaClass.name.endsWith("SnapFontTextView") - } - } - } - - @SuppressLint("SetTextI18n") - fun inject(viewGroup: ViewGroup, childView: View) { - try { - if (viewGroup.parent !is ScrollView) return - val parent = viewGroup.parent as ScrollView - if (parent.id != contextCardsScrollView) return - if (childView !is LinearLayout) return - if (!isViewGroupButtonMenuContainer(childView as ViewGroup)) return - - val linearLayout = LinearLayout(childView.getContext()) - linearLayout.orientation = LinearLayout.VERTICAL - linearLayout.gravity = Gravity.CENTER - linearLayout.layoutParams = - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - val button = Button(childView.getContext()) - button.text = context.translation.get("opera_context_menu.download") - button.setOnClickListener { context.feature(MediaDownloader::class).downloadLastOperaMediaAsync() } - applyTheme(linearLayout, button) - linearLayout.addView(button) - (childView as ViewGroup).addView(linearLayout, 0) - } catch (e: Throwable) { - Logger.xposedLog(e) - } - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/SettingsMenu.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/SettingsMenu.kt deleted file mode 100644 index f58f7447e7..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/SettingsMenu.kt +++ /dev/null @@ -1,242 +0,0 @@ -package me.rhunk.snapenhance.features.impl.ui.menus.impl - -import android.annotation.SuppressLint -import android.app.AlertDialog -import android.graphics.Color -import android.graphics.Typeface -import android.text.InputType -import android.view.View -import android.widget.Button -import android.widget.EditText -import android.widget.LinearLayout -import android.widget.Switch -import android.widget.TextView -import me.rhunk.snapenhance.BuildConfig -import me.rhunk.snapenhance.Constants -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.config.impl.ConfigIntegerValue -import me.rhunk.snapenhance.config.impl.ConfigStateListValue -import me.rhunk.snapenhance.config.impl.ConfigStateSelection -import me.rhunk.snapenhance.config.impl.ConfigStateValue -import me.rhunk.snapenhance.config.impl.ConfigStringValue -import me.rhunk.snapenhance.features.impl.ui.menus.AbstractMenu -import me.rhunk.snapenhance.features.impl.ui.menus.ViewAppearanceHelper - -class SettingsMenu : AbstractMenu() { - @SuppressLint("ClickableViewAccessibility") - private fun createCategoryTitle(viewModel: View, key: String): TextView { - val categoryText = TextView(viewModel.context) - categoryText.text = context.translation.get(key) - ViewAppearanceHelper.applyTheme(viewModel, categoryText) - categoryText.textSize = 20f - categoryText.typeface = categoryText.typeface?.let { Typeface.create(it, Typeface.BOLD) } - categoryText.setOnTouchListener { _, _ -> true } - return categoryText - } - - @SuppressLint("SetTextI18n") - private fun createPropertyView(viewModel: View, property: ConfigProperty): View { - val propertyName = context.translation.get(property.nameKey) - val updateButtonText: (TextView, String) -> Unit = { textView, text -> - textView.text = "$propertyName${if (text.isEmpty()) "" else ": $text"}" - } - - val updateLocalizedText: (TextView, String) -> Unit = { textView, value -> - updateButtonText(textView, value.let { - if (it.isEmpty()) { - "(empty)" - } - else { - if (property.disableValueLocalization) { - it - } else { - context.translation.get("option." + property.nameKey + "." + it) - } - } - }) - } - - val textEditor: ((String) -> Unit) -> Unit = { updateValue -> - val builder = AlertDialog.Builder(viewModel.context) - builder.setTitle(propertyName) - - val input = EditText(viewModel.context) - input.inputType = InputType.TYPE_CLASS_TEXT - input.setText(property.valueContainer.value().toString()) - - builder.setView(input) - builder.setPositiveButton("OK") { _, _ -> - updateValue(input.text.toString()) - } - - builder.setNegativeButton("Cancel") { dialog, _ -> dialog.cancel() } - builder.show() - } - - val resultView: View = when (property.valueContainer) { - is ConfigStringValue -> { - val textView = TextView(viewModel.context) - updateButtonText(textView, property.valueContainer.let { - if (it.isHidden) it.hiddenValue() - else it.value() - }) - ViewAppearanceHelper.applyTheme(viewModel, textView) - textView.setOnClickListener { - textEditor { value -> - property.valueContainer.writeFrom(value) - updateButtonText(textView, property.valueContainer.let { - if (it.isHidden) it.hiddenValue() - else it.value() - }) - } - } - textView - } - is ConfigIntegerValue -> { - val button = Button(viewModel.context) - updateButtonText(button, property.valueContainer.value().toString()) - button.setOnClickListener { - textEditor { value -> - runCatching { - property.valueContainer.writeFrom(value) - updateButtonText(button, value) - }.onFailure { - context.shortToast("Invalid value") - } - } - } - ViewAppearanceHelper.applyTheme(viewModel, button) - button - } - is ConfigStateValue -> { - val switch = Switch(viewModel.context) - switch.text = propertyName - switch.isChecked = property.valueContainer.value() - switch.setOnCheckedChangeListener { _, isChecked -> - property.valueContainer.writeFrom(isChecked.toString()) - } - ViewAppearanceHelper.applyTheme(viewModel, switch) - switch - } - is ConfigStateSelection -> { - val button = Button(viewModel.context) - updateLocalizedText(button, property.valueContainer.value()) - - button.setOnClickListener {_ -> - val builder = AlertDialog.Builder(viewModel.context) - builder.setTitle(propertyName) - - builder.setSingleChoiceItems( - property.valueContainer.keys().toTypedArray().map { - if (property.disableValueLocalization) it - else context.translation.get("option." + property.nameKey + "." + it) - }.toTypedArray(), - property.valueContainer.keys().indexOf(property.valueContainer.value()) - ) { _, which -> - property.valueContainer.writeFrom(property.valueContainer.keys()[which]) - } - - builder.setPositiveButton("OK") { _, _ -> - updateLocalizedText(button, property.valueContainer.value()) - } - - builder.show() - } - ViewAppearanceHelper.applyTheme(viewModel, button) - button - } - is ConfigStateListValue -> { - val button = Button(viewModel.context) - updateButtonText(button, "(${property.valueContainer.value().count { it.value }})") - - button.setOnClickListener {_ -> - val builder = AlertDialog.Builder(viewModel.context) - builder.setTitle(propertyName) - - val sortedStates = property.valueContainer.value().toSortedMap() - - builder.setMultiChoiceItems( - sortedStates.toSortedMap().map { - if (property.disableValueLocalization) it.key - else context.translation.get("option." + property.nameKey + "." + it.key) - }.toTypedArray(), - sortedStates.map { it.value }.toBooleanArray() - ) { _, which, isChecked -> - sortedStates.keys.toList()[which].let { key -> - property.valueContainer.setKey(key, isChecked) - } - } - - builder.setPositiveButton("OK") { _, _ -> - updateButtonText(button, "(${property.valueContainer.value().count { it.value }})") - } - - builder.show() - } - ViewAppearanceHelper.applyTheme(viewModel, button) - button - } - else -> { - TextView(viewModel.context) - } - } - return resultView - } - - private fun newSeparator(thickness: Int, color: Int = Color.BLACK): View { - return LinearLayout(context.mainActivity).apply { - setPadding(0, 0, 0, thickness) - setBackgroundColor(color) - } - } - - @SuppressLint("SetTextI18n") - @Suppress("deprecation") - fun inject(viewModel: View, addView: (View) -> Unit) { - val packageInfo = viewModel.context.packageManager.getPackageInfo(Constants.SNAPCHAT_PACKAGE_NAME, 0) - val versionTextBuilder = StringBuilder() - versionTextBuilder.append("SnapEnhance ").append(BuildConfig.VERSION_NAME) - .append(" by rhunk") - if (BuildConfig.DEBUG) { - versionTextBuilder.append("\n").append("Snapchat ").append(packageInfo.versionName) - .append(" (").append(packageInfo.longVersionCode).append(")") - } - val titleText = TextView(viewModel.context) - titleText.text = versionTextBuilder.toString() - ViewAppearanceHelper.applyTheme(viewModel, titleText) - titleText.textSize = 18f - titleText.minHeight = 80 * versionTextBuilder.chars().filter { ch: Int -> ch == '\n'.code } - .count().coerceAtLeast(2).toInt() - addView(titleText) - - val actions = context.actionManager.getActions().map { - Pair(it) { - val button = Button(viewModel.context) - button.text = context.translation.get(it.nameKey) - button.setOnClickListener { _ -> - it.run() - } - ViewAppearanceHelper.applyTheme(viewModel, button) - button - } - } - - context.config.entries().groupBy { - it.key.category - }.forEach { (category, value) -> - addView(createCategoryTitle(viewModel, category.key)) - value.filter { it.key.shouldAppearInSettings }.forEach { (property, _) -> - addView(createPropertyView(viewModel, property)) - actions.find { pair -> pair.first.dependsOnProperty == property}?.let { pair -> - addView(pair.second()) - } - } - } - - actions.filter { it.first.dependsOnProperty == null }.forEach { - addView(it.second()) - } - - addView(newSeparator(3, Color.parseColor("#f5f5f5"))) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/hook/Hooker.kt b/app/src/main/kotlin/me/rhunk/snapenhance/hook/Hooker.kt deleted file mode 100644 index ee8b438bfe..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/hook/Hooker.kt +++ /dev/null @@ -1,127 +0,0 @@ -package me.rhunk.snapenhance.hook - -import de.robv.android.xposed.XC_MethodHook -import de.robv.android.xposed.XposedBridge -import java.lang.reflect.Member - -object Hooker { - private fun newMethodHook( - stage: HookStage, - consumer: (HookAdapter) -> Unit, - filter: ((HookAdapter) -> Boolean) = { true } - ): XC_MethodHook { - val callEvent = { param: XC_MethodHook.MethodHookParam<*> -> - HookAdapter(param).takeIf(filter)?.also(consumer) - } - - return if (stage == HookStage.BEFORE) object : XC_MethodHook() { - override fun beforeHookedMethod(param: MethodHookParam<*>) { - callEvent(param) - } - } else object : XC_MethodHook() { - override fun afterHookedMethod(param: MethodHookParam<*>) { - callEvent(param) - } - } - } - - fun hook( - clazz: Class<*>, - methodName: String, - stage: HookStage, - consumer: (HookAdapter) -> Unit - ): Set<XC_MethodHook.Unhook> = hook(clazz, methodName, stage, { true }, consumer) - - fun hook( - clazz: Class<*>, - methodName: String, - stage: HookStage, - filter: (HookAdapter) -> Boolean, - consumer: (HookAdapter) -> Unit - ): Set<XC_MethodHook.Unhook> = XposedBridge.hookAllMethods(clazz, methodName, newMethodHook(stage, consumer, filter)) - - fun hook( - member: Member, - stage: HookStage, - consumer: (HookAdapter) -> Unit - ): XC_MethodHook.Unhook { - return hook(member, stage, { true }, consumer) - } - - fun hook( - member: Member, - stage: HookStage, - filter: ((HookAdapter) -> Boolean), - consumer: (HookAdapter) -> Unit - ): XC_MethodHook.Unhook { - return XposedBridge.hookMethod(member, newMethodHook(stage, consumer, filter)) - } - - - fun hookConstructor( - clazz: Class<*>, - stage: HookStage, - consumer: (HookAdapter) -> Unit - ) { - XposedBridge.hookAllConstructors(clazz, newMethodHook(stage, consumer)) - } - - fun hookConstructor( - clazz: Class<*>, - stage: HookStage, - filter: ((HookAdapter) -> Boolean), - consumer: (HookAdapter) -> Unit - ) { - XposedBridge.hookAllConstructors(clazz, newMethodHook(stage, consumer, filter)) - } - - fun ephemeralHookObjectMethod( - clazz: Class<*>, - instance: Any, - methodName: String, - stage: HookStage, - hookConsumer: (HookAdapter) -> Unit - ) { - val unhooks: MutableSet<XC_MethodHook.Unhook> = HashSet() - hook(clazz, methodName, stage) { param-> - if (param.thisObject<Any>() != instance) return@hook - hookConsumer(param) - unhooks.forEach{ it.unhook() } - }.also { unhooks.addAll(it) } - } -} - -fun Class<*>.hookConstructor( - stage: HookStage, - consumer: (HookAdapter) -> Unit -) = Hooker.hookConstructor(this, stage, consumer) - -fun Class<*>.hookConstructor( - stage: HookStage, - filter: ((HookAdapter) -> Boolean), - consumer: (HookAdapter) -> Unit -) = Hooker.hookConstructor(this, stage, filter, consumer) - -fun Class<*>.hook( - methodName: String, - stage: HookStage, - consumer: (HookAdapter) -> Unit -): Set<XC_MethodHook.Unhook> = Hooker.hook(this, methodName, stage, consumer) - -fun Class<*>.hook( - methodName: String, - stage: HookStage, - filter: (HookAdapter) -> Boolean, - consumer: (HookAdapter) -> Unit -): Set<XC_MethodHook.Unhook> = Hooker.hook(this, methodName, stage, filter, consumer) - -fun Member.hook( - stage: HookStage, - consumer: (HookAdapter) -> Unit -): XC_MethodHook.Unhook = Hooker.hook(this, stage, consumer) - -fun Member.hook( - stage: HookStage, - filter: ((HookAdapter) -> Boolean), - consumer: (HookAdapter) -> Unit -): XC_MethodHook.Unhook = Hooker.hook(this, stage, filter, consumer) \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/manager/Manager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/manager/Manager.kt deleted file mode 100644 index ba244213bb..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/manager/Manager.kt +++ /dev/null @@ -1,6 +0,0 @@ -package me.rhunk.snapenhance.manager - -interface Manager { - fun init() {} - fun onActivityCreate() {} -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/ActionManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/ActionManager.kt deleted file mode 100644 index 6d8e4d1d38..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/ActionManager.kt +++ /dev/null @@ -1,32 +0,0 @@ -package me.rhunk.snapenhance.manager.impl - -import me.rhunk.snapenhance.ModContext -import me.rhunk.snapenhance.action.AbstractAction -import me.rhunk.snapenhance.action.impl.CheckForUpdates -import me.rhunk.snapenhance.action.impl.CleanCache -import me.rhunk.snapenhance.action.impl.ClearMessageLogger -import me.rhunk.snapenhance.action.impl.OpenMap -import me.rhunk.snapenhance.action.impl.RefreshMappings -import me.rhunk.snapenhance.manager.Manager -import kotlin.reflect.KClass - -class ActionManager( - private val context: ModContext, -) : Manager { - private val actions = mutableMapOf<String, AbstractAction>() - fun getActions() = actions.values.toList() - private fun load(clazz: KClass<out AbstractAction>) { - val action = clazz.java.newInstance() - action.context = context - actions[action.nameKey] = action - } - override fun init() { - load(CleanCache::class) - load(ClearMessageLogger::class) - load(RefreshMappings::class) - load(OpenMap::class) - load(CheckForUpdates::class) - - actions.values.forEach(AbstractAction::init) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/ConfigManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/ConfigManager.kt deleted file mode 100644 index 0620848d15..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/ConfigManager.kt +++ /dev/null @@ -1,58 +0,0 @@ -package me.rhunk.snapenhance.manager.impl - -import com.google.gson.JsonObject -import me.rhunk.snapenhance.Logger -import me.rhunk.snapenhance.ModContext -import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType -import me.rhunk.snapenhance.config.ConfigAccessor -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.manager.Manager -import java.nio.charset.StandardCharsets - -class ConfigManager( - private val context: ModContext -) : ConfigAccessor(), Manager { - - override fun init() { - ConfigProperty.sortedByCategory().forEach { key -> - set(key, key.valueContainer) - } - - if (!context.bridgeClient.isFileExists(BridgeFileType.CONFIG)) { - writeConfig() - return - } - - runCatching { - loadConfig() - }.onFailure { - Logger.xposedLog("Failed to load config", it) - writeConfig() - } - } - - private fun loadConfig() { - val configContent = context.bridgeClient.createAndReadFile( - BridgeFileType.CONFIG, - "{}".toByteArray(Charsets.UTF_8) - ) - val configObject: JsonObject = context.gson.fromJson( - String(configContent, StandardCharsets.UTF_8), - JsonObject::class.java - ) - entries().forEach { (key, value) -> - value.writeFrom(configObject.get(key.name)?.asString ?: value.read()) - } - } - - fun writeConfig() { - val configObject = JsonObject() - entries().forEach { (key, value) -> - configObject.addProperty(key.name, value.read()) - } - context.bridgeClient.writeFile( - BridgeFileType.CONFIG, - context.gson.toJson(configObject).toByteArray(Charsets.UTF_8) - ) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt deleted file mode 100644 index 5d22435b22..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt +++ /dev/null @@ -1,121 +0,0 @@ -package me.rhunk.snapenhance.manager.impl - -import me.rhunk.snapenhance.Logger -import me.rhunk.snapenhance.ModContext -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.features.impl.AutoUpdater -import me.rhunk.snapenhance.features.impl.ConfigEnumKeys -import me.rhunk.snapenhance.features.impl.Messaging -import me.rhunk.snapenhance.features.impl.downloader.AntiAutoDownload -import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader -import me.rhunk.snapenhance.features.impl.experiments.AmoledDarkMode -import me.rhunk.snapenhance.features.impl.experiments.AppPasscode -import me.rhunk.snapenhance.features.impl.experiments.InfiniteStoryBoost -import me.rhunk.snapenhance.features.impl.experiments.MeoPasscodeBypass -import me.rhunk.snapenhance.features.impl.tweaks.AntiAutoSave -import me.rhunk.snapenhance.features.impl.tweaks.AutoSave -import me.rhunk.snapenhance.features.impl.tweaks.DisableVideoLengthRestriction -import me.rhunk.snapenhance.features.impl.tweaks.GalleryMediaSendOverride -import me.rhunk.snapenhance.features.impl.tweaks.LocationSpoofer -import me.rhunk.snapenhance.features.impl.tweaks.MediaQualityLevelOverride -import me.rhunk.snapenhance.features.impl.tweaks.Notifications -import me.rhunk.snapenhance.features.impl.tweaks.SnapchatPlus -import me.rhunk.snapenhance.features.impl.tweaks.UnlimitedSnapViewTime -import me.rhunk.snapenhance.features.impl.privacy.DisableMetrics -import me.rhunk.snapenhance.features.impl.privacy.PreventMessageSending -import me.rhunk.snapenhance.features.impl.spying.AnonymousStoryViewing -import me.rhunk.snapenhance.features.impl.spying.MessageLogger -import me.rhunk.snapenhance.features.impl.spying.PreventReadReceipts -import me.rhunk.snapenhance.features.impl.spying.StealthMode -import me.rhunk.snapenhance.features.impl.tweaks.CameraTweaks -import me.rhunk.snapenhance.features.impl.ui.UITweaks -import me.rhunk.snapenhance.features.impl.ui.menus.MenuViewInjector -import me.rhunk.snapenhance.manager.Manager -import java.util.concurrent.Executors -import kotlin.reflect.KClass - -class FeatureManager(private val context: ModContext) : Manager { - private val asyncLoadExecutorService = Executors.newCachedThreadPool() - private val features = mutableListOf<Feature>() - - private fun register(featureClass: KClass<out Feature>) { - runCatching { - with(featureClass.java.newInstance()) { - context = this@FeatureManager.context - features.add(this) - } - }.onFailure { - Logger.xposedLog("Failed to register feature ${featureClass.simpleName}", it) - } - } - - @Suppress("UNCHECKED_CAST") - fun <T : Feature> get(featureClass: KClass<T>): T? { - return features.find { it::class == featureClass } as? T - } - - override fun init() { - register(Messaging::class) - register(MediaDownloader::class) - register(StealthMode::class) - register(MenuViewInjector::class) - register(PreventReadReceipts::class) - register(AnonymousStoryViewing::class) - register(MessageLogger::class) - register(SnapchatPlus::class) - register(DisableMetrics::class) - register(PreventMessageSending::class) - register(Notifications::class) - register(AutoSave::class) - register(UITweaks::class) - register(ConfigEnumKeys::class) - register(AntiAutoDownload::class) - register(GalleryMediaSendOverride::class) - register(AntiAutoSave::class) - register(UnlimitedSnapViewTime::class) - register(DisableVideoLengthRestriction::class) - register(MediaQualityLevelOverride::class) - register(MeoPasscodeBypass::class) - register(AppPasscode::class) - register(LocationSpoofer::class) - register(AutoUpdater::class) - register(CameraTweaks::class) - register(InfiniteStoryBoost::class) - register(AmoledDarkMode::class) - - initializeFeatures() - } - - private fun featureInitializer(isAsync: Boolean, param: Int, action: (Feature) -> Unit) { - features.forEach { feature -> - if (feature.loadParams and param == 0) return@forEach - val callback = { - runCatching { - action(feature) - }.onFailure { - Logger.xposedLog("Failed to init feature ${feature.nameKey}", it) - context.longToast("Failed to init feature ${feature.nameKey}") - } - } - if (!isAsync) { - callback() - return@forEach - } - asyncLoadExecutorService.submit { - callback() - } - } - } - - private fun initializeFeatures() { - //TODO: async called when all features are initiated ? - featureInitializer(false, FeatureLoadParams.INIT_SYNC) { it.init() } - featureInitializer(true, FeatureLoadParams.INIT_ASYNC) { it.asyncInit() } - } - - override fun onActivityCreate() { - featureInitializer(false, FeatureLoadParams.ACTIVITY_CREATE_SYNC) { it.onActivityCreate() } - featureInitializer(true, FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { it.asyncOnActivityCreate() } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/MappingManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/MappingManager.kt deleted file mode 100644 index fd8d55822f..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/MappingManager.kt +++ /dev/null @@ -1,226 +0,0 @@ -package me.rhunk.snapenhance.manager.impl - -import android.app.AlertDialog -import com.google.gson.JsonElement -import com.google.gson.JsonObject -import com.google.gson.JsonParser -import kotlinx.coroutines.Job -import kotlinx.coroutines.joinAll -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import me.rhunk.snapenhance.Constants -import me.rhunk.snapenhance.Logger -import me.rhunk.snapenhance.ModContext -import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType -import me.rhunk.snapenhance.manager.Manager -import me.rhunk.snapenhance.mapping.Mapper -import me.rhunk.snapenhance.mapping.impl.BCryptClassMapper -import me.rhunk.snapenhance.mapping.impl.CallbackMapper -import me.rhunk.snapenhance.mapping.impl.EnumMapper -import me.rhunk.snapenhance.mapping.impl.DefaultMediaItemMapper -import me.rhunk.snapenhance.mapping.impl.OperaPageViewControllerMapper -import me.rhunk.snapenhance.mapping.impl.PlatformAnalyticsCreatorMapper -import me.rhunk.snapenhance.mapping.impl.PlusSubscriptionMapper -import me.rhunk.snapenhance.mapping.impl.ScCameraSettingsMapper -import me.rhunk.snapenhance.mapping.impl.StoryBoostStateMapper -import me.rhunk.snapenhance.util.getObjectField -import java.nio.charset.StandardCharsets -import java.util.concurrent.ConcurrentHashMap -import kotlin.concurrent.thread - -@Suppress("UNCHECKED_CAST") -class MappingManager(private val context: ModContext) : Manager { - private val mappers = mutableListOf<Mapper>().apply { - add(CallbackMapper()) - add(EnumMapper()) - add(OperaPageViewControllerMapper()) - add(PlusSubscriptionMapper()) - add(DefaultMediaItemMapper()) - add(BCryptClassMapper()) - add(PlatformAnalyticsCreatorMapper()) - add(ScCameraSettingsMapper()) - add(StoryBoostStateMapper()) - } - - private val mappings = ConcurrentHashMap<String, Any>() - val areMappingsLoaded: Boolean - get() = mappings.isNotEmpty() - private var snapBuildNumber = 0 - - @Suppress("deprecation") - override fun init() { - val currentBuildNumber = context.androidContext.packageManager.getPackageInfo( - Constants.SNAPCHAT_PACKAGE_NAME, - 0 - ).longVersionCode.toInt() - snapBuildNumber = currentBuildNumber - - if (context.bridgeClient.isFileExists(BridgeFileType.MAPPINGS)) { - runCatching { - loadCached() - }.onFailure { - context.crash("Failed to load cached mappings ${it.message}", it) - } - - if (snapBuildNumber != currentBuildNumber) { - context.bridgeClient.deleteFile(BridgeFileType.MAPPINGS) - context.softRestartApp() - } - return - } - context.runOnUiThread { - val statusDialogBuilder = AlertDialog.Builder(context.mainActivity) - .setMessage("Generating mappings, please wait...") - .setCancelable(false) - .setView(android.widget.ProgressBar(context.mainActivity).apply { - setPadding(0, 20, 0, 20) - }) - - val loadingDialog = statusDialogBuilder.show() - - context.executeAsync { - runCatching { - refresh() - }.onSuccess { - context.shortToast("Generated mappings for build $snapBuildNumber") - context.softRestartApp() - }.onFailure { - Logger.error("Failed to generate mappings", it) - context.runOnUiThread { - loadingDialog.dismiss() - statusDialogBuilder.setView(null) - statusDialogBuilder.setMessage("Failed to generate mappings: $it") - statusDialogBuilder.setNegativeButton("Close") { _, _ -> - context.mainActivity!!.finish() - } - statusDialogBuilder.show() - } - } - } - } - } - - private fun loadCached() { - if (!context.bridgeClient.isFileExists(BridgeFileType.MAPPINGS)) { - Logger.xposedLog("Mappings file does not exist") - return - } - val mappingsObject = JsonParser.parseString( - String( - context.bridgeClient.readFile(BridgeFileType.MAPPINGS), - StandardCharsets.UTF_8 - ) - ).asJsonObject.also { - snapBuildNumber = it["snap_build_number"].asInt - } - - mappingsObject.entrySet().forEach { (key, value): Map.Entry<String, JsonElement> -> - if (value.isJsonArray) { - mappings[key] = context.gson.fromJson(value, ArrayList::class.java) - return@forEach - } - if (value.isJsonObject) { - mappings[key] = context.gson.fromJson(value, ConcurrentHashMap::class.java) - return@forEach - } - mappings[key] = value.asString - } - } - - private fun executeMappers(classes: List<Class<*>>) = runBlocking { - val jobs = mutableListOf<Job>() - mappers.forEach { mapper -> - mapper.context = context - launch { - runCatching { - mapper.useClasses(context.androidContext.classLoader, classes, mappings) - }.onFailure { - Logger.xposedLog("Failed to execute mapper ${mapper.javaClass.simpleName}", it) - } - }.also { jobs.add(it) } - } - jobs.joinAll() - } - - @Suppress("UNCHECKED_CAST", "DEPRECATION") - private fun refresh() { - val classes: MutableList<Class<*>> = ArrayList() - - val classLoader = context.androidContext.classLoader - val dexPathList = classLoader.getObjectField("pathList")!! - val dexElements = dexPathList.getObjectField("dexElements") as Array<Any> - - dexElements.forEach { dexElement: Any -> - (dexElement.getObjectField("dexFile") as dalvik.system.DexFile?)?.apply { - entries().toList().forEach fileList@{ className -> - //ignore classes without a dot in them - if (className.contains(".") && !className.startsWith("com.snap")) return@fileList - runCatching { - classLoader.loadClass(className)?.let { - //force load fields to avoid ClassNotFoundExceptions when executing mappers - it.declaredFields - classes.add(it) - } - }.onFailure { - Logger.debug("Failed to load class $className") - } - } - } - } - - executeMappers(classes) - write() - } - - private fun write() { - val mappingsObject = JsonObject() - mappingsObject.addProperty("snap_build_number", snapBuildNumber) - mappings.forEach { (key, value) -> - if (value is List<*>) { - mappingsObject.add(key, context.gson.toJsonTree(value)) - return@forEach - } - if (value is Map<*, *>) { - mappingsObject.add(key, context.gson.toJsonTree(value)) - return@forEach - } - mappingsObject.addProperty(key, value.toString()) - } - - context.bridgeClient.writeFile( - BridgeFileType.MAPPINGS, - mappingsObject.toString().toByteArray() - ) - } - - fun getMappedObject(key: String): Any { - if (mappings.containsKey(key)) { - return mappings[key]!! - } - throw Exception("No mapping found for $key") - } - - fun getMappedClass(className: String): Class<*> { - return context.androidContext.classLoader.loadClass(getMappedObject(className) as String) - } - - fun getMappedClass(key: String, subKey: String): Class<*> { - return context.androidContext.classLoader.loadClass(getMappedValue(key, subKey)) - } - - fun getMappedValue(key: String): String { - return getMappedObject(key) as String - } - - fun <T : Any> getMappedList(key: String): List<T> { - return listOf(getMappedObject(key) as List<T>).flatten() - } - - fun getMappedValue(key: String, subKey: String): String { - return getMappedMap(key)[subKey] as String - } - - fun getMappedMap(key: String): Map<String, *> { - return getMappedObject(key) as Map<String, *> - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/TranslationManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/TranslationManager.kt deleted file mode 100644 index ede17f053c..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/TranslationManager.kt +++ /dev/null @@ -1,43 +0,0 @@ -package me.rhunk.snapenhance.manager.impl - -import com.google.gson.JsonObject -import com.google.gson.JsonParser -import me.rhunk.snapenhance.Logger -import me.rhunk.snapenhance.ModContext -import me.rhunk.snapenhance.manager.Manager -import java.util.Locale - -class TranslationManager( - private val context: ModContext -) : Manager { - private val translationMap = mutableMapOf<String, String>() - lateinit var locale: Locale - - override fun init() { - val messageLocaleResult = context.bridgeClient.fetchTranslations(); - locale = messageLocaleResult.locale?.split("_")?.let { Locale(it[0], it[1]) } ?: Locale.getDefault() - - val translations = JsonParser.parseString(messageLocaleResult.content?.toString(Charsets.UTF_8)).asJsonObject - if (translations == null || translations.isJsonNull) { - context.crash("Failed to fetch translations") - return - } - - fun scanObject(jsonObject: JsonObject, prefix: String = "") { - jsonObject.entrySet().forEach { - if (it.value.isJsonPrimitive) { - translationMap["$prefix${it.key}"] = it.value.asString - } - if (!it.value.isJsonObject) return@forEach - scanObject(it.value.asJsonObject, "$prefix${it.key}.") - } - } - - scanObject(translations) - } - - - fun get(key: String): String { - return translationMap[key] ?: key.also { Logger.xposedLog("Missing translation for $key") } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/Mapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/mapping/Mapper.kt deleted file mode 100644 index c9f3df16a7..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/Mapper.kt +++ /dev/null @@ -1,13 +0,0 @@ -package me.rhunk.snapenhance.mapping - -import me.rhunk.snapenhance.ModContext - -abstract class Mapper { - lateinit var context: ModContext - - abstract fun useClasses( - classLoader: ClassLoader, - classes: List<Class<*>>, - mappings: MutableMap<String, Any> - ) -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/BCryptClassMapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/BCryptClassMapper.kt deleted file mode 100644 index 28761668e1..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/BCryptClassMapper.kt +++ /dev/null @@ -1,26 +0,0 @@ -package me.rhunk.snapenhance.mapping.impl - -import me.rhunk.snapenhance.mapping.Mapper -import java.lang.reflect.Modifier - -class BCryptClassMapper : Mapper() { - override fun useClasses( - classLoader: ClassLoader, - classes: List<Class<*>>, - mappings: MutableMap<String, Any> - ) { - for (clazz in classes) { - if (!Modifier.isFinal(clazz.modifiers)) continue - clazz.fields.firstOrNull { it.type == IntArray::class.java && Modifier.isStatic(it.modifiers)}?.let { field -> - val fieldData = field.get(null) - if (fieldData !is IntArray) return@let - if (fieldData.size != 18 || fieldData[0] != 608135816) return@let - mappings["BCryptClass"] = clazz.name - mappings["BCryptClassHashMethod"] = clazz.methods.first { - it.parameterTypes.size == 2 && it.returnType == String::class.java && it.parameterTypes[0] == String::class.java && it.parameterTypes[1] == String::class.java - }.name - return - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/CallbackMapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/CallbackMapper.kt deleted file mode 100644 index 6bca4730a6..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/CallbackMapper.kt +++ /dev/null @@ -1,29 +0,0 @@ -package me.rhunk.snapenhance.mapping.impl - -import me.rhunk.snapenhance.Logger.debug -import me.rhunk.snapenhance.mapping.Mapper -import java.lang.reflect.Method -import java.lang.reflect.Modifier - -class CallbackMapper : Mapper() { - override fun useClasses( - classLoader: ClassLoader, - classes: List<Class<*>>, - mappings: MutableMap<String, Any> - ) { - val callbackMappings = HashMap<String, String>() - classes.forEach { clazz -> - val superClass = clazz.superclass ?: return@forEach - if (!superClass.name.endsWith("Callback") || superClass.name.endsWith("\$Callback")) return@forEach - if (!Modifier.isAbstract(superClass.modifiers)) return@forEach - - if (superClass.declaredMethods.any { method: Method -> - method.name == "onError" - }) { - callbackMappings[superClass.simpleName] = clazz.name - } - } - debug("found " + callbackMappings.size + " callbacks") - mappings["callbacks"] = callbackMappings - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/DefaultMediaItemMapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/DefaultMediaItemMapper.kt deleted file mode 100644 index d05754c4ae..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/DefaultMediaItemMapper.kt +++ /dev/null @@ -1,24 +0,0 @@ -package me.rhunk.snapenhance.mapping.impl - -import android.net.Uri -import me.rhunk.snapenhance.mapping.Mapper -import java.lang.reflect.Modifier - -class DefaultMediaItemMapper : Mapper() { - override fun useClasses( - classLoader: ClassLoader, - classes: List<Class<*>>, - mappings: MutableMap<String, Any> - ) { - for (clazz in classes) { - if (clazz.superclass == null || !Modifier.isAbstract(clazz.superclass.modifiers)) continue - if (clazz.superclass.interfaces.isEmpty() || clazz.superclass.interfaces[0] != Comparable::class.java) continue - if (clazz.methods.none { it.returnType == Uri::class.java }) continue - - val constructorParameters = clazz.constructors[0]?.parameterTypes ?: continue - if (constructorParameters.size < 6 || constructorParameters[5] != Long::class.javaPrimitiveType) continue - - mappings["DefaultMediaItem"] = clazz.name - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/EnumMapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/EnumMapper.kt deleted file mode 100644 index 61be21fb19..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/EnumMapper.kt +++ /dev/null @@ -1,66 +0,0 @@ -package me.rhunk.snapenhance.mapping.impl - -import me.rhunk.snapenhance.Logger.debug -import me.rhunk.snapenhance.mapping.Mapper -import java.lang.reflect.Method -import java.lang.reflect.Modifier -import java.util.Objects - - -class EnumMapper : Mapper() { - override fun useClasses( - classLoader: ClassLoader, - classes: List<Class<*>>, - mappings: MutableMap<String, Any> - ) { - val enumMappings = HashMap<String, String>() - var enumQualityLevel: Class<*>? = null - - //settings classes have an interface that extends Serializable and contains the getName method - //this enum classes are used to store the settings values - //Setting enum class -> implements an interface -> getName method - classes.forEach { clazz -> - if (!clazz.isEnum) return@forEach - - //quality level enum - if (enumQualityLevel == null) { - if (clazz.enumConstants.any { it.toString().startsWith("LEVEL_NONE") }) { - enumMappings["QualityLevel"] = clazz.name - enumQualityLevel = clazz - } - } - - if (clazz.interfaces.isEmpty()) return@forEach - val serializableInterfaceClass = clazz.interfaces[0] - if (serializableInterfaceClass.methods - .filter { method: Method -> method.declaringClass == serializableInterfaceClass } - .none { method: Method -> method.name == "getName" } - ) return@forEach - - runCatching { - val getEnumNameMethod = - serializableInterfaceClass.methods.first { it!!.returnType.isEnum } - clazz.enumConstants?.onEach { enumConstant -> - val enumName = - Objects.requireNonNull(getEnumNameMethod.invoke(enumConstant)).toString() - enumMappings[enumName] = clazz.name - } - } - } - - debug("found " + enumMappings.size + " enums") - mappings["enums"] = enumMappings - - //find the media quality level provider - for (clazz in classes) { - if (!Modifier.isAbstract(clazz.modifiers)) continue - if (clazz.fields.none { Modifier.isTransient(it.modifiers) }) continue - clazz.methods.firstOrNull { it.returnType == enumQualityLevel }?.let { - mappings["MediaQualityLevelProvider"] = clazz.name - mappings["MediaQualityLevelProviderMethod"] = it.name - debug("found MediaQualityLevelProvider: ${clazz.name}.${it.name}") - return - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/OperaPageViewControllerMapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/OperaPageViewControllerMapper.kt deleted file mode 100644 index 42f06a3ec0..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/OperaPageViewControllerMapper.kt +++ /dev/null @@ -1,78 +0,0 @@ -package me.rhunk.snapenhance.mapping.impl - -import me.rhunk.snapenhance.mapping.Mapper -import me.rhunk.snapenhance.util.ReflectionHelper -import java.lang.reflect.Field -import java.lang.reflect.Method -import java.lang.reflect.Modifier -import java.util.Arrays - - -class OperaPageViewControllerMapper : Mapper() { - override fun useClasses( - classLoader: ClassLoader, - classes: List<Class<*>>, - mappings: MutableMap<String, Any> - ) { - var operaPageViewControllerClass: Class<*>? = null - for (aClass in classes) { - if (!Modifier.isAbstract(aClass.modifiers)) continue - if (aClass.interfaces.isEmpty()) continue - val foundFields = Arrays.stream(aClass.declaredFields).filter { field: Field -> - val modifiers = field.modifiers - Modifier.isStatic(modifiers) && Modifier.isFinal( - modifiers - ) - }.filter { field: Field -> - try { - return@filter "ad_product_type" == String.format("%s", field[null]) - } catch (e: IllegalAccessException) { - e.printStackTrace() - } - false - }.count() - if (foundFields == 0L) continue - operaPageViewControllerClass = aClass - break - } - if (operaPageViewControllerClass == null) throw RuntimeException("OperaPageViewController not found") - - val members = HashMap<String, String>() - members["Class"] = operaPageViewControllerClass.name - - operaPageViewControllerClass.fields.forEach { field -> - val fieldType = field.type - if (fieldType.isEnum) { - fieldType.enumConstants.firstOrNull { enumConstant: Any -> enumConstant.toString() == "FULLY_DISPLAYED" } - .let { members["viewStateField"] = field.name } - } - if (fieldType == ArrayList::class.java) { - members["layerListField"] = field.name - } - } - val enumViewStateClass = operaPageViewControllerClass.fields.first { field: Field -> - field.name == members["viewStateField"] - }.type - - //find the method that call the onDisplayStateChange method - members["onDisplayStateChange"] = - operaPageViewControllerClass.methods.first { method: Method -> - if (method.returnType != Void.TYPE || method.parameterTypes.size != 1) return@first false - val firstParameterClass = method.parameterTypes[0] - //check if the class contains a field with the enumViewStateClass type - ReflectionHelper.searchFieldByType(firstParameterClass, enumViewStateClass) != null - }.name - - //find the method that call the onDisplayStateChange method from gestures - members["onDisplayStateChange2"] = - operaPageViewControllerClass.methods.first { method: Method -> - if (method.returnType != Void.TYPE || method.parameterTypes.size != 2) return@first false - val firstParameterClass = method.parameterTypes[0] - val secondParameterClass = method.parameterTypes[1] - firstParameterClass.isEnum && secondParameterClass.isEnum - }.name - - mappings["OperaPageViewController"] = members - return - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/PlatformAnalyticsCreatorMapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/PlatformAnalyticsCreatorMapper.kt deleted file mode 100644 index fba9713821..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/PlatformAnalyticsCreatorMapper.kt +++ /dev/null @@ -1,26 +0,0 @@ -package me.rhunk.snapenhance.mapping.impl - -import me.rhunk.snapenhance.mapping.Mapper - -class PlatformAnalyticsCreatorMapper : Mapper() { - override fun useClasses( - classLoader: ClassLoader, - classes: List<Class<*>>, - mappings: MutableMap<String, Any> - ) { - for (clazz in classes) { - if (clazz.isEnum || clazz.isInterface) continue - val constructors = clazz.constructors - if (constructors.isEmpty()) continue - val firstConstructor = constructors[0] - // 47 is the number of parameters of the constructor - // can change in future versions - if (firstConstructor.parameterCount != 47) continue - if (!firstConstructor.parameterTypes[0].isEnum) continue - if (firstConstructor.parameterTypes[0].enumConstants.none { it.toString() == "IN_APP_NOTIFICATION" }) continue - - mappings["PlatformAnalyticsCreator"] = clazz.name - return - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/PlusSubscriptionMapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/PlusSubscriptionMapper.kt deleted file mode 100644 index 69faa62532..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/PlusSubscriptionMapper.kt +++ /dev/null @@ -1,27 +0,0 @@ -package me.rhunk.snapenhance.mapping.impl - -import me.rhunk.snapenhance.mapping.Mapper -import java.lang.reflect.Modifier - - -class PlusSubscriptionMapper : Mapper() { - override fun useClasses( - classLoader: ClassLoader, - classes: List<Class<*>>, - mappings: MutableMap<String, Any> - ) { - for (clazz in classes) { - clazz.fields.firstOrNull { - it.type == clazz && - Modifier.isFinal(it.modifiers) && - Modifier.isStatic(it.modifiers) && - runCatching { - it?.get(null).toString().startsWith("PlusSubscriptionState") - }.getOrDefault(false) - } ?: continue - - mappings["SubscriptionInfoClass"] = clazz.constructors[0]!!.parameterTypes[0]!!.name - return - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/ScCameraSettingsMapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/ScCameraSettingsMapper.kt deleted file mode 100644 index fe4a6d8c56..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/ScCameraSettingsMapper.kt +++ /dev/null @@ -1,21 +0,0 @@ -package me.rhunk.snapenhance.mapping.impl - -import me.rhunk.snapenhance.mapping.Mapper - -class ScCameraSettingsMapper : Mapper() { - override fun useClasses( - classLoader: ClassLoader, - classes: List<Class<*>>, - mappings: MutableMap<String, Any> - ) { - for (clazz in classes) { - if (clazz.constructors.isEmpty()) continue - val parameters = clazz.constructors.first().parameterTypes - if (parameters.size < 27) continue - val firstParameter = parameters[0] - if (!firstParameter.isEnum || firstParameter.enumConstants.find { it.toString() == "CONTINUOUS_PICTURE" } == null) continue - mappings["ScCameraSettings"] = clazz.name - return - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/StoryBoostStateMapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/StoryBoostStateMapper.kt deleted file mode 100644 index c7b06f5df5..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/StoryBoostStateMapper.kt +++ /dev/null @@ -1,19 +0,0 @@ -package me.rhunk.snapenhance.mapping.impl - -import me.rhunk.snapenhance.mapping.Mapper - -class StoryBoostStateMapper : Mapper(){ - override fun useClasses( - classLoader: ClassLoader, - classes: List<Class<*>>, - mappings: MutableMap<String, Any> - ) { - for (clazz in classes) { - val firstField = clazz.fields.firstOrNull() ?: continue - if (!firstField.type.isEnum || firstField.type.enumConstants.none { it.toString() == "NeedSubscriptionCannotSubscribe" }) continue - mappings["StoryBoostStateClass"] = clazz.name - return - } - - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/AutoReloadHandler.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/AutoReloadHandler.kt new file mode 100644 index 0000000000..f9e362aa57 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/AutoReloadHandler.kt @@ -0,0 +1,45 @@ +package me.rhunk.snapenhance.scripting + +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class AutoReloadHandler( + private val coroutineScope: CoroutineScope, + private val onReload: (DocumentFile) -> Unit, +) { + private val files = mutableListOf<DocumentFile>() + private val lastModifiedMap = mutableMapOf<Uri, Long>() + + fun addFile(file: DocumentFile) { + synchronized(lastModifiedMap) { + files.add(file) + lastModifiedMap[file.uri] = file.lastModified() + } + } + + fun start() { + coroutineScope.launch(Dispatchers.IO) { + while (true) { + synchronized(lastModifiedMap) { + files.forEach { file -> + val lastModified = lastModifiedMap[file.uri] ?: return@forEach + runCatching { + val newLastModified = file.lastModified() + if (newLastModified > lastModified) { + lastModifiedMap[file.uri] = newLastModified + onReload(file) + } + }.onFailure { + it.printStackTrace() + } + } + } + delay(1000) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt new file mode 100644 index 0000000000..c4de8670f6 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt @@ -0,0 +1,258 @@ +package me.rhunk.snapenhance.scripting + +import android.annotation.SuppressLint +import android.net.Uri +import android.os.ParcelFileDescriptor +import androidx.documentfile.provider.DocumentFile +import me.rhunk.snapenhance.RemoteSideContext +import me.rhunk.snapenhance.bridge.scripting.AutoReloadListener +import me.rhunk.snapenhance.bridge.scripting.IPCListener +import me.rhunk.snapenhance.bridge.scripting.IScripting +import me.rhunk.snapenhance.common.scripting.ScriptRuntime +import me.rhunk.snapenhance.common.scripting.bindings.BindingSide +import me.rhunk.snapenhance.common.scripting.impl.ConfigInterface +import me.rhunk.snapenhance.common.scripting.impl.ConfigTransactionType +import me.rhunk.snapenhance.common.scripting.type.ModuleInfo +import me.rhunk.snapenhance.common.scripting.type.readModuleInfo +import me.rhunk.snapenhance.common.util.ktx.await +import me.rhunk.snapenhance.common.util.ktx.toParcelFileDescriptor +import me.rhunk.snapenhance.scripting.impl.IPCListeners +import me.rhunk.snapenhance.scripting.impl.ManagerIPC +import me.rhunk.snapenhance.scripting.impl.ManagerScriptConfig +import me.rhunk.snapenhance.storage.isScriptEnabled +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.io.InputStream +import kotlin.system.exitProcess + +class RemoteScriptManager( + val context: RemoteSideContext, +) : IScripting.Stub() { + val runtime = ScriptRuntime( + config = { context.config.root }, + androidContext = context.androidContext, + logger = context.log + ).apply { + scripting = this@RemoteScriptManager + } + + private val okHttpClient by lazy { + OkHttpClient.Builder().build() + } + + private var autoReloadListener: AutoReloadListener? = null + private val autoReloadHandler by lazy { + AutoReloadHandler(context.coroutineScope) { + runCatching { + autoReloadListener?.restartApp() + if (context.config.root.scripting.autoReload.getNullable() == "all") { + exitProcess(1) + } + }.onFailure { + context.log.warn("Failed to restart app") + autoReloadListener = null + } + }.apply { + start() + } + } + + private val cachedModuleInfo = mutableMapOf<String, ModuleInfo>() + private val ipcListeners = IPCListeners() + + fun getSyncedModules(): List<ModuleInfo> { + return cachedModuleInfo.values.toList() + } + + fun sync() { + cachedModuleInfo.clear() + getScriptFileNames().forEach { name -> + runCatching { + getScriptInputStream(name) { stream -> + stream?.use { + cachedModuleInfo[name] = it.bufferedReader().readModuleInfo() + } + } + }.onFailure { + context.log.error("Failed to load module info for $name", it) + } + } + } + + fun init() { + runtime.buildModuleObject = { module -> + putConst("currentSide", this, BindingSide.MANAGER.key) + module.registerBindings( + ManagerIPC(ipcListeners), + ManagerScriptConfig(this@RemoteScriptManager) + ) + } + + sync() + getEnabledScripts(listOf(BindingSide.MANAGER.key)).forEach { name -> + runCatching { + loadScript(name) + }.onFailure { + context.log.error("Failed to load script $name", it) + } + } + } + + fun getModulePath(name: String): String? { + return cachedModuleInfo.entries.find { it.value.name == name }?.key + } + + fun loadScript(path: String) { + val content = getScriptContent(path) ?: return + runtime.load(path, content) + if (context.config.root.scripting.autoReload.getNullable() != null) { + autoReloadHandler.addFile(getScriptsFolder()?.findFile(path) ?: return) + } + } + + fun unloadScript(scriptPath: String) { + runtime.unload(scriptPath) + } + + @SuppressLint("Recycle") + private fun <R> getScriptInputStream(name: String, callback: (InputStream?) -> R): R { + val file = getScriptsFolder()?.findFile(name) ?: return callback(null) + return context.androidContext.contentResolver.openInputStream(file.uri)?.let(callback) ?: callback(null) + } + + fun getModuleDataFolder(moduleFileName: String): File { + return context.androidContext.filesDir.resolve("modules").resolve(moduleFileName).also { + if (!it.exists()) { + it.mkdirs() + } + } + } + + fun getScriptsFolder() = runCatching { + DocumentFile.fromTreeUri(context.androidContext, Uri.parse(context.config.root.scripting.moduleFolder.get())) + }.getOrNull() + + private fun getScriptFileNames(): List<String> { + return (getScriptsFolder() ?: return emptyList()).listFiles().filter { it.name?.endsWith(".js") ?: false }.map { it.name!! } + } + + fun importFromUrl( + url: String, + filepath: String? = null + ): ModuleInfo { + val response = okHttpClient.newCall(Request.Builder().url(url).build()).execute() + if (!response.isSuccessful) { + throw Exception("Failed to fetch script. Code: ${response.code}") + } + response.body.byteStream().use { inputStream -> + val bufferedInputStream = inputStream.buffered() + bufferedInputStream.mark(0) + val moduleInfo = bufferedInputStream.bufferedReader().readModuleInfo() + bufferedInputStream.reset() + + val scriptPath = filepath ?: (moduleInfo.name + ".js") + val scriptFile = getScriptsFolder()?.findFile(scriptPath) ?: getScriptsFolder()?.createFile("text/javascript", scriptPath) + ?: throw Exception("Failed to create script file") + + context.androidContext.contentResolver.openOutputStream(scriptFile.uri, "wt")?.use { output -> + bufferedInputStream.copyTo(output) + } + + sync() + loadScript(scriptPath) + runtime.removeModule(scriptPath) + return moduleInfo + } + } + + suspend fun checkForUpdate(inputModuleInfo: ModuleInfo): ModuleInfo? { + return runCatching { + context.log.verbose("checking for updates for ${inputModuleInfo.name} ${inputModuleInfo.updateUrl}") + val response = okHttpClient.newCall(Request.Builder().url(inputModuleInfo.updateUrl ?: return@runCatching null).build()).await() + if (!response.isSuccessful) { + return@runCatching null + } + response.body.byteStream().use { inputStream -> + val reader = inputStream.buffered().bufferedReader() + val moduleInfo = reader.readModuleInfo() + moduleInfo.takeIf { + it.version != inputModuleInfo.version + } + } + }.onFailure { + context.log.error("Failed to check for updates", it) + }.getOrNull() + } + + private fun getEnabledScripts(sides: List<String>): List<String> { + return runCatching { + getScriptFileNames().filter { name -> + cachedModuleInfo[name]?.executionSides?.any { it in sides } ?: true && + context.database.isScriptEnabled(cachedModuleInfo[name]?.name ?: return@filter false) + } + }.onFailure { + context.log.error("Failed to get enabled scripts", it) + }.getOrDefault(emptyList()) + } + + override fun getEnabledScripts(): List<String> { + return getEnabledScripts(listOf(BindingSide.CORE.key)) + } + + override fun getScriptContent(moduleName: String): ParcelFileDescriptor? { + return getScriptInputStream(moduleName) { it?.toParcelFileDescriptor(context.coroutineScope) } + } + + override fun registerIPCListener(channel: String, eventName: String, listener: IPCListener) { + ipcListeners.getOrPut(channel) { mutableMapOf() }.getOrPut(eventName) { mutableSetOf() }.add(listener) + } + + override fun sendIPCMessage(channel: String, eventName: String, args: Array<out String>): Int { + var dispatchCount = 0 + ipcListeners[channel]?.get(eventName)?.toList()?.forEach { + runCatching { + it.onMessage(args) + dispatchCount++ + }.onFailure { + context.log.error("Failed to send message for $eventName", it) + } + } + return dispatchCount + } + + override fun configTransaction( + module: String?, + action: String, + key: String?, + value: String?, + save: Boolean + ): String? { + val scriptConfig = runtime.getModuleByName(module ?: return null)?.getBinding(ConfigInterface::class) ?: return null.also { + context.log.warn("Failed to get config interface for $module") + } + val transactionType = ConfigTransactionType.fromKey(action) + + return runCatching { + scriptConfig.run { + if (transactionType == ConfigTransactionType.GET) { + return get(key ?: return@runCatching null, value) + } + when (transactionType) { + ConfigTransactionType.SET -> set(key ?: return@runCatching null, value, save) + ConfigTransactionType.SAVE -> save() + ConfigTransactionType.LOAD -> load() + ConfigTransactionType.DELETE -> deleteConfig() + else -> {} + } + null + } + }.onFailure { + context.log.error("Failed to perform config transaction", it) + }.getOrDefault("") + } + + override fun registerAutoReloadListener(listener: AutoReloadListener?) { + autoReloadListener = listener + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ManagerIPC.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ManagerIPC.kt new file mode 100644 index 0000000000..908c872172 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ManagerIPC.kt @@ -0,0 +1,54 @@ +package me.rhunk.snapenhance.scripting.impl + +import android.os.DeadObjectException +import me.rhunk.snapenhance.bridge.scripting.IPCListener +import me.rhunk.snapenhance.common.scripting.impl.IPCInterface +import me.rhunk.snapenhance.common.scripting.impl.Listener +import java.util.concurrent.ConcurrentHashMap + +typealias IPCListeners = ConcurrentHashMap<String, MutableMap<String, MutableSet<IPCListener>>> // channel, eventName -> listeners + +class ManagerIPC( + private val ipcListeners: IPCListeners = ConcurrentHashMap(), +) : IPCInterface() { + companion object { + private const val TAG = "RemoteManagerIPC" + } + + override fun on(eventName: String, listener: Listener) { + onBroadcast(context.moduleInfo.name, eventName, listener) + } + + override fun emit(eventName: String, vararg args: String?): Int { + return broadcast(context.moduleInfo.name, eventName, *args) + } + + override fun onBroadcast(channel: String, eventName: String, listener: Listener) { + ipcListeners.getOrPut(channel) { mutableMapOf() }.getOrPut(eventName) { mutableSetOf() }.add(object: IPCListener.Stub() { + override fun onMessage(args: Array<out String?>) { + try { + listener(args.toList()) + } catch (doe: DeadObjectException) { + ipcListeners[channel]?.get(eventName)?.remove(this) + } catch (t: Throwable) { + context.runtime.logger.error("Failed to receive message for channel: $channel, event: $eventName", t, TAG) + } + } + }) + } + + override fun broadcast(channel: String, eventName: String, vararg args: String?): Int { + var dispatchCount = 0 + ipcListeners[channel]?.get(eventName)?.toList()?.forEach { + try { + it.onMessage(args) + dispatchCount++ + } catch (doe: DeadObjectException) { + ipcListeners[channel]?.get(eventName)?.remove(it) + } catch (t: Throwable) { + context.runtime.logger.error("Failed to send message for channel: $channel, event: $eventName", t, TAG) + } + } + return dispatchCount + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ManagerScriptConfig.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ManagerScriptConfig.kt new file mode 100644 index 0000000000..552f670980 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ManagerScriptConfig.kt @@ -0,0 +1,57 @@ +package me.rhunk.snapenhance.scripting.impl + +import com.google.gson.JsonObject +import me.rhunk.snapenhance.common.scripting.impl.ConfigInterface +import me.rhunk.snapenhance.scripting.RemoteScriptManager +import java.io.File + +class ManagerScriptConfig( + private val remoteScriptManager: RemoteScriptManager +) : ConfigInterface() { + private val configFile by lazy { File(remoteScriptManager.getModuleDataFolder(context.moduleInfo.name), "config.json") } + private var config = JsonObject() + + override fun get(key: String, defaultValue: Any?): String? { + return config[key]?.asString ?: defaultValue?.toString() + } + + override fun set(key: String, value: Any?, save: Boolean) { + when (value) { + is Int -> config.addProperty(key, value) + is Double -> config.addProperty(key, value) + is Boolean -> config.addProperty(key, value) + is Long -> config.addProperty(key, value) + is Float -> config.addProperty(key, value) + is Byte -> config.addProperty(key, value) + is Short -> config.addProperty(key, value) + else -> config.addProperty(key, value?.toString()) + } + + if (save) save() + } + + override fun save() { + configFile.writeText(config.toString()) + } + + override fun load() { + runCatching { + if (!configFile.exists()) { + save() + return@runCatching + } + config = remoteScriptManager.context.gson.fromJson(configFile.readText(), JsonObject::class.java) + }.onFailure { + context.runtime.logger.error("Failed to load config file", it) + save() + } + } + + override fun deleteConfig() { + configFile.delete() + } + + override fun onInit() { + load() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/AppDatabase.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/AppDatabase.kt new file mode 100644 index 0000000000..b59f36a559 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/storage/AppDatabase.kt @@ -0,0 +1,115 @@ +package me.rhunk.snapenhance.storage + +import android.database.sqlite.SQLiteDatabase +import me.rhunk.snapenhance.RemoteSideContext +import me.rhunk.snapenhance.common.data.MessagingFriendInfo +import me.rhunk.snapenhance.common.data.MessagingGroupInfo +import me.rhunk.snapenhance.common.util.SQLiteDatabaseHelper +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + + +class AppDatabase( + val context: RemoteSideContext, +) { + val executor: ExecutorService = Executors.newSingleThreadExecutor() + lateinit var database: SQLiteDatabase + + var receiveMessagingDataCallback: (friends: List<MessagingFriendInfo>, groups: List<MessagingGroupInfo>) -> Unit = { _, _ -> } + + fun executeAsync(block: () -> Unit) { + executor.execute { + runCatching { + block() + }.onFailure { + context.log.error("Failed to execute async block", it) + } + } + } + + fun init() { + database = context.androidContext.openOrCreateDatabase("main.db", 0, null) + SQLiteDatabaseHelper.createTablesFromSchema(database, mapOf( + "friends" to listOf( + "id INTEGER PRIMARY KEY AUTOINCREMENT", + "userId CHAR(36) UNIQUE", + "dmConversationId VARCHAR(36)", + "displayName VARCHAR", + "mutableUsername VARCHAR", + "bitmojiId VARCHAR", + "selfieId VARCHAR" + ), + "groups" to listOf( + "id INTEGER PRIMARY KEY AUTOINCREMENT", + "conversationId CHAR(36) UNIQUE", + "name VARCHAR", + "participantsCount INTEGER" + ), + "rules" to listOf( + "id INTEGER PRIMARY KEY AUTOINCREMENT", + "type VARCHAR", + "targetUuid VARCHAR" + ), + "streaks" to listOf( + "id VARCHAR PRIMARY KEY", + "notify BOOLEAN", + "expirationTimestamp BIGINT", + "length INTEGER" + ), + "enabled_scripts" to listOf( + "name VARCHAR PRIMARY KEY", + ), + "tracker_rules" to listOf( + "id INTEGER PRIMARY KEY AUTOINCREMENT", + "enabled BOOLEAN DEFAULT 1", + "name VARCHAR", + ), + "tracker_scopes" to listOf( + "id INTEGER PRIMARY KEY AUTOINCREMENT", + "rule_id INTEGER", + "scope_type VARCHAR", + "scope_id CHAR(36)" + ), + "tracker_rules_events" to listOf( + "id INTEGER PRIMARY KEY AUTOINCREMENT", + "rule_id INTEGER", + "flags INTEGER DEFAULT 1", + "event_type VARCHAR", + "params TEXT", + "actions TEXT" + ), + "friend_scores" to listOf( + "userId CHAR(36) PRIMARY KEY", + "score BIGINT" + ), + "quick_tiles" to listOf( + "key VARCHAR PRIMARY KEY", + "position INTEGER", + ), + "location_coordinates" to listOf( + "id INTEGER PRIMARY KEY AUTOINCREMENT", + "name VARCHAR", + "latitude DOUBLE", + "longitude DOUBLE", + "radius DOUBLE", + ), + "themes" to listOf( + "id INTEGER PRIMARY KEY AUTOINCREMENT", + "enabled BOOLEAN DEFAULT 0", + "name VARCHAR", + "description TEXT", + "version VARCHAR", + "author VARCHAR", + "updateUrl VARCHAR", + "content TEXT", + ), + "repositories" to listOf( + "url VARCHAR PRIMARY KEY", + ), + "notes" to listOf( + "id CHAR(36) PRIMARY KEY", + "content TEXT", + ), + )) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/Location.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Location.kt new file mode 100644 index 0000000000..c1ea98ac36 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Location.kt @@ -0,0 +1,61 @@ +package me.rhunk.snapenhance.storage + +import android.content.ContentValues +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.runBlocking +import me.rhunk.snapenhance.bridge.location.LocationCoordinates +import me.rhunk.snapenhance.common.util.ktx.getDoubleOrNull +import me.rhunk.snapenhance.common.util.ktx.getInteger +import me.rhunk.snapenhance.common.util.ktx.getStringOrNull + + +fun AppDatabase.getLocationCoordinates(): List<LocationCoordinates> { + return runBlocking(executor.asCoroutineDispatcher()) { + database.rawQuery("SELECT * FROM location_coordinates ORDER BY id DESC", null).use { cursor -> + val locationCoordinates = mutableListOf<LocationCoordinates>() + while (cursor.moveToNext()) { + locationCoordinates.add( + LocationCoordinates().run { + id = cursor.getInteger("id") + name = cursor.getStringOrNull("name") ?: return@run null + latitude = cursor.getDoubleOrNull("latitude") ?: return@run null + longitude = cursor.getDoubleOrNull("longitude") ?: return@run null + radius = cursor.getDoubleOrNull("radius") ?: return@run null + this + } ?: continue + ) + } + locationCoordinates + } + } +} + +fun AppDatabase.addOrUpdateLocationCoordinate(id: Int?, locationCoordinates: LocationCoordinates): Int { + return runBlocking(executor.asCoroutineDispatcher()) { + if (id == null) { + val resultId = database.insert("location_coordinates", null, ContentValues().apply { + put("name", locationCoordinates.name) + put("latitude", locationCoordinates.latitude) + put("longitude", locationCoordinates.longitude) + put("radius", locationCoordinates.radius) + }) + resultId.toInt() + } else { + database.update("location_coordinates", ContentValues().apply { + put("name", locationCoordinates.name) + put("latitude", locationCoordinates.latitude) + put("longitude", locationCoordinates.longitude) + put("radius", locationCoordinates.radius) + }, "id = ?", arrayOf(id.toString())) + id + } + } +} + +fun AppDatabase.removeLocationCoordinate(id: Int) { + runBlocking(executor.asCoroutineDispatcher()) { + database.delete("location_coordinates", "id = ?", arrayOf(id.toString())) + } +} + + diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/Messaging.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Messaging.kt new file mode 100644 index 0000000000..bfec9920de --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Messaging.kt @@ -0,0 +1,187 @@ +package me.rhunk.snapenhance.storage + +import me.rhunk.snapenhance.common.data.FriendStreaks +import me.rhunk.snapenhance.common.data.MessagingFriendInfo +import me.rhunk.snapenhance.common.data.MessagingGroupInfo +import me.rhunk.snapenhance.common.data.MessagingRuleType +import me.rhunk.snapenhance.common.util.ktx.getInteger +import me.rhunk.snapenhance.common.util.ktx.getLongOrNull +import me.rhunk.snapenhance.common.util.ktx.getStringOrNull + + +fun AppDatabase.getGroups(): List<MessagingGroupInfo> { + return database.rawQuery("SELECT * FROM groups", null).use { cursor -> + val groups = mutableListOf<MessagingGroupInfo>() + while (cursor.moveToNext()) { + groups.add(MessagingGroupInfo.fromCursor(cursor)) + } + groups + } +} + +fun AppDatabase.getFriends(descOrder: Boolean = false): List<MessagingFriendInfo> { + return database.rawQuery("SELECT * FROM friends LEFT OUTER JOIN streaks ON friends.userId = streaks.id ORDER BY id ${if (descOrder) "DESC" else "ASC"}", null).use { cursor -> + val friends = mutableListOf<MessagingFriendInfo>() + while (cursor.moveToNext()) { + runCatching { + friends.add(MessagingFriendInfo.fromCursor(cursor)) + }.onFailure { + context.log.error("Failed to parse friend", it) + } + } + friends + } +} + + +fun AppDatabase.syncGroupInfo(conversationInfo: MessagingGroupInfo) { + executeAsync { + try { + database.execSQL("INSERT OR REPLACE INTO groups (conversationId, name, participantsCount) VALUES (?, ?, ?)", arrayOf( + conversationInfo.conversationId, + conversationInfo.name, + conversationInfo.participantsCount + )) + } catch (e: Exception) { + throw e + } + } +} + +fun AppDatabase.syncFriend(friend: MessagingFriendInfo) { + executeAsync { + try { + database.execSQL( + "INSERT OR REPLACE INTO friends (userId, dmConversationId, displayName, mutableUsername, bitmojiId, selfieId) VALUES (?, ?, ?, ?, ?, ?)", + arrayOf( + friend.userId, + friend.dmConversationId, + friend.displayName, + friend.mutableUsername, + friend.bitmojiId, + friend.selfieId + ) + ) + //sync streaks + friend.streaks?.takeIf { it.length > 0 }?.also { + val streaks = getFriendStreaks(friend.userId) + + database.execSQL("INSERT OR REPLACE INTO streaks (id, notify, expirationTimestamp, length) VALUES (?, ?, ?, ?)", arrayOf( + friend.userId, + streaks?.notify != false, + it.expirationTimestamp, + it.length + )) + } ?: database.execSQL("DELETE FROM streaks WHERE id = ?", arrayOf(friend.userId)) + } catch (e: Exception) { + throw e + } + } +} + + + +fun AppDatabase.getRules(targetUuid: String): List<MessagingRuleType> { + return database.rawQuery("SELECT type FROM rules WHERE targetUuid = ?", arrayOf( + targetUuid + )).use { cursor -> + val rules = mutableListOf<MessagingRuleType>() + while (cursor.moveToNext()) { + runCatching { + rules.add(MessagingRuleType.getByName(cursor.getStringOrNull("type")!!) ?: return@runCatching) + }.onFailure { + context.log.error("Failed to parse rule", it) + } + } + rules + } +} + +fun AppDatabase.setRule(targetUuid: String, type: String, enabled: Boolean) { + executeAsync { + if (enabled) { + database.execSQL("INSERT OR REPLACE INTO rules (targetUuid, type) VALUES (?, ?)", arrayOf( + targetUuid, + type + )) + } else { + database.execSQL("DELETE FROM rules WHERE targetUuid = ? AND type = ?", arrayOf( + targetUuid, + type + )) + } + } +} + +fun AppDatabase.getFriendInfo(userId: String): MessagingFriendInfo? { + return database.rawQuery("SELECT * FROM friends LEFT OUTER JOIN streaks ON friends.userId = streaks.id WHERE userId = ?", arrayOf(userId)).use { cursor -> + if (!cursor.moveToFirst()) return@use null + MessagingFriendInfo.fromCursor(cursor) + } +} + +fun AppDatabase.findFriend(conversationId: String): MessagingFriendInfo? { + return database.rawQuery("SELECT * FROM friends WHERE dmConversationId = ?", arrayOf(conversationId)).use { cursor -> + if (!cursor.moveToFirst()) return@use null + MessagingFriendInfo.fromCursor(cursor) + } +} + +fun AppDatabase.deleteFriend(userId: String) { + executeAsync { + database.execSQL("DELETE FROM friends WHERE userId = ?", arrayOf(userId)) + database.execSQL("DELETE FROM streaks WHERE id = ?", arrayOf(userId)) + database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(userId)) + } +} + +fun AppDatabase.deleteGroup(conversationId: String) { + executeAsync { + database.execSQL("DELETE FROM groups WHERE conversationId = ?", arrayOf(conversationId)) + database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(conversationId)) + } +} + +fun AppDatabase.getGroupInfo(conversationId: String): MessagingGroupInfo? { + return database.rawQuery("SELECT * FROM groups WHERE conversationId = ?", arrayOf(conversationId)).use { cursor -> + if (!cursor.moveToFirst()) return@use null + MessagingGroupInfo.fromCursor(cursor) + } +} + +fun AppDatabase.getFriendStreaks(userId: String): FriendStreaks? { + return database.rawQuery("SELECT * FROM streaks WHERE id = ?", arrayOf(userId)).use { cursor -> + if (!cursor.moveToFirst()) return@use null + FriendStreaks( + notify = cursor.getInteger("notify") == 1, + expirationTimestamp = cursor.getLongOrNull("expirationTimestamp") ?: 0L, + length = cursor.getInteger("length") + ) + } +} + +fun AppDatabase.setFriendStreaksNotify(userId: String, notify: Boolean) { + executeAsync { + database.execSQL("UPDATE streaks SET notify = ? WHERE id = ?", arrayOf( + if (notify) 1 else 0, + userId + )) + } +} + +fun AppDatabase.getRuleIds(type: String): MutableList<String> { + return database.rawQuery("SELECT targetUuid FROM rules WHERE type = ?", arrayOf(type)).use { cursor -> + val ruleIds = mutableListOf<String>() + while (cursor.moveToNext()) { + ruleIds.add(cursor.getStringOrNull("targetUuid")!!) + } + ruleIds + } +} + +fun AppDatabase.clearRuleIds(type: String) { + executeAsync { + database.execSQL("DELETE FROM rules WHERE type = ?", arrayOf(type)) + } +} + diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/QuickTiles.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/QuickTiles.kt new file mode 100644 index 0000000000..facd56d255 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/storage/QuickTiles.kt @@ -0,0 +1,26 @@ +package me.rhunk.snapenhance.storage + +import me.rhunk.snapenhance.common.util.ktx.getStringOrNull + + +fun AppDatabase.getQuickTiles(): List<String> { + return database.rawQuery("SELECT `key` FROM quick_tiles ORDER BY position ASC", null).use { cursor -> + val keys = mutableListOf<String>() + while (cursor.moveToNext()) { + keys.add(cursor.getStringOrNull("key") ?: continue) + } + keys + } +} + +fun AppDatabase.setQuickTiles(keys: List<String>) { + executeAsync { + database.execSQL("DELETE FROM quick_tiles") + keys.forEachIndexed { index, key -> + database.execSQL("INSERT INTO quick_tiles (`key`, position) VALUES (?, ?)", arrayOf( + key, + index + )) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/Repositories.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Repositories.kt new file mode 100644 index 0000000000..1cd9843d1d --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Repositories.kt @@ -0,0 +1,34 @@ +package me.rhunk.snapenhance.storage + +import android.content.ContentValues +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.runBlocking +import me.rhunk.snapenhance.common.util.ktx.getStringOrNull + + +fun AppDatabase.getRepositories(): List<String> { + return runBlocking(executor.asCoroutineDispatcher()) { + database.rawQuery("SELECT url FROM repositories", null).use { cursor -> + val repos = mutableListOf<String>() + while (cursor.moveToNext()) { + repos.add(cursor.getStringOrNull("url") ?: continue) + } + repos + } + } +} + +fun AppDatabase.removeRepo(url: String) { + runBlocking(executor.asCoroutineDispatcher()) { + database.delete("repositories", "url = ?", arrayOf(url)) + } +} + +fun AppDatabase.addRepo(url: String) { + runBlocking(executor.asCoroutineDispatcher()) { + database.insert("repositories", null, ContentValues().apply { + put("url", url) + }) + } +} + diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/ScopeNotes.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/ScopeNotes.kt new file mode 100644 index 0000000000..2f4f795777 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/storage/ScopeNotes.kt @@ -0,0 +1,32 @@ +package me.rhunk.snapenhance.storage + +import androidx.core.database.getStringOrNull + +fun AppDatabase.getScopeNotes(id: String): String? { + return database.rawQuery("SELECT content FROM notes WHERE id = ?", arrayOf(id)).use { + if (it.moveToNext()) { + it.getStringOrNull(0) + } else { + null + } + } +} + +fun AppDatabase.setScopeNotes(id: String, content: String?) { + if (content == null || content.isEmpty() == true) { + executeAsync { + database.execSQL("DELETE FROM notes WHERE id = ?", arrayOf(id)) + } + return + } + + executeAsync { + database.execSQL("INSERT OR REPLACE INTO notes (id, content) VALUES (?, ?)", arrayOf(id, content)) + } +} + +fun AppDatabase.deleteScopeNotes(id: String) { + executeAsync { + database.execSQL("DELETE FROM notes WHERE id = ?", arrayOf(id)) + } +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/Scripting.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Scripting.kt new file mode 100644 index 0000000000..b7c0fa0feb --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Scripting.kt @@ -0,0 +1,23 @@ +package me.rhunk.snapenhance.storage + +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.runBlocking + + +fun AppDatabase.setScriptEnabled(name: String, enabled: Boolean) { + executeAsync { + if (enabled) { + database.execSQL("INSERT OR REPLACE INTO enabled_scripts (name) VALUES (?)", arrayOf(name)) + } else { + database.execSQL("DELETE FROM enabled_scripts WHERE name = ?", arrayOf(name)) + } + } +} + +fun AppDatabase.isScriptEnabled(name: String): Boolean { + return runBlocking(executor.asCoroutineDispatcher()) { + database.rawQuery("SELECT * FROM enabled_scripts WHERE name = ?", arrayOf(name)).use { + it.moveToNext() + } + } +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/Tracker.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Tracker.kt new file mode 100644 index 0000000000..d40eb170c6 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Tracker.kt @@ -0,0 +1,219 @@ +package me.rhunk.snapenhance.storage + +import android.content.ContentValues +import com.google.gson.JsonArray +import kotlinx.coroutines.runBlocking +import me.rhunk.snapenhance.common.data.TrackerRule +import me.rhunk.snapenhance.common.data.TrackerRuleAction +import me.rhunk.snapenhance.common.data.TrackerRuleActionParams +import me.rhunk.snapenhance.common.data.TrackerRuleEvent +import me.rhunk.snapenhance.common.data.TrackerScopeType +import me.rhunk.snapenhance.common.util.ktx.getInteger +import me.rhunk.snapenhance.common.util.ktx.getLongOrNull +import me.rhunk.snapenhance.common.util.ktx.getStringOrNull +import kotlin.coroutines.suspendCoroutine + + +fun AppDatabase.clearTrackerRules() { + runBlocking { + suspendCoroutine { continuation -> + executeAsync { + database.execSQL("DELETE FROM tracker_rules") + database.execSQL("DELETE FROM tracker_rules_events") + continuation.resumeWith(Result.success(Unit)) + } + } + } +} + +fun AppDatabase.deleteTrackerRule(ruleId: Int) { + executeAsync { + database.execSQL("DELETE FROM tracker_rules WHERE id = ?", arrayOf(ruleId)) + database.execSQL("DELETE FROM tracker_rules_events WHERE rule_id = ?", arrayOf(ruleId)) + } +} + +fun AppDatabase.newTrackerRule(name: String = "Custom Rule"): Int { + return runBlocking { + suspendCoroutine { continuation -> + executeAsync { + val id = database.insert("tracker_rules", null, ContentValues().apply { + put("name", name) + }) + continuation.resumeWith(Result.success(id.toInt())) + } + } + } +} + +fun AppDatabase.addOrUpdateTrackerRuleEvent( + ruleEventId: Int? = null, + ruleId: Int? = null, + eventType: String? = null, + params: TrackerRuleActionParams, + actions: List<TrackerRuleAction> +): Int? { + return runBlocking { + suspendCoroutine { continuation -> + executeAsync { + val id = if (ruleEventId != null) { + database.execSQL("UPDATE tracker_rules_events SET params = ?, actions = ? WHERE id = ?", arrayOf( + context.gson.toJson(params), + context.gson.toJson(actions.map { it.key }), + ruleEventId + )) + ruleEventId + } else { + database.insert("tracker_rules_events", null, ContentValues().apply { + put("rule_id", ruleId) + put("event_type", eventType) + put("params", context.gson.toJson(params)) + put("actions", context.gson.toJson(actions.map { it.key })) + }).toInt() + } + continuation.resumeWith(Result.success(id)) + } + } + } +} + +fun AppDatabase.deleteTrackerRuleEvent(eventId: Int) { + executeAsync { + database.execSQL("DELETE FROM tracker_rules_events WHERE id = ?", arrayOf(eventId)) + } +} + +fun AppDatabase.getTrackerRulesDesc(): List<TrackerRule> { + val rules = mutableListOf<TrackerRule>() + + database.rawQuery("SELECT * FROM tracker_rules ORDER BY id DESC", null).use { cursor -> + while (cursor.moveToNext()) { + rules.add( + TrackerRule( + id = cursor.getInteger("id"), + enabled = cursor.getInteger("enabled") == 1, + name = cursor.getStringOrNull("name") ?: "", + ) + ) + } + } + + return rules +} + +fun AppDatabase.getTrackerRule(ruleId: Int): TrackerRule? { + return database.rawQuery("SELECT * FROM tracker_rules WHERE id = ?", arrayOf(ruleId.toString())).use { cursor -> + if (!cursor.moveToFirst()) return@use null + TrackerRule( + id = cursor.getInteger("id"), + enabled = cursor.getInteger("enabled") == 1, + name = cursor.getStringOrNull("name") ?: "", + ) + } +} + +fun AppDatabase.setTrackerRuleName(ruleId: Int, name: String) { + executeAsync { + database.execSQL("UPDATE tracker_rules SET name = ? WHERE id = ?", arrayOf(name, ruleId)) + } +} + +fun AppDatabase.setTrackerRuleState(ruleId: Int, enabled: Boolean) { + executeAsync { + database.execSQL("UPDATE tracker_rules SET enabled = ? WHERE id = ?", arrayOf(if (enabled) 1 else 0, ruleId)) + } +} + +fun AppDatabase.getTrackerEvents(ruleId: Int): List<TrackerRuleEvent> { + val events = mutableListOf<TrackerRuleEvent>() + database.rawQuery("SELECT * FROM tracker_rules_events WHERE rule_id = ?", arrayOf(ruleId.toString())).use { cursor -> + while (cursor.moveToNext()) { + events.add( + TrackerRuleEvent( + id = cursor.getInteger("id"), + eventType = cursor.getStringOrNull("event_type") ?: continue, + enabled = cursor.getInteger("flags") == 1, + params = context.gson.fromJson(cursor.getStringOrNull("params") ?: "{}", TrackerRuleActionParams::class.java), + actions = context.gson.fromJson(cursor.getStringOrNull("actions") ?: "[]", JsonArray::class.java).mapNotNull { + TrackerRuleAction.fromString(it.asString) + } + ) + ) + } + } + return events +} + +fun AppDatabase.getTrackerEvents(eventType: String): Map<TrackerRuleEvent, TrackerRule> { + val events = mutableMapOf<TrackerRuleEvent, TrackerRule>() + database.rawQuery("SELECT tracker_rules_events.id as event_id, tracker_rules_events.params as event_params," + + "tracker_rules_events.actions, tracker_rules_events.flags, tracker_rules_events.event_type, tracker_rules.name, tracker_rules.id as rule_id " + + "FROM tracker_rules_events " + + "INNER JOIN tracker_rules " + + "ON tracker_rules_events.rule_id = tracker_rules.id " + + "WHERE event_type = ? AND tracker_rules.enabled = 1", arrayOf(eventType) + ).use { cursor -> + while (cursor.moveToNext()) { + val trackerRule = TrackerRule( + id = cursor.getInteger("rule_id"), + enabled = true, + name = cursor.getStringOrNull("name") ?: "", + ) + val trackerRuleEvent = TrackerRuleEvent( + id = cursor.getInteger("event_id"), + eventType = cursor.getStringOrNull("event_type") ?: continue, + enabled = cursor.getInteger("flags") == 1, + params = context.gson.fromJson(cursor.getStringOrNull("event_params") ?: "{}", TrackerRuleActionParams::class.java), + actions = context.gson.fromJson(cursor.getStringOrNull("actions") ?: "[]", JsonArray::class.java).mapNotNull { + TrackerRuleAction.fromString(it.asString) + } + ) + events[trackerRuleEvent] = trackerRule + } + } + return events +} + +fun AppDatabase.setRuleTrackerScopes(ruleId: Int, type: TrackerScopeType, scopes: List<String>) { + executeAsync { + database.execSQL("DELETE FROM tracker_scopes WHERE rule_id = ?", arrayOf(ruleId)) + scopes.forEach { scopeId -> + database.execSQL("INSERT INTO tracker_scopes (rule_id, scope_type, scope_id) VALUES (?, ?, ?)", arrayOf( + ruleId, + type.key, + scopeId + )) + } + } +} + +fun AppDatabase.getRuleTrackerScopes(ruleId: Int, limit: Int = Int.MAX_VALUE): Map<String, TrackerScopeType> { + val scopes = mutableMapOf<String, TrackerScopeType>() + database.rawQuery("SELECT * FROM tracker_scopes WHERE rule_id = ? LIMIT ?", arrayOf(ruleId.toString(), limit.toString())).use { cursor -> + while (cursor.moveToNext()) { + scopes[cursor.getStringOrNull("scope_id") ?: continue] = TrackerScopeType.entries.find { it.key == cursor.getStringOrNull("scope_type") } ?: continue + } + } + return scopes +} + +fun AppDatabase.updateFriendScore(userId: String, score: Long): Long { + return runBlocking { + suspendCoroutine { continuation -> + executeAsync { + val currentScore = database.rawQuery("SELECT score FROM friend_scores WHERE userId = ?", arrayOf(userId)).use { cursor -> + if (!cursor.moveToFirst()) return@use null + cursor.getLongOrNull("score") + } + + if (currentScore != null) { + database.execSQL("UPDATE friend_scores SET score = ? WHERE userId = ?", arrayOf(score, userId)) + } else { + database.execSQL("INSERT INTO friend_scores (userId, score) VALUES (?, ?)", arrayOf(userId, score)) + } + + continuation.resumeWith(Result.success(currentScore ?: -1)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/task/PendingTask.kt b/app/src/main/kotlin/me/rhunk/snapenhance/task/PendingTask.kt new file mode 100644 index 0000000000..63c94080e5 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/task/PendingTask.kt @@ -0,0 +1,135 @@ +package me.rhunk.snapenhance.task + + +enum class TaskType( + val key: String +) { + DOWNLOAD("download"), + CHAT_ACTION("chat_action"); + + companion object { + fun fromKey(key: String): TaskType { + return entries.find { it.key == key } ?: throw IllegalArgumentException("Invalid key $key") + } + } +} + +enum class TaskStatus( + val key: String +) { + PENDING("pending"), + RUNNING("running"), + SUCCESS("success"), + FAILURE("failure"), + CANCELLED("cancelled"); + + fun isFinalStage(): Boolean { + return this == SUCCESS || this == FAILURE || this == CANCELLED + } + + companion object { + fun fromKey(key: String): TaskStatus { + return entries.find { it.key == key } ?: throw IllegalArgumentException("Invalid key $key") + } + } +} + +data class PendingTaskListener( + val onSuccess: () -> Unit = {}, + val onCancel: () -> Unit = {}, + val onProgress: (label: String?, progress: Int) -> Unit = { _, _ -> }, + val onStateChange: (status: TaskStatus) -> Unit = {}, +) + +data class Task( + val type: TaskType, + val title: String, + val author: String?, + val hash: String +) { + var changeListener: () -> Unit = {} + + var extra: String? = null + set(value) { + field = value + changeListener() + } + var status: TaskStatus = TaskStatus.PENDING + set(value) { + field = value + changeListener() + } +} + +class PendingTask( + val taskId: Long, + val task: Task +) { + private val listeners = mutableListOf<PendingTaskListener>() + + fun addListener(listener: PendingTaskListener) { + synchronized(listeners) { listeners.add(listener) } + } + + fun removeListener(listener: PendingTaskListener) { + synchronized(listeners) { listeners.remove(listener) } + } + + var status + get() = task.status; + set(value) { + task.status = value; + synchronized(listeners) { + listeners.forEach { it.onStateChange(value) } + } + } + + var progressLabel: String? = null + set(value) { + field = value + synchronized(listeners) { + listeners.forEach { it.onProgress(value, progress) } + } + } + + private var _progress = 0 + set(value) { + assert(value in 0..100 || value == -1) + field = value + } + + var progress get() = _progress + set(value) { + _progress = value + synchronized(listeners) { + listeners.forEach { it.onProgress(progressLabel, value) } + } + } + + fun updateProgress(label: String, progress: Int = -1) { + _progress = progress.coerceIn(-1, 100) + progressLabel = label + } + + fun fail(reason: String) { + status = TaskStatus.FAILURE + synchronized(listeners) { + listeners.forEach { it.onCancel() } + } + updateProgress(reason) + } + + fun success() { + status = TaskStatus.SUCCESS + synchronized(listeners) { + listeners.forEach { it.onSuccess() } + } + } + + fun cancel() { + status = TaskStatus.CANCELLED + synchronized(listeners) { + listeners.forEach { it.onCancel() } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/task/TaskManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/task/TaskManager.kt new file mode 100644 index 0000000000..990f318a19 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/task/TaskManager.kt @@ -0,0 +1,165 @@ +package me.rhunk.snapenhance.task + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import me.rhunk.snapenhance.RemoteSideContext +import me.rhunk.snapenhance.common.util.SQLiteDatabaseHelper +import me.rhunk.snapenhance.common.util.ktx.getLong +import me.rhunk.snapenhance.common.util.ktx.getStringOrNull +import java.util.concurrent.Executors +import kotlin.coroutines.suspendCoroutine + +class TaskManager( + private val remoteSideContext: RemoteSideContext +) { + private lateinit var taskDatabase: SQLiteDatabase + private val queueExecutor = Executors.newSingleThreadExecutor() + + fun init() { + taskDatabase = remoteSideContext.androidContext.openOrCreateDatabase("tasks", Context.MODE_PRIVATE, null).apply { + SQLiteDatabaseHelper.createTablesFromSchema(this, mapOf( + "tasks" to listOf( + "id INTEGER PRIMARY KEY AUTOINCREMENT", + "hash VARCHAR UNIQUE", + "title VARCHAR(255) NOT NULL", + "author VARCHAR(255)", + "type VARCHAR(255) NOT NULL", + "status VARCHAR(255) NOT NULL", + "extra TEXT" + ) + )) + } + } + + private val activeTasks = mutableMapOf<Long, PendingTask>() + + private fun readTaskFromCursor(cursor: android.database.Cursor): Task { + val task = Task( + type = TaskType.fromKey(cursor.getStringOrNull("type")!!), + title = cursor.getStringOrNull("title")!!, + author = cursor.getStringOrNull("author"), + hash = cursor.getStringOrNull("hash")!! + ) + task.status = TaskStatus.fromKey(cursor.getStringOrNull("status")!!) + task.extra = cursor.getStringOrNull("extra") + task.changeListener = { + updateTask(cursor.getLong("id"), task) + } + return task + } + + private fun putNewTask(task: Task): Long { + return runBlocking { + suspendCoroutine { + queueExecutor.execute { + taskDatabase.rawQuery("SELECT * FROM tasks WHERE hash = ?", arrayOf(task.hash)).use { cursor -> + if (cursor.moveToNext()) { + it.resumeWith(Result.success(cursor.getLong("id"))) + return@execute + } + } + + val result = taskDatabase.insert("tasks", null, ContentValues().apply { + put("type", task.type.key) + put("hash", task.hash) + put("author", task.author) + put("title", task.title) + put("status", task.status.key) + put("extra", task.extra) + }) + + it.resumeWith(Result.success(result)) + } + } + } + } + + private fun updateTask(id: Long, task: Task) { + queueExecutor.execute { + taskDatabase.execSQL("UPDATE tasks SET status = ?, extra = ? WHERE id = ?", + arrayOf( + task.status.key, + task.extra, + id.toString() + ) + ) + } + } + + fun clearAllTasks() { + runBlocking { + launch(queueExecutor.asCoroutineDispatcher()) { + taskDatabase.execSQL("DELETE FROM tasks") + } + } + } + + fun removeTask(task: Task) { + runBlocking { + activeTasks.entries.find { it.value.task == task }?.let { + activeTasks.remove(it.key) + runCatching { + it.value.cancel() + }.onFailure { + remoteSideContext.log.warn("Failed to cancel task ${task.hash}") + } + } + launch(queueExecutor.asCoroutineDispatcher()) { + taskDatabase.execSQL("DELETE FROM tasks WHERE hash = ?", arrayOf(task.hash)) + } + } + } + + fun createPendingTask(task: Task): PendingTask { + val taskId = putNewTask(task) + task.changeListener = { + updateTask(taskId, task) + } + + val pendingTask = PendingTask(taskId, task) + activeTasks[taskId] = pendingTask + return pendingTask + } + + fun getTaskByHash(hash: String?): Task? { + if (hash == null) return null + taskDatabase.rawQuery("SELECT * FROM tasks WHERE hash = ?", arrayOf(hash)).use { cursor -> + if (cursor.moveToNext()) { + return readTaskFromCursor(cursor) + } + } + return null + } + + fun getActiveTasks() = activeTasks + + fun fetchStoredTasks(lastId: Long = Long.MAX_VALUE, limit: Int = 10): Map<Long, Task> { + val tasks = mutableMapOf<Long, Task>() + val invalidTasks = mutableListOf<Long>() + + taskDatabase.rawQuery("SELECT * FROM tasks WHERE id < ? ORDER BY id DESC LIMIT ?", arrayOf(lastId.toString(), limit.toString())).use { cursor -> + while (cursor.moveToNext()) { + runCatching { + val task = readTaskFromCursor(cursor) + if (!task.status.isFinalStage()) { task.status = TaskStatus.FAILURE } + tasks[cursor.getLong("id")] = task + }.onFailure { + invalidTasks.add(cursor.getLong("id")) + remoteSideContext.log.warn("Failed to read task ${cursor.getLong("id")}") + } + } + } + + invalidTasks.forEach { + queueExecutor.execute { + taskDatabase.execSQL("DELETE FROM tasks WHERE id = ?", arrayOf(it.toString())) + } + } + + return tasks + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/MainActivity.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/MainActivity.kt new file mode 100644 index 0000000000..58d91c65d7 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/MainActivity.kt @@ -0,0 +1,64 @@ +package me.rhunk.snapenhance.ui.manager + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.remember +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController +import me.rhunk.snapenhance.RemoteSideContext +import me.rhunk.snapenhance.SharedContextHolder +import me.rhunk.snapenhance.common.ui.AppMaterialTheme + +class MainActivity : ComponentActivity() { + private lateinit var navController: NavHostController + private lateinit var managerContext: RemoteSideContext + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + if (::navController.isInitialized.not()) return + intent.getStringExtra("route")?.let { route -> + navController.popBackStack() + navController.navigate(route) { + popUpTo(navController.graph.findStartDestination().id){ + inclusive = true + } + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + managerContext = SharedContextHolder.remote(this).apply { + activity = this@MainActivity + checkForRequirements() + } + + val routes = Routes(managerContext) + routes.getRoutes().forEach { it.init() } + + setContent { + navController = rememberNavController() + val navigation = remember { + Navigation(managerContext, navController, routes.also { + it.navController = navController + }) + } + val startDestination = remember { intent.getStringExtra("route") ?: routes.home.routeInfo.id } + + AppMaterialTheme { + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + topBar = { navigation.TopBar() }, + bottomBar = { navigation.BottomBar() }, + floatingActionButton = { navigation.FloatingActionButton() } + ) { innerPadding -> navigation.Content(innerPadding, startDestination) } + } + } + } +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Navigation.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Navigation.kt new file mode 100644 index 0000000000..05b7fc49a3 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Navigation.kt @@ -0,0 +1,145 @@ +package me.rhunk.snapenhance.ui.manager + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.lerp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.navigation +import me.rhunk.snapenhance.RemoteSideContext + +@OptIn(ExperimentalMaterial3Api::class) +class Navigation( + private val context: RemoteSideContext, + private val navController: NavHostController, + val routes: Routes = Routes(context).also { + it.navController = navController + } +){ + @Composable + fun TopBar() { + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) } + + val canGoBack = remember(navBackStackEntry) { currentRoute?.let { + !it.routeInfo.primary || it.routeInfo.childIds.contains(routes.currentDestination) + } == true } + + TopAppBar(title = { + currentRoute?.apply { + title?.invoke() ?: routeInfo.translatedKey?.value?.let { + Text(text = it) + } + } + }, navigationIcon = { + val backButtonAnimation by animateFloatAsState(if (canGoBack) 1f else 0f, + label = "backButtonAnimation" + ) + + Box( + modifier = Modifier + .graphicsLayer { alpha = backButtonAnimation } + .width(lerp(0.dp, 48.dp, backButtonAnimation)) + .height(48.dp) + ) { + IconButton( + onClick = { + if (canGoBack) { + navController.popBackStack() + } + } + ) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + } + }, actions = { + currentRoute?.topBarActions?.invoke(this) + }) + } + + @Composable + fun BottomBar() { + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) } + val primaryRoutes = remember { routes.getRoutes().filter { it.routeInfo.showInNavBar } } + + NavigationBar { + primaryRoutes.forEach { route -> + NavigationBarItem( + alwaysShowLabel = true, + icon = { + Icon(imageVector = route.routeInfo.icon, contentDescription = null) + }, + label = { + Text( + textAlign = TextAlign.Center, + softWrap = false, + fontSize = 12.sp, + modifier = Modifier.wrapContentWidth(unbounded = true), + text = remember(context.translation.loadedLocale) { context.translation["manager.routes.${route.routeInfo.key.substringBefore("/")}"] }, + ) + }, + selected = currentRoute == route, + onClick = { + route.navigateReset() + } + ) + } + } + } + + @Composable + fun FloatingActionButton() { + val navBackStackEntry by navController.currentBackStackEntryAsState() + remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) }?.floatingActionButton?.invoke() + } + + @Composable + fun Content(paddingValues: PaddingValues, startDestination: String) { + NavHost( + navController = navController, + startDestination = startDestination, + Modifier.padding(paddingValues), + enterTransition = { fadeIn(tween(100)) }, + exitTransition = { fadeOut(tween(100)) } + ) { + routes.getRoutes().filter { it.parentRoute == null }.forEach { route -> + val children = routes.getRoutes().filter { it.parentRoute == route } + if (children.isEmpty()) { + composable(route.routeInfo.id) { + route.content.invoke(it) + } + route.customComposables.invoke(this) + } else { + navigation("main_" + route.routeInfo.id, route.routeInfo.id) { + composable("main_" + route.routeInfo.id) { + route.content.invoke(it) + } + children.forEach { child -> + composable(child.routeInfo.id) { + child.content.invoke(it) + } + } + route.customComposables.invoke(this) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt new file mode 100644 index 0000000000..ac764bcc0a --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt @@ -0,0 +1,155 @@ +package me.rhunk.snapenhance.ui.manager + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavController +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavGraphBuilder +import me.rhunk.snapenhance.RemoteSideContext +import me.rhunk.snapenhance.ui.manager.pages.FileImportsRoot +import me.rhunk.snapenhance.ui.manager.pages.LoggerHistoryRoot +import me.rhunk.snapenhance.ui.manager.pages.ManageReposSection +import me.rhunk.snapenhance.ui.manager.pages.TasksRootSection +import me.rhunk.snapenhance.ui.manager.pages.features.FeaturesRootSection +import me.rhunk.snapenhance.ui.manager.pages.features.ManageRuleFeature +import me.rhunk.snapenhance.ui.manager.pages.home.HomeLogs +import me.rhunk.snapenhance.ui.manager.pages.home.HomeRootSection +import me.rhunk.snapenhance.ui.manager.pages.home.HomeSettings +import me.rhunk.snapenhance.ui.manager.pages.location.BetterLocationRoot +import me.rhunk.snapenhance.ui.manager.pages.scripting.ScriptingRootSection +import me.rhunk.snapenhance.ui.manager.pages.social.LoggedStories +import me.rhunk.snapenhance.ui.manager.pages.social.ManageScope +import me.rhunk.snapenhance.ui.manager.pages.social.MessagingPreview +import me.rhunk.snapenhance.ui.manager.pages.social.SocialRootSection +import me.rhunk.snapenhance.ui.manager.pages.tracker.EditRule +import me.rhunk.snapenhance.ui.manager.pages.tracker.FriendTrackerManagerRoot + + +data class RouteInfo( + val id: String, + val key: String = id, + val icon: ImageVector = Icons.Default.Home, + val primary: Boolean = false, + val showInNavBar: Boolean = primary, +) { + var translatedKey: Lazy<String?>? = null + val childIds = mutableListOf<String>() +} + +@Suppress("unused", "MemberVisibilityCanBePrivate") +class Routes( + private val context: RemoteSideContext, +) { + lateinit var navController: NavController + private val routes = mutableListOf<Route>() + + val tasks = route(RouteInfo("tasks", icon = Icons.Default.TaskAlt, primary = true), TasksRootSection()) + + val features = route(RouteInfo("features", icon = Icons.Default.Stars, primary = true), FeaturesRootSection()) + val manageRuleFeature = route(RouteInfo("manage_rule_feature/?rule_type={rule_type}"), ManageRuleFeature()).parent(features) + + val home = route(RouteInfo("home", icon = Icons.Default.Home, primary = true), HomeRootSection()) + val settings = route(RouteInfo("home_settings"), HomeSettings()).parent(home) + val homeLogs = route(RouteInfo("home_logs"), HomeLogs()).parent(home) + val loggerHistory = route(RouteInfo("logger_history"), LoggerHistoryRoot()).parent(home) + val friendTracker = route(RouteInfo("friend_tracker"), FriendTrackerManagerRoot()).parent(home) + val editRule = route(RouteInfo("edit_rule/?rule_id={rule_id}"), EditRule()) + + val fileImports = route(RouteInfo("file_imports"), FileImportsRoot()).parent(home) + val manageRepos = route(RouteInfo("manage_repos"), ManageReposSection()) + + val social = route(RouteInfo("social", icon = Icons.Default.Group, primary = true), SocialRootSection()) + val manageScope = route(RouteInfo("manage_scope/?scope={scope}&id={id}"), ManageScope()).parent(social) + val messagingPreview = route(RouteInfo("messaging_preview/?scope={scope}&id={id}"), MessagingPreview()).parent(social) + val loggedStories = route(RouteInfo("logged_stories/?id={id}"), LoggedStories()).parent(social) + + val scripting = route(RouteInfo("scripts", icon = Icons.Filled.DataObject, primary = true), ScriptingRootSection()) + + val betterLocation = route(RouteInfo("better_location", showInNavBar = false, primary = true), BetterLocationRoot()) + + open class Route { + open val init: () -> Unit = { } + open val title: @Composable (() -> Unit)? = null + open val topBarActions: @Composable RowScope.() -> Unit = {} + open val floatingActionButton: @Composable () -> Unit = {} + open val content: @Composable (NavBackStackEntry) -> Unit = {} + open val customComposables: NavGraphBuilder.() -> Unit = {} + + var parentRoute: Route? = null + private set + + lateinit var context: RemoteSideContext + lateinit var routeInfo: RouteInfo + lateinit var routes: Routes + + val translation by lazy { context.translation.getCategory("manager.sections.${routeInfo.key.substringBefore("/")}")} + + private fun replaceArguments(id: String, args: Map<String, String>) = args.takeIf { it.isNotEmpty() }?.let { + args.entries.fold(id) { acc, (key, value) -> + acc.replace("{$key}", value) + } + } ?: id + + fun navigate(args: MutableMap<String, String>.() -> Unit = {}) { + routes.navController.navigate(replaceArguments(routeInfo.id, HashMap<String, String>().apply { args() })) + } + + fun navigateReload() { + routes.navController.navigate(routeInfo.id) { + popUpTo(routeInfo.id) { inclusive = true } + } + } + + fun navigateReset(args: MutableMap<String, String>.() -> Unit = {}) { + routes.navController.navigate(replaceArguments(routeInfo.id, HashMap<String, String>().apply { args() })) { + popUpTo(routes.navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + + fun parent(route: Route): Route { + assert(route.routeInfo.primary) { "Parent route must be a primary route" } + parentRoute = route + return this + } + } + + val currentRoute: Route? + get() = routes.firstOrNull { route -> + navController.currentBackStackEntry?.destination?.hierarchy?.any { it.route == route.routeInfo.id } ?: false + } + + val currentDestination: String? + get() = navController.currentBackStackEntry?.destination?.route + + fun getCurrentRoute(navBackStackEntry: NavBackStackEntry?): Route? { + if (navBackStackEntry == null) return null + + return navBackStackEntry.destination.hierarchy.firstNotNullOfOrNull { destination -> + routes.firstOrNull { route -> + route.routeInfo.id == destination.route || route.routeInfo.childIds.contains(destination.route) + } + } + } + + fun getRoutes(): List<Route> = routes + + private fun route(routeInfo: RouteInfo, route: Route): Route { + route.apply { + this.routeInfo = routeInfo + routes = this@Routes + context = this@Routes.context + this.routeInfo.translatedKey = lazy { context.translation.getOrNull("manager.routes.${route.routeInfo.key.substringBefore("/")}") } + } + routes.add(route) + return route + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/data/InstallationSummary.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/data/InstallationSummary.kt new file mode 100644 index 0000000000..323b807ebd --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/data/InstallationSummary.kt @@ -0,0 +1,34 @@ +package me.rhunk.snapenhance.ui.manager.data + + +data class SnapchatAppInfo( + val packageName: String, + val version: String, + val versionCode: Long, + val isLSPatched: Boolean, + val isSplitApk: Boolean? +) + +data class ModInfo( + val loaderPackageName: String?, + val buildPackageName: String, + val buildVersion: String, + val buildVersionCode: Long, + val buildIssuer: String, + val gitHash: String, + val isDebugBuild: Boolean, + val mappingVersion: Long?, + val mappingsOutdated: Boolean?, +) + +data class PlatformInfo( + val device: String, + val androidVersion: String, + val systemAbi: String, +) + +data class InstallationSummary( + val platformInfo: PlatformInfo, + val snapchatInfo: SnapchatAppInfo?, + val modInfo: ModInfo?, +) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/data/Updater.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/data/Updater.kt new file mode 100644 index 0000000000..a4a40483b5 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/data/Updater.kt @@ -0,0 +1,63 @@ +package me.rhunk.snapenhance.ui.manager.data + +import com.google.gson.JsonParser +import me.rhunk.snapenhance.common.BuildConfig +import me.rhunk.snapenhance.common.logger.AbstractLogger +import okhttp3.OkHttpClient +import okhttp3.Request + + +object Updater { + data class LatestRelease( + val versionName: String, + val releaseUrl: String + ) + + private fun fetchLatestRelease() = runCatching { + val endpoint = Request.Builder().url("https://api.github.com/repos/rhunk/SnapEnhance/releases").build() + val response = OkHttpClient().newCall(endpoint).execute() + + if (!response.isSuccessful) throw Throwable("Failed to fetch releases: ${response.code}") + + val releases = JsonParser.parseString(response.body.string()).asJsonArray.also { + if (it.size() == 0) throw Throwable("No releases found") + } + + val latestRelease = releases.get(0).asJsonObject + val latestVersion = latestRelease.getAsJsonPrimitive("tag_name").asString + if (latestVersion.removePrefix("v") == BuildConfig.VERSION_NAME) return@runCatching null + + LatestRelease( + versionName = latestVersion, + releaseUrl = endpoint.url.toString().replace("api.", "").replace("repos/", "") + ) + }.onFailure { + AbstractLogger.directError("Failed to fetch latest release", it) + }.getOrNull() + + private fun fetchLatestDebugCI() = runCatching { + val actionRuns = OkHttpClient().newCall(Request.Builder().url("https://api.github.com/repos/rhunk/SnapEnhance/actions/runs?event=workflow_dispatch").build()).execute().use { + if (!it.isSuccessful) throw Throwable("Failed to fetch CI runs: ${it.code}") + JsonParser.parseString(it.body.string()).asJsonObject + } + val debugRuns = actionRuns.getAsJsonArray("workflow_runs")?.mapNotNull { it.asJsonObject }?.filter { run -> + run.get("conclusion")?.takeIf { it.isJsonPrimitive }?.asString == "success" && run.getAsJsonPrimitive("path")?.asString == ".github/workflows/debug.yml" + } ?: throw Throwable("No debug CI runs found") + + val latestRun = debugRuns.firstOrNull() ?: throw Throwable("No debug CI runs found") + val headSha = latestRun.getAsJsonPrimitive("head_sha")?.asString ?: throw Throwable("No head sha found") + + if (headSha == BuildConfig.GIT_HASH) return@runCatching null + + LatestRelease( + versionName = headSha.substring(0, headSha.length.coerceAtMost(7)) + "-debug", + releaseUrl = latestRun.getAsJsonPrimitive("html_url")?.asString?.replace("github.com", "nightly.link") ?: return@runCatching null + ) + }.onFailure { + AbstractLogger.directError("Failed to fetch latest debug CI", it) + }.getOrNull() + + val latestRelease by lazy { + if (BuildConfig.DEBUG) fetchLatestDebugCI() else fetchLatestRelease() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/FileImportsRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/FileImportsRoot.kt new file mode 100644 index 0000000000..d27a070bd4 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/FileImportsRoot.kt @@ -0,0 +1,158 @@ +package me.rhunk.snapenhance.ui.manager.pages + +import android.net.Uri +import android.text.format.Formatter +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.DeleteOutline +import androidx.compose.material.icons.filled.Upload +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.documentfile.provider.DocumentFile +import androidx.navigation.NavBackStackEntry +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.common.ui.AsyncUpdateDispatcher +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList +import me.rhunk.snapenhance.ui.manager.Routes +import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper +import me.rhunk.snapenhance.ui.util.openFile +import java.text.DateFormat + +class FileImportsRoot: Routes.Route() { + private lateinit var activityLauncherHelper: ActivityLauncherHelper + private val reloadDispatcher = AsyncUpdateDispatcher() + + override val init: () -> Unit = { + activityLauncherHelper = ActivityLauncherHelper(context.activity!!) + } + + override val floatingActionButton: @Composable () -> Unit = { + val coroutineScope = rememberCoroutineScope() + Row { + ExtendedFloatingActionButton( + icon = { + Icon(Icons.Default.Upload, contentDescription = null) + }, + text = { + Text(translation["import_file_button"]) + }, + onClick = { + context.coroutineScope.launch { + activityLauncherHelper.openFile { filePath -> + val fileUri = Uri.parse(filePath) + runCatching { + DocumentFile.fromSingleUri(context.activity!!, fileUri)?.let { file -> + if (!file.exists()) { + context.shortToast(translation["file_not_found"]) + return@openFile + } + context.fileHandleManager.importFile(file.name!!) { + context.androidContext.contentResolver.openInputStream(fileUri)?.use { inputStream -> + inputStream.copyTo(this) + } + } + } + }.onFailure { + context.log.error("Failed to import file", it) + context.shortToast(translation.format("file_import_failed", "error" to it.message.toString())) + }.onSuccess { + context.shortToast(translation["file_imported"]) + coroutineScope.launch { + reloadDispatcher.dispatch() + } + } + } + } + }) + } + } + + override val content: @Composable (NavBackStackEntry) -> Unit = { + val files = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = reloadDispatcher) { + context.fileHandleManager.getStoredFiles() + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(2.dp), + verticalArrangement = Arrangement.spacedBy(5.dp) + ) { + item { + if (files.isEmpty()) { + Text( + text = translation["no_files_hint"], + modifier = Modifier + .padding(8.dp) + .fillMaxWidth(), + textAlign = TextAlign.Center, + fontSize = 18.sp, + fontWeight = FontWeight.Light + ) + } + } + items(files, key = { it }) { file -> + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + val fileInfo by rememberAsyncMutableState(defaultValue = null) { + context.fileHandleManager.getFileInfo(file.name) + } + Row( + modifier = Modifier + .padding(8.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.AttachFile, contentDescription = null, modifier = Modifier.padding(5.dp)) + Column( + modifier = Modifier.weight(1f).padding(8.dp), + ) { + Text(text = file.name, fontWeight = FontWeight.Bold, fontSize = 18.sp, lineHeight = 20.sp) + fileInfo?.let { (size, lastModified) -> + Text(text = "${Formatter.formatFileSize(context.androidContext, size)} - ${DateFormat.getDateTimeInstance().format(lastModified)}", lineHeight = 15.sp) + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(5.dp) + ) { + IconButton(onClick = { + context.coroutineScope.launch { + if (context.fileHandleManager.deleteFile(file.name)) { + files.remove(file) + } else { + context.shortToast(translation["file_delete_failed"]) + } + } + }) { + Icon(Icons.Default.DeleteOutline, contentDescription = null) + } + } + } + } + } + item { + Spacer(modifier = Modifier.height(100.dp)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/LoggerHistoryRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/LoggerHistoryRoot.kt new file mode 100644 index 0000000000..652ec65f82 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/LoggerHistoryRoot.kt @@ -0,0 +1,404 @@ +package me.rhunk.snapenhance.ui.manager.pages + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.pointer.pointerInput +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavBackStackEntry +import com.google.gson.JsonParser +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.bridge.DownloadCallback +import me.rhunk.snapenhance.common.bridge.wrapper.ConversationInfo +import me.rhunk.snapenhance.common.bridge.wrapper.LoggedMessage +import me.rhunk.snapenhance.common.bridge.wrapper.LoggerWrapper +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.data.download.DownloadMetadata +import me.rhunk.snapenhance.common.data.download.DownloadRequest +import me.rhunk.snapenhance.common.data.download.MediaDownloadSource +import me.rhunk.snapenhance.common.data.download.createNewFilePath +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState +import me.rhunk.snapenhance.common.ui.transparentTextFieldColors +import me.rhunk.snapenhance.common.util.ktx.copyToClipboard +import me.rhunk.snapenhance.common.util.ktx.longHashCode +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.core.features.impl.downloader.decoder.DecodedAttachment +import me.rhunk.snapenhance.core.features.impl.downloader.decoder.MessageDecoder +import me.rhunk.snapenhance.download.DownloadProcessor +import me.rhunk.snapenhance.storage.findFriend +import me.rhunk.snapenhance.ui.manager.Routes +import java.text.DateFormat +import java.util.concurrent.ConcurrentHashMap +import kotlin.math.absoluteValue + + +class LoggerHistoryRoot : Routes.Route() { + private lateinit var loggerWrapper: LoggerWrapper + private var selectedConversation by mutableStateOf<String?>(null) + private var stringFilter by mutableStateOf("") + private var reverseOrder by mutableStateOf(true) + + private inline fun decodeMessage(message: LoggedMessage, result: (contentType: ContentType, messageReader: ProtoReader, attachments: List<DecodedAttachment>) -> Unit) { + runCatching { + val messageObject = JsonParser.parseString(String(message.messageData, Charsets.UTF_8)).asJsonObject + val messageContent = messageObject.getAsJsonObject("mMessageContent") + val messageReader = messageContent.getAsJsonArray("mContent").map { it.asByte }.toByteArray().let { ProtoReader(it) } + result(ContentType.fromMessageContainer(messageReader) ?: ContentType.UNKNOWN, messageReader, MessageDecoder.decode(messageContent)) + }.onFailure { + context.log.error("Failed to decode message", it) + } + } + + private fun downloadAttachment(creationTimestamp: Long, attachment: DecodedAttachment) { + context.shortToast("Download started!") + val attachmentHash = attachment.mediaUniqueId!!.longHashCode().absoluteValue.toString() + + DownloadProcessor( + remoteSideContext = context, + callback = object: DownloadCallback.Default() { + override fun onSuccess(outputPath: String?) { + context.shortToast("Downloaded to $outputPath") + } + + override fun onFailure(message: String?, throwable: String?) { + context.shortToast("Failed to download $message") + } + } + ).enqueue( + DownloadRequest( + inputMedias = arrayOf(attachment.createInputMedia()!!) + ), + DownloadMetadata( + mediaIdentifier = attachmentHash, + outputPath = createNewFilePath( + context.config.root, + attachment.mediaUniqueId!!, + MediaDownloadSource.MESSAGE_LOGGER, + attachmentHash, + creationTimestamp + ), + iconUrl = null, + mediaAuthor = null, + downloadSource = MediaDownloadSource.MESSAGE_LOGGER.translate(context.translation), + ) + ) + } + + @OptIn(ExperimentalLayoutApi::class) + @Composable + private fun MessageView(message: LoggedMessage) { + var contentView by remember { mutableStateOf<@Composable () -> Unit>({ + Spacer(modifier = Modifier.height(30.dp)) + }) } + + OutlinedCard( + modifier = Modifier + .padding(2.dp) + .fillMaxWidth() + ) { + Row( + modifier = Modifier + .padding(8.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + contentView() + + LaunchedEffect(Unit, message) { + runCatching { + decodeMessage(message) { contentType, messageReader, attachments -> + @Composable + fun ContentHeader() { + Text("${message.username} (${contentType.toString().lowercase()}) - ${DateFormat.getDateTimeInstance().format(message.sendTimestamp)}", modifier = Modifier.padding(end = 4.dp), fontWeight = FontWeight.ExtraLight) + } + + if (contentType == ContentType.CHAT) { + val content = messageReader.getString(2, 1) ?: "[${translation["empty_message"]}]" + contentView = { + Column { + Text(content, modifier = Modifier + .fillMaxWidth() + .pointerInput(Unit) { + detectTapGestures(onLongPress = { + context.androidContext.copyToClipboard(content) + }) + }) + + val edits by rememberAsyncMutableState(defaultValue = emptyList()) { + loggerWrapper.getChatEdits(selectedConversation!!, message.messageId) + } + edits.forEach { messageEdit -> + val date = remember { + DateFormat.getDateTimeInstance().format(messageEdit.timestamp) + } + Text( + modifier = Modifier.pointerInput(Unit) { + detectTapGestures(onLongPress = { + context.androidContext.copyToClipboard(messageEdit.message) + }) + }.fillMaxWidth().padding(start = 4.dp), + text = messageEdit.message + " (edited at $date)", + fontWeight = FontWeight.Light, + fontStyle = FontStyle.Italic, + fontSize = 12.sp + ) + } + ContentHeader() + } + } + return@runCatching + } + contentView = { + Column column@{ + if (attachments.isEmpty()) return@column + + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(2.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + attachments.forEachIndexed { index, attachment -> + ElevatedButton(onClick = { + context.coroutineScope.launch { + runCatching { + downloadAttachment(message.sendTimestamp, attachment) + }.onFailure { + context.log.error("Failed to download attachment", it) + context.shortToast(translation["download_attachment_failed_toast"]) + } + } + }) { + Icon( + imageVector = Icons.Default.Download, + contentDescription = "Download", + modifier = Modifier.padding(end = 4.dp) + ) + Text(translation.format("chat_attachment", "index" to (index + 1).toString())) + } + } + } + ContentHeader() + } + } + } + }.onFailure { + context.log.error("Failed to parse message", it) + contentView = { + Text("[${translation["message_parse_failed"]}]") + } + } + } + } + } + } + + + @OptIn(ExperimentalMaterial3Api::class) + override val content: @Composable (NavBackStackEntry) -> Unit = { + LaunchedEffect(Unit) { + loggerWrapper = LoggerWrapper(context.androidContext) + } + + val conversationInfoCache = remember { ConcurrentHashMap<String, String?>() } + + Column { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + fun formatConversationInfo(conversationInfo: ConversationInfo?): String? { + if (conversationInfo == null) return null + + return conversationInfo.groupTitle?.let { + translation.format("list_group_format", "name" to it) + } ?: conversationInfo.usernames.takeIf { it.size > 1 }?.let { + translation.format("list_friend_format", "name" to ("(" + it.joinToString(", ") + ")")) + } ?: context.database.findFriend(conversationInfo.conversationId)?.let { + translation.format("list_friend_format", "name" to "(" + (conversationInfo.usernames + listOf(it.mutableUsername)).toSet().joinToString(", ") + ")") + } ?: conversationInfo.usernames.firstOrNull()?.let { + translation.format("list_friend_format", "name" to "($it)") + } + } + + val selectedConversationInfo by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(selectedConversation)) { + selectedConversation?.let { + conversationInfoCache.getOrPut(it) { + formatConversationInfo(loggerWrapper.getConversationInfo(it)) + } + } + } + + OutlinedTextField( + value = selectedConversationInfo ?: "Select a conversation", + onValueChange = {}, + readOnly = true, + modifier = Modifier + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + .fillMaxWidth() + ) + + val conversations by rememberAsyncMutableState(defaultValue = emptyList()) { + loggerWrapper.getAllConversations().toMutableList() + } + + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + conversations.forEach { conversationId -> + DropdownMenuItem(onClick = { + selectedConversation = conversationId + expanded = false + }, text = { + val conversationInfo by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(conversationId)) { + conversationInfoCache.getOrPut(conversationId) { + formatConversationInfo(loggerWrapper.getConversationInfo(conversationId)) + } + } + + Text( + text = remember(conversationInfo) { conversationInfo ?: conversationId }, + fontWeight = if (conversationId == selectedConversation) FontWeight.Bold else FontWeight.Normal, + overflow = TextOverflow.Ellipsis + ) + }) + } + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(2.dp), + horizontalArrangement = Arrangement.End + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text(translation["reverse_order_checkbox"]) + Checkbox(checked = reverseOrder, onCheckedChange = { + reverseOrder = it + }) + } + } + + var hasReachedEnd by remember(selectedConversation, stringFilter, reverseOrder) { mutableStateOf(false) } + var lastFetchMessageTimestamp by remember(selectedConversation, stringFilter, reverseOrder) { mutableLongStateOf(if (reverseOrder) Long.MAX_VALUE else Long.MIN_VALUE) } + val messages = remember(selectedConversation, stringFilter, reverseOrder) { mutableStateListOf<LoggedMessage>() } + + LazyColumn { + items(messages) { message -> + MessageView(message) + } + item { + if (selectedConversation != null) { + if (hasReachedEnd) { + Text(translation["no_more_messages"], modifier = Modifier + .padding(8.dp) + .fillMaxWidth(), textAlign = TextAlign.Center) + } else { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + CircularProgressIndicator( + modifier = Modifier + .height(20.dp) + .padding(8.dp) + ) + } + } + } + LaunchedEffect(Unit, selectedConversation, stringFilter, reverseOrder) { + withContext(Dispatchers.IO) { + val newMessages = loggerWrapper.fetchMessages( + selectedConversation ?: return@withContext, + lastFetchMessageTimestamp, + 30, + reverseOrder + ) { messageData -> + if (stringFilter.isEmpty()) return@fetchMessages true + var isMatch = false + decodeMessage(messageData) { contentType, messageReader, _ -> + if (contentType == ContentType.CHAT) { + val content = messageReader.getString(2, 1) ?: return@decodeMessage + isMatch = content.contains(stringFilter, ignoreCase = true) + } + } + isMatch + } + if (newMessages.isEmpty()) { + hasReachedEnd = true + return@withContext + } + lastFetchMessageTimestamp = newMessages.lastOrNull()?.sendTimestamp ?: return@withContext + withContext(Dispatchers.Main) { + messages.addAll(newMessages) + } + } + } + } + } + } + } + + override val topBarActions: @Composable (RowScope.() -> Unit) = { + val focusRequester = remember { FocusRequester() } + var showSearchTextField by remember { mutableStateOf(false) } + + if (showSearchTextField) { + var searchValue by remember { mutableStateOf("") } + + TextField( + value = searchValue, + onValueChange = { keyword -> + searchValue = keyword + stringFilter = keyword + }, + keyboardActions = KeyboardActions(onDone = { focusRequester.freeFocus() }), + modifier = Modifier + .focusRequester(focusRequester) + .weight(1f, fill = true) + .padding(end = 10.dp) + .height(70.dp), + singleLine = true, + colors = transparentTextFieldColors() + ) + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + } + + IconButton(onClick = { + showSearchTextField = !showSearchTextField + stringFilter = "" + }) { + Icon( + imageVector = if (showSearchTextField) Icons.Filled.Close + else Icons.Filled.Search, + contentDescription = null + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/ManageReposSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/ManageReposSection.kt new file mode 100644 index 0000000000..637f5dbab2 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/ManageReposSection.kt @@ -0,0 +1,190 @@ +package me.rhunk.snapenhance.ui.manager.pages + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Public +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.layout.onGloballyPositioned +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.core.net.toUri +import androidx.navigation.NavBackStackEntry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.common.data.RepositoryIndex +import me.rhunk.snapenhance.common.ui.AsyncUpdateDispatcher +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList +import me.rhunk.snapenhance.common.util.ktx.copyToClipboard +import me.rhunk.snapenhance.common.util.ktx.getUrlFromClipboard +import me.rhunk.snapenhance.storage.addRepo +import me.rhunk.snapenhance.storage.getRepositories +import me.rhunk.snapenhance.storage.removeRepo +import me.rhunk.snapenhance.ui.manager.Routes +import okhttp3.OkHttpClient + +class ManageReposSection: Routes.Route() { + private val updateDispatcher = AsyncUpdateDispatcher() + private val okHttpClient by lazy { OkHttpClient() } + + override val floatingActionButton: @Composable () -> Unit = { + var showAddDialog by remember { mutableStateOf(false) } + ExtendedFloatingActionButton(onClick = { + showAddDialog = true + }) { + Text("Add Repository") + } + + if (showAddDialog) { + val coroutineScope = rememberCoroutineScope { Dispatchers.IO } + + suspend fun addRepo(url: String) { + var modifiedUrl = url; + + if (url.startsWith("https://github.com/")) { + val splitUrl = modifiedUrl.removePrefix("https://github.com/").split("/") + val repoName = splitUrl[0] + "/" + splitUrl[1] + // fetch default branch + okHttpClient.newCall( + okhttp3.Request.Builder().url("https://api.github.com/repos/$repoName").build() + ).execute().use { response -> + if (!response.isSuccessful) { + throw Exception("Failed to fetch default branch: ${response.code}") + } + val json = response.body.string() + val defaultBranch = context.gson.fromJson(json, Map::class.java)["default_branch"] as String + context.log.info("Default branch for $repoName is $defaultBranch") + modifiedUrl = "https://raw.githubusercontent.com/$repoName/$defaultBranch/" + } + } + + val indexUri = modifiedUrl.toUri().buildUpon().appendPath("index.json").build() + okHttpClient.newCall( + okhttp3.Request.Builder().url(indexUri.toString()).build() + ).execute().use { response -> + if (!response.isSuccessful) { + throw Exception("Failed to fetch index from $indexUri: ${response.code}") + } + runCatching { + val repoIndex = context.gson.fromJson(response.body.charStream(), RepositoryIndex::class.java).also { + context.log.info("repository index: $it") + } + + context.database.addRepo(modifiedUrl) + context.shortToast("Repository added successfully! $repoIndex") + showAddDialog = false + updateDispatcher.dispatch() + }.onFailure { + throw Exception("Failed to parse index from $indexUri") + } + } + } + + var url by remember { mutableStateOf("") } + var loading by remember { mutableStateOf(false) } + + AlertDialog(onDismissRequest = { + showAddDialog = false + }, title = { + Text("Add Repository URL") + }, text = { + val focusRequester = remember { FocusRequester() } + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .onGloballyPositioned { + focusRequester.requestFocus() + }, + value = url, + onValueChange = { + url = it + }, label = { + Text("Repository URL") + } + ) + LaunchedEffect(Unit) { + context.androidContext.getUrlFromClipboard()?.let { + url = it + } + } + }, confirmButton = { + Button( + enabled = !loading, + onClick = { + loading = true; + coroutineScope.launch { + runCatching { + addRepo(url) + }.onFailure { + context.log.error("Failed to add repository", it) + context.shortToast("Failed to add repository: ${it.message}") + } + loading = false + } + } + ) { + if (loading) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } else { + Text("Add") + } + } + }) + } + } + + override val content: @Composable (NavBackStackEntry) -> Unit = { + val coroutineScope = rememberCoroutineScope() + val repositories = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = updateDispatcher) { + context.database.getRepositories() + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(8.dp), + ) { + item { + if (repositories.isEmpty()) { + Text("No repositories added", modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), fontSize = 15.sp, fontWeight = FontWeight.Light, textAlign = TextAlign.Center) + } + } + items(repositories) { url -> + ElevatedCard(onClick = { + context.androidContext.copyToClipboard(url) + }) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + Icon(Icons.Default.Public, contentDescription = null) + Text(text = url, modifier = Modifier.weight(1f), overflow = TextOverflow.Ellipsis, maxLines = 4, fontSize = 15.sp, lineHeight = 15.sp) + Button( + onClick = { + context.database.removeRepo(url) + coroutineScope.launch { + updateDispatcher.dispatch() + } + } + ) { + Text("Remove") + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/TasksRootSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/TasksRootSection.kt new file mode 100644 index 0000000000..922029423d --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/TasksRootSection.kt @@ -0,0 +1,516 @@ +package me.rhunk.snapenhance.ui.manager.pages + +import android.content.Intent +import android.graphics.drawable.ColorDrawable +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.Lifecycle +import androidx.navigation.NavBackStackEntry +import coil.compose.rememberAsyncImagePainter +import coil.request.ImageRequest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.bridge.DownloadCallback +import me.rhunk.snapenhance.common.data.download.DownloadMetadata +import me.rhunk.snapenhance.common.data.download.MediaDownloadSource +import me.rhunk.snapenhance.common.data.download.createNewFilePath +import me.rhunk.snapenhance.common.ui.TopBarActionButton +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState +import me.rhunk.snapenhance.common.util.ktx.longHashCode +import me.rhunk.snapenhance.download.DownloadProcessor +import me.rhunk.snapenhance.download.FFMpegProcessor +import me.rhunk.snapenhance.task.* +import me.rhunk.snapenhance.ui.manager.Routes +import me.rhunk.snapenhance.ui.util.OnLifecycleEvent +import me.rhunk.snapenhance.ui.util.coil.cacheKey +import java.io.File +import java.util.UUID +import kotlin.math.absoluteValue + +class TasksRootSection : Routes.Route() { + private var activeTasks by mutableStateOf(listOf<PendingTask>()) + private lateinit var recentTasks: MutableList<Task> + private val taskSelection = mutableStateListOf<Pair<Task, DocumentFile?>>() + + private fun fetchActiveTasks(scope: CoroutineScope = context.coroutineScope) { + scope.launch(Dispatchers.IO) { + activeTasks = context.taskManager.getActiveTasks().values.sortedByDescending { it.taskId }.toMutableList() + } + } + + private fun mergeSelection(selection: List<Pair<Task, DocumentFile>>) { + val firstTask = selection.first().first + + val taskHash = UUID.randomUUID().toString().longHashCode().absoluteValue.toString(16) + val pendingTask = context.taskManager.createPendingTask( + Task(TaskType.DOWNLOAD, "Merge ${selection.size} files", firstTask.author, taskHash) + ) + pendingTask.status = TaskStatus.RUNNING + fetchActiveTasks() + + context.coroutineScope.launch { + val filesToMerge = mutableListOf<File>() + + selection.forEach { (task, documentFile) -> + val tempFile = File.createTempFile(task.hash, "." + documentFile.name?.substringAfterLast("."), context.androidContext.cacheDir).also { + it.deleteOnExit() + } + + runCatching { + pendingTask.updateProgress("Copying ${documentFile.name}") + context.androidContext.contentResolver.openInputStream(documentFile.uri)?.use { inputStream -> + //copy with progress + val length = documentFile.length().toFloat() + tempFile.outputStream().use { outputStream -> + val buffer = ByteArray(16 * 1024) + var read: Int + while (inputStream.read(buffer).also { read = it } != -1) { + outputStream.write(buffer, 0, read) + pendingTask.updateProgress("Copying ${documentFile.name}", (outputStream.channel.position().toFloat() / length * 100f).toInt()) + } + outputStream.flush() + filesToMerge.add(tempFile) + } + } + }.onFailure { + pendingTask.fail("Failed to copy file $documentFile to $tempFile") + filesToMerge.forEach { it.delete() } + return@launch + } + } + + val mergedFile = File.createTempFile("merged", ".mp4", context.androidContext.cacheDir).also { + it.deleteOnExit() + } + + runCatching { + context.shortToast(translation.format("merge_files_toast", "count" to filesToMerge.size.toString())) + FFMpegProcessor.newFFMpegProcessor(context, pendingTask).execute( + FFMpegProcessor.Request(FFMpegProcessor.Action.MERGE_MEDIA, filesToMerge.map { it.absolutePath }, mergedFile) + ) + DownloadProcessor(context, object: DownloadCallback.Default() { + override fun onSuccess(outputPath: String) { + context.log.verbose("Merged files to $outputPath") + } + }).saveMediaToGallery(pendingTask, mergedFile, DownloadMetadata( + mediaIdentifier = taskHash, + outputPath = createNewFilePath( + context.config.root, + taskHash, + downloadSource = MediaDownloadSource.MERGED, + mediaAuthor = firstTask.author, + creationTimestamp = System.currentTimeMillis() + ), + mediaAuthor = firstTask.author, + downloadSource = MediaDownloadSource.MERGED.translate(context.translation), + iconUrl = null + )) + }.onFailure { + context.log.error("Failed to merge files", it) + pendingTask.fail(it.message ?: "Failed to merge files") + }.onSuccess { + pendingTask.success() + } + filesToMerge.forEach { it.delete() } + mergedFile.delete() + }.also { + pendingTask.addListener(PendingTaskListener(onCancel = { it.cancel() })) + } + } + + override val topBarActions: @Composable (RowScope.() -> Unit) = { + var showConfirmDialog by remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + + if (taskSelection.size > 1) { + val canMergeSelection by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(taskSelection.size)) { + taskSelection.all { it.second?.type?.contains("video") == true } + } + + if (canMergeSelection) { + TopBarActionButton( + onClick = { + mergeSelection(taskSelection.toList().also { + taskSelection.clear() + }.map { it.first to it.second!! }) + }, + icon = Icons.Filled.Merge, + text = translation["merge_button"] + ) + } + } + + IconButton(onClick = { + showConfirmDialog = true + }) { + Icon(Icons.Filled.Delete, contentDescription = "Clear tasks") + } + + if (showConfirmDialog) { + var alsoDeleteFiles by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = { showConfirmDialog = false }, + title = { + if (taskSelection.isNotEmpty()) { + Text(translation.format("remove_selected_tasks_confirm", "count" to taskSelection.size.toString())) + } else { + Text(translation["remove_all_tasks_confirm"]) + } + }, + text = { + Column { + if (taskSelection.isNotEmpty()) { + Text(translation["remove_selected_tasks_title"]) + Row ( + modifier = Modifier + .padding(top = 10.dp) + .fillMaxWidth() + .clickable { + alsoDeleteFiles = !alsoDeleteFiles + }, + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox(checked = alsoDeleteFiles, onCheckedChange = { + alsoDeleteFiles = it + }) + Text(translation["delete_files_option"]) + } + } else { + Text(translation["remove_all_tasks_title"]) + } + } + }, + confirmButton = { + Button( + onClick = { + showConfirmDialog = false + if (taskSelection.isNotEmpty()) { + taskSelection.forEach { (task, documentFile) -> + coroutineScope.launch(Dispatchers.IO) { + context.taskManager.removeTask(task) + if (alsoDeleteFiles) { + documentFile?.delete() + } + } + recentTasks.remove(task) + } + activeTasks = activeTasks.filter { task -> !taskSelection.map { it.first }.contains(task.task) } + taskSelection.clear() + } else { + coroutineScope.launch(Dispatchers.IO) { + context.taskManager.clearAllTasks() + } + recentTasks.clear() + activeTasks.forEach { + runCatching { + it.cancel() + }.onFailure { throwable -> + context.log.error("Failed to cancel task $it", throwable) + } + } + activeTasks = listOf() + context.taskManager.getActiveTasks().clear() + } + } + ) { + Text(context.translation["button.positive"]) + } + }, + dismissButton = { + Button( + onClick = { + showConfirmDialog = false + } + ) { + Text(context.translation["button.negative"]) + } + } + ) + } + } + + @Composable + private fun TaskCard(modifier: Modifier, task: Task, pendingTask: PendingTask? = null) { + var taskStatus by remember { mutableStateOf(task.status) } + var taskProgressLabel by remember { mutableStateOf<String?>(null) } + var taskProgress by remember { mutableIntStateOf(-1) } + val isSelected by remember { derivedStateOf { taskSelection.any { it.first == task } } } + + var documentFileMimeType by remember { mutableStateOf("") } + var isDocumentFileReadable by remember { mutableStateOf(true) } + val documentFile by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(taskStatus.key)) { + DocumentFile.fromSingleUri(context.androidContext, task.extra?.toUri() ?: return@rememberAsyncMutableState null)?.apply { + documentFileMimeType = type ?: "" + isDocumentFileReadable = canRead() + } + } + + + val listener = remember { PendingTaskListener( + onStateChange = { + taskStatus = it + }, + onProgress = { label, progress -> + taskProgressLabel = label + taskProgress = progress + } + ) } + + LaunchedEffect(Unit) { + pendingTask?.addListener(listener) + } + + DisposableEffect(Unit) { + onDispose { + pendingTask?.removeListener(listener) + } + } + + fun toggleSelection() { + if (isSelected) { + taskSelection.removeIf { it.first == task } + return + } + taskSelection.add(task to documentFile) + } + + fun openFile() { + if (!isDocumentFileReadable || documentFile == null) return + runCatching { + context.androidContext.startActivity(Intent(Intent.ACTION_VIEW).apply { + setDataAndType(documentFile!!.uri, documentFile!!.type) + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK + }) + }.onFailure { + context.log.error("Failed to open file ${documentFile?.uri}", it) + context.shortToast(translation["failed_to_open_file"]) + } + } + + OutlinedCard(modifier = modifier + .pointerInput(Unit) { + detectTapGestures( + onTap = { + if (taskSelection.isNotEmpty()) { + toggleSelection() + return@detectTapGestures + } + openFile() + }, + onLongPress = { + if (taskSelection.isNotEmpty()) { + openFile() + return@detectTapGestures + } + toggleSelection() + } + ) + } + .let { + if (isSelected) { + it + .border(2.dp, MaterialTheme.colorScheme.primary) + .clip(MaterialTheme.shapes.medium) + } else it + } + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .padding(end = 15.dp) + .size(50.dp) + .clipToBounds(), + contentAlignment = Alignment.Center + ) { + var loadFailed by remember { mutableStateOf(false) } + documentFile?.let { + if (taskStatus.isFinalStage() && isDocumentFileReadable && !loadFailed && (documentFileMimeType.contains("image") || documentFileMimeType.contains("video"))) { + Image( + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(context.androidContext) + .data(it.uri) + .cacheKey(it.uri.toString()) + .placeholder(ColorDrawable(MaterialTheme.colorScheme.surfaceVariant.toArgb())) + .build(), + imageLoader = context.imageLoader, + onError = { loadFailed = true } + ), + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier + .size(50.dp) + .clip(MaterialTheme.shapes.medium) + ) + } else { + when { + !isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = "File not found") + documentFileMimeType.contains("image") -> Icon(Icons.Filled.Image, contentDescription = "Image") + documentFileMimeType.contains("video") -> Icon(Icons.Filled.Videocam, contentDescription = "Video") + documentFileMimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = "Audio") + else -> Icon(Icons.Filled.FileCopy, contentDescription = "File") + } + } + } ?: run { + when (task.type) { + TaskType.DOWNLOAD -> Icon(Icons.Filled.Download, contentDescription = "Download") + TaskType.CHAT_ACTION -> Icon(Icons.Filled.ChatBubble, contentDescription = "Chat Action") + } + } + } + Column( + modifier = Modifier.weight(1f), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text(task.title, style = MaterialTheme.typography.bodyMedium) + task.author?.takeIf { it != "null" }?.let { + Spacer(modifier = Modifier.width(5.dp)) + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + Text(task.hash, style = MaterialTheme.typography.labelSmall) + Column( + modifier = Modifier.padding(top = 5.dp), + verticalArrangement = Arrangement.spacedBy(5.dp) + ) { + if (taskStatus.isFinalStage()) { + if (taskStatus != TaskStatus.SUCCESS) { + Text("$taskStatus", style = MaterialTheme.typography.bodySmall) + } + } else { + taskProgressLabel?.let { + Text(it, style = MaterialTheme.typography.bodySmall) + } + if (taskProgress != -1) { + LinearProgressIndicator( + progress = { taskProgress.toFloat() / 100f }, + strokeCap = StrokeCap.Round, + ) + } else { + task.extra?.let { + Text(it, style = MaterialTheme.typography.bodySmall) + } + } + } + } + } + + Column { + if (pendingTask != null && !taskStatus.isFinalStage()) { + FilledIconButton(onClick = { + runCatching { + pendingTask.cancel() + }.onFailure { throwable -> + context.log.error("Failed to cancel task $pendingTask", throwable) + } + }) { + Icon(Icons.Filled.Close, contentDescription = "Cancel") + } + } else { + when (taskStatus) { + TaskStatus.SUCCESS -> Icon(Icons.Filled.Check, contentDescription = "Success", tint = MaterialTheme.colorScheme.primary) + TaskStatus.FAILURE -> Icon(Icons.Filled.Error, contentDescription = "Failure", tint = MaterialTheme.colorScheme.error) + TaskStatus.CANCELLED -> Icon(Icons.Filled.Cancel, contentDescription = "Cancelled", tint = MaterialTheme.colorScheme.error) + else -> {} + } + } + } + } + } + } + + override val content: @Composable (NavBackStackEntry) -> Unit = { + val scrollState = rememberLazyListState() + val scope = rememberCoroutineScope() + recentTasks = remember { mutableStateListOf() } + var lastFetchedTaskId by remember { mutableStateOf(null as Long?) } + + fun fetchNewRecentTasks() { + scope.launch(Dispatchers.IO) { + val tasks = context.taskManager.fetchStoredTasks(lastFetchedTaskId ?: Long.MAX_VALUE, limit = 20) + if (tasks.isNotEmpty()) { + lastFetchedTaskId = tasks.keys.last() + val activeTaskIds = activeTasks.map { it.taskId } + recentTasks.addAll(tasks.filter { it.key !in activeTaskIds }.values) + } + } + } + + LaunchedEffect(Unit) { + fetchActiveTasks(this) + } + + DisposableEffect(Unit) { + onDispose { + taskSelection.clear() + } + } + + OnLifecycleEvent { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + fetchActiveTasks(scope) + } + } + + LazyColumn( + state = scrollState, + modifier = Modifier.fillMaxSize() + ) { + item { + if (activeTasks.isEmpty() && recentTasks.isEmpty()) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + translation["no_tasks"].let { + Icon(Icons.Filled.CheckCircle, contentDescription = it, tint = MaterialTheme.colorScheme.primary) + Text(it, style = MaterialTheme.typography.bodyLarge) + } + } + } + } + items(activeTasks, key = { it.taskId }) {pendingTask -> + TaskCard(modifier = Modifier.padding(8.dp), pendingTask.task, pendingTask = pendingTask) + } + items(recentTasks, key = { it.hash }) { task -> + TaskCard(modifier = Modifier.padding(8.dp), task) + } + item { + Spacer(modifier = Modifier.height(40.dp)) + LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) { + fetchNewRecentTasks() + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/CallbackAlias.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/CallbackAlias.kt new file mode 100644 index 0000000000..469d5d1c37 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/CallbackAlias.kt @@ -0,0 +1,4 @@ +package me.rhunk.snapenhance.ui.manager.pages.features + +typealias ClickCallback = (Boolean) -> Unit +typealias RegisterClickCallback = (ClickCallback) -> ClickCallback \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRootSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRootSection.kt new file mode 100644 index 0000000000..9b59f9564c --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRootSection.kt @@ -0,0 +1,737 @@ +package me.rhunk.snapenhance.ui.manager.pages.features + +import android.content.Intent +import android.net.Uri +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +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.lifecycle.Lifecycle +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.common.config.* +import me.rhunk.snapenhance.common.ui.TopBarActionButton +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList +import me.rhunk.snapenhance.common.ui.transparentTextFieldColors +import me.rhunk.snapenhance.ui.manager.MainActivity +import me.rhunk.snapenhance.ui.manager.Routes +import me.rhunk.snapenhance.ui.util.* + +class FeaturesRootSection : Routes.Route() { + private val alertDialogs by lazy { AlertDialogs(context.translation) } + + companion object { + const val FEATURE_CONTAINER_ROUTE = "feature_container/{name}" + const val SEARCH_FEATURE_ROUTE = "search_feature/{keyword}" + } + + private var activityLauncherHelper: ActivityLauncherHelper? = null + + private val allContainers by lazy { + val containers = mutableMapOf<String, PropertyPair<*>>() + fun queryContainerRecursive(container: ConfigContainer) { + container.properties.forEach { + if (it.key.dataType.type == DataProcessors.Type.CONTAINER) { + containers[it.key.name] = PropertyPair(it.key, it.value) + queryContainerRecursive(it.value.get() as ConfigContainer) + } + } + } + queryContainerRecursive(context.config.root) + containers + } + + private val allProperties by lazy { + val properties = mutableMapOf<PropertyKey<*>, PropertyValue<*>>() + allContainers.values.forEach { + val container = it.value.get() as ConfigContainer + container.properties.forEach { property -> + properties[property.key] = property.value + } + } + properties + } + + private fun navigateToMainRoot() { + routes.navController.navigate(routeInfo.id, NavOptions.Builder() + .setPopUpTo(routes.navController.graph.findStartDestination().id, false) + .setLaunchSingleTop(true) + .build() + ) + } + + override val init: () -> Unit = { + activityLauncherHelper = ActivityLauncherHelper(context.activity!!) + } + + private fun activityLauncher(block: ActivityLauncherHelper.() -> Unit) { + activityLauncherHelper?.let(block) ?: run { + //open manager if activity launcher is null + val intent = Intent(context.androidContext, MainActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + intent.putExtra("route", routeInfo.id) + context.androidContext.startActivity(intent) + } + } + + override val content: @Composable (NavBackStackEntry) -> Unit = { + Container(context.config.root) + } + + override val customComposables: NavGraphBuilder.() -> Unit = { + routeInfo.childIds.addAll(listOf(FEATURE_CONTAINER_ROUTE, SEARCH_FEATURE_ROUTE)) + + composable(FEATURE_CONTAINER_ROUTE, enterTransition = { + slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, animationSpec = tween(100)) + }, exitTransition = { + slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, animationSpec = tween(300)) + }) { backStackEntry -> + backStackEntry.arguments?.getString("name")?.let { containerName -> + allContainers[containerName]?.let { + Container(it.value.get() as ConfigContainer) + } + } + } + + composable(SEARCH_FEATURE_ROUTE) { backStackEntry -> + backStackEntry.arguments?.getString("keyword")?.let { keyword -> + val properties = allProperties.filter { + it.key.name.contains(keyword, ignoreCase = true) || + context.translation[it.key.propertyName()].contains(keyword, ignoreCase = true) || + context.translation[it.key.propertyDescription()].contains(keyword, ignoreCase = true) + }.map { PropertyPair(it.key, it.value) } + + PropertiesView(properties) + } + } + } + + @Composable + private fun PropertyAction(property: PropertyPair<*>, registerClickCallback: RegisterClickCallback) { + var showDialog by remember { mutableStateOf(false) } + var dialogComposable by remember { mutableStateOf<@Composable () -> Unit>({}) } + + fun registerDialogOnClickCallback() = registerClickCallback { showDialog = true } + + if (showDialog) { + Dialog( + properties = DialogProperties( + usePlatformDefaultWidth = false + ), + onDismissRequest = { showDialog = false }, + ) { + dialogComposable() + } + } + + val propertyValue = property.value + + if (property.key.params.flags.contains(ConfigFlag.USER_IMPORT)) { + registerDialogOnClickCallback() + dialogComposable = { + var isEmpty by remember { mutableStateOf(false) } + val files = rememberAsyncMutableStateList(defaultValue = listOf()) { + context.fileHandleManager.getStoredFiles { + property.key.params.filenameFilter?.invoke(it.name) == true + }.also { + isEmpty = it.isEmpty() + if (isEmpty) { + propertyValue.setAny(null) + } + } + } + var selectedFile by remember(files.size) { mutableStateOf(files.firstOrNull { it.name == propertyValue.getNullable() }.also { + if (files.isNotEmpty() && it == null) propertyValue.setAny(null) + }?.name) } + + Card( + shape = MaterialTheme.shapes.large, + modifier = Modifier + .fillMaxWidth(), + ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp), + ) { + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = context.translation["manager.dialogs.file_imports.settings_select_file_hint"], + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + ) + if (isEmpty) { + Text( + text = context.translation["manager.dialogs.file_imports.no_files_settings_hint"], + fontSize = 16.sp, + modifier = Modifier.padding(top = 10.dp), + ) + } + } + } + items(files, key = { it.name }) { file -> + Row( + modifier = Modifier + .clickable { + selectedFile = + if (selectedFile == file.name) null else file.name + propertyValue.setAny(selectedFile) + } + .padding(5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Filled.AttachFile, contentDescription = null, modifier = Modifier.padding(5.dp)) + Text( + text = file.name, + modifier = Modifier + .padding(3.dp) + .weight(1f), + fontSize = 14.sp, + lineHeight = 16.sp + ) + if (selectedFile == file.name) { + Icon(Icons.Filled.Check, contentDescription = null, modifier = Modifier.padding(5.dp)) + } + } + } + } + } + } + + Icon(Icons.Filled.AttachFile, contentDescription = null) + return + } + + if (property.key.params.flags.contains(ConfigFlag.FOLDER)) { + IconButton(onClick = registerClickCallback { + activityLauncher { + chooseFolder { uri -> + propertyValue.setAny(uri) + } + } + }.let { { it.invoke(true) } }) { + Icon(Icons.Filled.FolderOpen, contentDescription = null) + } + return + } + + when (val dataType = remember { property.key.dataType.type }) { + DataProcessors.Type.BOOLEAN -> { + var state by remember { mutableStateOf(propertyValue.get() as Boolean) } + Switch( + checked = state, + onCheckedChange = registerClickCallback { + state = state.not() + propertyValue.setAny(state) + } + ) + } + + DataProcessors.Type.MAP_COORDINATES -> { + registerDialogOnClickCallback() + dialogComposable = { + alertDialogs.ChooseLocationDialog(property) { + showDialog = false + } + } + + Text( + overflow = TextOverflow.Ellipsis, + maxLines = 1, + modifier = Modifier.widthIn(0.dp, 120.dp), + text = (propertyValue.get() as Pair<*, *>).let { + "${it.first.toString().toFloatOrNull() ?: 0F}, ${it.second.toString().toFloatOrNull() ?: 0F}" + } + ) + } + + DataProcessors.Type.STRING_UNIQUE_SELECTION -> { + registerDialogOnClickCallback() + + dialogComposable = { + alertDialogs.UniqueSelectionDialog(property) + } + + Text( + overflow = TextOverflow.Ellipsis, + maxLines = 1, + modifier = Modifier.widthIn(0.dp, 120.dp), + text = (propertyValue.getNullable() as? String ?: "null").let { + property.key.propertyOption(context.translation, it) + } + ) + } + + DataProcessors.Type.STRING_MULTIPLE_SELECTION, DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> { + dialogComposable = { + when (dataType) { + DataProcessors.Type.STRING_MULTIPLE_SELECTION -> { + alertDialogs.MultipleSelectionDialog(property) + } + DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> { + alertDialogs.KeyboardInputDialog(property) { showDialog = false } + } + else -> {} + } + } + + registerDialogOnClickCallback().let { { it.invoke(true) } }.also { + if (dataType == DataProcessors.Type.INTEGER || + dataType == DataProcessors.Type.FLOAT) { + FilledIconButton(onClick = it) { + Text( + text = propertyValue.get().toString(), + modifier = Modifier.wrapContentWidth(), + overflow = TextOverflow.Ellipsis + ) + } + } else { + IconButton(onClick = it) { + Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null) + } + } + } + } + + DataProcessors.Type.INT_COLOR -> { + dialogComposable = { + alertDialogs.ColorPickerPropertyDialog(property) { + showDialog = false + } + } + + registerDialogOnClickCallback().let { { it.invoke(true) } }.also { + CircularAlphaTile(selectedColor = (propertyValue.getNullable() as? Int)?.let { Color(it) }) + } + } + + DataProcessors.Type.CONTAINER -> { + val container = propertyValue.get() as ConfigContainer + + registerClickCallback { + routes.navController.navigate(FEATURE_CONTAINER_ROUTE.replace("{name}", property.name)) + } + + if (!container.hasGlobalState) return + + var state by remember { mutableStateOf(container.globalState ?: false) } + + Box( + modifier = Modifier + .padding(end = 15.dp), + ) { + + Box(modifier = Modifier + .height(50.dp) + .width(1.dp) + .background( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), + shape = RoundedCornerShape(5.dp) + )) + } + + Switch( + checked = state, + onCheckedChange = { + state = state.not() + container.globalState = state + } + ) + } + } + + } + + @Composable + private fun PropertyCard(property: PropertyPair<*>) { + var clickCallback by remember { mutableStateOf<ClickCallback?>(null) } + val noticeColorMap = mapOf( + FeatureNotice.UNSTABLE.key to Color(0xFFFFFB87), + FeatureNotice.BAN_RISK.key to Color(0xFFFF8585), + FeatureNotice.INTERNAL_BEHAVIOR.key to Color(0xFFFFFB87), + ) + + val versionCheck = remember { property.key.params.versionCheck } + val versionCheckPair = remember(property) { versionCheck?.checkVersion(context.installationSummary.snapchatInfo?.versionCode ?: return@remember null)} + val isComponentDisabled = remember { versionCheckPair != null && versionCheck?.isDisabled == true } + + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .then( + if (isComponentDisabled) Modifier.graphicsLayer(alpha = 0.5f) + else Modifier + ) + .padding(start = 10.dp, end = 10.dp, top = 5.dp, bottom = 5.dp) + ) { + Row( + modifier = Modifier + .fillMaxSize() + .clickable { + clickCallback?.invoke(true) + } + .padding(all = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + property.key.params.icon?.let { icon -> + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier + .align(Alignment.CenterVertically) + .padding(start = 10.dp) + ) + } + + Column( + modifier = Modifier + .align(Alignment.CenterVertically) + .weight(1f, fill = true) + .padding(all = 10.dp) + ) { + Text( + text = context.translation[property.key.propertyName()], + fontSize = 16.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Bold + ) + Text( + text = context.translation[property.key.propertyDescription()], + fontSize = 12.sp, + lineHeight = 15.sp + ) + property.key.params.notices.also { + if (it.isNotEmpty()) Spacer(modifier = Modifier.height(5.dp)) + }.forEach { + Text( + text = context.translation["features.notices.${it.key}"], + color = noticeColorMap[it.key] ?: Color(0xFFFFFB87), + fontSize = 12.sp, + lineHeight = 15.sp + ) + } + + if (versionCheckPair != null) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = context.translation.format( + "manager.sections.features.${versionCheckPair.second.key}", + "version" to versionCheckPair.first.first + ), + color = Color(0xFFFF8585), + fontSize = 12.sp, + lineHeight = 15.sp + ) + } + } + + Row( + modifier = Modifier + .align(Alignment.CenterVertically) + .padding(all = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + PropertyAction(property, registerClickCallback = { callback -> + if (property.key.propertyTranslationPath().startsWith("rules.properties")) { + clickCallback = { + routes.manageRuleFeature.navigate { + put("rule_type", property.key.name) + } + } + return@PropertyAction clickCallback!! + } + clickCallback = callback + callback + }) + } + } + } + } + + @Composable + private fun FeatureSearchBar(rowScope: RowScope, focusRequester: FocusRequester) { + var searchValue by remember { mutableStateOf("") } + val scope = rememberCoroutineScope() + var currentSearchJob by remember { mutableStateOf<Job?>(null) } + + rowScope.apply { + TextField( + value = searchValue, + onValueChange = { keyword -> + searchValue = keyword + if (keyword.isEmpty()) { + navigateToMainRoot() + return@TextField + } + currentSearchJob?.cancel() + scope.launch { + delay(300) + routes.navController.navigate(SEARCH_FEATURE_ROUTE.replace("{keyword}", keyword), NavOptions.Builder() + .setLaunchSingleTop(true) + .setPopUpTo(routeInfo.id, false) + .build() + ) + }.also { currentSearchJob = it } + }, + + keyboardActions = KeyboardActions(onDone = { + focusRequester.freeFocus() + }), + modifier = Modifier + .focusRequester(focusRequester) + .weight(1f, fill = true) + .padding(end = 10.dp) + .height(70.dp), + singleLine = true, + colors = transparentTextFieldColors() + ) + } + } + + override val topBarActions: @Composable (RowScope.() -> Unit) = topBarActions@{ + var showSearchBar by remember { mutableStateOf(false) } + val focusRequester = remember { FocusRequester() } + + if (showSearchBar) { + FeatureSearchBar(this, focusRequester) + LaunchedEffect(true) { + focusRequester.requestFocus() + } + } + + + if (showSearchBar) { + IconButton(onClick = { + showSearchBar = false + if (routes.currentDestination == SEARCH_FEATURE_ROUTE) { + navigateToMainRoot() + } + }) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = null + ) + } + } else { + TopBarActionButton( + onClick = { + showSearchBar = true + }, + icon = Icons.Filled.Search, + text = translation["search_button"] + ) + } + + if (showSearchBar) return@topBarActions + + var showExportDropdownMenu by remember { mutableStateOf(false) } + var showResetConfirmationDialog by remember { mutableStateOf(false) } + var showExportDialog by remember { mutableStateOf(false) } + + if (showResetConfirmationDialog) { + AlertDialog( + title = { Text(text = context.translation["manager.dialogs.reset_config.title"]) }, + text = { Text(text = context.translation["manager.dialogs.reset_config.content"]) }, + onDismissRequest = { showResetConfirmationDialog = false }, + confirmButton = { + Button( + onClick = { + context.config.reset() + context.shortToast(context.translation["manager.dialogs.reset_config.success_toast"]) + showResetConfirmationDialog = false + } + ) { + Text(text = context.translation["button.positive"]) + } + }, + dismissButton = { + Button( + onClick = { + showResetConfirmationDialog = false + } + ) { + Text(text = context.translation["button.negative"]) + } + } + ) + } + + if (showExportDialog) { + fun exportConfig( + exportSensitiveData: Boolean + ) { + showExportDialog = false + activityLauncher { + saveFile("config.json", "application/json") { uri -> + runCatching { + context.androidContext.contentResolver.openOutputStream(Uri.parse(uri))?.use { + context.config.writeConfig() + context.config.exportToString(exportSensitiveData).byteInputStream().copyTo(it) + context.shortToast(translation["config_export_success_toast"]) + } + }.onFailure { + context.longToast(translation.format("config_export_failure_toast", "error" to it.message.toString())) + } + } + } + } + + AlertDialog( + title = { Text(text = context.translation["manager.dialogs.export_config.title"]) }, + text = { Text(text = context.translation["manager.dialogs.export_config.content"]) }, + onDismissRequest = { showExportDialog = false }, + confirmButton = { + Button( + onClick = { exportConfig(true) } + ) { + Text(text = context.translation["button.positive"]) + } + }, + dismissButton = { + Button( + onClick = { exportConfig(false) } + ) { + Text(text = context.translation["button.negative"]) + } + } + ) + } + + val actions = remember { + mapOf( + translation["export_option"] to { showExportDialog = true }, + translation["import_option"] to { + activityLauncher { + openFile("application/json") { uri -> + context.androidContext.contentResolver.openInputStream(Uri.parse(uri))?.use { + runCatching { + context.config.loadFromString(it.readBytes().toString(Charsets.UTF_8)) + }.onFailure { + context.longToast(translation.format("config_import_failure_toast", "error" to it.message.toString())) + return@use + } + context.shortToast(translation["config_import_success_toast"]) + context.coroutineScope.launch(Dispatchers.Main) { + navigateReload() + } + } + } + } + }, + translation["reset_option"] to { showResetConfirmationDialog = true } + ) + } + + if (context.activity != null) { + IconButton(onClick = { showExportDropdownMenu = !showExportDropdownMenu}) { + Icon( + imageVector = Icons.Filled.MoreVert, + contentDescription = null + ) + } + } + + if (showExportDropdownMenu) { + DropdownMenu(expanded = true, onDismissRequest = { showExportDropdownMenu = false }) { + actions.forEach { (name, action) -> + DropdownMenuItem( + text = { + Text(text = name) + }, + onClick = { + action() + showExportDropdownMenu = false + } + ) + } + } + } + } + + @Composable + private fun PropertiesView( + properties: List<PropertyPair<*>> + ) { + Scaffold( + modifier = Modifier.fillMaxSize(), + content = { innerPadding -> + LazyColumn( + modifier = Modifier + .fillMaxHeight() + .padding(innerPadding), + //save button space + contentPadding = PaddingValues(top = 10.dp, bottom = 110.dp), + verticalArrangement = Arrangement.Top + ) { + items(properties, key = { it.key.propertyName() }) { + PropertyCard(it) + } + } + } + ) + } + + override val floatingActionButton: @Composable () -> Unit = { + fun saveConfig() { + context.coroutineScope.launch(Dispatchers.IO) { + context.config.writeConfig() + context.log.verbose("saved config!") + } + } + + OnLifecycleEvent { _, event -> + if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) { + saveConfig() + } + } + + DisposableEffect(Unit) { + onDispose { + saveConfig() + } + } + } + + + @Composable + private fun Container( + configContainer: ConfigContainer + ) { + PropertiesView(remember { + configContainer.properties.map { PropertyPair(it.key, it.value) } + }) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/ManageRuleFeature.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/ManageRuleFeature.kt new file mode 100644 index 0000000000..b657b157c3 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/ManageRuleFeature.kt @@ -0,0 +1,215 @@ +package me.rhunk.snapenhance.ui.manager.pages.features + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavBackStackEntry +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.common.data.MessagingRuleType +import me.rhunk.snapenhance.common.data.RuleState +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState +import me.rhunk.snapenhance.common.ui.rememberAsyncUpdateDispatcher +import me.rhunk.snapenhance.storage.clearRuleIds +import me.rhunk.snapenhance.storage.getRuleIds +import me.rhunk.snapenhance.storage.setRule +import me.rhunk.snapenhance.ui.manager.Routes +import me.rhunk.snapenhance.ui.manager.pages.social.AddFriendDialog +import me.rhunk.snapenhance.ui.manager.pages.social.AddFriendDialog.Actions +import me.rhunk.snapenhance.ui.util.AlertDialogs +import me.rhunk.snapenhance.ui.util.Dialog + +class ManageRuleFeature : Routes.Route() { + @Composable + fun SelectRuleTypeRadio( + checked: Boolean, + text: String, + onStateChanged: (Boolean) -> Unit, + selectedBlock: @Composable () -> Unit = {}, + ) { + Box(modifier = Modifier.clickable { + onStateChanged(!checked) + }) { + Column( + modifier = Modifier + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = checked, onClick = null) + Text(text) + } + if (checked) { + Column(modifier = Modifier + .offset(x = 15.dp) + .padding(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + selectedBlock() + } + } + } + } + } + + override val content: @Composable (NavBackStackEntry) -> Unit = content@{ navBackStackEntry -> + val currentRuleType = navBackStackEntry.arguments?.getString("rule_type")?.let { + MessagingRuleType.getByName(it) + } ?: return@content + + var ruleState by remember { + mutableStateOf(context.config.root.rules.getRuleState(currentRuleType)) + } + + val propertyKeyPair = remember { + context.config.root.rules.getPropertyPair(currentRuleType.key) + } + + val updateDispatcher = rememberAsyncUpdateDispatcher() + val currentRuleIds by rememberAsyncMutableState(defaultValue = mutableListOf(), updateDispatcher = updateDispatcher) { + context.database.getRuleIds(currentRuleType.key) + } + + fun setRuleState(newState: RuleState?) { + ruleState = newState + propertyKeyPair.value.setAny(newState?.key) + context.coroutineScope.launch { + context.config.writeConfig(dispatchConfigListener = false) + } + } + + var addFriendDialog by remember { mutableStateOf(null as AddFriendDialog?) } + + LaunchedEffect(addFriendDialog) { + if (addFriendDialog == null) { + updateDispatcher.dispatch() + } + } + + fun showAddFriendDialog() { + addFriendDialog = AddFriendDialog( + context = context, + pinnedIds = currentRuleIds, + actionHandler = Actions( + onFriendState = { friend, state -> + context.database.setRule(friend.userId, currentRuleType.key, state) + if (state) { + currentRuleIds.add(friend.userId) + } else { + currentRuleIds.remove(friend.userId) + } + }, + onGroupState = { group, state -> + context.database.setRule(group.conversationId, currentRuleType.key, state) + if (state) { + currentRuleIds.add(group.conversationId) + } else { + currentRuleIds.remove(group.conversationId) + } + }, + getFriendState = { friend -> + currentRuleIds.contains(friend.userId) + }, + getGroupState = { group -> + currentRuleIds.contains(group.conversationId) + } + ) + ) + } + + if (addFriendDialog != null) { + addFriendDialog?.Content { + addFriendDialog = null + } + } + + Column( + modifier = Modifier.fillMaxSize() + ) { + Column( + modifier = Modifier.padding(10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = remember { + context.translation[propertyKeyPair.key.propertyName()] + }, + fontSize = 20.sp, + ) + Text( + text = remember { + context.translation[propertyKeyPair.key.propertyDescription()] + }, + fontWeight = FontWeight.Light, + fontSize = 12.sp, + lineHeight = 16.sp, + ) + } + + SelectRuleTypeRadio(checked = ruleState == null, text = translation["disable_state_option"], onStateChanged = { + setRuleState(null) + }) { + Text(text = translation["disable_state_subtext"], fontWeight = FontWeight.Light, fontSize = 12.sp) + } + SelectRuleTypeRadio(checked = ruleState == RuleState.WHITELIST, text = translation["whitelist_state_option"], onStateChanged = { + setRuleState(RuleState.WHITELIST) + }) { + Text(text = translation.format("whitelist_state_subtext", "count" to currentRuleIds.size.toString()), fontWeight = FontWeight.Light, fontSize = 12.sp) + OutlinedButton(onClick = { + showAddFriendDialog() + }) { + Text(text = translation["whitelist_state_button"]) + } + } + SelectRuleTypeRadio(checked = ruleState == RuleState.BLACKLIST, text = translation["blacklist_state_option"], onStateChanged = { + setRuleState(RuleState.BLACKLIST) + }) { + Text(text = translation.format("blacklist_state_subtext", "count" to currentRuleIds.size.toString()), fontWeight = FontWeight.Light, fontSize = 12.sp) + OutlinedButton(onClick = { showAddFriendDialog() }) { + Text(text = translation["blacklist_state_button"]) + } + } + + Row( + modifier = Modifier.fillMaxWidth().padding(5.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + var confirmationDialog by remember { mutableStateOf(false) } + + if (confirmationDialog) { + Dialog(onDismissRequest = { + confirmationDialog = false + }) { + remember { AlertDialogs(context.translation) }.ConfirmDialog( + title = translation["dialog_clear_confirmation_text"], + onDismiss = { confirmationDialog = false }, + onConfirm = { + context.database.clearRuleIds(currentRuleType.key) + context.coroutineScope.launch(context.database.executor.asCoroutineDispatcher()) { + updateDispatcher.dispatch() + } + confirmationDialog = false + } + ) + } + } + + Button(onClick = { confirmationDialog = true }) { + Text(text = translation["clear_list_button"]) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeLogs.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeLogs.kt new file mode 100644 index 0000000000..2a2af0aeff --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeLogs.kt @@ -0,0 +1,260 @@ +package me.rhunk.snapenhance.ui.manager.pages.home + +import android.net.Uri +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardDoubleArrowDown +import androidx.compose.material.icons.filled.KeyboardDoubleArrowUp +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.outlined.BugReport +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Report +import androidx.compose.material.icons.outlined.Warning +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +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.navigation.NavBackStackEntry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.LogReader +import me.rhunk.snapenhance.common.logger.LogChannel +import me.rhunk.snapenhance.common.logger.LogLevel +import me.rhunk.snapenhance.ui.manager.Routes +import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper +import me.rhunk.snapenhance.ui.util.pullrefresh.PullRefreshIndicator +import me.rhunk.snapenhance.ui.util.pullrefresh.rememberPullRefreshState +import me.rhunk.snapenhance.ui.util.saveFile + +class HomeLogs : Routes.Route() { + private val logListState by lazy { LazyListState(0) } + private lateinit var activityLauncherHelper: ActivityLauncherHelper + + override val init: () -> Unit = { + activityLauncherHelper = ActivityLauncherHelper(context.activity!!) + } + + override val topBarActions: @Composable (RowScope.() -> Unit) = { + var showDropDown by remember { mutableStateOf(false) } + + IconButton(onClick = { + showDropDown = true + }) { + Icon(Icons.Filled.MoreVert, contentDescription = null) + } + + DropdownMenu( + expanded = showDropDown, + onDismissRequest = { showDropDown = false }, + modifier = Modifier.align(Alignment.CenterVertically) + ) { + DropdownMenuItem(onClick = { + context.coroutineScope.launch { + context.log.clearLogs() + } + navigateReload() + showDropDown = false + }, text = { + Text(translation["clear_logs_button"]) + }) + + DropdownMenuItem(onClick = { + activityLauncherHelper.saveFile("snapenhance-logs-${System.currentTimeMillis()}.zip", "application/zip") { uri -> + context.coroutineScope.launch { + context.shortToast(translation["saving_logs_toast"]) + context.androidContext.contentResolver.openOutputStream(Uri.parse(uri))?.use { + runCatching { + context.log.exportLogsToZip(it) + context.longToast(translation["saved_logs_success_toast"]) + }.onFailure { + context.longToast(translation["saved_logs_failure_toast"]) + context.log.error("Failed to save logs to $uri!", it) + } + } + } + } + showDropDown = false + }, text = { + Text(translation["export_logs_button"]) + }) + } + } + + override val content: @Composable (NavBackStackEntry) -> Unit = { + val coroutineScope = rememberCoroutineScope() + val clipboardManager = LocalClipboardManager.current + var lineCount by remember { mutableIntStateOf(0) } + var logReader by remember { mutableStateOf<LogReader?>(null) } + var isRefreshing by remember { mutableStateOf(false) } + + fun refreshLogs() { + coroutineScope.launch(Dispatchers.IO) { + runCatching { + logReader = context.log.newReader { + lineCount++ + } + lineCount = logReader!!.lineCount + }.onFailure { + context.longToast("Failed to read logs!") + } + delay(300) + isRefreshing = false + withContext(Dispatchers.Main) { + logListState.scrollToItem((logListState.layoutInfo.totalItemsCount - 1).takeIf { it >= 0 } ?: return@withContext) + } + } + } + + val pullRefreshState = rememberPullRefreshState(isRefreshing, onRefresh = { + refreshLogs() + }) + + LaunchedEffect(Unit) { + isRefreshing = true + refreshLogs() + } + + Box( + modifier = Modifier + .fillMaxSize() + ) { + LazyColumn( + modifier = Modifier + .background(MaterialTheme.colorScheme.surface) + .horizontalScroll(ScrollState(0)), + state = logListState + ) { + item { + if (lineCount == 0 && logReader != null) { + Text( + text = translation["no_logs_hint"], + modifier = Modifier.padding(16.dp), + fontSize = 12.sp, + fontWeight = FontWeight.Light + ) + } + } + items(lineCount) { index -> + val logLine by remember(index) { + mutableStateOf(runBlocking(Dispatchers.IO) { + logReader?.getLogLine(index) + }) + } + logLine?.let { line -> + Box(modifier = Modifier + .fillMaxWidth() + .pointerInput(Unit) { + detectTapGestures( + onLongPress = { + coroutineScope.launch { + clipboardManager.setText( + AnnotatedString( + line.message + ) + ) + } + } + ) + }) { + Column( + modifier = Modifier + .padding(4.dp) + .fillMaxWidth() + .defaultMinSize(minHeight = 30.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = when (line.logLevel) { + LogLevel.DEBUG -> Icons.Outlined.BugReport + LogLevel.ERROR, LogLevel.ASSERT -> Icons.Outlined.Report + LogLevel.INFO, LogLevel.VERBOSE -> Icons.Outlined.Info + LogLevel.WARN -> Icons.Outlined.Warning + else -> Icons.Outlined.Info + }, + modifier = Modifier.size(16.dp), + contentDescription = null, + ) + + Text( + text = LogChannel.fromChannel(line.tag)?.shortName ?: line.tag, + modifier = Modifier.padding(start = 4.dp), + fontWeight = FontWeight.Bold, + fontSize = 12.sp, + ) + + Text( + text = line.dateTime, + modifier = Modifier.padding(start = 4.dp, end = 4.dp), + fontSize = 10.sp + ) + } + + Text( + text = line.message.trimIndent(), + lineHeight = 10.sp, + fontSize = 9.sp, + maxLines = Int.MAX_VALUE, + ) + } + } + } + } + } + + PullRefreshIndicator( + refreshing = isRefreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter) + ) + } + } + + override val floatingActionButton: @Composable () -> Unit = { + val coroutineScope = rememberCoroutineScope() + Column( + verticalArrangement = Arrangement.spacedBy(5.dp), + ) { + val firstVisibleItem by remember { derivedStateOf { logListState.firstVisibleItemIndex } } + val layoutInfo by remember { derivedStateOf { logListState.layoutInfo } } + FilledIconButton( + onClick = { + coroutineScope.launch { + logListState.scrollToItem(0) + } + }, + enabled = firstVisibleItem != 0 + ) { + Icon(Icons.Filled.KeyboardDoubleArrowUp, contentDescription = null) + } + + FilledIconButton( + onClick = { + coroutineScope.launch { + logListState.scrollToItem((logListState.layoutInfo.totalItemsCount - 1).takeIf { it >= 0 } ?: return@launch) + } + }, + enabled = layoutInfo.visibleItemsInfo.lastOrNull()?.index != layoutInfo.totalItemsCount - 1 + ) { + Icon(Icons.Filled.KeyboardDoubleArrowDown, contentDescription = null) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeRootSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeRootSection.kt new file mode 100644 index 0000000000..185b688728 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeRootSection.kt @@ -0,0 +1,410 @@ +package me.rhunk.snapenhance.ui.manager.pages.home + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Help +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +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.text.withLink +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavBackStackEntry +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.R +import me.rhunk.snapenhance.action.EnumQuickActions +import me.rhunk.snapenhance.common.BuildConfig +import me.rhunk.snapenhance.common.action.EnumAction +import me.rhunk.snapenhance.common.ui.TopBarActionButton +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList +import me.rhunk.snapenhance.common.util.ktx.openLink +import me.rhunk.snapenhance.core.ui.Snapenhance +import me.rhunk.snapenhance.storage.getQuickTiles +import me.rhunk.snapenhance.storage.setQuickTiles +import me.rhunk.snapenhance.ui.manager.Routes +import me.rhunk.snapenhance.ui.manager.data.Updater +import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper +import java.text.DateFormat + +class HomeRootSection : Routes.Route() { + companion object { + val cardMargin = 10.dp + } + + private lateinit var activityLauncherHelper: ActivityLauncherHelper + + private val cards by lazy { + EnumQuickActions.entries.map { + (context.translation["actions.${it.key}.name"] to it.icon) to it.action + }.associate { + it.first to it.second + }.toMutableMap().apply { + EnumAction.entries.forEach { action -> + this[context.translation["actions.${action.key}.name"] to action.icon] = { + context.launchActionIntent(action) + } + } + } + } + + @Composable + private fun InfoCard( + content: @Composable ColumnScope.() -> Unit, + ) { + OutlinedCard( + modifier = Modifier + .padding(start = cardMargin, end = cardMargin) + .fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(all = 10.dp) + ) { + content() + } + } + } + + @Composable + fun ExternalLinkIcon( + modifier: Modifier = Modifier, + size: Dp = 32.dp, + imageVector: ImageVector, + ) { + Icon( + imageVector = imageVector, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .size(size) + .clip(RoundedCornerShape(50)) + .then(modifier) + ) + } + + override val title: @Composable (() -> Unit)? = {} + + override val init: () -> Unit = { + activityLauncherHelper = ActivityLauncherHelper(context.activity!!) + } + + override val topBarActions: @Composable (RowScope.() -> Unit) = { + TopBarActionButton( + onClick = { + routes.homeLogs.navigate() + }, + icon = Icons.Filled.BugReport, + text = context.translation["manager.routes.home_logs"] + ) + Spacer(modifier = Modifier.width(8.dp)) + TopBarActionButton( + onClick = { + routes.settings.navigate() + }, + icon = Icons.Filled.Settings, + text = context.translation["manager.routes.home_settings"] + ) + } + + @OptIn(ExperimentalLayoutApi::class) + override val content: @Composable (NavBackStackEntry) -> Unit = { + val avenirNext = remember { + FontFamily( + Font(R.font.avenir_next_medium, FontWeight.Medium) + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + Icon( + imageVector = Snapenhance, contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .padding(all = 8.dp) + .align(Alignment.CenterHorizontally), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Text( + text = translation.format( + "version_title", + "versionName" to BuildConfig.VERSION_NAME + ), + fontSize = 14.sp, + fontFamily = avenirNext, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + + Row( + horizontalArrangement = Arrangement.spacedBy( + 15.dp, Alignment.CenterHorizontally + ), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(all = 5.dp) + ) { + ExternalLinkIcon( + modifier = Modifier.clickable { + context.androidContext.openLink("https://t.me/snapenhance") + }, + imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram), + ) + + ExternalLinkIcon( + modifier = Modifier.clickable { + context.androidContext.openLink("https://github.com/rhunk/SnapEnhance") + }, + imageVector = ImageVector.vectorResource(id = R.drawable.ic_github), + ) + + ExternalLinkIcon( + modifier = Modifier.offset(x = (-3).dp).clickable { + context.androidContext.openLink("https://github.com/rhunk/SnapEnhance/wiki") + }, + size = 40.dp, + imageVector = Icons.AutoMirrored.Default.Help, + ) + } + + val selectedTiles = rememberAsyncMutableStateList(defaultValue = listOf()) { + context.database.getQuickTiles() + } + + val latestUpdate by rememberAsyncMutableState(defaultValue = null) { Updater.latestRelease } + + if (latestUpdate != null) { + Spacer(modifier = Modifier.height(10.dp)) + InfoCard { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = translation["update_title"], + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + ) + Text( + fontSize = 12.sp, + text = translation.format( + "update_content", + "version" to (latestUpdate?.versionName ?: "unknown") + ), + lineHeight = 20.sp, + overflow = TextOverflow.Ellipsis, + ) + } + Button( + modifier = Modifier.height(40.dp), + onClick = { + latestUpdate?.releaseUrl?.let { context.androidContext.openLink(it) } + } + ) { + Text(text = translation["update_button"]) + } + } + } + } + + if (BuildConfig.DEBUG) { + Spacer(modifier = Modifier.height(10.dp)) + InfoCard { + Text( + text = translation["debug_build_summary_title"], + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + ) + val buildSummary = buildAnnotatedString { + withStyle( + style = SpanStyle( + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Light + ) + ) { + append( + remember { + translation.format( + "debug_build_summary_content", + "versionName" to BuildConfig.VERSION_NAME, + "versionCode" to BuildConfig.VERSION_CODE.toString(), + ) + } + ) + append(" - ") + } + withLink( + LinkAnnotation.Clickable( + "git_hash", + linkInteractionListener = { + context.androidContext.openLink("https://github.com/rhunk/SnapEnhance/commit/${BuildConfig.GIT_HASH}") + } + ) + ) { + withStyle( + style = SpanStyle( + fontSize = 13.sp, fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + ) { + append(BuildConfig.GIT_HASH.substring(0, 7)) + } + } + } + Text( + text = buildSummary + ) + Text( + fontSize = 12.sp, + text = remember { + translation.format( + "debug_build_summary_date", + "date" to DateFormat.getDateTimeInstance() + .format(BuildConfig.BUILD_TIMESTAMP), + "days" to ((System.currentTimeMillis() - BuildConfig.BUILD_TIMESTAMP) / 86400000).toInt() + .toString() + ) + }, + lineHeight = 20.sp, + fontWeight = FontWeight.Light + ) + } + } + + var showQuickActionsMenu by remember { mutableStateOf(false) } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 10.dp, top = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + translation["quick_actions_title"], fontSize = 20.sp, + modifier = Modifier.weight(1f) + ) + Box { + IconButton( + onClick = { showQuickActionsMenu = !showQuickActionsMenu }, + ) { + Icon(Icons.Default.MoreVert, contentDescription = null) + } + DropdownMenu( + expanded = showQuickActionsMenu, + onDismissRequest = { showQuickActionsMenu = false } + ) { + cards.forEach { (card, _) -> + fun toggle(state: Boolean? = null) { + if (state?.let { !it } ?: selectedTiles.contains(card.first)) { + selectedTiles.remove(card.first) + } else { + selectedTiles.add(0, card.first) + } + context.coroutineScope.launch { + context.database.setQuickTiles(selectedTiles) + } + } + + DropdownMenuItem(onClick = { toggle() }, text = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(all = 5.dp) + ) { + Checkbox( + checked = selectedTiles.contains(card.first), + onCheckedChange = { + toggle(it) + } + ) + Text(text = card.first) + } + }) + } + } + } + } + + FlowRow( + modifier = Modifier + .padding(all = cardMargin) + .fillMaxWidth(), + maxItemsInEachRow = 3, + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + val tileHeight = LocalDensity.current.run { + remember { (context.androidContext.resources.displayMetrics.widthPixels / 3).toDp() - cardMargin / 2 } + } + + remember(selectedTiles.size, context.translation.loadedLocale) { + selectedTiles.mapNotNull { + cards.entries.find { entry -> entry.key.first == it } + } + }.forEach { (card, action) -> + ElevatedCard( + modifier = Modifier + .height(tileHeight) + .weight(1f) + .padding(all = 6.dp), + onClick = { action(routes) } + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(all = 5.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceEvenly, + ) { + Icon( + imageVector = card.second, contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(50.dp) + ) + Text( + text = card.first, + lineHeight = 16.sp, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } + } +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeSettings.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeSettings.kt new file mode 100644 index 0000000000..fa4f01e3de --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeSettings.kt @@ -0,0 +1,293 @@ +package me.rhunk.snapenhance.ui.manager.pages.home + +import android.content.SharedPreferences +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.core.content.edit +import androidx.core.net.toUri +import androidx.navigation.NavBackStackEntry +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.common.action.EnumAction +import me.rhunk.snapenhance.common.bridge.InternalFileHandleType +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState +import me.rhunk.snapenhance.ui.manager.Routes +import me.rhunk.snapenhance.ui.setup.Requirements +import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper +import me.rhunk.snapenhance.ui.util.AlertDialogs +import me.rhunk.snapenhance.ui.util.saveFile + +class HomeSettings : Routes.Route() { + private lateinit var activityLauncherHelper: ActivityLauncherHelper + private val dialogs by lazy { AlertDialogs(context.translation) } + + override val init: () -> Unit = { + activityLauncherHelper = ActivityLauncherHelper(context.activity!!) + } + + @Composable + private fun RowTitle(title: String) { + Text(text = title, modifier = Modifier.padding(16.dp), fontSize = 20.sp, fontWeight = FontWeight.Bold) + } + + @Composable + private fun PreferenceToggle(sharedPreferences: SharedPreferences, key: String, text: String) { + val realKey = "debug_$key" + var value by remember { mutableStateOf(sharedPreferences.getBoolean(realKey, false)) } + + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 55.dp) + .clickable { + value = !value + sharedPreferences + .edit() { + putBoolean(realKey, value) + } + }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = text, modifier = Modifier.padding(end = 16.dp), fontSize = 14.sp) + Switch(checked = value, onCheckedChange = { + value = it + sharedPreferences.edit().putBoolean(realKey, it).apply() + }, modifier = Modifier.padding(end = 26.dp)) + } + } + + @Composable + private fun RowAction(key: String, requireConfirmation: Boolean = false, action: () -> Unit) { + var confirmationDialog by remember { + mutableStateOf(false) + } + + fun takeAction() { + if (requireConfirmation) { + confirmationDialog = true + } else { + action() + } + } + + if (requireConfirmation && confirmationDialog) { + Dialog(onDismissRequest = { confirmationDialog = false }) { + dialogs.ConfirmDialog(title = context.translation["manager.dialogs.action_confirm.title"], onConfirm = { + action() + confirmationDialog = false + }, onDismiss = { + confirmationDialog = false + }) + } + } + + ShiftedRow( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 55.dp) + .clickable { + takeAction() + }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Text(text = context.translation["actions.$key.name"], fontSize = 16.sp, fontWeight = FontWeight.Bold, lineHeight = 20.sp) + context.translation.getOrNull("actions.$key.description")?.let { Text(text = it, fontSize = 12.sp, fontWeight = FontWeight.Light, lineHeight = 15.sp) } + } + IconButton(onClick = { takeAction() }, + modifier = Modifier.padding(end = 2.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } + } + } + + @Composable + private fun ShiftedRow( + modifier: Modifier = Modifier, + horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, + verticalAlignment: Alignment.Vertical = Alignment.Top, + content: @Composable RowScope.() -> Unit + ) { + Row( + modifier = modifier.padding(start = 26.dp), + horizontalArrangement = horizontalArrangement, + verticalAlignment = verticalAlignment + ) { content(this) } + } + + @OptIn(ExperimentalMaterial3Api::class) + override val content: @Composable (NavBackStackEntry) -> Unit = { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + RowTitle(title = translation["actions_title"]) + EnumAction.entries.forEach { enumAction -> + RowAction(key = enumAction.key) { + context.launchActionIntent(enumAction) + } + } + RowAction(key = "regen_mappings") { + context.checkForRequirements(Requirements.MAPPINGS) + } + RowAction(key = "change_language") { + context.checkForRequirements(Requirements.LANGUAGE) + } + RowTitle(title = translation["message_logger_title"]) + ShiftedRow { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) { + context.messageLogger.getStoredMessageCount() + } + var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) { + context.messageLogger.getStoredStoriesCount() + } + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(5.dp) + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + translation.format("message_logger_summary", + "messageCount" to storedMessagesCount.toString(), + "storyCount" to storedStoriesCount.toString() + ), maxLines = 2) + } + Button(onClick = { + runCatching { + activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri -> + context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { outputStream -> + context.messageLogger.databaseFile.inputStream().use { inputStream -> + inputStream.copyTo(outputStream) + } + } + } + }.onFailure { + context.log.error("Failed to export database", it) + context.longToast("Failed to export database! ${it.localizedMessage}") + } + }) { + Text(text = translation["export_button"]) + } + Button(onClick = { + runCatching { + context.messageLogger.purgeAll() + storedMessagesCount = 0 + storedStoriesCount = 0 + }.onFailure { + context.log.error("Failed to clear messages", it) + context.longToast("Failed to clear messages! ${it.localizedMessage}") + }.onSuccess { + context.shortToast(translation["success_toast"]) + } + }) { + Text(text = translation["clear_button"]) + } + } + OutlinedButton( + modifier = Modifier + .fillMaxWidth() + .padding(5.dp), + onClick = { + routes.loggerHistory.navigate() + } + ) { + Text(translation["view_logger_history_button"]) + } + } + } + + RowTitle(title = translation["debug_title"]) + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + var selectedFileType by remember { mutableStateOf(InternalFileHandleType.entries.first()) } + Box( + modifier = Modifier + .weight(1f) + .padding(start = 26.dp) + ) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = Modifier.fillMaxWidth(0.7f) + ) { + TextField( + value = selectedFileType.fileName, + onValueChange = {}, + readOnly = true, + modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable) + ) + + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + InternalFileHandleType.entries.forEach { fileType -> + DropdownMenuItem(onClick = { + expanded = false + selectedFileType = fileType + }, text = { + Text(text = fileType.fileName) + }) + } + } + } + } + Button(onClick = { + runCatching { + context.coroutineScope.launch { + selectedFileType.resolve(context.androidContext).delete() + } + }.onFailure { + context.log.error("Failed to clear file", it) + context.longToast("Failed to clear file! ${it.localizedMessage}") + }.onSuccess { + context.shortToast(translation["success_toast"]) + } + }) { + Text(translation["clear_button"]) + } + } + ShiftedRow { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + PreferenceToggle(context.sharedPreferences, key = "test_mode", text = "Test Mode (FOR DEBUGGING ONLY)") + PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = "Disable Feature Loading") + PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = "Disable Auto Mapper") + } + } + Spacer(modifier = Modifier.height(50.dp)) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/location/AddCoordinatesDialog.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/location/AddCoordinatesDialog.kt new file mode 100644 index 0000000000..ce0b636a8f --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/location/AddCoordinatesDialog.kt @@ -0,0 +1,91 @@ +package me.rhunk.snapenhance.ui.manager.pages.location + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay +import me.rhunk.snapenhance.bridge.location.LocationCoordinates +import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper +import me.rhunk.snapenhance.ui.util.AlertDialogs + + +@Composable +fun AddCoordinatesDialog( + alertDialogs: AlertDialogs, + translation: LocaleWrapper, + locationCoordinates: LocationCoordinates, + confirm: (locationCoordinates: LocationCoordinates) -> Unit +) { + var savedName by remember { + mutableStateOf( + (locationCoordinates.name ?: "").let { + TextFieldValue(it, selection = TextRange(it.length)) + } + ) + } + var savedLatitude by remember { mutableStateOf(locationCoordinates.latitude.toFloat().toString()) } + var savedLongitude by remember { mutableStateOf(locationCoordinates.longitude.toFloat().toString()) } + + alertDialogs.DefaultDialogCard { + val focusRequester = remember { FocusRequester() } + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text(translation["save_coordinates_dialog_title"], fontSize = 20.sp, fontWeight = FontWeight.Bold) + OutlinedTextField( + modifier = Modifier + .focusRequester(focusRequester), + value = savedName, + onValueChange = { savedName = it }, + label = { Text(translation["saved_name_dialog_hint"]) } + ) + + LaunchedEffect(Unit) { + delay(200) + focusRequester.requestFocus() + } + + OutlinedTextField( + value = savedLatitude, + onValueChange = { savedLatitude = it }, + label = { Text(translation["latitude_dialog_hint"]) } + ) + OutlinedTextField( + value = savedLongitude, + onValueChange = { savedLongitude = it }, + label = { Text(translation["longitude_dialog_hint"]) } + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + horizontalArrangement = Arrangement.End + ) { + Button( + onClick = { + confirm(LocationCoordinates().apply { + this.name = savedName.text + this.latitude = savedLatitude.toDoubleOrNull() ?: 0.0 + this.longitude = savedLongitude.toDoubleOrNull() ?: 0.0 + }) + }, + enabled = savedName.text.isNotBlank() && savedLatitude.isNotBlank() && savedLongitude.isNotBlank() + ) { + Text(translation["save_dialog_button"]) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/location/BetterLocationRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/location/BetterLocationRoot.kt new file mode 100644 index 0000000000..6372c451a6 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/location/BetterLocationRoot.kt @@ -0,0 +1,464 @@ +package me.rhunk.snapenhance.ui.manager.pages.location + +import android.os.Parcel +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.DeleteOutline +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +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.navigation.NavBackStackEntry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.bridge.location.FriendLocation +import me.rhunk.snapenhance.bridge.location.LocationCoordinates +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList +import me.rhunk.snapenhance.common.ui.rememberAsyncUpdateDispatcher +import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie +import me.rhunk.snapenhance.storage.addOrUpdateLocationCoordinate +import me.rhunk.snapenhance.storage.getLocationCoordinates +import me.rhunk.snapenhance.storage.removeLocationCoordinate +import me.rhunk.snapenhance.ui.manager.Routes +import me.rhunk.snapenhance.ui.util.AlertDialogs +import me.rhunk.snapenhance.ui.util.DialogProperties +import me.rhunk.snapenhance.ui.util.coil.BitmojiImage +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.MapView +import org.osmdroid.views.overlay.Marker + +class BetterLocationRoot : Routes.Route() { + private val alertDialogs by lazy { AlertDialogs(context.translation) } + + @Composable + private fun FriendLocationItem( + friendLocation: FriendLocation, + dismiss: () -> Unit + ) { + ElevatedCard(onClick = { + context.config.root.global.betterLocation.coordinates.setAny(friendLocation.latitude to friendLocation.longitude) + dismiss() + }, modifier = Modifier.padding(4.dp)) { + Row( + modifier = Modifier + .padding(8.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + BitmojiImage( + context = context, + url = BitmojiSelfie.getBitmojiSelfie( + friendLocation.bitmojiSelfieId, + friendLocation.bitmojiId, + BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D + ), + size = 48, + modifier = Modifier.padding(6.dp) + ) + Column( + modifier = Modifier.weight(1f), + ) { + Text(friendLocation.displayName?.let { "$it (${friendLocation.username})" } + ?: friendLocation.username, fontSize = 16.sp, fontWeight = FontWeight.Bold) + Text( + text = buildString { + append(friendLocation.localityPieces.joinToString(", ")) + append("\n") + append("Lat: ${friendLocation.latitude.toFloat()}, Lng: ${friendLocation.longitude.toFloat()}") + }, + fontSize = 10.sp, + fontWeight = FontWeight.Light, + lineHeight = 15.sp + ) + } + } + } + } + + @Composable + private fun FriendLocationsDialogs( + friendsLocation: List<FriendLocation>, + dismiss: () -> Unit + ) { + var search by remember { mutableStateOf("") } + val filteredFriendsLocation = rememberAsyncMutableStateList(defaultValue = friendsLocation, keys = arrayOf(search)) { + search.takeIf { it.isNotBlank() }?.let { + friendsLocation.filter { + it.displayName?.contains(search, ignoreCase = true) == true || it.username.contains(search, ignoreCase = true) + } + } ?: friendsLocation + } + + ElevatedCard( + shape = MaterialTheme.shapes.large, + modifier = Modifier.padding(top = 32.dp, bottom = 32.dp) + ) { + Text( + translation["teleport_to_friend_title"], + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + ) + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + value = search, + onValueChange = { search = it }, + label = { Text(translation["search_bar"]) } + ) + LazyColumn( + modifier = Modifier + .fillMaxSize() + ) { + item { + if (friendsLocation.isEmpty()) { + Text( + translation["no_friends_map"], + fontSize = 16.sp, + modifier = Modifier.padding(16.dp), + fontWeight = FontWeight.Light + ) + } else if (filteredFriendsLocation.isEmpty()) { + Text( + translation["no_friends_found"], + fontSize = 16.sp, + modifier = Modifier.padding(16.dp), + fontWeight = FontWeight.Light + ) + } + } + items(filteredFriendsLocation) { friendLocation -> + FriendLocationItem(friendLocation, dismiss) + } + } + } + } + + override val content: @Composable (NavBackStackEntry) -> Unit = { + val coordinatesProperty = remember { + context.config.root.global.betterLocation.getPropertyPair("coordinates") + } + + val updateDispatcher = rememberAsyncUpdateDispatcher() + val savedCoordinates = rememberAsyncMutableStateList( + defaultValue = listOf(), + updateDispatcher = updateDispatcher + ) { + context.database.getLocationCoordinates() + } + var showMap by remember { mutableStateOf(false) } + var addSavedCoordinateDialog by remember { mutableStateOf(false) } + var showTeleportDialog by remember { mutableStateOf(false) } + + val marker = remember { mutableStateOf<Marker?>(null) } + val mapView = remember { mutableStateOf<MapView?>(null) } + var spoofedCoordinates by remember(showTeleportDialog, showMap) { mutableStateOf(coordinatesProperty.value.get() as? Pair<*, *>) } + + fun addSavedCoordinate(id: Int?, locationCoordinates: LocationCoordinates, onSuccess: suspend (id: Int) -> Unit = {}) { + context.coroutineScope.launch { + onSuccess(context.database.addOrUpdateLocationCoordinate(id, locationCoordinates)) + } + } + + if (showTeleportDialog) { + me.rhunk.snapenhance.ui.util.Dialog( + properties = DialogProperties(usePlatformDefaultWidth = false), + onDismissRequest = { showTeleportDialog = false }, + content = { + FriendLocationsDialogs(remember { context.locationManager.friendsLocation }) { + showTeleportDialog = false + context.coroutineScope.launch { + context.config.writeConfig() + } + } + } + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + ) { + Text( + translation.format( + "spoofed_coordinates_title", + "latitude" to ((spoofedCoordinates?.first as? Double)?.toFloat() ?: "0.0").toString(), + "longitude" to ((spoofedCoordinates?.second as? Double)?.toFloat() ?: "0.0").toString() + ), + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) + + if (addSavedCoordinateDialog) { + me.rhunk.snapenhance.ui.util.Dialog( + onDismissRequest = { addSavedCoordinateDialog = false }, + content = { + AddCoordinatesDialog( + alertDialogs, + translation, + LocationCoordinates().apply { + this.latitude = marker.value?.position?.latitude ?: 0.0 + this.longitude = marker.value?.position?.longitude ?: 0.0 + }, + ) { coordinates -> + addSavedCoordinateDialog = false + addSavedCoordinate(null, coordinates) { + withContext(Dispatchers.Main) { + savedCoordinates.add(0, coordinates.apply { id = it }) + } + } + } + } + ) + } + + if (showMap) { + me.rhunk.snapenhance.ui.util.Dialog( + onDismissRequest = { showMap = false }, + content = { + alertDialogs.ChooseLocationDialog(property = coordinatesProperty, marker, mapView, saveCoordinates = { + addSavedCoordinateDialog = true + }) { + showMap = false + context.config.writeConfig() + } + DisposableEffect(Unit) { + onDispose { + marker.value = null + } + } + } + ) + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .clipToBounds() + ) { + + item { + @Composable + fun ConfigToggle( + text: String, + state: MutableState<Boolean>, + onCheckedChange: (Boolean) -> Unit + ) { + Row( + modifier = Modifier.padding(start = 16.dp, end = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = text) + Spacer(modifier = Modifier.weight(1f)) + Switch( + checked = state.value, + onCheckedChange = { + state.value = it + onCheckedChange(it) + } + ) + } + } + ConfigToggle( + translation["spoof_location_toggle"], + remember { mutableStateOf(context.config.root.global.betterLocation.spoofLocation.get()) } + ) { + context.config.root.global.betterLocation.spoofLocation.set(it) + } + ConfigToggle( + translation["suspend_location_updates"], + remember { mutableStateOf(context.config.root.global.betterLocation.suspendLocationUpdates.get()) } + ) { + context.config.root.global.betterLocation.suspendLocationUpdates.set(it) + } + } + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + Button(onClick = { showMap = true }) { + Text(translation["choose_location_button"]) + } + Button(onClick = { showTeleportDialog = true }) { + Text(translation["teleport_to_friend_button"]) + } + } + } + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp, end = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + translation["saved_coordinates_title"], + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + lineHeight = 20.sp + ) + IconButton( + onClick = { + addSavedCoordinateDialog = true + } + ) { + Icon(Icons.Default.Add, contentDescription = "Add") + } + } + } + item { + if (savedCoordinates.isEmpty()) { + Text( + translation["no_saved_coordinates_hint"], + fontSize = 16.sp, + modifier = Modifier.padding(start = 20.dp), + fontWeight = FontWeight.Light + ) + } + } + items(savedCoordinates, key = { it.id }) { coordinates -> + var mutableCoordinates by remember { mutableStateOf(coordinates) } + val isSelected = spoofedCoordinates == mutableCoordinates.latitude to mutableCoordinates.longitude + var showDeleteDialog by remember { mutableStateOf(false) } + var showEditDialog by remember { mutableStateOf(false) } + + fun setSpoofedCoordinates() { + spoofedCoordinates = mutableCoordinates.latitude to mutableCoordinates.longitude + coordinatesProperty.value.setAny(spoofedCoordinates) + context.coroutineScope.launch { + context.config.writeConfig() + } + } + + if (showDeleteDialog) { + me.rhunk.snapenhance.ui.util.Dialog( + onDismissRequest = { showDeleteDialog = false }, + content = { + alertDialogs.ConfirmDialog( + title = translation["delete_dialog_title"], + message = translation["delete_dialog_message"], + onConfirm = { + showDeleteDialog = false + context.coroutineScope.launch { + context.database.removeLocationCoordinate(coordinates.id) + savedCoordinates.remove(coordinates) + } + }, + onDismiss = { showDeleteDialog = false } + ) + } + ) + } + + if (showEditDialog) { + me.rhunk.snapenhance.ui.util.Dialog( + onDismissRequest = { showEditDialog = false }, + content = { + AddCoordinatesDialog( + alertDialogs, + translation, + mutableCoordinates + ) { + val itemId = coordinates.id + context.coroutineScope.launch { + addSavedCoordinate(itemId, it) + } + Parcel.obtain().apply { + it.writeToParcel(this, 0) + setDataPosition(0) + coordinates.readFromParcel(this) + coordinates.id = itemId + recycle() + } + mutableCoordinates = it + if (isSelected) setSpoofedCoordinates() + showEditDialog = false + } + } + ) + } + + ElevatedCard( + onClick = { + mutableCoordinates = coordinates + setSpoofedCoordinates() + GeoPoint(coordinates.latitude, coordinates.longitude).also { + marker.value?.position = it + mapView.value?.controller?.apply { + animateTo(it) + setZoom(16.0) + } + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(5.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .padding(2.dp) + .weight(1f) + ) { + Text( + text = remember(mutableCoordinates) { mutableCoordinates.name }, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Light, + fontSize = 16.sp, + lineHeight = 20.sp, + overflow = TextOverflow.Ellipsis + ) + Text( + text = remember(mutableCoordinates) { "(${mutableCoordinates.latitude.toFloat()}, ${mutableCoordinates.longitude.toFloat()})" }, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Light, + fontSize = 12.sp, + lineHeight = 15.sp, + overflow = TextOverflow.Ellipsis + ) + } + FilledIconButton(onClick = { + showEditDialog = true + }) { + Icon(Icons.Default.Edit, contentDescription = "Delete") + } + Spacer(modifier = Modifier.width(4.dp)) + FilledIconButton(onClick = { + showDeleteDialog = true + }) { + Icon(Icons.Default.DeleteOutline, contentDescription = "Delete") + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptingRootSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptingRootSection.kt new file mode 100644 index 0000000000..fdae690e24 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptingRootSection.kt @@ -0,0 +1,585 @@ +package me.rhunk.snapenhance.ui.manager.pages.scripting + +import android.content.Intent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onGloballyPositioned +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.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavBackStackEntry +import kotlinx.coroutines.* +import me.rhunk.snapenhance.common.scripting.type.ModuleInfo +import me.rhunk.snapenhance.common.scripting.ui.EnumScriptInterface +import me.rhunk.snapenhance.common.scripting.ui.InterfaceManager +import me.rhunk.snapenhance.common.scripting.ui.ScriptInterface +import me.rhunk.snapenhance.common.ui.AsyncUpdateDispatcher +import me.rhunk.snapenhance.common.ui.TopBarActionButton +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState +import me.rhunk.snapenhance.common.ui.rememberAsyncUpdateDispatcher +import me.rhunk.snapenhance.common.util.ktx.getUrlFromClipboard +import me.rhunk.snapenhance.common.util.ktx.openLink +import me.rhunk.snapenhance.storage.isScriptEnabled +import me.rhunk.snapenhance.storage.setScriptEnabled +import me.rhunk.snapenhance.ui.manager.Routes +import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper +import me.rhunk.snapenhance.ui.util.Dialog +import me.rhunk.snapenhance.ui.util.chooseFolder +import me.rhunk.snapenhance.ui.util.pullrefresh.PullRefreshIndicator +import me.rhunk.snapenhance.ui.util.pullrefresh.pullRefresh +import me.rhunk.snapenhance.ui.util.pullrefresh.rememberPullRefreshState + +class ScriptingRootSection : Routes.Route() { + private lateinit var activityLauncherHelper: ActivityLauncherHelper + private val reloadDispatcher = AsyncUpdateDispatcher(updateOnFirstComposition = false) + + override val init: () -> Unit = { + activityLauncherHelper = ActivityLauncherHelper(context.activity!!) + } + + @Composable + private fun ImportRemoteScript( + dismiss: () -> Unit + ) { + Dialog(onDismissRequest = dismiss) { + var url by remember { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + var isLoading by remember { + mutableStateOf(false) + } + ElevatedCard( + modifier = Modifier + .fillMaxWidth(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Import Script from URL", + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(8.dp), + ) + Text( + text = "Warning: Imported scripts can be harmful to your device. Only import scripts from trusted sources.", + fontSize = 14.sp, + fontWeight = FontWeight.Light, + fontStyle = FontStyle.Italic, + modifier = Modifier.padding(8.dp), + textAlign = TextAlign.Center, + ) + TextField( + value = url, + onValueChange = { + url = it + }, + label = { + Text(text = "Enter URL here:") + }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .onGloballyPositioned { + focusRequester.requestFocus() + } + ) + LaunchedEffect(Unit) { + context.androidContext.getUrlFromClipboard()?.let { + url = it + } + } + Spacer(modifier = Modifier.height(8.dp)) + Button( + enabled = url.isNotBlank(), + onClick = { + isLoading = true + context.coroutineScope.launch { + runCatching { + val moduleInfo = context.scriptManager.importFromUrl(url) + context.shortToast("Script ${moduleInfo.name} imported!") + reloadDispatcher.dispatch() + withContext(Dispatchers.Main) { + dismiss() + } + return@launch + }.onFailure { + context.log.error("Failed to import script", it) + context.shortToast("Failed to import script. ${it.message}. Check logs for more details") + } + isLoading = false + } + }, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier + .size(30.dp), + strokeWidth = 3.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text(text = "Import") + } + } + } + } + } + } + + + @Composable + private fun ModuleActions( + script: ModuleInfo, + canUpdate: Boolean, + dismiss: () -> Unit + ) { + Dialog( + onDismissRequest = dismiss, + ) { + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .padding(2.dp), + ) { + val actions = remember { + mutableMapOf<Pair<String, ImageVector>, suspend () -> Unit>().apply { + if (canUpdate) { + put("Update Module" to Icons.Default.Download) { + dismiss() + context.shortToast("Updating script ${script.name}...") + runCatching { + val modulePath = context.scriptManager.getModulePath(script.name) ?: throw Exception("Module not found") + context.scriptManager.unloadScript(modulePath) + val moduleInfo = context.scriptManager.importFromUrl(script.updateUrl!!, filepath = modulePath) + context.shortToast("Updated ${script.name} to version ${moduleInfo.version}") + context.database.setScriptEnabled(script.name, false) + withContext(context.database.executor.asCoroutineDispatcher()) { + reloadDispatcher.dispatch() + } + }.onFailure { + context.log.error("Failed to update module", it) + context.shortToast("Failed to update module. Check logs for more details") + } + } + } + + put("Edit Module" to Icons.Default.Edit) { + runCatching { + val modulePath = context.scriptManager.getModulePath(script.name)!! + context.androidContext.startActivity( + Intent(Intent.ACTION_VIEW).apply { + data = context.scriptManager.getScriptsFolder()!! + .findFile(modulePath)!!.uri + flags = + Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + } + ) + dismiss() + }.onFailure { + context.log.error("Failed to open module file", it) + context.shortToast("Failed to open module file. Check logs for more details") + } + } + put("Clear Module Data" to Icons.Default.Save) { + runCatching { + context.scriptManager.getModuleDataFolder(script.name) + .deleteRecursively() + context.shortToast("Module data cleared!") + dismiss() + }.onFailure { + context.log.error("Failed to clear module data", it) + context.shortToast("Failed to clear module data. Check logs for more details") + } + } + put("Delete Module" to Icons.Default.DeleteOutline) { + context.scriptManager.apply { + runCatching { + val modulePath = getModulePath(script.name)!! + unloadScript(modulePath) + getScriptsFolder()?.findFile(modulePath)?.delete() + reloadDispatcher.dispatch() + context.shortToast("Deleted script ${script.name}!") + dismiss() + }.onFailure { + context.log.error("Failed to delete module", it) + context.shortToast("Failed to delete module. Check logs for more details") + } + } + } + }.toMap() + } + + LazyColumn( + modifier = Modifier.fillMaxWidth() + ) { + item { + Text( + text = "Actions", + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + textAlign = TextAlign.Center, + ) + } + items(actions.size) { index -> + val action = actions.entries.elementAt(index) + ListItem( + modifier = Modifier + .clickable { + context.coroutineScope.launch { + action.value() + dismiss() + } + } + .fillMaxWidth(), + leadingContent = { + Icon( + imageVector = action.key.second, + contentDescription = action.key.first + ) + }, + headlineContent = { + Text(text = action.key.first) + }, + ) + } + } + } + } + } + + @Composable + fun ModuleItem(script: ModuleInfo) { + var enabled by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(script)) { + context.database.isScriptEnabled(script.name) + } + var openSettings by remember(script) { mutableStateOf(false) } + var openActions by remember { mutableStateOf(false) } + + val dispatcher = rememberAsyncUpdateDispatcher() + val reloadCallback = remember { suspend { dispatcher.dispatch() } } + val latestUpdate by rememberAsyncMutableState(defaultValue = null, updateDispatcher = dispatcher, keys = arrayOf(script)) { + context.scriptManager.checkForUpdate(script) + } + + LaunchedEffect(Unit) { + reloadDispatcher.addCallback(reloadCallback) + } + + DisposableEffect(Unit) { + onDispose { + reloadDispatcher.removeCallback(reloadCallback) + } + } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + elevation = CardDefaults.cardElevation() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + if (!enabled) return@clickable + openSettings = !openSettings + } + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (enabled) { + Icon( + imageVector = if (openSettings) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + modifier = Modifier + .padding(end = 8.dp) + .size(32.dp), + ) + } + + Column( + modifier = Modifier + .weight(1f) + .padding(end = 8.dp) + ) { + Text(text = script.displayName ?: script.name, fontSize = 20.sp) + Text(text = script.description ?: "No description", fontSize = 14.sp) + latestUpdate?.let { + Text(text = "Update available: ${it.version}", fontSize = 14.sp, fontStyle = FontStyle.Italic, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + IconButton(onClick = { + openActions = !openActions + }) { + Icon(imageVector = Icons.Default.Build, contentDescription = "Actions") + } + Switch( + checked = enabled, + onCheckedChange = { isChecked -> + openSettings = false + context.coroutineScope.launch(Dispatchers.IO) { + runCatching { + val modulePath = context.scriptManager.getModulePath(script.name)!! + context.scriptManager.unloadScript(modulePath) + if (isChecked) { + context.scriptManager.loadScript(modulePath) + context.scriptManager.runtime.getModuleByName(script.name) + ?.callFunction("module.onSnapEnhanceLoad") + context.shortToast("Loaded script ${script.name}") + } else { + context.shortToast("Unloaded script ${script.name}") + } + + context.database.setScriptEnabled(script.name, isChecked) + withContext(Dispatchers.Main) { + enabled = isChecked + } + }.onFailure { throwable -> + withContext(Dispatchers.Main) { + enabled = !isChecked + } + ("Failed to ${if (isChecked) "enable" else "disable"} script. Check logs for more details").also { + context.log.error(it, throwable) + context.shortToast(it) + } + } + } + } + ) + } + + if (openSettings) { + ScriptSettings(script) + } + } + + if (openActions) { + ModuleActions( + script = script, + canUpdate = latestUpdate != null, + ) { openActions = false } + } + } + + override val floatingActionButton: @Composable () -> Unit = { + var showImportDialog by remember { + mutableStateOf(false) + } + if (showImportDialog) { + ImportRemoteScript { + showImportDialog = false + } + } + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.End, + ) { + ExtendedFloatingActionButton( + onClick = { + if (context.scriptManager.getScriptsFolder() == null) { + return@ExtendedFloatingActionButton + } + showImportDialog = true + }, + icon = { Icon(imageVector = Icons.Default.Link, contentDescription = "Link") }, + text = { + Text(text = "Import from URL") + }, + ) + ExtendedFloatingActionButton( + onClick = { + context.scriptManager.getScriptsFolder()?.let { + context.androidContext.openLink(it.uri.toString()) + } + }, + icon = { + Icon( + imageVector = Icons.Default.FolderOpen, + contentDescription = "Folder" + ) + }, + text = { + Text(text = "Open Scripts Folder") + }, + ) + } + } + + + @Composable + fun ScriptSettings(script: ModuleInfo) { + val settingsInterface = remember { + val module = + context.scriptManager.runtime.getModuleByName(script.name) ?: return@remember null + (module.getBinding(InterfaceManager::class))?.buildInterface(EnumScriptInterface.SETTINGS) + } + + if (settingsInterface == null) { + Text( + text = "This module does not have any settings", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(8.dp) + ) + } else { + ScriptInterface(interfaceBuilder = settingsInterface) + } + } + + override val content: @Composable (NavBackStackEntry) -> Unit = { + val scriptingFolder by rememberAsyncMutableState( + defaultValue = null, + updateDispatcher = reloadDispatcher + ) { + context.scriptManager.getScriptsFolder() + } + val scriptModules by rememberAsyncMutableState( + defaultValue = emptyList(), + updateDispatcher = reloadDispatcher + ) { + context.scriptManager.sync() + context.scriptManager.getSyncedModules() + } + + val coroutineScope = rememberCoroutineScope() + + var refreshing by remember { + mutableStateOf(false) + } + + LaunchedEffect(Unit) { + refreshing = true + withContext(Dispatchers.IO) { + reloadDispatcher.dispatch() + refreshing = false + } + } + + val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = { + refreshing = true + coroutineScope.launch(Dispatchers.IO) { + reloadDispatcher.dispatch() + refreshing = false + } + }) + + Box( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .pullRefresh(pullRefreshState), + horizontalAlignment = Alignment.CenterHorizontally + ) { + item { + if (scriptingFolder == null && !refreshing) { + Text( + text = "No scripts folder selected", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(8.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = { + activityLauncherHelper.chooseFolder { + context.config.root.scripting.moduleFolder.set(it) + context.config.writeConfig() + coroutineScope.launch { + reloadDispatcher.dispatch() + } + } + }) { + Text(text = "Select folder") + } + } else if (scriptModules.isEmpty()) { + Text( + text = "No scripts found", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(8.dp) + ) + } + } + items(scriptModules.size, key = { scriptModules[it].hashCode() }) { index -> + ModuleItem(scriptModules[index]) + } + item { + Spacer(modifier = Modifier.height(200.dp)) + } + } + + PullRefreshIndicator( + refreshing = refreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter) + ) + } + + var scriptingWarning by remember { + mutableStateOf(context.sharedPreferences.run { + getBoolean("scripting_warning", true).also { + edit().putBoolean("scripting_warning", false).apply() + } + }) + } + + if (scriptingWarning) { + var timeout by remember { + mutableIntStateOf(10) + } + + LaunchedEffect(Unit) { + while (timeout > 0) { + delay(1000) + timeout-- + } + } + + AlertDialog(onDismissRequest = { + if (timeout == 0) { + scriptingWarning = false + } + }, title = { + Text(text = context.translation["manager.dialogs.scripting_warning.title"]) + }, text = { + Text(text = context.translation["manager.dialogs.scripting_warning.content"]) + }, confirmButton = { + TextButton( + onClick = { + scriptingWarning = false + }, + enabled = timeout == 0 + ) { + Text(text = "OK " + if (timeout > 0) "($timeout)" else "") + } + }) + } + } + + override val topBarActions: @Composable() (RowScope.() -> Unit) = { + TopBarActionButton( + onClick = { + context.androidContext.openLink("https://github.com/SnapEnhance/scripting-docs") + }, + icon = Icons.Default.CollectionsBookmark, + text = "Documentation", + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/AddFriendDialog.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/AddFriendDialog.kt new file mode 100644 index 0000000000..c83382ab07 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/AddFriendDialog.kt @@ -0,0 +1,299 @@ +package me.rhunk.snapenhance.ui.manager.pages.social + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.* +import me.rhunk.snapenhance.RemoteSideContext +import me.rhunk.snapenhance.common.ReceiversConfig +import me.rhunk.snapenhance.common.data.MessagingFriendInfo +import me.rhunk.snapenhance.common.data.MessagingGroupInfo +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState +import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie +import me.rhunk.snapenhance.common.util.snap.SnapWidgetBroadcastReceiverHelper +import me.rhunk.snapenhance.ui.util.coil.BitmojiImage + +class AddFriendDialog( + private val context: RemoteSideContext, + private val actionHandler: Actions, + private val pinnedIds: List<String>? = null +) { + class Actions( + val onFriendState: (friend: MessagingFriendInfo, state: Boolean) -> Unit, + val onGroupState: (group: MessagingGroupInfo, state: Boolean) -> Unit, + val getFriendState: (friend: MessagingFriendInfo) -> Boolean, + val getGroupState: (group: MessagingGroupInfo) -> Boolean, + ) + + private val stateCache = mutableMapOf<String, Boolean>() + private val translation by lazy { context.translation.getCategory("manager.dialogs.add_friend")} + + @Composable + private fun ListCardEntry( + id: String, + bitmoji: String? = null, + name: String, + participantsCount: Int? = null, + getCurrentState: () -> Boolean, + onState: (Boolean) -> Unit = {}, + ) { + var currentState by rememberAsyncMutableState(defaultValue = stateCache[id] ?: false) { + getCurrentState().also { stateCache[id] = it } + } + val coroutineScope = rememberCoroutineScope() + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + currentState = !currentState + stateCache[id] = currentState + coroutineScope.launch(Dispatchers.IO) { + onState(currentState) + } + } + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + BitmojiImage( + context = this@AddFriendDialog.context, + url = bitmoji, + modifier = Modifier.padding(end = 2.dp), + size = 32, + ) + + Column( + modifier = Modifier + .weight(1f) + ) { + Text( + text = name, + fontSize = 15.sp, + ) + + participantsCount?.let { + Text( + text = translation.format("participants_text", "count" to it.toString()), + fontSize = 12.sp, + lineHeight = 12.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + } + + Checkbox( + checked = currentState, + onCheckedChange = { + currentState = it + stateCache[id] = currentState + coroutineScope.launch(Dispatchers.IO) { + onState(currentState) + } + } + ) + } + } + + @Composable + private fun DialogHeader(searchKeyword: MutableState<String>) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + ) { + Text( + text = translation["title"], + fontSize = 23.sp, + fontWeight = FontWeight.ExtraBold, + modifier = Modifier + .align(alignment = Alignment.CenterHorizontally) + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextField( + value = searchKeyword.value, + onValueChange = { searchKeyword.value = it }, + label = { + Text(text = translation["search_hint"]) + }, + modifier = Modifier + .weight(1f) + .padding(end = 10.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), + leadingIcon = { + Icon(Icons.Filled.Search, contentDescription = "Search") + } + ) + } + } + + + @Composable + fun Content(dismiss: () -> Unit = { }) { + var cachedFriends by remember { mutableStateOf(null as List<MessagingFriendInfo>?) } + var cachedGroups by remember { mutableStateOf(null as List<MessagingGroupInfo>?) } + + val coroutineScope = rememberCoroutineScope() + + var timeoutJob: Job? = null + var hasFetchError by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + context.database.receiveMessagingDataCallback = { friends, groups -> + cachedFriends = friends.run { + if (pinnedIds != null) { + sortedBy { -pinnedIds.indexOf(it.userId) } + } else friends + } + cachedGroups = groups.run { + if (pinnedIds != null) { + sortedBy { -pinnedIds.indexOf(it.conversationId) } + } else groups + } + timeoutJob?.cancel() + hasFetchError = false + } + SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {}.also { + runCatching { + context.androidContext.sendBroadcast(it) + }.onFailure { + context.log.error("Failed to send broadcast", it) + hasFetchError = true + } + } + timeoutJob = coroutineScope.launch { + withContext(Dispatchers.IO) { + delay(20000) + hasFetchError = true + } + } + } + + me.rhunk.snapenhance.ui.util.Dialog( + onDismissRequest = { + timeoutJob?.cancel() + dismiss() + }, + properties = me.rhunk.snapenhance.ui.util.DialogProperties(usePlatformDefaultWidth = false) + ) { + Card( + colors = CardDefaults.elevatedCardColors(), + modifier = Modifier + .fillMaxSize() + .fillMaxWidth() + .padding(all = 20.dp) + ) { + if (cachedGroups == null || cachedFriends == null) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(10.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (hasFetchError) { + Text( + text = translation["fetch_error"], + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 10.dp, top = 10.dp) + ) + return@Card + } + CircularProgressIndicator( + modifier = Modifier + .padding() + .size(30.dp), + strokeWidth = 3.dp, + color = MaterialTheme.colorScheme.primary + ) + } + return@Card + } + + val searchKeyword = remember { mutableStateOf("") } + + val filteredGroups = cachedGroups!!.takeIf { searchKeyword.value.isNotBlank() }?.filter { + it.name.contains(searchKeyword.value, ignoreCase = true) + } ?: cachedGroups!! + + val filteredFriends = cachedFriends!!.takeIf { searchKeyword.value.isNotBlank() }?.filter { + it.mutableUsername.contains(searchKeyword.value, ignoreCase = true) || + it.displayName?.contains(searchKeyword.value, ignoreCase = true) == true + } ?: cachedFriends!! + + DialogHeader(searchKeyword) + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(10.dp) + ) { + item { + if (filteredGroups.isEmpty()) return@item + Text(text = translation["category_groups"], + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 10.dp, top = 10.dp) + ) + } + + items(filteredGroups.size) { + val group = filteredGroups[it] + ListCardEntry( + id = group.conversationId, + name = group.name, + participantsCount = group.participantsCount, + getCurrentState = { actionHandler.getGroupState(group) } + ) { state -> + actionHandler.onGroupState(group, state) + } + } + + item { + if (filteredFriends.isEmpty()) return@item + Text(text = translation["category_friends"], + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 10.dp, top = 10.dp) + ) + } + + items(filteredFriends.size) { index -> + val friend = filteredFriends[index] + + ListCardEntry( + id = friend.userId, + bitmoji = friend.takeIf { it.bitmojiId != null }?.let { + BitmojiSelfie.getBitmojiSelfie(it.selfieId, it.bitmojiId, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D) + }, + name = friend.displayName?.takeIf { name -> name.isNotBlank() } ?: friend.mutableUsername, + getCurrentState = { actionHandler.getFriendState(friend) } + ) { state -> + actionHandler.onFriendState(friend, state) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/LoggedStories.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/LoggedStories.kt new file mode 100644 index 0000000000..bd26b7b592 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/LoggedStories.kt @@ -0,0 +1,258 @@ +package me.rhunk.snapenhance.ui.manager.pages.social + +import android.content.Intent +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.FileProvider +import androidx.navigation.NavBackStackEntry +import coil.annotation.ExperimentalCoilApi +import coil.compose.rememberAsyncImagePainter +import me.rhunk.snapenhance.bridge.DownloadCallback +import me.rhunk.snapenhance.common.data.FileType +import me.rhunk.snapenhance.common.data.StoryData +import me.rhunk.snapenhance.common.data.download.* +import me.rhunk.snapenhance.common.util.ktx.longHashCode +import me.rhunk.snapenhance.download.DownloadProcessor +import me.rhunk.snapenhance.storage.getFriendInfo +import me.rhunk.snapenhance.ui.manager.Routes +import me.rhunk.snapenhance.ui.util.Dialog +import me.rhunk.snapenhance.ui.util.coil.ImageRequestHelper +import java.io.File +import java.text.DateFormat +import java.util.Date +import java.util.UUID +import kotlin.math.absoluteValue + +class LoggedStories : Routes.Route() { + @OptIn(ExperimentalCoilApi::class, ExperimentalLayoutApi::class) + override val content: @Composable (NavBackStackEntry) -> Unit = content@{ navBackStackEntry -> + val userId = navBackStackEntry.arguments?.getString("id") ?: return@content + + val stories = remember { mutableStateListOf<StoryData>() } + val friendInfo = remember { context.database.getFriendInfo(userId) } + var lastStoryTimestamp by remember { mutableLongStateOf(Long.MAX_VALUE) } + + var selectedStory by remember { mutableStateOf<StoryData?>(null) } + + selectedStory?.let { story -> + fun downloadSelectedStory( + inputMedia: InputMedia, + ) { + val mediaAuthor = friendInfo?.mutableUsername ?: userId + val uniqueHash = UUID.randomUUID().toString().longHashCode().absoluteValue.toString(16) + + DownloadProcessor( + remoteSideContext = context, + callback = object: DownloadCallback.Default() { + override fun onSuccess(outputPath: String?) { + context.shortToast("Downloaded to $outputPath") + } + + override fun onFailure(message: String?, throwable: String?) { + context.shortToast("Failed to download $message") + } + } + ).enqueue(DownloadRequest( + inputMedias = arrayOf(inputMedia) + ), DownloadMetadata( + mediaIdentifier = uniqueHash, + outputPath = createNewFilePath( + context.config.root, + uniqueHash, + MediaDownloadSource.STORY_LOGGER, + mediaAuthor, + story.createdAt + ), + iconUrl = null, + mediaAuthor = friendInfo?.mutableUsername ?: userId, + downloadSource = MediaDownloadSource.STORY_LOGGER.translate(context.translation), + )) + } + + Dialog(onDismissRequest = { + selectedStory = null + }) { + Card( + modifier = Modifier + .padding(4.dp) + ) { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + remember { + story.postedAt.takeIf { it >= 0L }?.let { + DateFormat.getDateTimeInstance().format(Date(it)) + } + }?.let { + Text(text = "Posted at $it") + } + remember { + story.createdAt.takeIf { it >= 0L }?.let { + DateFormat.getDateTimeInstance().format(Date(it)) + } + }?.let { + Text(text = "Created at $it") + } + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Button(onClick = { + context.androidContext.externalCacheDir?.let { cacheDir -> + context.imageLoader.diskCache?.openSnapshot(story.url)?.use { diskCacheSnapshot -> + val cacheFile = diskCacheSnapshot.data.toFile() + val targetFile = File(cacheDir, cacheFile.name).also { + it.deleteOnExit() + } + + runCatching { + cacheFile.inputStream().let { + story.getEncryptionKeyPair()?.decryptInputStream(it) ?: it + }.use { inputStream -> + targetFile.outputStream().use { outputStream -> + inputStream.copyTo(outputStream) + } + } + + context.androidContext.startActivity(Intent().apply { + action = Intent.ACTION_VIEW + setDataAndType( + FileProvider.getUriForFile( + context.androidContext, + "me.rhunk.snapenhance.fileprovider", + targetFile + ), + FileType.fromFile(targetFile).mimeType + ) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + }) + }.onFailure { + context.shortToast("Failed to open file. Check logs for more info") + context.log.error("Failed to open file", it) + } + } ?: run { + context.shortToast("Failed to get file") + return@Button + } + } + }) { + Text(text = context.translation["button.open"]) + } + + Button(onClick = { + downloadSelectedStory( + InputMedia( + content = story.url, + type = DownloadMediaType.REMOTE_MEDIA, + encryption = story.getEncryptionKeyPair() + ) + ) + }) { + Text(text = context.translation["button.download"]) + } + + if (remember { + context.imageLoader.diskCache?.openSnapshot(story.url)?.also { it.close() } != null + }) { + Button(onClick = { + downloadSelectedStory( + InputMedia( + content = context.imageLoader.diskCache?.openSnapshot(story.url)?.use { + it.data.toFile().absolutePath + } ?: run { + context.shortToast("Failed to get file") + return@Button + }, + type = DownloadMediaType.LOCAL_MEDIA, + encryption = story.getEncryptionKeyPair() + ) + ) + }) { + Text(text = translation["save_from_cache_button"]) + } + } + } + } + } + } + } + + if (stories.isEmpty()) { + Text(text = translation["no_stories"], Modifier.fillMaxWidth(), textAlign = TextAlign.Center) + } + + LazyVerticalGrid( + columns = GridCells.Adaptive(100.dp), + contentPadding = PaddingValues(8.dp), + ) { + items(stories, key = { it.url }) { story -> + var hasFailed by remember(story.url) { mutableStateOf(false) } + + Column( + modifier = Modifier + .padding(8.dp) + .clickable { + selectedStory = story + } + .clip(MaterialTheme.shapes.medium) + .heightIn(min = 128.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + if (hasFailed) { + Text(text = translation["story_failed_to_load"], Modifier.padding(8.dp), fontSize = 10.sp) + } else { + Image( + painter = rememberAsyncImagePainter( + model = ImageRequestHelper.newPreviewImageRequest( + context.androidContext, + story.url, + story.getEncryptionKeyPair(), + ), + imageLoader = context.imageLoader, + onError = { + hasFailed = true + } + ), + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier + .fillMaxSize() + .height(128.dp) + ) + } + } + } + item { + LaunchedEffect(Unit) { + context.messageLogger.getStories(userId, lastStoryTimestamp, 20).also { result -> + stories.addAll(result.values.reversed()) + result.keys.minOrNull()?.let { + lastStoryTimestamp = it + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/ManageScope.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/ManageScope.kt new file mode 100644 index 0000000000..79d318fc06 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/ManageScope.kt @@ -0,0 +1,474 @@ +package me.rhunk.snapenhance.ui.manager.pages.social + +import android.content.Intent +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.DeleteForever +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavBackStackEntry +import androidx.navigation.compose.currentBackStackEntryAsState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.common.data.FriendStreaks +import me.rhunk.snapenhance.common.data.MessagingFriendInfo +import me.rhunk.snapenhance.common.data.MessagingGroupInfo +import me.rhunk.snapenhance.common.data.MessagingRuleType +import me.rhunk.snapenhance.common.data.SocialScope +import me.rhunk.snapenhance.common.ui.AutoClearKeyboardFocus +import me.rhunk.snapenhance.common.ui.EditNoteTextField +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList +import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie +import me.rhunk.snapenhance.storage.* +import me.rhunk.snapenhance.ui.manager.Routes +import me.rhunk.snapenhance.ui.util.AlertDialogs +import me.rhunk.snapenhance.ui.util.Dialog +import me.rhunk.snapenhance.ui.util.coil.BitmojiImage +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +class ManageScope: Routes.Route() { + private val dialogs by lazy { AlertDialogs(context.translation) } + + private fun deleteScope(scope: SocialScope, id: String, coroutineScope: CoroutineScope) { + when (scope) { + SocialScope.FRIEND -> context.database.deleteFriend(id) + SocialScope.GROUP -> context.database.deleteGroup(id) + } + context.database.executeAsync { + coroutineScope.launch { + routes.navController.popBackStack() + } + } + } + + override val topBarActions: @Composable (RowScope.() -> Unit) = topBarActions@{ + val navBackStackEntry by routes.navController.currentBackStackEntryAsState() + var deleteConfirmDialog by remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + + if (deleteConfirmDialog) { + val scope = navBackStackEntry?.arguments?.getString("scope")?.let { SocialScope.getByName(it) } ?: return@topBarActions + val id = navBackStackEntry?.arguments?.getString("id")!! + + Dialog(onDismissRequest = { + deleteConfirmDialog = false + }) { + remember { AlertDialogs(context.translation) }.ConfirmDialog( + title = translation.format("delete_scope_confirm_dialog_title", "scope" to context.translation["scopes.${scope.key}"]), + onDismiss = { deleteConfirmDialog = false }, + onConfirm = { + deleteScope(scope, id, coroutineScope); deleteConfirmDialog = false + } + ) + } + } + + IconButton( + onClick = { deleteConfirmDialog = true }, + ) { + Icon( + imageVector = Icons.Rounded.DeleteForever, + contentDescription = null + ) + } + } + + override val content: @Composable (NavBackStackEntry) -> Unit = content@{ navBackStackEntry -> + val scope = SocialScope.getByName(navBackStackEntry.arguments?.getString("scope")!!) + val id = navBackStackEntry.arguments?.getString("id")!! + + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .fillMaxSize() + ) { + var bottomComposable by remember { + mutableStateOf(null as (@Composable () -> Unit)?) + } + var hasScope by remember { + mutableStateOf(null as Boolean?) + } + when (scope) { + SocialScope.FRIEND -> { + var streaks by remember { mutableStateOf(null as FriendStreaks?) } + val friend by rememberAsyncMutableState(null) { + context.database.getFriendInfo(id)?.also { + streaks = context.database.getFriendStreaks(id) + }.also { + hasScope = it != null + } + } + friend?.let { + Friend(id, it, streaks) { bottomComposable = it } + } + } + SocialScope.GROUP -> { + val group by rememberAsyncMutableState(null) { + context.database.getGroupInfo(id).also { + hasScope = it != null + } + } + group?.let { + Group(it) { bottomComposable = it } + } + } + } + if (hasScope == true) { + if (context.config.root.experimental.friendNotes.get()) { + NotesCard(id) + } + RulesCard(id) + } + bottomComposable?.invoke() + if (hasScope == false) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = translation["not_found"], + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + } + } + } + } + + @Composable + private fun NotesCard( + id: String + ) { + val coroutineScope = rememberCoroutineScope { Dispatchers.IO } + var scopeNotes by rememberAsyncMutableState(null) { + context.database.getScopeNotes(id) + } + + AutoClearKeyboardFocus() + + EditNoteTextField( + modifier = Modifier.padding(8.dp), + primaryColor = Color.White, + translation = context.translation, + content = scopeNotes, + setContent = { scopeNotes = it } + ) + + DisposableEffect(Unit) { + onDispose { + coroutineScope.launch { + context.database.setScopeNotes(id, scopeNotes) + } + } + } + } + + @Composable + private fun RulesCard( + id: String + ) { + Spacer(modifier = Modifier.height(16.dp)) + + val rules = rememberAsyncMutableStateList(listOf()) { + context.database.getRules(id) + } + + SectionTitle(translation["rules_title"]) + + ContentCard { + MessagingRuleType.entries.forEach { ruleType -> + var ruleEnabled by remember(rules.size) { + mutableStateOf(rules.any { it.key == ruleType.key }) + } + + val ruleState = context.config.root.rules.getRuleState(ruleType) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(all = 4.dp) + ) { + Text( + text = if (ruleType.listMode && ruleState != null) { + context.translation["rules.properties.${ruleType.key}.options.${ruleState.key}"] + } else context.translation["rules.properties.${ruleType.key}.name"], + modifier = Modifier + .weight(1f) + .padding(start = 5.dp, end = 5.dp) + ) + Switch(checked = ruleEnabled, + enabled = if (ruleType.listMode) ruleState != null else true, + onCheckedChange = { + context.database.setRule(id, ruleType.key, it) + ruleEnabled = it + } + ) + } + } + } + } + + @Composable + private fun ContentCard(modifier: Modifier = Modifier, content: @Composable () -> Unit) { + ElevatedCard( + modifier = Modifier + .padding(10.dp) + .fillMaxWidth() + ) { + Column( + modifier = Modifier + .padding(10.dp) + .fillMaxWidth() + .then(modifier) + ) { + content() + } + } + } + + @Composable + private fun SectionTitle(title: String) { + Text( + text = title, + maxLines = 1, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier + .offset(x = 20.dp) + .padding(bottom = 10.dp) + ) + } + + private fun computeStreakETA(timestamp: Long): String? { + val now = System.currentTimeMillis() + val stringBuilder = StringBuilder() + val diff = timestamp - now + val seconds = diff / 1000 + val minutes = seconds / 60 + val hours = minutes / 60 + val days = hours / 24 + if (days > 0) { + stringBuilder.append("$days day ") + return stringBuilder.toString() + } + if (hours > 0) { + stringBuilder.append("$hours hours ") + return stringBuilder.toString() + } + if (minutes > 0) { + stringBuilder.append("$minutes minutes ") + return stringBuilder.toString() + } + if (seconds > 0) { + stringBuilder.append("$seconds seconds ") + return stringBuilder.toString() + } + return null + } + + @OptIn(ExperimentalEncodingApi::class) + @Composable + private fun Friend( + id: String, + friend: MessagingFriendInfo, + streaks: FriendStreaks?, + setBottomComposable: ((@Composable () -> Unit)?) -> Unit = {} + ) { + LaunchedEffect(Unit) { + setBottomComposable { + Spacer(modifier = Modifier.height(16.dp)) + + if (context.config.root.experimental.e2eEncryption.globalState == true) { + SectionTitle(translation["e2ee_title"]) + var hasSecretKey by rememberAsyncMutableState(defaultValue = false) { + context.e2eeImplementation.friendKeyExists(friend.userId) + } + var importDialog by remember { mutableStateOf(false) } + + if (importDialog) { + Dialog( + onDismissRequest = { importDialog = false } + ) { + dialogs.RawInputDialog(onDismiss = { importDialog = false }, onConfirm = { newKey -> + importDialog = false + runCatching { + val key = Base64.decode(newKey) + if (key.size != 32) { + context.longToast("Invalid key size (must be 32 bytes)") + return@runCatching + } + + context.coroutineScope.launch { + context.e2eeImplementation.storeSharedSecretKey(friend.userId, key) + context.longToast("Successfully imported key") + } + + hasSecretKey = true + }.onFailure { + context.longToast("Failed to import key: ${it.message}") + context.log.error("Failed to import key", it) + } + }) + } + } + + ContentCard { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + if (hasSecretKey) { + OutlinedButton(onClick = { + context.coroutineScope.launch { + val secretKey = Base64.encode(context.e2eeImplementation.getSharedSecretKey(friend.userId) ?: return@launch) + //TODO: fingerprint auth + context.activity!!.startActivity(Intent.createChooser(Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, secretKey) + type = "text/plain" + }, "").apply { + putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf( + Intent().apply { + putExtra(Intent.EXTRA_TEXT, secretKey) + putExtra(Intent.EXTRA_SUBJECT, secretKey) + }) + ) + }) + } + }) { + Text( + text = "Export Base64", + maxLines = 1 + ) + } + } + + OutlinedButton(onClick = { importDialog = true }) { + Text( + text = "Import Base64", + maxLines = 1 + ) + } + } + } + } + } + } + Column( + modifier = Modifier + .padding(5.dp) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + val bitmojiUrl = BitmojiSelfie.getBitmojiSelfie( + friend.selfieId, friend.bitmojiId, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D + ) + BitmojiImage(context = context, url = bitmojiUrl, size = 120) + Text( + text = friend.displayName ?: friend.mutableUsername, + maxLines = 1, + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + Text( + text = friend.mutableUsername, + maxLines = 1, + fontSize = 12.sp, + fontWeight = FontWeight.Light + ) + } + + if (context.config.root.experimental.storyLogger.get()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), + ) { + Button(onClick = { + routes.loggedStories.navigate { + put("id", id) + } + }) { + Text(translation["logged_stories_button"]) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + + Column { + //streaks + streaks?.let { + var shouldNotify by remember { mutableStateOf(it.notify) } + SectionTitle(translation["streaks_title"]) + ContentCard { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = translation.format( + "streaks_length_text", "length" to streaks.length.toString() + ), maxLines = 1 + ) + Text( + text = computeStreakETA(streaks.expirationTimestamp)?.let { translation.format( + "streaks_expiration_text", + "eta" to it + ) } ?: translation["streaks_expiration_text_expired"], + maxLines = 1 + ) + } + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = translation["reminder_button"], + maxLines = 1, + modifier = Modifier.padding(end = 10.dp) + ) + Switch(checked = shouldNotify, onCheckedChange = { + context.database.setFriendStreaksNotify(id, it) + shouldNotify = it + }) + } + } + } + } + } + } + + @Composable + private fun Group( + group: MessagingGroupInfo, + setBottomComposable: ((@Composable () -> Unit)?) -> Unit = {} + ) { + Column( + modifier = Modifier + .padding(10.dp) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = group.name, maxLines = 1, fontSize = 20.sp, fontWeight = FontWeight.Bold + ) + Text( + text = translation.format( + "participants_text", "count" to group.participantsCount.toString() + ), maxLines = 1, fontSize = 12.sp, fontWeight = FontWeight.Light + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/MessagingPreview.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/MessagingPreview.kt new file mode 100644 index 0000000000..0dbff3a54f --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/MessagingPreview.kt @@ -0,0 +1,533 @@ +package me.rhunk.snapenhance.ui.manager.pages.social + +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.rounded.BookmarkAdded +import androidx.compose.material.icons.rounded.BookmarkBorder +import androidx.compose.material.icons.rounded.DeleteForever +import androidx.compose.material.icons.rounded.RemoveRedEye +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import androidx.navigation.NavBackStackEntry +import kotlinx.coroutines.* +import me.rhunk.snapenhance.bridge.snapclient.MessagingBridge +import me.rhunk.snapenhance.bridge.snapclient.SessionStartListener +import me.rhunk.snapenhance.bridge.snapclient.types.Message +import me.rhunk.snapenhance.common.Constants +import me.rhunk.snapenhance.common.ReceiversConfig +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.data.SocialScope +import me.rhunk.snapenhance.common.messaging.MessagingConstraints +import me.rhunk.snapenhance.common.messaging.MessagingTask +import me.rhunk.snapenhance.common.messaging.MessagingTaskConstraint +import me.rhunk.snapenhance.common.messaging.MessagingTaskType +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.common.util.snap.SnapWidgetBroadcastReceiverHelper +import me.rhunk.snapenhance.ui.manager.Routes +import me.rhunk.snapenhance.ui.util.Dialog + +class MessagingPreview: Routes.Route() { + private lateinit var coroutineScope: CoroutineScope + private lateinit var previewScrollState: LazyListState + + private val contentTypeTranslation by lazy { context.translation.getCategory("content_type") } + private val messagingBridge: MessagingBridge? get() = context.bridgeService?.messagingBridge + + private var messages = mutableStateListOf<Message>() + private var conversationId by mutableStateOf<String?>(null) + private val selectedMessages = mutableStateListOf<Long>() // client message id + + private fun toggleSelectedMessage(messageId: Long) { + if (selectedMessages.contains(messageId)) selectedMessages.remove(messageId) + else selectedMessages.add(messageId) + } + + @Composable + private fun ActionButton( + text: String, + icon: ImageVector, + onClick: () -> Unit, + ) { + DropdownMenuItem( + onClick = onClick, + text = { + Row( + modifier = Modifier.padding(5.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null + ) + Text(text = text) + } + } + ) + } + + @Composable + private fun ConstraintsSelectionDialog( + onChoose: (Array<ContentType>) -> Unit, + onDismiss: () -> Unit + ) { + val selectedTypes = remember { mutableStateListOf<ContentType>() } + var selectAllState by remember { mutableStateOf(false) } + val availableTypes = remember { arrayOf( + ContentType.CHAT, + ContentType.NOTE, + ContentType.SNAP, + ContentType.STICKER, + ContentType.EXTERNAL_MEDIA + ) } + + fun toggleContentType(contentType: ContentType) { + if (selectAllState) return + if (selectedTypes.contains(contentType)) { + selectedTypes.remove(contentType) + } else { + selectedTypes.add(contentType) + } + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + ) { + Column( + modifier = Modifier.padding(15.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(5.dp) + ) { + Text(context.translation["manager.dialogs.messaging_action.title"]) + Spacer(modifier = Modifier.height(5.dp)) + availableTypes.forEach { contentType -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(2.dp) + .pointerInput(Unit) { + detectTapGestures(onTap = { toggleContentType(contentType) }) + }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = selectedTypes.contains(contentType), + enabled = !selectAllState, + onCheckedChange = { toggleContentType(contentType) } + ) + Text(text = contentTypeTranslation[contentType.name]) + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(5.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Switch(checked = selectAllState, onCheckedChange = { + selectAllState = it + }) + Text(text = context.translation["manager.dialogs.messaging_action.select_all_button"]) + } + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Button(onClick = { onDismiss() }) { + Text(context.translation["button.cancel"]) + } + Button(onClick = { + onChoose(if (selectAllState) ContentType.entries.toTypedArray() + else selectedTypes.toTypedArray()) + }) { + Text(context.translation["button.ok"]) + } + } + } + } + } + + override val topBarActions: @Composable (RowScope.() -> Unit) = { + var taskSelectionDropdown by remember { mutableStateOf(false) } + var selectConstraintsDialog by remember { mutableStateOf(false) } + var activeTask by remember { mutableStateOf(null as MessagingTask?) } + var activeJob by remember { mutableStateOf(null as Job?) } + val processMessageCount = remember { mutableIntStateOf(0) } + + fun runCurrentTask() { + activeJob = coroutineScope.launch(Dispatchers.IO) { + activeTask?.run() + withContext(Dispatchers.Main) { + activeTask = null + activeJob = null + } + }.also { job -> + job.invokeOnCompletion { + if (it != null) { + context.log.verbose("Failed to process messages: ${it.message}") + return@invokeOnCompletion + } + context.longToast("Processed ${processMessageCount.intValue} messages") + } + } + } + + fun launchMessagingTask(taskType: MessagingTaskType, constraints: List<MessagingTaskConstraint> = listOf(), onSuccess: (Message) -> Unit = {}) { + if (messagingBridge == null) { + context.longToast(translation["bridge_connection_failed"]) + return + } + taskSelectionDropdown = false + processMessageCount.intValue = 0 + activeTask = MessagingTask( + messagingBridge!!, conversationId!!, taskType, constraints, + overrideClientMessageIds = selectedMessages.takeIf { it.isNotEmpty() }?.toList(), + processedMessageCount = processMessageCount, + onSuccess = onSuccess, + onFailure = { message, reason -> + context.log.verbose("Failed to process message ${message.clientMessageId}: $reason") + } + ) + selectedMessages.clear() + } + + if (selectConstraintsDialog && activeTask != null) { + Dialog(onDismissRequest = { + selectConstraintsDialog = false + activeTask = null + }) { + ConstraintsSelectionDialog( + onChoose = { contentTypes -> + launchMessagingTask( + taskType = activeTask!!.taskType, + constraints = activeTask!!.constraints + MessagingConstraints.CONTENT_TYPE(contentTypes), + onSuccess = activeTask!!.onSuccess + ) + runCurrentTask() + selectConstraintsDialog = false + }, + onDismiss = { + selectConstraintsDialog = false + activeTask = null + } + ) + } + } + + if (activeJob != null) { + Dialog(onDismissRequest = { + activeJob?.cancel() + activeJob = null + activeTask = null + }) { + Column(modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(15.dp) + .border(1.dp, MaterialTheme.colorScheme.onSurface, RoundedCornerShape(20.dp)), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(5.dp)) + { + Text("Processed ${processMessageCount.intValue} messages") + if (activeTask?.hasFixedGoal() == true) { + LinearProgressIndicator( + progress = { processMessageCount.intValue.toFloat() / selectedMessages.size.toFloat() }, + modifier = Modifier + .fillMaxWidth() + .padding(5.dp), + color = MaterialTheme.colorScheme.primary, + ) + } else { + CircularProgressIndicator( + modifier = Modifier + .padding() + .size(30.dp), + strokeWidth = 3.dp, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + } + + IconButton(onClick = { taskSelectionDropdown = !taskSelectionDropdown }) { + Icon(imageVector = Icons.Filled.MoreVert, contentDescription = null) + } + + if (selectedMessages.isNotEmpty()) { + IconButton(onClick = { selectedMessages.clear() }) { + Icon(imageVector = Icons.Filled.Close, contentDescription = "Close") + } + } + + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy( + surface = MaterialTheme.colorScheme.inverseSurface, + onSurface = MaterialTheme.colorScheme.inverseOnSurface + ), + shapes = MaterialTheme.shapes.copy(medium = RoundedCornerShape(50.dp)) + ) { + DropdownMenu( + expanded = taskSelectionDropdown && messages.isNotEmpty(), onDismissRequest = { taskSelectionDropdown = false } + ) { + val hasSelection = selectedMessages.isNotEmpty() + ActionButton(text = translation[if (hasSelection) "save_selection_option" else "save_all_option"], icon = Icons.Rounded.BookmarkAdded) { + launchMessagingTask(MessagingTaskType.SAVE) + if (hasSelection) runCurrentTask() + else selectConstraintsDialog = true + } + ActionButton(text = translation[if (hasSelection) "unsave_selection_option" else "unsave_all_option"], icon = Icons.Rounded.BookmarkBorder) { + launchMessagingTask(MessagingTaskType.UNSAVE) + if (hasSelection) runCurrentTask() + else selectConstraintsDialog = true + } + ActionButton(text = translation[if (hasSelection) "mark_selection_as_seen_option" else "mark_all_as_seen_option"], icon = Icons.Rounded.RemoveRedEye) { + if (messagingBridge == null) { + context.longToast(translation["bridge_connection_failed"]) + return@ActionButton + } + launchMessagingTask( + MessagingTaskType.READ, listOf( + MessagingConstraints.NO_USER_ID(messagingBridge!!.myUserId), + MessagingConstraints.CONTENT_TYPE(arrayOf(ContentType.SNAP)) + )) + runCurrentTask() + } + ActionButton(text = translation[if (hasSelection) "delete_selection_option" else "delete_all_option"], icon = Icons.Rounded.DeleteForever) { + if (messagingBridge == null) { + context.longToast(translation["bridge_connection_failed"]) + return@ActionButton + } + launchMessagingTask(MessagingTaskType.DELETE, listOf(MessagingConstraints.USER_ID(messagingBridge!!.myUserId), { + contentType != ContentType.STATUS.id + })) { message -> + coroutineScope.launch { + message.contentType = ContentType.STATUS.id + } + } + if (hasSelection) runCurrentTask() + else selectConstraintsDialog = true + } + } + } + } + + @Composable + private fun ConversationPreview( + messages: List<Message>, + fetchNewMessages: () -> Unit + ) { + DisposableEffect(Unit) { + onDispose { + selectedMessages.clear() + } + } + + LazyColumn( + reverseLayout = true, + modifier = Modifier + .fillMaxWidth(), + state = previewScrollState, + ) { + items(messages, key = { it.serverMessageId }) {message -> + val messageReader = remember(message.contentType) { ProtoReader(message.content) } + val contentType = ContentType.fromMessageContainer(messageReader) + + Card( + modifier = Modifier + .padding(5.dp) + .pointerInput(Unit) { + if (contentType == ContentType.STATUS) return@pointerInput + detectTapGestures( + onLongPress = { + toggleSelectedMessage(message.clientMessageId) + }, + onTap = { + if (selectedMessages.isNotEmpty()) { + toggleSelectedMessage(message.clientMessageId) + } + } + ) + }, + colors = CardDefaults.cardColors( + containerColor = if (selectedMessages.contains(message.clientMessageId)) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant + ), + ) { + val contentMessage = remember(message.contentType) { "[${contentType?.let { contentTypeTranslation.getOrNull(it.name) ?: it.name } }] ${messageReader.getString(2, 1) ?: "" }" } + Row( + modifier = Modifier + .padding(5.dp) + ) { + Text(contentMessage) + } + } + } + item { + if (messages.isEmpty()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(40.dp), + horizontalArrangement = Arrangement.Center + ) { + Text(translation["no_message_hint"]) + } + } + Spacer(modifier = Modifier.height(20.dp)) + + LaunchedEffect(Unit) { + if (messages.isNotEmpty()) { + fetchNewMessages() + } + } + } + } + } + + + @Composable + private fun LoadingRow() { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(40.dp), + horizontalArrangement = Arrangement.Center + ) { + CircularProgressIndicator( + modifier = Modifier + .padding() + .size(30.dp), + strokeWidth = 3.dp, + color = MaterialTheme.colorScheme.primary + ) + } + } + + override val content: @Composable (NavBackStackEntry) -> Unit = { navBackStackEntry -> + val scope = remember { SocialScope.getByName(navBackStackEntry.arguments?.getString("scope")!!) } + val id = remember { navBackStackEntry.arguments?.getString("id")!! } + + previewScrollState = rememberLazyListState() + coroutineScope = rememberCoroutineScope() + + var lastMessageId by remember { mutableLongStateOf(Long.MAX_VALUE) } + var isBridgeConnected by remember { mutableStateOf(false) } + var hasBridgeError by remember { mutableStateOf(false) } + + fun fetchNewMessages() { + coroutineScope.launch(Dispatchers.IO) cs@{ + runCatching { + val queriedMessages = messagingBridge!!.fetchConversationWithMessagesPaginated( + conversationId!!, + 20, + lastMessageId + )?.reversed() ?: throw IllegalStateException("Failed to fetch messages. Bridge returned null") + + withContext(Dispatchers.Main) { + messages.addAll(queriedMessages) + lastMessageId = queriedMessages.lastOrNull()?.clientMessageId ?: lastMessageId + } + }.onFailure { + context.log.error("Failed to fetch messages", it) + context.shortToast(translation["message_fetch_failed"]) + } + } + } + + fun onMessagingBridgeReady(scope: SocialScope, scopeId: String) { + context.log.verbose("onMessagingBridgeReady: $scope $scopeId") + + runCatching { + conversationId = (if (scope == SocialScope.FRIEND) messagingBridge!!.getOneToOneConversationId(scopeId) else scopeId) ?: throw IllegalStateException("Failed to get conversation id") + if (runCatching { !messagingBridge!!.isSessionStarted }.getOrDefault(true)) { + context.androidContext.packageManager.getLaunchIntentForPackage( + Constants.SNAPCHAT_PACKAGE_NAME + )?.let { + val mainIntent = Intent.makeMainActivity(it.component).apply { + putExtra(ReceiversConfig.MESSAGING_PREVIEW_EXTRA, true) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.androidContext.startActivity(mainIntent) + } + messagingBridge!!.registerSessionStartListener(object: SessionStartListener.Stub() { + override fun onConnected() { + fetchNewMessages() + } + }) + return + } + fetchNewMessages() + }.onFailure { + context.longToast(translation["bridge_init_failed"]) + context.log.error("Failed to initialize messaging bridge", it) + } + } + + LaunchedEffect(Unit) { + messages.clear() + conversationId = null + + isBridgeConnected = context.hasMessagingBridge() + if (isBridgeConnected) { + withContext(Dispatchers.IO) { + onMessagingBridgeReady(scope, id) + } + } else { + coroutineScope.launch(Dispatchers.IO) { + SnapWidgetBroadcastReceiverHelper.create("wakeup") {}.also { + context.androidContext.sendBroadcast(it) + } + withTimeout(10000) { + while (!context.hasMessagingBridge()) { + delay(100) + } + isBridgeConnected = true + onMessagingBridgeReady(scope, id) + } + }.invokeOnCompletion { + if (it != null) { + hasBridgeError = true + } + } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + ) { + if (hasBridgeError) { + Text(translation["bridge_connection_failed"]) + } + + if (!isBridgeConnected && !hasBridgeError) { + LoadingRow() + } + + if (isBridgeConnected && !hasBridgeError) { + ConversationPreview(messages, ::fetchNewMessages) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/SocialRootSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/SocialRootSection.kt new file mode 100644 index 0000000000..f861384169 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/SocialRootSection.kt @@ -0,0 +1,288 @@ +package me.rhunk.snapenhance.ui.manager.pages.social + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.RemoveRedEye +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +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.navigation.NavBackStackEntry +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.R +import me.rhunk.snapenhance.common.data.MessagingFriendInfo +import me.rhunk.snapenhance.common.data.MessagingGroupInfo +import me.rhunk.snapenhance.common.data.SocialScope +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState +import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie +import me.rhunk.snapenhance.storage.* +import me.rhunk.snapenhance.ui.manager.Routes +import me.rhunk.snapenhance.ui.util.coil.BitmojiImage +import me.rhunk.snapenhance.ui.util.pagerTabIndicatorOffset + +class SocialRootSection : Routes.Route() { + private var friendList: List<MessagingFriendInfo> by mutableStateOf(emptyList()) + private var groupList: List<MessagingGroupInfo> by mutableStateOf(emptyList()) + + private fun updateScopeLists() { + context.coroutineScope.launch { + friendList = context.database.getFriends(descOrder = true) + groupList = context.database.getGroups() + } + } + + @Composable + private fun ScopeList(scope: SocialScope) { + val remainingHours = remember { context.config.root.streaksReminder.remainingHours.get() } + + LazyColumn( + modifier = Modifier + .fillMaxSize(), + contentPadding = PaddingValues(top = 10.dp, bottom = 110.dp, start = 8.dp, end = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + //check if scope list is empty + val listSize = when (scope) { + SocialScope.GROUP -> groupList.size + SocialScope.FRIEND -> friendList.size + } + + if (listSize == 0) { + item { + Text( + text = translation["empty_hint"], modifier = Modifier + .fillMaxWidth() + .padding(10.dp), textAlign = TextAlign.Center + ) + } + } + + items(listSize) { index -> + val id = when (scope) { + SocialScope.GROUP -> groupList[index].conversationId + SocialScope.FRIEND -> friendList[index].userId + } + + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .height(70.dp), + onClick = { + routes.manageScope.navigate { + put("id", id) + put("scope", scope.key) + } + } + ) { + Row( + modifier = Modifier + .padding(10.dp) + .fillMaxSize(), + verticalAlignment = Alignment.CenterVertically + ) { + when (scope) { + SocialScope.GROUP -> { + val group = groupList[index] + Column( + modifier = Modifier + .padding(start = 7.dp) + .fillMaxWidth() + .weight(1f) + ) { + Text( + text = group.name, + maxLines = 1, + fontWeight = FontWeight.Bold + ) + } + } + + SocialScope.FRIEND -> { + val friend = friendList[index] + val streaks by rememberAsyncMutableState(defaultValue = friend.streaks) { + context.database.getFriendStreaks(friend.userId) + } + + BitmojiImage( + context = context, + url = BitmojiSelfie.getBitmojiSelfie( + friend.selfieId, + friend.bitmojiId, + BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D + ) + ) + + Column( + modifier = Modifier + .padding(start = 7.dp) + .fillMaxWidth() + .weight(1f) + ) { + Text( + text = friend.displayName ?: friend.mutableUsername, + maxLines = 1, + fontWeight = FontWeight.Bold + ) + Text( + text = friend.mutableUsername, + maxLines = 1, + fontSize = 12.sp, + fontWeight = FontWeight.Light + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + streaks?.takeIf { it.notify }?.let { streaks -> + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.streak_icon), + contentDescription = null, + modifier = Modifier.height(40.dp), + tint = if (streaks.isAboutToExpire(remainingHours)) + MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.primary + ) + Text( + text = translation.format( + "streaks_expiration_short", + "hours" to (((streaks.expirationTimestamp - System.currentTimeMillis()) / 3600000).toInt().takeIf { it > 0 } ?: 0) + .toString() + ), + maxLines = 1, + fontWeight = FontWeight.Bold + ) + } + } + } + } + + FilledIconButton(onClick = { + routes.messagingPreview.navigate { + put("id", id) + put("scope", scope.key) + } + }) { + Icon(imageVector = Icons.Filled.RemoveRedEye, contentDescription = null) + } + } + } + } + } + } + + @OptIn(ExperimentalFoundationApi::class) + override val content: @Composable (NavBackStackEntry) -> Unit = { + val titles = remember { + listOf(translation["friends_tab"], translation["groups_tab"]) + } + val coroutineScope = rememberCoroutineScope() + val pagerState = rememberPagerState { titles.size } + var addFriendDialog by remember { mutableStateOf(null as AddFriendDialog?) } + + if (addFriendDialog != null) { + addFriendDialog?.Content { + addFriendDialog = null + } + DisposableEffect(Unit) { + onDispose { + updateScopeLists() + } + } + } + + LaunchedEffect(Unit) { + updateScopeLists() + } + + Scaffold( + floatingActionButton = { + FloatingActionButton( + onClick = { + addFriendDialog = AddFriendDialog( + context, + AddFriendDialog.Actions( + onFriendState = { friend, state -> + if (state) { + context.bridgeService?.triggerScopeSync(SocialScope.FRIEND, friend.userId) + } else { + context.database.deleteFriend(friend.userId) + } + }, + onGroupState = { group, state -> + if (state) { + context.bridgeService?.triggerScopeSync(SocialScope.GROUP, group.conversationId) + } else { + context.database.deleteGroup(group.conversationId) + } + }, + getFriendState = { friend -> context.database.getFriendInfo(friend.userId) != null }, + getGroupState = { group -> context.database.getGroupInfo(group.conversationId) != null } + ), + pinnedIds = (friendList.map { it.userId } + groupList.map { it.conversationId }).reversed(), + ) + }, + modifier = Modifier.padding(10.dp), + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + shape = RoundedCornerShape(16.dp), + ) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = null + ) + } + } + ) { paddingValues -> + Column(modifier = Modifier.padding(paddingValues)) { + TabRow(selectedTabIndex = pagerState.currentPage, indicator = { tabPositions -> + TabRowDefaults.SecondaryIndicator( + Modifier.pagerTabIndicatorOffset( + pagerState = pagerState, + tabPositions = tabPositions + ) + ) + }) { + titles.forEachIndexed { index, title -> + Tab( + selected = pagerState.currentPage == index, + onClick = { + coroutineScope.launch { + pagerState.animateScrollToPage(index) + } + }, + text = { + Text( + text = title, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + ) + } + } + + HorizontalPager( + modifier = Modifier.padding(paddingValues), + state = pagerState + ) { page -> + when (page) { + 0 -> ScopeList(SocialScope.FRIEND) + 1 -> ScopeList(SocialScope.GROUP) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/EditRule.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/EditRule.kt new file mode 100644 index 0000000000..74be789526 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/EditRule.kt @@ -0,0 +1,456 @@ +package me.rhunk.snapenhance.ui.manager.pages.tracker + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +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.navigation.NavBackStackEntry +import me.rhunk.snapenhance.common.data.* +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList +import me.rhunk.snapenhance.storage.* +import me.rhunk.snapenhance.ui.manager.Routes +import me.rhunk.snapenhance.ui.manager.pages.social.AddFriendDialog + +@Composable +fun ActionCheckbox( + text: String, + checked: MutableState<Boolean>, + onChanged: (Boolean) -> Unit = {} +) { + Row( + modifier = Modifier.clickable { + checked.value = !checked.value + onChanged(checked.value) + }, + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + modifier = Modifier.size(30.dp), + checked = checked.value, + onCheckedChange = { + checked.value = it + onChanged(it) + } + ) + Text(text, fontSize = 12.sp) + } +} + + +@Composable +fun ConditionCheckboxes( + params: TrackerRuleActionParams +) { + ActionCheckbox(text = "Only when I'm inside conversation", checked = remember { mutableStateOf(params.onlyInsideConversation) }, onChanged = { params.onlyInsideConversation = it }) + ActionCheckbox(text = "Only when I'm outside conversation", checked = remember { mutableStateOf(params.onlyOutsideConversation) }, onChanged = { params.onlyOutsideConversation = it }) + ActionCheckbox(text = "Only when Snapchat is active", checked = remember { mutableStateOf(params.onlyWhenAppActive) }, onChanged = { params.onlyWhenAppActive = it }) + ActionCheckbox(text = "Only when Snapchat is inactive", checked = remember { mutableStateOf(params.onlyWhenAppInactive) }, onChanged = { params.onlyWhenAppInactive = it }) + ActionCheckbox(text = "No notification when Snapchat is active", checked = remember { mutableStateOf(params.noPushNotificationWhenAppActive) }, onChanged = { params.noPushNotificationWhenAppActive = it }) +} + +class EditRule : Routes.Route() { + private val fab = mutableStateOf<@Composable (() -> Unit)?>(null) + + // persistent add event state + private var currentEventType by mutableStateOf(TrackerEventType.CONVERSATION_ENTER.key) + private var addEventActions by mutableStateOf(emptySet<TrackerRuleAction>()) + private val addEventActionParams by mutableStateOf(TrackerRuleActionParams()) + + override val floatingActionButton: @Composable () -> Unit = { + fab.value?.invoke() + } + + @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) + override val content: @Composable (NavBackStackEntry) -> Unit = { navBackStackEntry -> + val currentRuleId = navBackStackEntry.arguments?.getString("rule_id")?.toIntOrNull() + + val events = rememberAsyncMutableStateList(defaultValue = emptyList()) { + currentRuleId?.let { ruleId -> + context.database.getTrackerEvents(ruleId) + } ?: emptyList() + } + var currentScopeType by remember { mutableStateOf(TrackerScopeType.BLACKLIST) } + val scopes = rememberAsyncMutableStateList(defaultValue = emptyList()) { + currentRuleId?.let { ruleId -> + context.database.getRuleTrackerScopes(ruleId).also { + currentScopeType = if (it.isEmpty()) { + TrackerScopeType.WHITELIST + } else { + it.values.first() + } + }.map { it.key } + } ?: emptyList() + } + val ruleName = rememberAsyncMutableState(defaultValue = "", keys = arrayOf(currentRuleId)) { + currentRuleId?.let { ruleId -> + context.database.getTrackerRule(ruleId)?.name ?: "Custom Rule" + } ?: "Custom Rule" + } + + LaunchedEffect(Unit) { + fab.value = { + var deleteConfirmation by remember { mutableStateOf(false) } + + if (deleteConfirmation) { + AlertDialog( + onDismissRequest = { deleteConfirmation = false }, + title = { Text("Delete Rule") }, + text = { Text("Are you sure you want to delete this rule?") }, + confirmButton = { + Button( + onClick = { + if (currentRuleId != null) { + context.database.deleteTrackerRule(currentRuleId) + } + routes.navController.popBackStack() + } + ) { + Text("Delete") + } + }, + dismissButton = { + Button( + onClick = { deleteConfirmation = false } + ) { + Text("Cancel") + } + } + ) + } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(6.dp), + horizontalAlignment = Alignment.End + ) { + ExtendedFloatingActionButton( + onClick = { + val ruleId = currentRuleId ?: context.database.newTrackerRule() + events.forEach { event -> + context.database.addOrUpdateTrackerRuleEvent( + event.id.takeIf { it > -1 }, + ruleId, + event.eventType, + event.params, + event.actions + ) + } + context.database.setTrackerRuleName(ruleId, ruleName.value.trim()) + context.database.setRuleTrackerScopes(ruleId, currentScopeType, scopes) + routes.navController.popBackStack() + }, + text = { Text("Save Rule") }, + icon = { Icon(Icons.Default.Save, contentDescription = "Save Rule") } + ) + + if (currentRuleId != null) { + ExtendedFloatingActionButton( + containerColor = MaterialTheme.colorScheme.error, + onClick = { deleteConfirmation = true }, + text = { Text("Delete Rule") }, + icon = { Icon(Icons.Default.DeleteOutline, contentDescription = "Delete Rule") } + ) + } + } + } + } + + DisposableEffect(Unit) { + onDispose { fab.value = null } + } + + LazyColumn( + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + item { + TextField( + value = ruleName.value, + onValueChange = { + ruleName.value = it + }, + singleLine = true, + placeholder = { + Text( + "Rule Name", + fontSize = 18.sp, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + }, + modifier = Modifier.fillMaxWidth(), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent + ), + textStyle = TextStyle(fontSize = 20.sp, textAlign = TextAlign.Center, fontWeight = FontWeight.Bold) + ) + } + + + item { + Column( + modifier = Modifier.fillMaxWidth(), + ){ + Text("Scope", fontSize = 16.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(16.dp)) + + var addFriendDialog by remember { mutableStateOf(null as AddFriendDialog?) } + + val friendDialogActions = remember { + AddFriendDialog.Actions( + onFriendState = { friend, state -> + if (state) { + scopes.add(friend.userId) + } else { + scopes.remove(friend.userId) + } + }, + onGroupState = { group, state -> + if (state) { + scopes.add(group.conversationId) + } else { + scopes.remove(group.conversationId) + } + }, + getFriendState = { friend -> + friend.userId in scopes + }, + getGroupState = { group -> + group.conversationId in scopes + } + ) + } + + Box(modifier = Modifier.clickable { scopes.clear() }) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = scopes.isEmpty(), onClick = null) + Text("All Friends/Groups") + } + } + + Box(modifier = Modifier.clickable { + currentScopeType = TrackerScopeType.WHITELIST + addFriendDialog = AddFriendDialog( + context, + friendDialogActions, + pinnedIds = scopes, + ) + }) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = scopes.isNotEmpty() && currentScopeType == TrackerScopeType.WHITELIST, onClick = null) + Text("No one except " + if (currentScopeType == TrackerScopeType.WHITELIST && scopes.isNotEmpty()) scopes.size.toString() + " friends/groups" else "...") + } + } + + Box(modifier = Modifier.clickable { + currentScopeType = TrackerScopeType.BLACKLIST + addFriendDialog = AddFriendDialog( + context, + friendDialogActions, + pinnedIds = scopes, + ) + }) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = scopes.isNotEmpty() && currentScopeType == TrackerScopeType.BLACKLIST, onClick = null) + Text("Everyone except " + if (currentScopeType == TrackerScopeType.BLACKLIST && scopes.isNotEmpty()) scopes.size.toString() + " friends/groups" else "...") + } + } + + addFriendDialog?.Content { + addFriendDialog = null + } + } + + var addEventDialog by remember { mutableStateOf(false) } + val showDropdown = remember { mutableStateOf(false) } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Events", fontSize = 16.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(16.dp)) + IconButton(onClick = { addEventDialog = true }, modifier = Modifier.padding(8.dp)) { + Icon(Icons.Default.Add, contentDescription = "Add Event", modifier = Modifier.size(32.dp)) + } + } + + if (addEventDialog) { + AlertDialog( + onDismissRequest = { addEventDialog = false }, + title = { Text("Add Event", fontSize = 20.sp, fontWeight = FontWeight.Bold) }, + text = { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(2.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Type", fontSize = 14.sp, fontWeight = FontWeight.Bold) + ExposedDropdownMenuBox(expanded = showDropdown.value, onExpandedChange = { showDropdown.value = it }) { + ElevatedButton( + onClick = { showDropdown.value = true }, + modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable) + ) { + Text(context.translation["tracker_events.$currentEventType"], overflow = TextOverflow.Ellipsis, maxLines = 1) + } + DropdownMenu(expanded = showDropdown.value, onDismissRequest = { showDropdown.value = false }) { + TrackerEventType.entries.forEach { eventType -> + DropdownMenuItem(onClick = { + currentEventType = eventType.key + showDropdown.value = false + }, text = { + Text(context.translation["tracker_events.${eventType.key}"]) + }) + } + } + } + } + + Text("Triggers", fontSize = 14.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(2.dp)) + + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(2.dp), + ) { + TrackerRuleAction.entries.forEach { action -> + ActionCheckbox(context.translation["tracker_actions.${action.key}"], checked = remember { mutableStateOf(addEventActions.contains(action)) }) { + if (it) { + addEventActions += action + } else { + addEventActions -= action + } + } + } + } + + Text("Conditions", fontSize = 14.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(2.dp)) + ConditionCheckboxes(addEventActionParams) + } + }, + confirmButton = { + Button( + onClick = { + events.add(0, TrackerRuleEvent(-1, true, currentEventType, addEventActionParams.copy(), addEventActions.toList())) + addEventDialog = false + } + ) { + Text("Add") + } + } + ) + } + } + + item { + if (events.isEmpty()) { + Text("No events", fontSize = 12.sp, fontWeight = FontWeight.Light, modifier = Modifier + .padding(10.dp) + .fillMaxWidth(), textAlign = TextAlign.Center) + } + } + + items(events) { event -> + var expanded by remember { mutableStateOf(false) } + + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .padding(4.dp), + onClick = { expanded = !expanded } + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f, fill = false), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Column { + Text(context.translation["tracker_events.${event.eventType}"], lineHeight = 20.sp, fontSize = 18.sp, fontWeight = FontWeight.Bold) + Text(text = event.actions.joinToString(", ") { context.translation["tracker_actions.${it.key}"] }, fontSize = 10.sp, fontWeight = FontWeight.Light, overflow = TextOverflow.Ellipsis, maxLines = 1, lineHeight = 14.sp) + } + } + OutlinedIconButton( + onClick = { + if (event.id > -1) { + context.database.deleteTrackerRuleEvent(event.id) + } + events.remove(event) + } + ) { + Icon(Icons.Default.DeleteOutline, contentDescription = "Delete") + } + } + if (expanded) { + Column( + modifier = Modifier.padding(10.dp) + ) { + ConditionCheckboxes(event.params) + } + } + } + } + } + + item { + Spacer(modifier = Modifier.height(140.dp)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/FriendTrackerManagerRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/FriendTrackerManagerRoot.kt new file mode 100644 index 0000000000..962eee3154 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/FriendTrackerManagerRoot.kt @@ -0,0 +1,277 @@ +package me.rhunk.snapenhance.ui.manager.pages.tracker + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.DeleteOutline +import androidx.compose.material.icons.filled.SaveAlt +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.rememberCoroutineScope +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.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.navigation.NavBackStackEntry +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList +import me.rhunk.snapenhance.common.ui.rememberAsyncUpdateDispatcher +import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie +import me.rhunk.snapenhance.storage.* +import me.rhunk.snapenhance.ui.manager.Routes +import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper +import me.rhunk.snapenhance.ui.util.coil.BitmojiImage +import me.rhunk.snapenhance.ui.util.pagerTabIndicatorOffset + + +@OptIn(ExperimentalFoundationApi::class) +class FriendTrackerManagerRoot : Routes.Route() { + enum class FilterType { + CONVERSATION, USERNAME, EVENT + } + + private val titles = listOf("Logs", "Rules") + private var currentPage by mutableIntStateOf(0) + private lateinit var logDeleteAction : () -> Unit + private lateinit var exportAction : () -> Unit + + private lateinit var activityLauncherHelper: ActivityLauncherHelper + + override val init: () -> Unit = { + activityLauncherHelper = ActivityLauncherHelper(context.activity!!) + } + + override val floatingActionButton: @Composable () -> Unit = { + when (currentPage) { + 0 -> { + Column( + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + ExtendedFloatingActionButton( + icon = { Icon(Icons.Default.SaveAlt, contentDescription = "Export") }, + expanded = true, + text = { Text("Export") }, + onClick = { + context.coroutineScope.launch { exportAction() } + } + ) + ExtendedFloatingActionButton( + icon = { Icon(Icons.Default.DeleteOutline, contentDescription = "Delete") }, + expanded = true, + text = { Text("Delete") }, + onClick = { + context.coroutineScope.launch { logDeleteAction() } + } + ) + } + } + 1 -> { + ExtendedFloatingActionButton( + icon = { Icon(Icons.Default.Add, contentDescription = "Add Rule") }, + expanded = true, + text = { Text("Add Rule") }, + onClick = { routes.editRule.navigate() } + ) + } + } + } + + @Composable + private fun ConfigRulesTab() { + val updateRules = rememberAsyncUpdateDispatcher() + val rules = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = updateRules) { + context.database.getTrackerRulesDesc() + } + + Column( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + modifier = Modifier.weight(1f) + ) { + item { + if (rules.isEmpty()) { + Text("No rules found", modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), textAlign = TextAlign.Center, fontWeight = FontWeight.Light) + } + } + items(rules, key = { it.id }) { rule -> + val ruleName by rememberAsyncMutableState(defaultValue = rule.name) { + context.database.getTrackerRule(rule.id)?.name ?: "(empty)" + } + val eventCount by rememberAsyncMutableState(defaultValue = 0) { + context.database.getTrackerEvents(rule.id).size + } + val scopeCount by rememberAsyncMutableState(defaultValue = 0) { + context.database.getRuleTrackerScopes(rule.id).size + } + var enabled by rememberAsyncMutableState(defaultValue = rule.enabled) { + context.database.getTrackerRule(rule.id)?.enabled ?: false + } + + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .clickable { + routes.editRule.navigate { + this["rule_id"] = rule.id.toString() + } + } + .padding(5.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text(ruleName, fontSize = 20.sp, fontWeight = FontWeight.Bold) + Text(buildString { + append(eventCount) + append(" events") + if (scopeCount > 0) { + append(", ") + append(scopeCount) + append(" scopes") + } + }, fontSize = 13.sp, fontWeight = FontWeight.Light) + } + + Row( + modifier = Modifier.padding(10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + val scopesBitmoji = rememberAsyncMutableStateList(defaultValue = emptyList()) { + context.database.getRuleTrackerScopes(rule.id, limit = 10).mapNotNull { + context.database.getFriendInfo(it.key)?.let { friend -> + friend.selfieId to friend.bitmojiId + } + }.take(3) + } + + Row { + scopesBitmoji.forEachIndexed { index, friend -> + Box( + modifier = Modifier + .offset(x = (-index * 20).dp + (scopesBitmoji.size * 20).dp - 20.dp) + ) { + BitmojiImage( + size = 50, + modifier = Modifier + .border( + BorderStroke(1.dp, Color.White), + CircleShape + ) + .background(Color.White, CircleShape) + .clip(CircleShape), + context = context, + url = BitmojiSelfie.getBitmojiSelfie(friend.first, friend.second, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D), + ) + } + } + } + + Box(modifier = Modifier + .padding(start = 5.dp, end = 5.dp) + .height(50.dp) + .width(1.dp) + .background( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), + shape = RoundedCornerShape(5.dp) + ) + ) + + Switch( + checked = enabled, + onCheckedChange = { + enabled = it + context.database.setTrackerRuleState(rule.id, it) + } + ) + } + } + } + } + } + } + } + + + @OptIn(ExperimentalFoundationApi::class) + override val content: @Composable (NavBackStackEntry) -> Unit = { + val coroutineScope = rememberCoroutineScope() + val pagerState = rememberPagerState { titles.size } + currentPage = pagerState.currentPage + + Column { + TabRow(selectedTabIndex = pagerState.currentPage, indicator = { tabPositions -> + TabRowDefaults.SecondaryIndicator( + Modifier.pagerTabIndicatorOffset( + pagerState = pagerState, + tabPositions = tabPositions + ) + ) + }) { + titles.forEachIndexed { index, title -> + Tab( + selected = pagerState.currentPage == index, + onClick = { + coroutineScope.launch { + pagerState.animateScrollToPage(index) + } + }, + text = { + Text( + text = title, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + ) + } + } + + HorizontalPager( + modifier = Modifier.weight(1f), + state = pagerState + ) { page -> + when (page) { + 0 -> LogsTab( + context = context, + activityLauncherHelper = activityLauncherHelper, + deleteAction = { logDeleteAction = it }, + exportAction = { exportAction = it } + ) + 1 -> ConfigRulesTab() + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/LogsTab.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/LogsTab.kt new file mode 100644 index 0000000000..cd5048dc0c --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/LogsTab.kt @@ -0,0 +1,600 @@ +package me.rhunk.snapenhance.ui.manager.pages.tracker + +import android.net.Uri +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.DeleteOutline +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +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.compose.ui.window.PopupProperties +import com.google.gson.stream.JsonWriter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.RemoteSideContext +import me.rhunk.snapenhance.common.bridge.wrapper.TrackerLog +import me.rhunk.snapenhance.common.data.MessagingFriendInfo +import me.rhunk.snapenhance.common.data.TrackerEventType +import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie +import me.rhunk.snapenhance.storage.getFriendInfo +import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper +import me.rhunk.snapenhance.ui.util.coil.BitmojiImage +import me.rhunk.snapenhance.ui.util.saveFile +import java.text.DateFormat + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LogsTab( + context: RemoteSideContext, + activityLauncherHelper: ActivityLauncherHelper, + deleteAction: (() -> Unit) -> Unit, + exportAction: (() -> Unit) -> Unit, +) { + val coroutineScope = rememberCoroutineScope() + + val logs = remember { mutableStateListOf<TrackerLog>() } + var isLoading by remember { mutableStateOf(false) } + var pageIndex by remember { mutableIntStateOf(0) } + var filterType by remember { mutableStateOf(FriendTrackerManagerRoot.FilterType.USERNAME) } + var reverseSortOrder by remember { mutableStateOf(true) } + val sinceDatePickerState = rememberDatePickerState( + initialDisplayMode = DisplayMode.Picker + ) + + var filter by remember { mutableStateOf("") } + var searchTimeoutJob by remember { mutableStateOf<Job?>(null) } + + fun getPaginatedLogs(pageIndex: Int) = context.messageLogger.getLogs( + pageIndex = pageIndex, + pageSize = 30, + timestamp = sinceDatePickerState.selectedDateMillis, + reverseOrder = reverseSortOrder, + filter = { + when (filterType) { + FriendTrackerManagerRoot.FilterType.USERNAME -> it.username.contains(filter, ignoreCase = true) + FriendTrackerManagerRoot.FilterType.CONVERSATION -> it.conversationTitle?.contains(filter, ignoreCase = true) == true || (it.username == filter && !it.isGroup) + FriendTrackerManagerRoot.FilterType.EVENT -> it.eventType.contains(filter, ignoreCase = true) + } + }) + + suspend fun loadNewLogs() { + withContext(Dispatchers.IO) { + getPaginatedLogs(pageIndex).let { + withContext(Dispatchers.Main) { + logs.addAll(it) + pageIndex += 1 + } + } + } + } + + suspend fun resetAndLoadLogs() { + isLoading = true + logs.clear() + pageIndex = 0 + loadNewLogs() + isLoading = false + } + + var showDeleteDialog by remember { mutableStateOf(false) } + var showExportSelectionDialog by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + deleteAction { showDeleteDialog = true } + exportAction { showExportSelectionDialog = true } + } + + if (showDeleteDialog) { + val deleteCoroutineScope = rememberCoroutineScope { Dispatchers.IO } + var deleteLogsTask by remember { mutableStateOf<Job?>(null) } + var deletedLogsCount by remember { mutableIntStateOf(0) } + + fun deleteLogs() { + deleteLogsTask = deleteCoroutineScope.launch { + var index = 0 + while (true) { + val newLogs = getPaginatedLogs(index++) + if (newLogs.isEmpty()) { + break + } + newLogs.forEach { + context.messageLogger.deleteTrackerLog(it.id) + deletedLogsCount++ + } + } + + withContext(Dispatchers.Main) { + delay(500) + resetAndLoadLogs() + context.shortToast("Deleted $deletedLogsCount logs") + showDeleteDialog = false + } + } + } + + DisposableEffect(Unit) { + onDispose { + deleteLogsTask?.cancel() + } + } + + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text("Delete logs?") }, + text = { + if (deleteLogsTask != null) { + Text("Deleting $deletedLogsCount logs...") + } else { + Text("This will delete logs based on the current filter and the search query. This action cannot be undone.") + } + }, + confirmButton = { + Button( + enabled = deleteLogsTask == null, + onClick = { + deleteLogs() + } + ) { + if (deleteLogsTask != null) { + CircularProgressIndicator(modifier = Modifier + .size(30.dp), + strokeWidth = 3.dp + ) + } else { + Text("Delete") + } + } + }, + dismissButton = { + Button(onClick = { showDeleteDialog = false }) { + Text(context.translation["button.cancel"]) + } + } + ) + } + + if (showExportSelectionDialog) { + val exportCoroutineScope = rememberCoroutineScope { Dispatchers.IO } + var exportTask by remember { mutableStateOf<Job?>(null) } + var exportType by remember { mutableStateOf("json") } + + fun exportLogs() { + activityLauncherHelper.saveFile("tracker_logs_${System.currentTimeMillis()}.$exportType") { uri -> + exportTask = exportCoroutineScope.launch { + context.androidContext.contentResolver.openOutputStream(Uri.parse(uri))?.use { + val writer = it.writer() + val jsonWriter by lazy { + JsonWriter(writer).apply { + setIndent(" ") + beginArray() + } + } + + var index = 0 + while (true) { + val newLogs = getPaginatedLogs(index++) + if (newLogs.isEmpty()) { + break + } + newLogs.forEach { log -> + when (exportType) { + "json" -> { + jsonWriter.jsonValue(log.toJson().toString()) + } + "csv" -> { + writer.write(log.toCsv()) + writer.write("\n") + } + } + writer.flush() + } + } + when (exportType) { + "json" -> { + jsonWriter.endArray() + jsonWriter.close() + } + "csv" -> writer.close() + } + } + }.apply { + invokeOnCompletion { + exportTask = null + showExportSelectionDialog = false + if (it == null) { + context.shortToast("Exported logs!") + } else { + context.log.error("Failed to export logs", it) + context.shortToast("Failed to export logs. Check logcat for more details.") + } + } + } + } + } + + AlertDialog( + onDismissRequest = { showExportSelectionDialog = false }, + title = { Text("Export logs?") }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (exportTask != null) { + Text("Exporting logs...") + } else { + Text("This will export logs based on the current filter and the search query.") + Spacer(modifier = Modifier.height(10.dp)) + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + Card( + modifier = Modifier + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + .padding(2.dp) + ) { + Text("Export as $exportType", modifier = Modifier.padding(8.dp)) + } + DropdownMenu(expanded = expanded, onDismissRequest = { + expanded = false + }) { + listOf("json", "csv").forEach { type -> + DropdownMenuItem(onClick = { + exportType = type + expanded = false + }, text = { + Text(type) + }) + } + } + } + } + } + }, + confirmButton = { + Button( + enabled = exportTask == null, + onClick = { + exportLogs() + } + ) { + if (exportTask != null) { + CircularProgressIndicator(modifier = Modifier + .size(30.dp), + strokeWidth = 3.dp + ) + } else { + Text("Export") + } + } + }, + dismissButton = { + Button(onClick = { showExportSelectionDialog = false }) { + Text(context.translation["button.cancel"]) + } + } + ) + } + + + @Composable + fun FilterSelection( + selectionExpanded: MutableState<Boolean> + ) { + var dropDownExpanded by remember { mutableStateOf(false) } + var showDatePicker by remember { mutableStateOf(false) } + + if (showDatePicker) { + DatePickerDialog(onDismissRequest = { + showDatePicker = false + }, confirmButton = {}) { + DatePicker( + state = sinceDatePickerState, + modifier = Modifier.weight(1f), + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + Button(onClick = { + showDatePicker = false + sinceDatePickerState.selectedDateMillis = null + }) { + Text(context.translation["button.cancel"]) + } + Button(onClick = { + showDatePicker = false + }) { + Text(context.translation["button.ok"]) + } + } + } + } + + DropdownMenu(expanded = selectionExpanded.value, onDismissRequest = { + selectionExpanded.value = false + }) { + Column( + modifier = Modifier + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + val rowHSpacing = 10.dp + + Text("Filters", fontWeight = FontWeight.Bold, fontSize = 20.sp) + Row( + horizontalArrangement = Arrangement.spacedBy(rowHSpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Search by") + ExposedDropdownMenuBox( + expanded = dropDownExpanded, + onExpandedChange = { dropDownExpanded = it }, + ) { + Card( + modifier = Modifier + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + .padding(2.dp) + ) { + Text(filterType.name, modifier = Modifier.padding(8.dp)) + } + DropdownMenu(expanded = dropDownExpanded, onDismissRequest = { + dropDownExpanded = false + }) { + FriendTrackerManagerRoot.FilterType.entries.forEach { type -> + DropdownMenuItem(onClick = { + filter = "" + filterType = type + dropDownExpanded = false + coroutineScope.launch { + resetAndLoadLogs() + } + }, text = { + Text(type.name) + }) + } + } + } + } + Row( + horizontalArrangement = Arrangement.spacedBy(rowHSpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Newest first") + Switch( + checked = reverseSortOrder, + onCheckedChange = { + reverseSortOrder = it + selectionExpanded.value = false + } + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(rowHSpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(if (reverseSortOrder) "Since" else "Until") + Button(onClick = { + showDatePicker = true + }) { + Text(remember(showDatePicker) { + sinceDatePickerState.selectedDateMillis?.let { + DateFormat.getDateInstance().format(it) + } ?: "Pick a date" + }) + } + } + } + } + } + + Column( + modifier = Modifier.fillMaxSize() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + var showAutoComplete by remember { mutableStateOf(false) } + val showFilterSelection = remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = showAutoComplete, + onExpandedChange = { showAutoComplete = it }, + ) { + TextField( + value = filter, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + .padding(8.dp), + onValueChange = { + filter = it + coroutineScope.launch { + searchTimeoutJob?.cancel() + searchTimeoutJob = coroutineScope.launch { + delay(200) + showAutoComplete = true + resetAndLoadLogs() + } + } + }, + placeholder = { Text("Search") }, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent + ), + maxLines = 1, + leadingIcon = { + IconButton( + onClick = { + showFilterSelection.value = !showFilterSelection.value + }, + modifier = Modifier + .padding(2.dp) + ) { + Icon(Icons.Default.FilterList, contentDescription = "Filter") + } + FilterSelection(showFilterSelection) + if (showFilterSelection.value) { + DisposableEffect(Unit) { + onDispose { + coroutineScope.launch { + resetAndLoadLogs() + } + } + } + } + }, + trailingIcon = { + if (filter != "") { + IconButton(onClick = { + filter = "" + coroutineScope.launch { + resetAndLoadLogs() + } + }) { + Icon(Icons.Default.Clear, contentDescription = "Clear") + } + } + + DropdownMenu( + expanded = showAutoComplete, + onDismissRequest = { + showAutoComplete = false + }, + properties = PopupProperties(focusable = false), + ) { + val suggestedEntries = remember(filter) { + mutableStateListOf<String>() + } + + LaunchedEffect(filter) { + launch(Dispatchers.IO) { + suggestedEntries.addAll(when (filterType) { + FriendTrackerManagerRoot.FilterType.USERNAME -> context.messageLogger.findUsername(filter) + FriendTrackerManagerRoot.FilterType.CONVERSATION -> context.messageLogger.findConversation(filter) + context.messageLogger.findUsername(filter) + FriendTrackerManagerRoot.FilterType.EVENT -> TrackerEventType.entries.filter { it.name.contains(filter, ignoreCase = true) }.map { it.key } + }.take(5)) + } + } + + suggestedEntries.forEach { entry -> + DropdownMenuItem(onClick = { + filter = entry + coroutineScope.launch { + resetAndLoadLogs() + } + showAutoComplete = false + }, text = { + Text(entry) + }) + } + } + }, + ) + } + } + + LazyColumn( + modifier = Modifier.weight(1f) + ) { + item { + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + if (logs.isEmpty() && !isLoading) { + Text("No logs found", modifier = Modifier.padding(16.dp), fontWeight = FontWeight.Light, textAlign = TextAlign.Center) + } + } + } + items(logs, key = { it.userId + it.id }) { log -> + var databaseFriend by remember { mutableStateOf<MessagingFriendInfo?>(null) } + LaunchedEffect(Unit) { + launch(Dispatchers.IO) { + databaseFriend = context.database.getFriendInfo(log.userId) + } + } + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .padding(3.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + + BitmojiImage( + modifier = Modifier.padding(5.dp), + size = 55, + context = context, + url = databaseFriend?.takeIf { it.bitmojiId != null }?.let { + BitmojiSelfie.getBitmojiSelfie(it.selfieId, it.bitmojiId, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D) + }, + ) + + Column( + modifier = Modifier + .weight(1f), + ) { + Text(databaseFriend?.displayName?.let { + "$it (${log.username})" + } ?: log.username, lineHeight = 20.sp, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = 14.sp) + Text("${log.eventType} in ${log.conversationTitle}", fontSize = 10.sp, fontWeight = FontWeight.Light, lineHeight = 15.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + DateFormat.getDateTimeInstance().format(log.timestamp), + fontSize = 10.sp, + fontWeight = FontWeight.Light, + lineHeight = 15.sp, + ) + } + + IconButton( + onClick = { + context.messageLogger.deleteTrackerLog(log.id) + logs.remove(log) + } + ) { + Icon(Icons.Default.DeleteOutline, contentDescription = "Delete") + } + } + } + } + item { + Spacer(modifier = Modifier.height(16.dp)) + + LaunchedEffect(pageIndex) { + loadNewLogs() + } + } + + item { + Spacer(modifier = Modifier.height(100.dp)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/overlay/RemoteOverlay.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/overlay/RemoteOverlay.kt new file mode 100644 index 0000000000..3b89bb9a29 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/overlay/RemoteOverlay.kt @@ -0,0 +1,126 @@ +package me.rhunk.snapenhance.ui.overlay + +import android.app.Dialog +import android.content.Intent +import android.graphics.drawable.ColorDrawable +import android.net.Uri +import android.provider.Settings +import android.view.WindowManager +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.unit.dp +import androidx.navigation.compose.rememberNavController +import com.arthenica.ffmpegkit.Packages.getPackageName +import me.rhunk.snapenhance.R +import me.rhunk.snapenhance.RemoteSideContext +import me.rhunk.snapenhance.common.ui.createComposeView +import me.rhunk.snapenhance.ui.manager.Navigation +import me.rhunk.snapenhance.ui.manager.Routes + + +class RemoteOverlay( + private val context: RemoteSideContext +) { + private lateinit var dialog: Dialog + private var dismissCallback: (() -> Boolean)? = null + + private fun checkForPermissions(): Boolean { + if (!Settings.canDrawOverlays(context.androidContext)) { + val myIntent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION) + myIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + myIntent.setData(Uri.parse("package:" + getPackageName())) + context.androidContext.startActivity(myIntent) + return false + } + return true + } + + @Composable + private fun OverlayContent(startRoute: (Routes) -> Routes.Route) { + val navHostController = rememberNavController() + + LaunchedEffect(Unit) { + dismissCallback = { navHostController.popBackStack() } + } + + val navigation = remember { Navigation(context, navHostController) } + + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + topBar = { navigation.TopBar() } + ) { innerPadding -> + navigation.Content( + innerPadding, + startDestination = remember { startRoute(navigation.routes).routeInfo.id } + ) + } + } + + fun close() { + if (!::dialog.isInitialized || !dialog.isShowing) return + dismissCallback = null + context.androidContext.mainExecutor.execute { + dialog.dismiss() + } + } + + fun show(route: (Routes) -> Routes.Route) { + if (!checkForPermissions()) { + return + } + + if (::dialog.isInitialized && dialog.isShowing) { + return + } + + context.androidContext.mainExecutor.execute { + dialog = object: Dialog(context.androidContext, R.style.FullscreenOverlayDialog) { + override fun dismiss() { + dismissCallback?.also { + if (it()) return + } + super.dismiss() + this@RemoteOverlay.context.config.writeConfig() + } + } + dialog.window?.apply { + setBackgroundDrawable(ColorDrawable(Color.Transparent.value.toInt())) + setLayout( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.MATCH_PARENT, + ) + clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY) + } + + dialog.setContentView( + createComposeView(context.androidContext) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(start = 12.dp, end = 12.dp, top = 10.dp, bottom = 20.dp) + .clip(shape = MaterialTheme.shapes.large), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + OverlayContent(route) + } + } + ) + + dialog.setCancelable(true) + dialog.show() + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/Requirements.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/Requirements.kt new file mode 100644 index 0000000000..4d81c03037 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/Requirements.kt @@ -0,0 +1,11 @@ +package me.rhunk.snapenhance.ui.setup + +object Requirements { + const val FIRST_RUN = 0b000001 + const val LANGUAGE = 0b000010 + const val MAPPINGS = 0b000100 + const val SAVE_FOLDER = 0b001000 + const val GRANT_PERMISSIONS = 0b010000 + const val SIF = 0b100000 +} + diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/SetupActivity.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/SetupActivity.kt new file mode 100644 index 0000000000..85a4e661be --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/SetupActivity.kt @@ -0,0 +1,165 @@ +package me.rhunk.snapenhance.ui.setup + +import android.annotation.SuppressLint +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +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.alpha +import androidx.compose.ui.unit.dp +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import me.rhunk.snapenhance.SharedContextHolder +import me.rhunk.snapenhance.common.ui.AppMaterialTheme +import me.rhunk.snapenhance.ui.setup.screens.SetupScreen +import me.rhunk.snapenhance.ui.setup.screens.impl.MappingsScreen +import me.rhunk.snapenhance.ui.setup.screens.impl.PermissionsScreen +import me.rhunk.snapenhance.ui.setup.screens.impl.PickLanguageScreen +import me.rhunk.snapenhance.ui.setup.screens.impl.SaveFolderScreen + + +class SetupActivity : ComponentActivity() { + @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val setupContext = SharedContextHolder.remote(this).apply { + activity = this@SetupActivity + } + + fun endActivity() { + setupContext.reload() + finish() + } + + val requirements = intent.getIntExtra("requirements", Requirements.FIRST_RUN) + + fun hasRequirement(requirement: Int) = requirements and requirement == requirement + + val requiredScreens = mutableListOf<SetupScreen>() + + with(requiredScreens) { + val isFirstRun = hasRequirement(Requirements.FIRST_RUN) + if (isFirstRun || hasRequirement(Requirements.LANGUAGE)) { + add(PickLanguageScreen().apply { route = "language" }) + } + if (isFirstRun || hasRequirement(Requirements.GRANT_PERMISSIONS)) { + add(PermissionsScreen().apply { route = "permissions" }) + } + if (isFirstRun || hasRequirement(Requirements.SAVE_FOLDER)) { + add(SaveFolderScreen().apply { route = "saveFolder" }) + } + if (isFirstRun || hasRequirement(Requirements.MAPPINGS)) { + add(MappingsScreen().apply { route = "mappings" }) + } + } + + // If there are no required screens, we can just finish the activity + if (requiredScreens.isEmpty()) { + endActivity() + return + } + + requiredScreens.forEach { screen -> + screen.context = setupContext + screen.init() + } + + setContent { + val navController = rememberNavController() + var canGoNext by remember { mutableStateOf(false) } + + fun nextScreen() { + if (!canGoNext) return + requiredScreens.firstOrNull()?.onLeave() + if (requiredScreens.size > 1) { + canGoNext = false + requiredScreens.removeFirst() + navController.navigate(requiredScreens.first().route) + } else { + endActivity() + } + } + + AppMaterialTheme { + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + bottomBar = { + Column( + modifier = Modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + val alpha: Float by animateFloatAsState(if (canGoNext) 1f else 0f, + label = "NextButton" + ) + + FilledIconButton( + onClick = { nextScreen() }, + modifier = Modifier.padding(50.dp) + .width(60.dp) + .height(60.dp) + .alpha(alpha) + ) { + Icon( + imageVector = if (requiredScreens.size <= 1 && canGoNext) { + Icons.Default.Check + } else { + Icons.AutoMirrored.Default.ArrowForwardIos + }, + contentDescription = null + ) + } + } + }, + ) { + Column( + modifier = Modifier + .background(MaterialTheme.colorScheme.background) + .fillMaxSize() + ) { + NavHost( + navController = navController, + startDestination = requiredScreens.first().route + ) { + requiredScreens.forEach { screen -> + screen.allowNext = { canGoNext = it } + screen.goNext = { + canGoNext = true + nextScreen() + } + composable(screen.route) { + BackHandler(true) {} + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + screen.Content() + } + } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/SetupScreen.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/SetupScreen.kt new file mode 100644 index 0000000000..683942dc09 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/SetupScreen.kt @@ -0,0 +1,33 @@ +package me.rhunk.snapenhance.ui.setup.screens + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import me.rhunk.snapenhance.RemoteSideContext + +abstract class SetupScreen { + lateinit var context: RemoteSideContext + lateinit var allowNext: (canGoNext: Boolean) -> Unit + lateinit var goNext: () -> Unit + lateinit var route: String + + @Composable + fun DialogText(text: String, modifier: Modifier = Modifier) { + Text( + text = text, + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + modifier = Modifier.padding(16.dp).then(modifier) + ) + } + + open fun init() {} + open fun onLeave() {} + + @Composable + abstract fun Content() +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/MappingsScreen.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/MappingsScreen.kt new file mode 100644 index 0000000000..1a52d26c3d --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/MappingsScreen.kt @@ -0,0 +1,77 @@ +package me.rhunk.snapenhance.ui.setup.screens.impl + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.ui.setup.screens.SetupScreen +import me.rhunk.snapenhance.ui.util.AlertDialogs + +class MappingsScreen : SetupScreen() { + @Composable + override fun Content() { + val coroutineScope = rememberCoroutineScope() + var infoText by remember { mutableStateOf(null as String?) } + var isGenerating by remember { mutableStateOf(false) } + + if (infoText != null) { + fun dismiss() { + infoText = null + goNext() + } + + Dialog(onDismissRequest = { dismiss() }) { + remember { AlertDialogs(context.translation) }.InfoDialog(title = infoText!!) { + dismiss() + } + } + } + + LaunchedEffect(Unit) { + coroutineScope.launch(Dispatchers.IO) { + if (isGenerating) return@launch + isGenerating = true + runCatching { + if (context.installationSummary.snapchatInfo == null) { + throw Exception(context.translation["setup.mappings.generate_failure_no_snapchat"]) + } + val warnings = context.mappings.refresh() + + if (warnings.isNotEmpty()) { + isGenerating = false + infoText = "${warnings.size} warning(s) occurred while generating mappings:\n\n${warnings.joinToString("\n")}".also { + context.log.warn(it) + } + return@launch + } + + withContext(Dispatchers.Main) { + goNext() + } + }.onFailure { + isGenerating = false + infoText = context.translation["setup.mappings.generate_failure"] + "\n\n" + it.message + context.log.error("Failed to generate mappings", it) + } + } + } + + if (isGenerating) { + DialogText(text = context.translation["setup.mappings.dialog"]) + CircularProgressIndicator( + modifier = Modifier + .padding() + .size(50.dp), + strokeWidth = 3.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/PermissionsScreen.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/PermissionsScreen.kt new file mode 100644 index 0000000000..aeb8fc6aca --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/PermissionsScreen.kt @@ -0,0 +1,184 @@ +package me.rhunk.snapenhance.ui.setup.screens.impl + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.PowerManager +import android.provider.Settings +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.ui.setup.screens.SetupScreen +import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper +import me.rhunk.snapenhance.ui.util.OnLifecycleEvent + +data class PermissionData( + val translationKey: String, + val isPermissionGranted: () -> Boolean, + val requestPermission: (PermissionData) -> Unit, +) + +class PermissionsScreen : SetupScreen() { + private lateinit var activityLauncherHelper: ActivityLauncherHelper + + override fun init() { + activityLauncherHelper = ActivityLauncherHelper(context.activity!!) + } + + @Composable + private fun RequestButton(onClick: () -> Unit) { + Button(onClick = onClick) { + Text(text = context.translation["setup.permissions.request_button"]) + } + } + + @Composable + private fun GrantedIcon() { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + modifier = Modifier + .size(24.dp) + .padding(5.dp) + ) + } + + @SuppressLint("BatteryLife") + @Composable + override fun Content() { + val coroutineScope = rememberCoroutineScope() + val grantedPermissions = remember { + mutableStateMapOf<String, Boolean>() + } + val permissions = remember { + listOf( + PermissionData( + translationKey = "notification_access", + isPermissionGranted = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.androidContext.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + } else { + true + } + }, + requestPermission = { perm -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + activityLauncherHelper.requestPermission(Manifest.permission.POST_NOTIFICATIONS) { resultCode, _ -> + coroutineScope.launch { + grantedPermissions[perm.translationKey] = resultCode == Activity.RESULT_OK + } + } + } + } + ), + PermissionData( + translationKey = "battery_optimization", + isPermissionGranted = { + val powerManager = + context.androidContext.getSystemService(Context.POWER_SERVICE) as PowerManager + powerManager.isIgnoringBatteryOptimizations(context.androidContext.packageName) + }, + requestPermission = { perm -> + activityLauncherHelper.launch(Intent().apply { + action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + data = Uri.parse("package:${context.androidContext.packageName}") + }) { resultCode, _ -> + coroutineScope.launch { + grantedPermissions[perm.translationKey] = resultCode == 0 + } + } + } + ), + PermissionData( + translationKey = "display_over_other_apps", + isPermissionGranted = { + Settings.canDrawOverlays(context.androidContext) + }, + requestPermission = { perm -> + activityLauncherHelper.launch(Intent().apply { + action = Settings.ACTION_MANAGE_OVERLAY_PERMISSION + data = Uri.parse("package:${context.androidContext.packageName}") + }) { resultCode, _ -> + coroutineScope.launch { + grantedPermissions[perm.translationKey] = resultCode == 0 + } + } + } + ) + ) + } + + fun updateState() { + permissions.forEach { perm -> + grantedPermissions[perm.translationKey] = perm.isPermissionGranted() + } + if (permissions.all { perm -> grantedPermissions[perm.translationKey] == true }) { + goNext() + } + } + + OnLifecycleEvent { _, event -> + if (event != Lifecycle.Event.ON_RESUME) return@OnLifecycleEvent + coroutineScope.launch { + updateState() + delay(1000) + updateState() + } + } + + LaunchedEffect(Unit) { + updateState() + } + + DialogText(text = context.translation["setup.permissions.dialog"]) + + OutlinedCard( + modifier = Modifier + .fillMaxWidth(), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .padding(all = 16.dp), + ) { + permissions.forEach { perm -> + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + DialogText( + text = context.translation["setup.permissions.${perm.translationKey}"], + modifier = Modifier.weight(1f) + ) + if (grantedPermissions[perm.translationKey] == true) { + GrantedIcon() + } else { + RequestButton { + if (perm.isPermissionGranted()) { + grantedPermissions[perm.translationKey] = true + } else { + perm.requestPermission(perm) + } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/PickLanguageScreen.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/PickLanguageScreen.kt new file mode 100644 index 0000000000..f9dd50e16a --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/PickLanguageScreen.kt @@ -0,0 +1,133 @@ +package me.rhunk.snapenhance.ui.setup.screens.impl + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.Button +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.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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper +import me.rhunk.snapenhance.ui.setup.screens.SetupScreen +import me.rhunk.snapenhance.ui.util.ObservableMutableState +import java.util.Locale + + +class PickLanguageScreen : SetupScreen(){ + private val availableLocales by lazy { + LocaleWrapper.fetchAvailableLocales(context.androidContext) + } + + private lateinit var selectedLocale: ObservableMutableState<String> + + private fun getLocaleDisplayName(locale: String): String { + locale.split("_").let { + if (it.size != 2) return Locale(locale).getDisplayName(Locale.getDefault()) + return Locale(it[0], it[1]).getDisplayName(Locale.getDefault()) + } + } + + private fun reloadTranslation(selectedLocale: String) { + context.translation.reload(selectedLocale) + } + + private fun setLocale(locale: String) { + with(context) { + config.locale = locale + config.writeConfig() + reloadTranslation(locale) + } + } + + override fun onLeave() { + context.config.locale = selectedLocale.value + context.config.writeConfig() + } + + override fun init() { + val deviceLocale = Locale.getDefault().toString() + selectedLocale = + ObservableMutableState( + defaultValue = availableLocales.firstOrNull { + locale -> locale == deviceLocale + } ?: LocaleWrapper.DEFAULT_LOCALE + ) { _, newValue -> + setLocale(newValue) + }.also { reloadTranslation(it.value) } + } + + @Composable + override fun Content() { + allowNext(true) + + DialogText(text = context.translation["setup.dialogs.select_language"]) + + var isDialog by remember { mutableStateOf(false) } + + if (isDialog) { + Dialog(onDismissRequest = { isDialog = false }) { + Surface( + modifier = Modifier + .padding(10.dp) + .fillMaxWidth(), + shape = MaterialTheme.shapes.medium + ) { + LazyColumn( + modifier = Modifier.scrollable(rememberScrollState(), orientation = Orientation.Vertical) + ) { + items(availableLocales) { locale -> + Box( + modifier = Modifier + .height(70.dp) + .fillMaxWidth() + .clickable { + selectedLocale.value = locale + isDialog = false + }, + contentAlignment = Alignment.Center + ) { + Text( + text = remember(locale) { getLocaleDisplayName(locale) }, + fontSize = 16.sp, + fontWeight = FontWeight.Light, + ) + } + } + } + } + } + } + + Box( + modifier = Modifier + .padding(top = 40.dp) + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Button(onClick = { + isDialog = true + }) { + Text(text = remember(selectedLocale.value) { getLocaleDisplayName(selectedLocale.value) }, fontSize = 16.sp, + fontWeight = FontWeight.Normal) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/SaveFolderScreen.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/SaveFolderScreen.kt new file mode 100644 index 0000000000..6f719c9cb1 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/SaveFolderScreen.kt @@ -0,0 +1,36 @@ +package me.rhunk.snapenhance.ui.setup.screens.impl + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import me.rhunk.snapenhance.ui.setup.screens.SetupScreen +import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper +import me.rhunk.snapenhance.ui.util.chooseFolder + +class SaveFolderScreen : SetupScreen() { + private lateinit var activityLauncherHelper: ActivityLauncherHelper + + override fun init() { + activityLauncherHelper = ActivityLauncherHelper(context.activity!!) + } + + @Composable + override fun Content() { + DialogText(text = context.translation["setup.dialogs.save_folder"]) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { + activityLauncherHelper.chooseFolder { + if (it.isBlank()) return@chooseFolder + context.config.root.downloader.saveFolder.set(it) + context.config.writeConfig() + goNext() + } + }) { + Text(text = context.translation["setup.dialogs.select_save_folder_button"]) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/Accompagnist.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/Accompagnist.kt new file mode 100644 index 0000000000..0822332b1c --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/Accompagnist.kt @@ -0,0 +1,56 @@ +package me.rhunk.snapenhance.ui.util + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.pager.PagerState +import androidx.compose.material3.TabPosition +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.lerp + +//https://github.com/google/accompanist/blob/main/pager-indicators/src/main/java/com/google/accompanist/pager/PagerTab.kt#L78 +@OptIn(ExperimentalFoundationApi::class) +fun Modifier.pagerTabIndicatorOffset( + pagerState: PagerState, + tabPositions: List<TabPosition>, + pageIndexMapping: (Int) -> Int = { it }, +): Modifier = layout { measurable, constraints -> + if (tabPositions.isEmpty()) { + // If there are no pages, nothing to show + layout(constraints.maxWidth, 0) {} + } else { + val currentPage = minOf(tabPositions.lastIndex, pageIndexMapping(pagerState.currentPage)) + val currentTab = tabPositions[currentPage] + val previousTab = tabPositions.getOrNull(currentPage - 1) + val nextTab = tabPositions.getOrNull(currentPage + 1) + val fraction = pagerState.currentPageOffsetFraction + val indicatorWidth = if (fraction > 0 && nextTab != null) { + lerp(currentTab.width, nextTab.width, fraction).roundToPx() + } else if (fraction < 0 && previousTab != null) { + lerp(currentTab.width, previousTab.width, -fraction).roundToPx() + } else { + currentTab.width.roundToPx() + } + val indicatorOffset = if (fraction > 0 && nextTab != null) { + lerp(currentTab.left, nextTab.left, fraction).roundToPx() + } else if (fraction < 0 && previousTab != null) { + lerp(currentTab.left, previousTab.left, -fraction).roundToPx() + } else { + currentTab.left.roundToPx() + } + val placeable = measurable.measure( + Constraints( + minWidth = indicatorWidth, + maxWidth = indicatorWidth, + minHeight = 0, + maxHeight = constraints.maxHeight + ) + ) + layout(constraints.maxWidth, maxOf(placeable.height, constraints.minHeight)) { + placeable.placeRelative( + indicatorOffset, + maxOf(constraints.minHeight - placeable.height, 0) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ActivityLauncherHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ActivityLauncherHelper.kt new file mode 100644 index 0000000000..c3c7102891 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ActivityLauncherHelper.kt @@ -0,0 +1,109 @@ +package me.rhunk.snapenhance.ui.util + +import android.app.Activity +import android.content.Intent +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import me.rhunk.snapenhance.common.logger.AbstractLogger + +typealias ActivityLauncherCallback = (resultCode: Int, intent: Intent?) -> Unit + +class ActivityLauncherHelper( + val activity: ComponentActivity, +) { + private var callback: ActivityLauncherCallback? = null + private var permissionResultLauncher: ActivityResultLauncher<String> = + activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) { result -> + runCatching { + callback?.let { it(if (result) Activity.RESULT_OK else Activity.RESULT_CANCELED, null) } + }.onFailure { + AbstractLogger.directError("Failed to process activity result", it) + } + callback = null + } + + private var activityResultLauncher: ActivityResultLauncher<Intent> = + activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + runCatching { + callback?.let { it(result.resultCode, result.data) } + }.onFailure { + AbstractLogger.directError("Failed to process activity result", it) + } + callback = null + } + + fun launch(intent: Intent, callback: ActivityLauncherCallback) { + if (this.callback != null) { + throw IllegalStateException("Already launching an activity") + } + this.callback = callback + activityResultLauncher.launch(intent) + } + + fun requestPermission(permission: String, callback: ActivityLauncherCallback) { + if (this.callback != null) { + throw IllegalStateException("Already launching an activity") + } + this.callback = callback + permissionResultLauncher.launch(permission) + } +} + +fun ActivityLauncherHelper.chooseFolder(callback: (uri: String) -> Unit) { + launch( + Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + ) { resultCode, intent -> + if (resultCode != Activity.RESULT_OK) { + return@launch + } + val uri = intent?.data ?: return@launch + val value = uri.toString() + this.activity.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + callback(value) + } +} + +fun ActivityLauncherHelper.saveFile(name: String, type: String = "*/*", callback: (uri: String) -> Unit) { + launch( + Intent(Intent.ACTION_CREATE_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType(type) + .putExtra(Intent.EXTRA_TITLE, name) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + ) { resultCode, intent -> + if (resultCode != Activity.RESULT_OK) { + return@launch + } + val uri = intent?.data ?: return@launch + val value = uri.toString() + this.activity.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + callback(value) + } +} +fun ActivityLauncherHelper.openFile(type: String = "*/*", callback: (uri: String) -> Unit) { + launch( + Intent(Intent.ACTION_OPEN_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType(type) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + ) { resultCode, intent -> + if (resultCode != Activity.RESULT_OK) { + return@launch + } + val uri = intent?.data ?: return@launch + val value = uri.toString() + this.activity.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + callback(value) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AlertDialogs.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AlertDialogs.kt new file mode 100644 index 0000000000..e48a13a321 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AlertDialogs.kt @@ -0,0 +1,748 @@ +package me.rhunk.snapenhance.ui.util + +import android.content.Context +import android.view.MotionEvent +import android.widget.Toast +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.DeleteOutline +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.net.toUri +import com.github.skydoves.colorpicker.compose.* +import com.google.gson.JsonParser +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.common.Constants +import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper +import me.rhunk.snapenhance.common.config.DataProcessors +import me.rhunk.snapenhance.common.config.PropertyPair +import me.rhunk.snapenhance.common.ui.AutoClearKeyboardFocus +import me.rhunk.snapenhance.common.util.ktx.await +import okhttp3.OkHttpClient +import okhttp3.Request +import org.osmdroid.config.Configuration +import org.osmdroid.tileprovider.tilesource.TileSourceFactory +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.CustomZoomButtonsController +import org.osmdroid.views.MapView +import org.osmdroid.views.overlay.Marker +import org.osmdroid.views.overlay.Overlay +import java.io.File + + +class AlertDialogs( + private val translation: LocaleWrapper, +){ + @Composable + fun DefaultDialogCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { + Card( + shape = MaterialTheme.shapes.large, + modifier = Modifier + .padding(16.dp) + .then(modifier), + ) { + Column( + modifier = Modifier + .padding(10.dp, 10.dp, 10.dp, 10.dp) + .verticalScroll(ScrollState(0)), + ) { content() } + } + } + + @Composable + fun ConfirmDialog( + title: String, + message: String? = null, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + ) { + DefaultDialogCard { + Text( + text = title, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 5.dp, bottom = 10.dp) + ) + if (message != null) { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 15.dp) + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Button(onClick = { onDismiss() }) { + Text(text = translation["button.cancel"]) + } + Button(onClick = { onConfirm() }) { + Text(text = translation["button.ok"]) + } + } + } + } + + @Composable + fun InfoDialog( + title: String, + message: String? = null, + onDismiss: () -> Unit, + ) { + DefaultDialogCard { + Text( + text = title, + fontSize = 20.sp, + modifier = Modifier.padding(start = 5.dp, bottom = 10.dp) + ) + if (message != null) { + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 15.dp) + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Button(onClick = { onDismiss() }) { + Text(text = translation["button.ok"]) + } + } + } + } + + @Composable + fun TranslatedText(property: PropertyPair<*>, key: String, modifier: Modifier = Modifier) { + Text( + text = property.key.propertyOption(translation, key), + modifier = Modifier + .padding(10.dp, 10.dp, 10.dp, 10.dp) + .then(modifier) + ) + } + + @Composable + @Suppress("UNCHECKED_CAST") + fun UniqueSelectionDialog(property: PropertyPair<*>) { + val keys = (property.value.defaultValues as List<String>).toMutableList().apply { + add(0, "null") + } + + var selectedValue by remember { + mutableStateOf(property.value.getNullable()?.toString() ?: "null") + } + + DefaultDialogCard { + keys.forEachIndexed { index, item -> + fun select() { + selectedValue = item + property.value.setAny(if (index == 0) { + null + } else { + item + }) + } + + Row( + modifier = Modifier.clickable { select() }, + verticalAlignment = Alignment.CenterVertically + ) { + TranslatedText( + property = property, + key = item, + modifier = Modifier.weight(1f) + ) + RadioButton( + selected = selectedValue == item, + onClick = { select() } + ) + } + } + } + } + + @Composable + fun KeyboardInputDialog(property: PropertyPair<*>, dismiss: () -> Unit = {}) { + val focusRequester = remember { FocusRequester() } + val context = LocalContext.current + + DefaultDialogCard { + var fieldValue by remember { + mutableStateOf(property.value.get().toString().let { + TextFieldValue( + text = it, + selection = TextRange(it.length) + ) + }) + } + + TextField( + modifier = Modifier + .fillMaxWidth() + .padding(all = 10.dp) + .onGloballyPositioned { + focusRequester.requestFocus() + } + .focusRequester(focusRequester), + value = fieldValue, + onValueChange = { fieldValue = it }, + keyboardOptions = when (property.key.dataType.type) { + DataProcessors.Type.INTEGER -> KeyboardOptions(keyboardType = KeyboardType.Number) + DataProcessors.Type.FLOAT -> KeyboardOptions(keyboardType = KeyboardType.Decimal) + else -> KeyboardOptions(keyboardType = KeyboardType.Text) + }, + singleLine = true + ) + + Row( + modifier = Modifier + .padding(top = 10.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Button(onClick = { dismiss() }) { + Text(text = translation["button.cancel"]) + } + Button(onClick = { + if (fieldValue.text.isNotEmpty() && property.key.params.inputCheck?.invoke(fieldValue.text) == false) { + Toast.makeText(context, "Invalid input! Make sure you entered a valid value.", Toast.LENGTH_SHORT).show() //TODO: i18n + return@Button + } + + when (property.key.dataType.type) { + DataProcessors.Type.INTEGER -> { + runCatching { + property.value.setAny(fieldValue.text.toInt()) + }.onFailure { + property.value.setAny(0) + } + } + DataProcessors.Type.FLOAT -> { + runCatching { + property.value.setAny(fieldValue.text.toFloat()) + }.onFailure { + property.value.setAny(0f) + } + } + else -> property.value.setAny(fieldValue.text) + } + dismiss() + }) { + Text(text = translation["button.ok"]) + } + } + } + } + + @Composable + fun RawInputDialog(onDismiss: () -> Unit, onConfirm: (value: String) -> Unit) { + val focusRequester = remember { FocusRequester() } + + DefaultDialogCard { + val fieldValue = remember { + mutableStateOf(TextFieldValue()) + } + + TextField( + modifier = Modifier + .fillMaxWidth() + .padding(all = 10.dp) + .onGloballyPositioned { + focusRequester.requestFocus() + } + .focusRequester(focusRequester), + value = fieldValue.value, + onValueChange = { + fieldValue.value = it + }, + singleLine = true + ) + + Row( + modifier = Modifier + .padding(top = 10.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Button(onClick = { onDismiss() }) { + Text(text = translation["button.cancel"]) + } + Button(onClick = { + onConfirm(fieldValue.value.text) + }) { + Text(text = translation["button.ok"]) + } + } + } + } + + @Composable + @Suppress("UNCHECKED_CAST") + fun MultipleSelectionDialog(property: PropertyPair<*>) { + val defaultItems = property.value.defaultValues as List<String> + val toggledStates = property.value.get() as MutableList<String> + DefaultDialogCard { + defaultItems.forEach { key -> + var state by remember { mutableStateOf(toggledStates.contains(key)) } + + fun toggle(value: Boolean? = null) { + state = value ?: !state + if (state) { + toggledStates.add(key) + } else { + toggledStates.remove(key) + } + } + + Row( + modifier = Modifier.clickable { toggle() }, + verticalAlignment = Alignment.CenterVertically + ) { + TranslatedText( + property = property, + key = key, + modifier = Modifier + .weight(1f) + ) + Switch( + checked = state, + onCheckedChange = { + toggle(it) + } + ) + } + } + } + } + + @Composable + fun ColorPickerDialog( + initialColor: Color?, + setProperty: (Color?) -> Unit, + dismiss: () -> Unit + ) { + var currentColor by remember { mutableStateOf(initialColor) } + + DefaultDialogCard { + val controller = remember { ColorPickerController().apply { + if (currentColor == null) { + setWheelAlpha(1f) + setBrightness(1f, false) + } + } } + var colorHexValue by remember { + mutableStateOf(currentColor?.toArgb()?.let { Integer.toHexString(it) } ?: "") + } + + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + TextField( + value = colorHexValue, + onValueChange = { value -> + colorHexValue = value + runCatching { + currentColor = Color(android.graphics.Color.parseColor("#$value")).also { + controller.selectByColor(it, true) + setProperty(it) + } + }.onFailure { + currentColor = null + } + }, + label = { Text(text = "Hex Color") }, + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + singleLine = true, + colors = TextFieldDefaults.colors( + unfocusedContainerColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + ) + ) + } + HsvColorPicker( + modifier = Modifier + .fillMaxWidth() + .height(300.dp) + .padding(10.dp), + initialColor = remember { currentColor }, + controller = controller, + onColorChanged = { + if (!it.fromUser) return@HsvColorPicker + currentColor = it.color + colorHexValue = Integer.toHexString(it.color.toArgb()) + setProperty(it.color) + } + ) + AlphaSlider( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp) + .height(35.dp), + initialColor = remember { currentColor }, + controller = controller, + ) + BrightnessSlider( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp) + .height(35.dp), + initialColor = remember { currentColor }, + controller = controller, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(5.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + AlphaTile( + modifier = Modifier + .size(80.dp) + .clip(RoundedCornerShape(6.dp)), + controller = controller + ) + IconButton(onClick = { + setProperty(null) + dismiss() + }) { + Icon( + modifier = Modifier.size(60.dp), + imageVector = Icons.Filled.DeleteOutline, + contentDescription = null + ) + } + } + } + } + + @Composable + fun ColorPickerPropertyDialog( + property: PropertyPair<*>, + dismiss: () -> Unit = {}, + ) { + var currentColor by remember { + mutableStateOf((property.value.getNullable() as? Int)?.let { Color(it) }) + } + + ColorPickerDialog( + initialColor = currentColor, + setProperty = setProperty@{ + currentColor = it + property.value.setAny(it?.toArgb()) + if (it == null) { + property.value.setAny(property.value.defaultValues?.firstOrNull() ?: return@setProperty) + } + }, + dismiss = dismiss + ) + } + + @Composable + fun ChooseLocationDialog( + property: PropertyPair<*>, + marker: MutableState<Marker?> = remember { mutableStateOf(null) }, + mapView: MutableState<MapView?> = remember { mutableStateOf(null) }, + saveCoordinates: (() -> Unit)? = null, + dismiss: () -> Unit = {} + ) { + val coordinates = remember { + (property.value.get() as Pair<*, *>).let { + it.first.toString().toDouble() to it.second.toString().toDouble() + } + } + val context = LocalContext.current + + mapView.value = remember { + Configuration.getInstance().apply { + osmdroidBasePath = File(context.cacheDir, "osmdroid") + load(context, context.getSharedPreferences("osmdroid", Context.MODE_PRIVATE)) + } + MapView(context).apply { + setMultiTouchControls(true) + zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) + setTileSource(TileSourceFactory.MAPNIK) + + val startPoint = GeoPoint(coordinates.first, coordinates.second) + controller.setZoom(10.0) + controller.setCenter(startPoint) + + marker.value = Marker(this).apply { + isDraggable = true + position = startPoint + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + } + + overlays.add(object: Overlay() { + override fun onSingleTapConfirmed(e: MotionEvent, mapView: MapView): Boolean { + marker.value?.position = mapView.projection.fromPixels(e.x.toInt(), e.y.toInt()) as GeoPoint + mapView.invalidate() + return true + } + }) + + overlays.add(marker.value) + } + } + + DisposableEffect(Unit) { + onDispose { + mapView.value?.onDetach() + } + } + + var customCoordinatesDialog by remember { mutableStateOf(false) } + + + val coroutineScope = rememberCoroutineScope { Dispatchers.IO } + val okHttpClient by lazy { OkHttpClient() } + + Box( + modifier = Modifier + .fillMaxWidth() + .clipToBounds() + .fillMaxHeight(fraction = 0.9f), + ) { + AndroidView( + factory = { mapView.value!! }, + ) + Column( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + ) { + var locationName by remember { mutableStateOf<String>("") } + var addressResults by remember { mutableStateOf<List<Triple<String, String, String>>>(emptyList()) } + var searchJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) } + + suspend fun search() { + okHttpClient.newCall(Request.Builder() + .url("https://nominatim.openstreetmap.org/search".toUri().buildUpon().appendQueryParameter("q", locationName).appendQueryParameter("format", "jsonv2").build().toString()) + .header("User-Agent", Constants.USER_AGENT) + .build() + ).await().use { response -> + if (!response.isSuccessful) { + return@use + } + + runCatching { + val body = JsonParser.parseString(response.body.string()).asJsonArray + addressResults = body.take(5).map { jsonElement -> + val jsonObject = jsonElement.asJsonObject + Triple( + jsonObject.get("display_name").asString, + jsonObject.get("lat").asString, + jsonObject.get("lon").asString + ) + } + } + } + + searchJob = null + } + + TextField( + modifier = Modifier + .fillMaxWidth(), + value = locationName, + onValueChange = { + locationName = it.replace("\n", "") + if (locationName == "") { + addressResults = emptyList() + searchJob?.cancel() + searchJob = null + return@TextField + } + searchJob?.cancel() + searchJob = coroutineScope.launch { + delay(500) + search() + } + }, + label = { Text(text = "Search") }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.None) + ) + + AutoClearKeyboardFocus(onFocusClear = { + locationName = "" + addressResults = emptyList() + searchJob?.cancel() + searchJob = null + }) + + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background) + .verticalScroll(ScrollState(0)), + ) { + if (addressResults.isNotEmpty()) { + addressResults.forEach { address -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + marker.value?.position = GeoPoint(address.second.toDouble(), address.third.toDouble()) + mapView.value?.controller?.setCenter(marker.value?.position) + mapView.value?.invalidate() + } + ) { + Text( + text = address.first, + modifier = Modifier + .padding(10.dp) + .fillMaxWidth(), + ) + } + } + } else { + if (searchJob?.isActive == true) { + Row( + modifier = Modifier.fillMaxWidth().padding(10.dp), + horizontalArrangement = Arrangement.Center + ) { + CircularProgressIndicator() + } + } + } + } + } + + + Row( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + FilledIconButton( + onClick = { + val lat = marker.value?.position?.latitude ?: coordinates.first + val lon = marker.value?.position?.longitude ?: coordinates.second + property.value.setAny(lat to lon) + dismiss() + }) { + Icon( + modifier = Modifier + .size(60.dp) + .padding(5.dp), + imageVector = Icons.Filled.Check, + contentDescription = null + ) + } + saveCoordinates?.let { + FilledIconButton( + onClick = { it() }) { + Icon( + modifier = Modifier + .size(60.dp) + .padding(5.dp), + imageVector = Icons.Filled.Save, + contentDescription = null + ) + } + } + + FilledIconButton( + onClick = { + customCoordinatesDialog = true + }) { + Icon( + modifier = Modifier + .size(60.dp) + .padding(5.dp), + imageVector = Icons.Filled.Edit, + contentDescription = null + ) + } + } + + if (customCoordinatesDialog) { + val lat = remember { mutableStateOf(coordinates.first.toString()) } + val lon = remember { mutableStateOf(coordinates.second.toString()) } + + Dialog(onDismissRequest = { + customCoordinatesDialog = false + }) { + DefaultDialogCard( + modifier = Modifier.align(Alignment.Center) + ) { + TextField( + modifier = Modifier + .fillMaxWidth() + .padding(all = 10.dp), + value = lat.value, + onValueChange = { lat.value = it }, + label = { Text(text = "Latitude") }, + singleLine = true + ) + TextField( + modifier = Modifier + .fillMaxWidth() + .padding(all = 10.dp), + value = lon.value, + onValueChange = { lon.value = it }, + label = { Text(text = "Longitude") }, + singleLine = true + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Button(onClick = { + customCoordinatesDialog = false + }) { + Text(text = translation["button.cancel"]) + } + + Button(onClick = { + marker.value?.position = GeoPoint(lat.value.toDouble(), lon.value.toDouble()) + mapView.value?.controller?.setCenter(marker.value?.position) + mapView.value?.invalidate() + customCoordinatesDialog = false + }) { + Text(text = translation["button.ok"]) + } + } + } + } + } + } + } +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AndroidDialogCustom.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AndroidDialogCustom.kt new file mode 100644 index 0000000000..41260fe1e6 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AndroidDialogCustom.kt @@ -0,0 +1,358 @@ +package me.rhunk.snapenhance.ui.util + + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.graphics.Outline +import android.os.Build +import android.provider.Settings +import android.view.* +import androidx.activity.ComponentDialog +import androidx.activity.addCallback +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.R +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.* +import androidx.compose.ui.semantics.dialog +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.SecureFlagPolicy +import androidx.core.view.WindowCompat +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.lifecycle.findViewTreeViewModelStoreOwner +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.lifecycle.setViewTreeViewModelStoreOwner +import androidx.savedstate.findViewTreeSavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import java.util.UUID +import kotlin.math.roundToInt + +class DialogProperties constructor( + val dismissOnBackPress: Boolean = true, + val dismissOnClickOutside: Boolean = true, + val securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit, + val usePlatformDefaultWidth: Boolean = true, + val decorFitsSystemWindows: Boolean = true +) { + + constructor( + dismissOnBackPress: Boolean = true, + dismissOnClickOutside: Boolean = true, + securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit, + ) : this( + dismissOnBackPress = dismissOnBackPress, + dismissOnClickOutside = dismissOnClickOutside, + securePolicy = securePolicy, + usePlatformDefaultWidth = true, + decorFitsSystemWindows = true + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is DialogProperties) return false + + if (dismissOnBackPress != other.dismissOnBackPress) return false + if (dismissOnClickOutside != other.dismissOnClickOutside) return false + if (securePolicy != other.securePolicy) return false + if (usePlatformDefaultWidth != other.usePlatformDefaultWidth) return false + if (decorFitsSystemWindows != other.decorFitsSystemWindows) return false + + return true + } + + override fun hashCode(): Int { + var result = dismissOnBackPress.hashCode() + result = 31 * result + dismissOnClickOutside.hashCode() + result = 31 * result + securePolicy.hashCode() + result = 31 * result + usePlatformDefaultWidth.hashCode() + result = 31 * result + decorFitsSystemWindows.hashCode() + return result + } +} + +@Composable +fun Dialog( + onDismissRequest: () -> Unit, + properties: DialogProperties = DialogProperties(), + content: @Composable () -> Unit +) { + val view = LocalView.current + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + val composition = rememberCompositionContext() + val currentContent by rememberUpdatedState(content) + val dialogId = rememberSaveable { UUID.randomUUID() } + val dialog = remember(view, density) { + DialogWrapper( + onDismissRequest, + properties, + view, + layoutDirection, + density, + dialogId + ).apply { + setContent(composition) { + // TODO(b/159900354): draw a scrim and add margins around the Compose Dialog, and + // consume clicks so they can't pass through to the underlying UI + DialogLayout( + Modifier.semantics { dialog() }, + ) { + currentContent() + } + } + } + } + + DisposableEffect(dialog) { + // Set the dialog's window type to TYPE_APPLICATION_OVERLAY so it's compatible with compose overlays + if (Settings.canDrawOverlays(view.context) && view.context !is Activity) { + dialog.window?.setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY) + } + dialog.show() + + onDispose { + dialog.dismiss() + dialog.disposeComposition() + } + } + + SideEffect { + dialog.updateParameters( + onDismissRequest = onDismissRequest, + properties = properties, + layoutDirection = layoutDirection + ) + } +} + +interface DialogWindowProvider { + val window: Window +} + +@Suppress("ViewConstructor") +private class DialogLayout( + context: Context, + override val window: Window +) : AbstractComposeView(context), DialogWindowProvider { + + private var content: @Composable () -> Unit by mutableStateOf({}) + + var usePlatformDefaultWidth = false + + override var shouldCreateCompositionOnAttachedToWindow: Boolean = false + private set + + fun setContent(parent: CompositionContext, content: @Composable () -> Unit) { + setParentCompositionContext(parent) + this.content = content + shouldCreateCompositionOnAttachedToWindow = true + createComposition() + } + + override fun measureChild( + child: View?, + parentWidthMeasureSpec: Int, + parentHeightMeasureSpec: Int + ) { + + super.measureChild(child, parentWidthMeasureSpec, parentHeightMeasureSpec) + } + + private val displayWidth: Int + get() { + val density = context.resources.displayMetrics.density + return (context.resources.configuration.screenWidthDp * density).roundToInt() + } + + private val displayHeight: Int + get() { + val density = context.resources.displayMetrics.density + return (context.resources.configuration.screenHeightDp * density).roundToInt() + } + + @Composable + override fun Content() { + content() + } +} + +@SuppressLint("PrivateResource") +private class DialogWrapper( + private var onDismissRequest: () -> Unit, + private var properties: DialogProperties, + private val composeView: View, + layoutDirection: LayoutDirection, + density: Density, + dialogId: UUID +) : ComponentDialog( + ContextThemeWrapper( + composeView.context, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S || properties.decorFitsSystemWindows) { + R.style.DialogWindowTheme + } else { + R.style.FloatingDialogWindowTheme + } + ) +), + ViewRootForInspector { + + private val dialogLayout: DialogLayout + + // On systems older than Android S, there is a bug in the surface insets matrix math used by + // elevation, so high values of maxSupportedElevation break accessibility services: b/232788477. + private val maxSupportedElevation = 8.dp + + override val subCompositionView: AbstractComposeView get() = dialogLayout + + private val defaultSoftInputMode: Int + + init { + val window = window ?: error("Dialog has no window") + defaultSoftInputMode = + window.attributes.softInputMode and WindowManager.LayoutParams.SOFT_INPUT_MASK_ADJUST + window.requestFeature(Window.FEATURE_NO_TITLE) + window.setBackgroundDrawableResource(android.R.color.transparent) + @OptIn(ExperimentalComposeUiApi::class) + WindowCompat.setDecorFitsSystemWindows(window, properties.decorFitsSystemWindows) + dialogLayout = DialogLayout(context, window).apply { + // Set unique id for AbstractComposeView. This allows state restoration for the state + // defined inside the Dialog via rememberSaveable() + setTag(R.id.compose_view_saveable_id_tag, "Dialog:$dialogId") + // Enable children to draw their shadow by not clipping them + clipChildren = false + // Allocate space for elevation + with(density) { elevation = maxSupportedElevation.toPx() } + // Simple outline to force window manager to allocate space for shadow. + // Note that the outline affects clickable area for the dismiss listener. In case of + // shapes like circle the area for dismiss might be to small (rectangular outline + // consuming clicks outside of the circle). + outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, result: Outline) { + result.setRect(0, 0, view.width, view.height) + // We set alpha to 0 to hide the view's shadow and let the composable to draw + // its own shadow. This still enables us to get the extra space needed in the + // surface. + result.alpha = 0f + } + } + } + + /** + * Disables clipping for [this] and all its descendant [ViewGroup]s until we reach a + * [DialogLayout] (the [ViewGroup] containing the Compose hierarchy). + */ + fun ViewGroup.disableClipping() { + clipChildren = false + if (this is DialogLayout) return + for (i in 0 until childCount) { + (getChildAt(i) as? ViewGroup)?.disableClipping() + } + } + + // Turn of all clipping so shadows can be drawn outside the window + (window.decorView as? ViewGroup)?.disableClipping() + setContentView(dialogLayout) + dialogLayout.setViewTreeLifecycleOwner(composeView.findViewTreeLifecycleOwner()) + dialogLayout.setViewTreeViewModelStoreOwner(composeView.findViewTreeViewModelStoreOwner()) + dialogLayout.setViewTreeSavedStateRegistryOwner( + composeView.findViewTreeSavedStateRegistryOwner() + ) + + // Initial setup + updateParameters(onDismissRequest, properties, layoutDirection) + + // Due to how the onDismissRequest callback works + // (it enforces a just-in-time decision on whether to update the state to hide the dialog) + // we need to unconditionally add a callback here that is always enabled, + // meaning we'll never get a system UI controlled predictive back animation + // for these dialogs + onBackPressedDispatcher.addCallback(this) { + if (properties.dismissOnBackPress) { + onDismissRequest() + } + } + } + + private fun setLayoutDirection(layoutDirection: LayoutDirection) { + dialogLayout.layoutDirection = when (layoutDirection) { + LayoutDirection.Ltr -> android.util.LayoutDirection.LTR + LayoutDirection.Rtl -> android.util.LayoutDirection.RTL + } + } + + // TODO(b/159900354): Make the Android Dialog full screen and the scrim fully transparent + + fun setContent(parentComposition: CompositionContext, children: @Composable () -> Unit) { + dialogLayout.setContent(parentComposition, children) + } + + fun updateParameters( + onDismissRequest: () -> Unit, + properties: DialogProperties, + layoutDirection: LayoutDirection + ) { + this.onDismissRequest = onDismissRequest + this.properties = properties + setLayoutDirection(layoutDirection) + if (properties.usePlatformDefaultWidth && !dialogLayout.usePlatformDefaultWidth) { + // Undo fixed size in internalOnLayout, which would suppress size changes when + // usePlatformDefaultWidth is true. + window?.setLayout( + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.WRAP_CONTENT + ) + } + dialogLayout.usePlatformDefaultWidth = properties.usePlatformDefaultWidth + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + @OptIn(ExperimentalComposeUiApi::class) + if (properties.decorFitsSystemWindows) { + window?.setSoftInputMode(defaultSoftInputMode) + } else { + @Suppress("DEPRECATION") + window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + } + } + } + + fun disposeComposition() { + dialogLayout.disposeComposition() + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + val result = super.onTouchEvent(event) + if (result && properties.dismissOnClickOutside) { + onDismissRequest() + } + + return result + } + + override fun cancel() { + // Prevents the dialog from dismissing itself + return + } +} + +@Composable +private fun DialogLayout( + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Layout( + content = content, + modifier = modifier + ) { measurables, constraints -> + val placeables = measurables.map { it.measure(constraints) } + val width = placeables.maxBy { it.width }.width + val height = placeables.maxBy { it.height }.height + layout(width, height) { + placeables.forEach { it.placeRelative(0, 0) } + } + } +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ColorPicker.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ColorPicker.kt new file mode 100644 index 0000000000..6b5a5b31bf --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ColorPicker.kt @@ -0,0 +1,27 @@ +package me.rhunk.snapenhance.ui.util + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +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.unit.dp +import com.github.skydoves.colorpicker.compose.AlphaTile + +@Composable +fun CircularAlphaTile( + selectedColor: Color?, +) { + AlphaTile( + modifier = Modifier + .size(30.dp) + .border(2.dp, Color.White, shape = RoundedCornerShape(15.dp)) + .clip(RoundedCornerShape(15.dp)), + selectedColor = selectedColor ?: Color.Transparent, + tileEvenColor = selectedColor?.let { Color(0xFFCBCBCB) } ?: Color.Transparent, + tileOddColor = selectedColor?.let { Color.White } ?: Color.Transparent, + tileSize = 8.dp, + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/LifecycleHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/LifecycleHelper.kt new file mode 100644 index 0000000000..493890713b --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/LifecycleHelper.kt @@ -0,0 +1,28 @@ +package me.rhunk.snapenhance.ui.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.rememberUpdatedState +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner + +//https://stackoverflow.com/questions/66546962/jetpack-compose-how-do-i-refresh-a-screen-when-app-returns-to-foreground +@Composable +fun OnLifecycleEvent(onEvent: (owner: LifecycleOwner, event: Lifecycle.Event) -> Unit) { + val eventHandler = rememberUpdatedState(onEvent) + val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current) + + DisposableEffect(lifecycleOwner.value) { + val lifecycle = lifecycleOwner.value.lifecycle + val observer = LifecycleEventObserver { owner, event -> + eventHandler.value(owner, event) + } + + lifecycle.addObserver(observer) + onDispose { + lifecycle.removeObserver(observer) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ObservableMutableState.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ObservableMutableState.kt new file mode 100644 index 0000000000..73ea1b3c17 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ObservableMutableState.kt @@ -0,0 +1,19 @@ +package me.rhunk.snapenhance.ui.util + +import androidx.compose.runtime.MutableState + +class ObservableMutableState<T>( + defaultValue: T, + val onChange: (T, T) -> Unit = { _, _ -> }, +) : MutableState<T> { + private var mutableValue: T = defaultValue + override var value: T + get() = mutableValue + set(value) { + val oldValue = mutableValue + mutableValue = value + onChange(oldValue, value) + } + override fun component1() = value + override fun component2(): (T) -> Unit = { value = it } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/coil/CoilPreviewDecoder.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/coil/CoilPreviewDecoder.kt new file mode 100644 index 0000000000..aad1e31c2c --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/coil/CoilPreviewDecoder.kt @@ -0,0 +1,61 @@ +package me.rhunk.snapenhance.ui.util.coil + +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import coil.decode.DecodeResult +import coil.decode.Decoder +import coil.fetch.SourceResult +import me.rhunk.snapenhance.common.data.FileType +import me.rhunk.snapenhance.common.data.download.MediaEncryptionKeyPair +import me.rhunk.snapenhance.common.data.download.SplitMediaAssetType +import me.rhunk.snapenhance.common.logger.AbstractLogger +import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper +import me.rhunk.snapenhance.core.util.media.PreviewUtils + +class CoilPreviewDecoder( + private val resources: Resources, + private val sourceResult: SourceResult, + private val encryptionKeyPair: MediaEncryptionKeyPair? = null, + private val mergeOverlay: Boolean = false +): Decoder { + override suspend fun decode(): DecodeResult { + return sourceResult.source.file().toFile().inputStream().use { fileInputStream -> + val cipherInputStream = encryptionKeyPair?.decryptInputStream(fileInputStream) ?: fileInputStream + + var bitmap: Bitmap? = null + var overlayBitmap: Bitmap? = null + + MediaDownloaderHelper.getSplitElements(cipherInputStream) { type, inputStream -> + if (inputStream.available() > 50 * 1024 * 1024) { + return@getSplitElements + } + if (type == SplitMediaAssetType.ORIGINAL || (mergeOverlay && type == SplitMediaAssetType.OVERLAY)) { + runCatching { + val bytes = inputStream.readBytes() + PreviewUtils.createPreview(bytes, isVideo = FileType.fromByteArray(bytes).isVideo)?.let { + if (type == SplitMediaAssetType.ORIGINAL) { + bitmap = it + } else { + overlayBitmap = it + } + } + }.onFailure { + AbstractLogger.directError("CoilPreviewDecoder", it) + } + } + } + + if (mergeOverlay && overlayBitmap != null) { + bitmap = PreviewUtils.mergeBitmapOverlay(bitmap!!, overlayBitmap!!) + } + + cipherInputStream.close() + + DecodeResult( + drawable = BitmapDrawable(resources, bitmap!!), + isSampled = true + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/coil/ComposeImageHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/coil/ComposeImageHelper.kt new file mode 100644 index 0000000000..49ca72f159 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/coil/ComposeImageHelper.kt @@ -0,0 +1,71 @@ +package me.rhunk.snapenhance.ui.util.coil + +import android.content.Context +import android.graphics.drawable.ColorDrawable +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.requiredWidthIn +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil.compose.rememberAsyncImagePainter +import coil.request.ImageRequest +import coil.size.Precision +import me.rhunk.snapenhance.R +import me.rhunk.snapenhance.RemoteSideContext +import me.rhunk.snapenhance.common.data.download.MediaEncryptionKeyPair + +@Composable +fun BitmojiImage(context: RemoteSideContext, modifier: Modifier = Modifier, size: Int = 48, url: String?) { + Image( + painter = rememberAsyncImagePainter( + model = ImageRequestHelper.newBitmojiImageRequest( + context.androidContext, + url + ), + imageLoader = context.imageLoader + ), + contentDescription = null, + contentScale = ContentScale.Inside, + modifier = Modifier + .requiredWidthIn(min = 0.dp, max = size.dp) + .height(size.dp) + .clip(MaterialTheme.shapes.medium) + .then(modifier) + ) +} + +fun ImageRequest.Builder.cacheKey(key: String?) = apply { + memoryCacheKey(key) + diskCacheKey(key) +} + +object ImageRequestHelper { + fun newBitmojiImageRequest(context: Context, url: String?) = ImageRequest.Builder(context) + .data(url) + .fallback(R.drawable.bitmoji_blank) + .precision(Precision.INEXACT) + .crossfade(true) + .cacheKey(url) + .build() + + fun newPreviewImageRequest(context: Context, url: String, mediaEncryptionKeyPair: MediaEncryptionKeyPair? = null) = ImageRequest.Builder(context) + .cacheKey(url) + .precision(Precision.INEXACT) + .crossfade(true) + .placeholder(ColorDrawable(0x1EFFFFFF)) + .crossfade(200) + .data(url) + .decoderFactory { result, _, _ -> + CoilPreviewDecoder( + context.resources, + result, + mediaEncryptionKeyPair, + mergeOverlay = true + ) + } + .build() +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/pullrefresh/PullRefresh.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/pullrefresh/PullRefresh.kt new file mode 100644 index 0000000000..ddafcfbe79 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/pullrefresh/PullRefresh.kt @@ -0,0 +1,122 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("DEPRECATION") + +package me.rhunk.snapenhance.ui.util.pullrefresh + +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.NestedScrollSource.Companion.Drag +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.platform.inspectable +import androidx.compose.ui.unit.Velocity + +/** + * A nested scroll modifier that provides scroll events to [state]. + * + * Note that this modifier must be added above a scrolling container, such as a lazy column, in + * order to receive scroll events. For example: + * + * @sample androidx.compose.material.samples.PullRefreshSample + * + * @param state The [PullRefreshState] associated with this pull-to-refresh component. + * The state will be updated by this modifier. + * @param enabled If not enabled, all scroll delta and fling velocity will be ignored. + */ +// TODO(b/244423199): Move pullRefresh into its own material library similar to material-ripple. +fun Modifier.pullRefresh( + state: PullRefreshState, + enabled: Boolean = true, +) = inspectable( + inspectorInfo = debugInspectorInfo { + name = "pullRefresh" + properties["state"] = state + properties["enabled"] = enabled + }, +) { + Modifier.pullRefresh(state::onPull, state::onRelease, enabled) +} + +/** + * A nested scroll modifier that provides [onPull] and [onRelease] callbacks to aid building custom + * pull refresh components. + * + * Note that this modifier must be added above a scrolling container, such as a lazy column, in + * order to receive scroll events. For example: + * + * @sample androidx.compose.material.samples.CustomPullRefreshSample + * + * @param onPull Callback for dispatching vertical scroll delta, takes float pullDelta as argument. + * Positive delta (pulling down) is dispatched only if the child does not consume it (i.e. pulling + * down despite being at the top of a scrollable component), whereas negative delta (swiping up) is + * dispatched first (in case it is needed to push the indicator back up), and then the unconsumed + * delta is passed on to the child. The callback returns how much delta was consumed. + * @param onRelease Callback for when drag is released, takes float flingVelocity as argument. + * The callback returns how much velocity was consumed - in most cases this should only consume + * velocity if pull refresh has been dragged already and the velocity is positive (the fling is + * downwards), as an upwards fling should typically still scroll a scrollable component beneath the + * pullRefresh. This is invoked before any remaining velocity is passed to the child. + * @param enabled If not enabled, all scroll delta and fling velocity will be ignored and neither + * [onPull] nor [onRelease] will be invoked. + */ +fun Modifier.pullRefresh( + onPull: (pullDelta: Float) -> Float, + onRelease: suspend (flingVelocity: Float) -> Float, + enabled: Boolean = true, +) = inspectable( + inspectorInfo = debugInspectorInfo { + name = "pullRefresh" + properties["onPull"] = onPull + properties["onRelease"] = onRelease + properties["enabled"] = enabled + }, +) { + Modifier.nestedScroll(PullRefreshNestedScrollConnection(onPull, onRelease, enabled)) +} + +private class PullRefreshNestedScrollConnection( + private val onPull: (pullDelta: Float) -> Float, + private val onRelease: suspend (flingVelocity: Float) -> Float, + private val enabled: Boolean, +) : NestedScrollConnection { + + override fun onPreScroll( + available: Offset, + source: NestedScrollSource, + ): Offset = when { + !enabled -> Offset.Zero + source == Drag && available.y < 0 -> Offset(0f, onPull(available.y)) // Swiping up + else -> Offset.Zero + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset = when { + !enabled -> Offset.Zero + source == Drag && available.y > 0 -> Offset(0f, onPull(available.y)) // Pulling down + else -> Offset.Zero + } + + override suspend fun onPreFling(available: Velocity): Velocity { + return Velocity(0f, onRelease(available.y)) + } +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/pullrefresh/PullRefreshIndicator.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/pullrefresh/PullRefreshIndicator.kt new file mode 100644 index 0000000000..862f215b8d --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/pullrefresh/PullRefreshIndicator.kt @@ -0,0 +1,238 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package me.rhunk.snapenhance.ui.util.pullrefresh + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.center +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow + +/** + * The default indicator for Compose pull-to-refresh, based on Android's SwipeRefreshLayout. + * + * @sample androidx.compose.material.samples.PullRefreshSample + * + * @param refreshing A boolean representing whether a refresh is occurring. + * @param state The [PullRefreshState] which controls where and how the indicator will be drawn. + * @param modifier Modifiers for the indicator. + * @param backgroundColor The color of the indicator's background. + * @param contentColor The color of the indicator's arc and arrow. + * @param scale A boolean controlling whether the indicator's size scales with pull progress or not. + */ +// TODO(b/244423199): Consider whether the state parameter should be replaced with lambdas to +// enable people to use this indicator with custom pull-to-refresh components. +@Composable +fun PullRefreshIndicator( + refreshing: Boolean, + state: PullRefreshState, + modifier: Modifier = Modifier, + backgroundColor: Color = MaterialTheme.colorScheme.surface, + contentColor: Color = contentColorFor(backgroundColor), + scale: Boolean = false, +) { + val showElevation by remember(refreshing, state) { + derivedStateOf { refreshing || state.position > 0.5f } + } + + Surface( + modifier = modifier + .size(IndicatorSize) + .pullRefreshIndicatorTransform(state, scale), + shape = SpinnerShape, + color = backgroundColor, + shadowElevation = if (showElevation) Elevation else 0.dp, + ) { + Crossfade( + targetState = refreshing, + animationSpec = tween(durationMillis = CrossfadeDurationMs), + ) { refreshing -> + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + val spinnerSize = (ArcRadius + StrokeWidth).times(2) + + if (refreshing) { + CircularProgressIndicator( + color = contentColor, + strokeWidth = StrokeWidth, + modifier = Modifier.size(spinnerSize), + ) + } else { + CircularArrowIndicator(state, contentColor, Modifier.size(spinnerSize)) + } + } + } + } +} + +/** + * Modifier.size MUST be specified. + */ +@Composable +private fun CircularArrowIndicator( + state: PullRefreshState, + color: Color, + modifier: Modifier, +) { + val path = remember { Path().apply { fillType = PathFillType.EvenOdd } } + + val targetAlpha by remember(state) { + derivedStateOf { + if (state.progress >= 1f) MaxAlpha else MinAlpha + } + } + + val alphaState = animateFloatAsState(targetValue = targetAlpha, animationSpec = AlphaTween) + + // Empty semantics for tests + Canvas(modifier.semantics {}) { + val values = ArrowValues(state.progress) + val alpha = alphaState.value + + rotate(degrees = values.rotation) { + val arcRadius = ArcRadius.toPx() + StrokeWidth.toPx() / 2f + val arcBounds = Rect( + size.center.x - arcRadius, + size.center.y - arcRadius, + size.center.x + arcRadius, + size.center.y + arcRadius, + ) + drawArc( + color = color, + alpha = alpha, + startAngle = values.startAngle, + sweepAngle = values.endAngle - values.startAngle, + useCenter = false, + topLeft = arcBounds.topLeft, + size = arcBounds.size, + style = Stroke( + width = StrokeWidth.toPx(), + cap = StrokeCap.Square, + ), + ) + drawArrow(path, arcBounds, color, alpha, values) + } + } +} + +@Immutable +private class ArrowValues( + val rotation: Float, + val startAngle: Float, + val endAngle: Float, + val scale: Float, +) + +private fun ArrowValues(progress: Float): ArrowValues { + // Discard first 40% of progress. Scale remaining progress to full range between 0 and 100%. + val adjustedPercent = max(min(1f, progress) - 0.4f, 0f) * 5 / 3 + // How far beyond the threshold pull has gone, as a percentage of the threshold. + val overshootPercent = abs(progress) - 1.0f + // Limit the overshoot to 200%. Linear between 0 and 200. + val linearTension = overshootPercent.coerceIn(0f, 2f) + // Non-linear tension. Increases with linearTension, but at a decreasing rate. + val tensionPercent = linearTension - linearTension.pow(2) / 4 + + // Calculations based on SwipeRefreshLayout specification. + val endTrim = adjustedPercent * MaxProgressArc + val rotation = (-0.25f + 0.4f * adjustedPercent + tensionPercent) * 0.5f + val startAngle = rotation * 360 + val endAngle = (rotation + endTrim) * 360 + val scale = min(1f, adjustedPercent) + + return ArrowValues(rotation, startAngle, endAngle, scale) +} + +private fun DrawScope.drawArrow( + arrow: Path, + bounds: Rect, + color: Color, + alpha: Float, + values: ArrowValues, +) { + arrow.reset() + arrow.moveTo(0f, 0f) // Move to left corner + arrow.lineTo(x = ArrowWidth.toPx() * values.scale, y = 0f) // Line to right corner + + // Line to tip of arrow + arrow.lineTo( + x = ArrowWidth.toPx() * values.scale / 2, + y = ArrowHeight.toPx() * values.scale, + ) + + val radius = min(bounds.width, bounds.height) / 2f + val inset = ArrowWidth.toPx() * values.scale / 2f + arrow.translate( + Offset( + x = radius + bounds.center.x - inset, + y = bounds.center.y + StrokeWidth.toPx() / 2f, + ), + ) + arrow.close() + rotate(degrees = values.endAngle) { + drawPath(path = arrow, color = color, alpha = alpha) + } +} + +private const val CrossfadeDurationMs = 100 +private const val MaxProgressArc = 0.8f + +private val IndicatorSize = 40.dp +private val SpinnerShape = CircleShape +private val ArcRadius = 7.5.dp +private val StrokeWidth = 2.5.dp +private val ArrowWidth = 10.dp +private val ArrowHeight = 5.dp +private val Elevation = 6.dp + +// Values taken from SwipeRefreshLayout +private const val MinAlpha = 0.3f +private const val MaxAlpha = 1f +private val AlphaTween = tween<Float>(300, easing = LinearEasing) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/pullrefresh/PullRefreshIndicatorTransform.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/pullrefresh/PullRefreshIndicatorTransform.kt new file mode 100644 index 0000000000..c5398762b2 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/pullrefresh/PullRefreshIndicatorTransform.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("DEPRECATION") + +package me.rhunk.snapenhance.ui.util.pullrefresh + +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.platform.inspectable + +/** + * A modifier for translating the position and scaling the size of a pull-to-refresh indicator + * based on the given [PullRefreshState]. + * + * @sample androidx.compose.material.samples.PullRefreshIndicatorTransformSample + * + * @param state The [PullRefreshState] which determines the position of the indicator. + * @param scale A boolean controlling whether the indicator's size scales with pull progress or not. + */ +// TODO: Consider whether the state parameter should be replaced with lambdas. +fun Modifier.pullRefreshIndicatorTransform( + state: PullRefreshState, + scale: Boolean = false, +) = inspectable( + inspectorInfo = debugInspectorInfo { + name = "pullRefreshIndicatorTransform" + properties["state"] = state + properties["scale"] = scale + }, +) { + Modifier + // Essentially we only want to clip the at the top, so the indicator will not appear when + // the position is 0. It is preferable to clip the indicator as opposed to the layout that + // contains the indicator, as this would also end up clipping shadows drawn by items in a + // list for example - so we leave the clipping to the scrolling container. We use MAX_VALUE + // for the other dimensions to allow for more room for elevation / arbitrary indicators - we + // only ever really want to clip at the top edge. + .drawWithContent { + clipRect( + top = 0f, + left = -Float.MAX_VALUE, + right = Float.MAX_VALUE, + bottom = Float.MAX_VALUE, + ) { + this@drawWithContent.drawContent() + } + } + .graphicsLayer { + translationY = state.position - size.height + + if (scale && !state.refreshing) { + val scaleFraction = LinearOutSlowInEasing + .transform(state.position / state.threshold) + .coerceIn(0f, 1f) + scaleX = scaleFraction + scaleY = scaleFraction + } + } +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/pullrefresh/PullRefreshState.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/pullrefresh/PullRefreshState.kt new file mode 100644 index 0000000000..1811f5fe91 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/pullrefresh/PullRefreshState.kt @@ -0,0 +1,219 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package me.rhunk.snapenhance.ui.util.pullrefresh + +import androidx.compose.animation.core.animate +import androidx.compose.foundation.MutatorMutex +import androidx.compose.runtime.* +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlin.math.abs +import kotlin.math.pow + +/** + * Creates a [PullRefreshState] that is remembered across compositions. + * + * Changes to [refreshing] will result in [PullRefreshState] being updated. + * + * @sample androidx.compose.material.samples.PullRefreshSample + * + * @param refreshing A boolean representing whether a refresh is currently occurring. + * @param onRefresh The function to be called to trigger a refresh. + * @param refreshThreshold The threshold below which, if a release + * occurs, [onRefresh] will be called. + * @param refreshingOffset The offset at which the indicator will be drawn while refreshing. This + * offset corresponds to the position of the bottom of the indicator. + */ +@Composable +fun rememberPullRefreshState( + refreshing: Boolean, + onRefresh: () -> Unit, + refreshThreshold: Dp = PullRefreshDefaults.RefreshThreshold, + refreshingOffset: Dp = PullRefreshDefaults.RefreshingOffset, +): PullRefreshState { + require(refreshThreshold > 0.dp) { "The refresh trigger must be greater than zero!" } + + val scope = rememberCoroutineScope() + val onRefreshState = rememberUpdatedState(onRefresh) + val thresholdPx: Float + val refreshingOffsetPx: Float + + with(LocalDensity.current) { + thresholdPx = refreshThreshold.toPx() + refreshingOffsetPx = refreshingOffset.toPx() + } + + val state = remember(scope) { + PullRefreshState(scope, onRefreshState, refreshingOffsetPx, thresholdPx) + } + + SideEffect { + state.setRefreshing(refreshing) + state.setThreshold(thresholdPx) + state.setRefreshingOffset(refreshingOffsetPx) + } + + return state +} + +/** + * A state object that can be used in conjunction with [pullRefresh] to add pull-to-refresh + * behaviour to a scroll component. Based on Android's SwipeRefreshLayout. + * + * Provides [progress], a float representing how far the user has pulled as a percentage of the + * refreshThreshold. Values of one or less indicate that the user has not yet pulled past the + * threshold. Values greater than one indicate how far past the threshold the user has pulled. + * + * Can be used in conjunction with [pullRefreshIndicatorTransform] to implement Android-like + * pull-to-refresh behaviour with a custom indicator. + * + * Should be created using [rememberPullRefreshState]. + */ +class PullRefreshState internal constructor( + private val animationScope: CoroutineScope, + private val onRefreshState: State<() -> Unit>, + refreshingOffset: Float, + threshold: Float, +) { + /** + * A float representing how far the user has pulled as a percentage of the refreshThreshold. + * + * If the component has not been pulled at all, progress is zero. If the pull has reached + * halfway to the threshold, progress is 0.5f. A value greater than 1 indicates that pull has + * gone beyond the refreshThreshold - e.g. a value of 2f indicates that the user has pulled to + * two times the refreshThreshold. + */ + val progress get() = adjustedDistancePulled / threshold + + internal val refreshing get() = _refreshing + internal val position get() = _position + internal val threshold get() = _threshold + + private val adjustedDistancePulled by derivedStateOf { distancePulled * DragMultiplier } + + private var _refreshing by mutableStateOf(false) + private var _position by mutableFloatStateOf(0f) + private var distancePulled by mutableFloatStateOf(0f) + private var _threshold by mutableFloatStateOf(threshold) + private var _refreshingOffset by mutableFloatStateOf(refreshingOffset) + + internal fun onPull(pullDelta: Float): Float { + if (_refreshing) return 0f // Already refreshing, do nothing. + + val newOffset = (distancePulled + pullDelta).coerceAtLeast(0f) + val dragConsumed = newOffset - distancePulled + distancePulled = newOffset + _position = calculateIndicatorPosition() + return dragConsumed + } + + internal fun onRelease(velocity: Float): Float { + if (refreshing) return 0f // Already refreshing, do nothing + + if (adjustedDistancePulled > threshold) { + onRefreshState.value() + } + animateIndicatorTo(0f) + val consumed = when { + // We are flinging without having dragged the pull refresh (for example a fling inside + // a list) - don't consume + distancePulled == 0f -> 0f + // If the velocity is negative, the fling is upwards, and we don't want to prevent the + // the list from scrolling + velocity < 0f -> 0f + // We are showing the indicator, and the fling is downwards - consume everything + else -> velocity + } + distancePulled = 0f + return consumed + } + + internal fun setRefreshing(refreshing: Boolean) { + if (_refreshing != refreshing) { + _refreshing = refreshing + distancePulled = 0f + animateIndicatorTo(if (refreshing) _refreshingOffset else 0f) + } + } + + internal fun setThreshold(threshold: Float) { + _threshold = threshold + } + + internal fun setRefreshingOffset(refreshingOffset: Float) { + if (_refreshingOffset != refreshingOffset) { + _refreshingOffset = refreshingOffset + if (refreshing) animateIndicatorTo(refreshingOffset) + } + } + + // Make sure to cancel any existing animations when we launch a new one. We use this instead of + // Animatable as calling snapTo() on every drag delta has a one frame delay, and some extra + // overhead of running through the animation pipeline instead of directly mutating the state. + private val mutatorMutex = MutatorMutex() + + private fun animateIndicatorTo(offset: Float) = animationScope.launch { + mutatorMutex.mutate { + animate(initialValue = _position, targetValue = offset) { value, _ -> + _position = value + } + } + } + + private fun calculateIndicatorPosition(): Float = when { + // If drag hasn't gone past the threshold, the position is the adjustedDistancePulled. + adjustedDistancePulled <= threshold -> adjustedDistancePulled + else -> { + // How far beyond the threshold pull has gone, as a percentage of the threshold. + val overshootPercent = abs(progress) - 1.0f + // Limit the overshoot to 200%. Linear between 0 and 200. + val linearTension = overshootPercent.coerceIn(0f, 2f) + // Non-linear tension. Increases with linearTension, but at a decreasing rate. + val tensionPercent = linearTension - linearTension.pow(2) / 4 + // The additional offset beyond the threshold. + val extraOffset = threshold * tensionPercent + threshold + extraOffset + } + } +} + +/** + * Default parameter values for [rememberPullRefreshState]. + */ +object PullRefreshDefaults { + /** + * If the indicator is below this threshold offset when it is released, a refresh + * will be triggered. + */ + val RefreshThreshold = 80.dp + + /** + * The offset at which the indicator should be rendered whilst a refresh is occurring. + */ + val RefreshingOffset = 56.dp +} + +/** + * The distance pulled is multiplied by this value to give us the adjusted distance pulled, which + * is used in calculating the indicator position (when the adjusted distance pulled is less than + * the refresh threshold, it is the indicator position, otherwise the indicator position is + * derived from the progress). + */ +private const val DragMultiplier = 0.5f diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/EncryptionHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/EncryptionHelper.kt deleted file mode 100644 index b10159dca5..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/util/EncryptionHelper.kt +++ /dev/null @@ -1,67 +0,0 @@ -package me.rhunk.snapenhance.util - -import me.rhunk.snapenhance.Constants -import me.rhunk.snapenhance.data.ContentType -import me.rhunk.snapenhance.util.protobuf.ProtoReader -import java.io.InputStream -import java.util.Base64 -import javax.crypto.Cipher -import javax.crypto.CipherInputStream -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec - -object EncryptionUtils { - fun decryptInputStreamFromArroyo( - inputStream: InputStream, - contentType: ContentType, - messageProto: ProtoReader - ): InputStream { - var resultInputStream = inputStream - val encryptionProtoPath: IntArray = when (contentType) { - ContentType.NOTE -> Constants.ARROYO_NOTE_ENCRYPTION_PROTO_PATH - ContentType.SNAP -> Constants.ARROYO_SNAP_ENCRYPTION_PROTO_PATH - ContentType.EXTERNAL_MEDIA -> Constants.ARROYO_EXTERNAL_MEDIA_ENCRYPTION_PROTO_PATH - else -> throw IllegalArgumentException("Invalid content type: $contentType") - } - - //decrypt the content if needed - messageProto.readPath(*encryptionProtoPath)?.let { - val encryptionProtoIndex: Int = if (it.exists(Constants.ARROYO_ENCRYPTION_PROTO_INDEX_V2)) { - Constants.ARROYO_ENCRYPTION_PROTO_INDEX_V2 - } else if (it.exists(Constants.ARROYO_ENCRYPTION_PROTO_INDEX)) { - Constants.ARROYO_ENCRYPTION_PROTO_INDEX - } else { - return resultInputStream - } - resultInputStream = decryptInputStream( - resultInputStream, - encryptionProtoIndex == Constants.ARROYO_ENCRYPTION_PROTO_INDEX_V2, - it, - encryptionProtoIndex - ) - } - return resultInputStream - } - - fun decryptInputStream( - inputStream: InputStream, - base64Encryption: Boolean, - mediaInfoProto: ProtoReader, - encryptionProtoIndex: Int - ): InputStream { - val mediaEncryption = mediaInfoProto.readPath(encryptionProtoIndex)!! - var key: ByteArray = mediaEncryption.getByteArray(1)!! - var iv: ByteArray = mediaEncryption.getByteArray(2)!! - - //audio note and external medias have their key and iv encoded in base64 - if (base64Encryption) { - val decoder = Base64.getMimeDecoder() - key = decoder.decode(key) - iv = decoder.decode(iv) - } - - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv)) - return CipherInputStream(inputStream, cipher) - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/MediaDownloaderHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/MediaDownloaderHelper.kt deleted file mode 100644 index dea7ce9e74..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/util/MediaDownloaderHelper.kt +++ /dev/null @@ -1,117 +0,0 @@ -package me.rhunk.snapenhance.util - -import com.arthenica.ffmpegkit.FFmpegKit -import me.rhunk.snapenhance.Logger -import me.rhunk.snapenhance.data.FileType -import me.rhunk.snapenhance.util.download.RemoteMediaResolver -import java.io.ByteArrayInputStream -import java.io.File -import java.io.FileInputStream -import java.io.FileNotFoundException -import java.io.FileOutputStream -import java.io.InputStream -import java.util.zip.ZipInputStream - -enum class MediaType { - ORIGINAL, OVERLAY -} -object MediaDownloaderHelper { - fun downloadMediaFromReference(mediaReference: ByteArray, mergeOverlay: Boolean, isPreviewMode: Boolean, decryptionCallback: (InputStream) -> InputStream): Map<MediaType, ByteArray> { - val inputStream: InputStream = RemoteMediaResolver.downloadBoltMedia(mediaReference) ?: throw FileNotFoundException("Unable to get media key. Check the logs for more info") - val content = decryptionCallback(inputStream).readBytes() - val fileType = FileType.fromByteArray(content) - val isZipFile = fileType == FileType.ZIP - - //videos with overlay are packed in a zip file - //there are 2 files in the zip file, the video (webm) and the overlay (png) - if (isZipFile) { - var videoData: ByteArray? = null - var overlayData: ByteArray? = null - val zipInputStream = ZipInputStream(ByteArrayInputStream(content)) - while (zipInputStream.nextEntry != null) { - val zipEntryData: ByteArray = zipInputStream.readBytes() - val entryFileType = FileType.fromByteArray(zipEntryData) - if (entryFileType.isVideo) { - videoData = zipEntryData - } else if (entryFileType.isImage) { - overlayData = zipEntryData - } - } - videoData ?: throw FileNotFoundException("Unable to find video file in zip file") - overlayData ?: throw FileNotFoundException("Unable to find overlay file in zip file") - if (mergeOverlay) { - val mergedVideo = mergeOverlay(videoData, overlayData, isPreviewMode) - return mapOf(MediaType.ORIGINAL to mergedVideo) - } - return mapOf(MediaType.ORIGINAL to videoData, MediaType.OVERLAY to overlayData) - } - - return mapOf(MediaType.ORIGINAL to content) - } - - fun downloadDashChapter(playlistXmlData: String, startTime: Long, duration: Long?): ByteArray { - val outputFile = File.createTempFile("output", ".mp4") - val playlistFile = File.createTempFile("playlist", ".mpd").also { - with(FileOutputStream(it)) { - write(playlistXmlData.toByteArray(Charsets.UTF_8)) - close() - } - } - - val ffmpegSession = FFmpegKit.execute( - "-y -i " + - playlistFile.absolutePath + - " -ss '${startTime}ms'" + - (if (duration != null) " -t '${duration}ms'" else "") + - " -c:v libx264 -threads 6 -q:v 13 " + outputFile.absolutePath - ) - - playlistFile.delete() - if (!ffmpegSession.returnCode.isValueSuccess) { - throw Exception(ffmpegSession.output) - } - val outputData = FileInputStream(outputFile).readBytes() - outputFile.delete() - return outputData - } - - fun mergeOverlay(original: ByteArray, overlay: ByteArray, isPreviewMode: Boolean): ByteArray { - val originalFileType = FileType.fromByteArray(original) - val overlayFileType = FileType.fromByteArray(overlay) - //merge files - val mergedFile = File.createTempFile("merged", "." + originalFileType.fileExtension) - val tempVideoFile = File.createTempFile("original", "." + originalFileType.fileExtension).also { - with(FileOutputStream(it)) { - write(original) - close() - } - } - val tempOverlayFile = File.createTempFile("overlay", "." + overlayFileType.fileExtension).also { - with(FileOutputStream(it)) { - write(overlay) - close() - } - } - - //TODO: improve ffmpeg speed - val fFmpegSession = FFmpegKit.execute( - "-y -i " + - tempVideoFile.absolutePath + - " -i " + - tempOverlayFile.absolutePath + - " -filter_complex \"[0]scale2ref[img][vid];[img]setsar=1[img];[vid]nullsink; [img][1]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw*sar/2):2*trunc(ih/2)\" -c:v libx264 -q:v 13 -c:a copy " + - " -threads 6 ${(if (isPreviewMode) "-frames:v 1" else "")} " + - mergedFile.absolutePath - ) - tempVideoFile.delete() - tempOverlayFile.delete() - if (fFmpegSession.returnCode.value != 0) { - mergedFile.delete() - Logger.xposedLog(fFmpegSession.output) - throw IllegalStateException("Failed to merge video and overlay. See logs for more details.") - } - val mergedFileData: ByteArray = FileInputStream(mergedFile).readBytes() - mergedFile.delete() - return mergedFileData - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/PreviewCreator.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/PreviewCreator.kt deleted file mode 100644 index a5a288b0b9..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/util/PreviewCreator.kt +++ /dev/null @@ -1,41 +0,0 @@ -package me.rhunk.snapenhance.util - -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.media.MediaDataSource -import android.media.MediaMetadataRetriever - -object PreviewUtils { - fun createPreview(data: ByteArray, isVideo: Boolean): Bitmap? { - if (!isVideo) { - return BitmapFactory.decodeByteArray(data, 0, data.size) - } - val retriever = MediaMetadataRetriever() - retriever.setDataSource(object : MediaDataSource() { - override fun readAt( - position: Long, - buffer: ByteArray, - offset: Int, - size: Int - ): Int { - var newSize = size - val length = data.size - if (position >= length) { - return -1 - } - if (position + newSize > length) { - newSize = length - position.toInt() - } - System.arraycopy(data, position.toInt(), buffer, offset, newSize) - return newSize - } - - override fun getSize(): Long { - return data.size.toLong() - } - - override fun close() {} - }) - return retriever.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC) - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/ReflectionHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/ReflectionHelper.kt deleted file mode 100644 index 86a026fc58..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/util/ReflectionHelper.kt +++ /dev/null @@ -1,119 +0,0 @@ -package me.rhunk.snapenhance.util - -import java.lang.reflect.Field -import java.lang.reflect.Method -import java.util.Arrays -import java.util.Objects - -object ReflectionHelper { - /** - * Searches for a field with a class that has a method with the specified name - */ - fun searchFieldWithClassMethod(clazz: Class<*>, methodName: String): Field? { - return clazz.declaredFields.firstOrNull { f: Field? -> - try { - return@firstOrNull Arrays.stream( - f!!.type.declaredMethods - ).anyMatch { method: Method -> method.name == methodName } - } catch (e: Exception) { - return@firstOrNull false - } - } - } - - fun searchFieldByType(clazz: Class<*>, type: Class<*>): Field? { - return clazz.declaredFields.firstOrNull { f: Field? -> f!!.type == type } - } - - fun searchFieldTypeInSuperClasses(clazz: Class<*>, type: Class<*>): Field? { - val field = searchFieldByType(clazz, type) - if (field != null) { - return field - } - val superclass = clazz.superclass - return superclass?.let { searchFieldTypeInSuperClasses(it, type) } - } - - fun searchFieldStartsWithToString( - clazz: Class<*>, - instance: Any, - toString: String? - ): Field? { - return clazz.declaredFields.firstOrNull { f: Field -> - try { - f.isAccessible = true - return@firstOrNull Objects.requireNonNull(f[instance]).toString() - .startsWith( - toString!! - ) - } catch (e: Throwable) { - return@firstOrNull false - } - } - } - - - fun searchFieldContainsToString( - clazz: Class<*>, - instance: Any?, - toString: String? - ): Field? { - return clazz.declaredFields.firstOrNull { f: Field -> - try { - f.isAccessible = true - return@firstOrNull Objects.requireNonNull(f[instance]).toString() - .contains(toString!!) - } catch (e: Throwable) { - return@firstOrNull false - } - } - } - - fun searchFirstFieldTypeInClassRecursive(clazz: Class<*>, type: Class<*>): Field? { - return clazz.declaredFields.firstOrNull { - val field = searchFieldByType(it.type, type) - return@firstOrNull field != null - } - } - - /** - * Searches for a field with a class that has a method with the specified return type - */ - fun searchMethodWithReturnType(clazz: Class<*>, returnType: Class<*>): Method? { - return clazz.declaredMethods.first { m: Method -> m.returnType == returnType } - } - - /** - * Searches for a field with a class that has a method with the specified return type and parameter types - */ - fun searchMethodWithParameterAndReturnType( - aClass: Class<*>, - returnType: Class<*>, - vararg parameters: Class<*> - ): Method? { - return aClass.declaredMethods.firstOrNull { m: Method -> - if (m.returnType != returnType) { - return@firstOrNull false - } - val parameterTypes = m.parameterTypes - if (parameterTypes.size != parameters.size) { - return@firstOrNull false - } - for (i in parameterTypes.indices) { - if (parameterTypes[i] != parameters[i]) { - return@firstOrNull false - } - } - true - } - } - - fun getDeclaredFieldsRecursively(clazz: Class<*>): List<Field> { - val fields = clazz.declaredFields.toMutableList() - val superclass = clazz.superclass - if (superclass != null) { - fields.addAll(getDeclaredFieldsRecursively(superclass)) - } - return fields - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/download/DownloadServer.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/download/DownloadServer.kt deleted file mode 100644 index 64a05fec02..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/util/download/DownloadServer.kt +++ /dev/null @@ -1,116 +0,0 @@ -package me.rhunk.snapenhance.util.download - -import me.rhunk.snapenhance.Logger -import me.rhunk.snapenhance.Logger.debug -import me.rhunk.snapenhance.ModContext -import java.io.BufferedReader -import java.io.File -import java.io.InputStreamReader -import java.io.PrintWriter -import java.net.ServerSocket -import java.net.Socket -import java.util.Locale -import java.util.StringTokenizer -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.ThreadLocalRandom -import java.util.function.Consumer - -class DownloadServer( - private val context: ModContext -) { - private val port = ThreadLocalRandom.current().nextInt(10000, 65535) - - private val cachedData = ConcurrentHashMap<String, ByteArray>() - private var serverSocket: ServerSocket? = null - - fun startFileDownload(destination: File, content: ByteArray, callback: Consumer<Boolean>) { - val httpKey = java.lang.Long.toHexString(System.nanoTime()) - ensureServerStarted { - putDownloadableContent(httpKey, content) - val url = "http://127.0.0.1:$port/$httpKey" - context.executeAsync { - val result: Boolean = context.bridgeClient.downloadContent(url, destination.absolutePath) - callback.accept(result) - } - } - } - - private fun ensureServerStarted(callback: Runnable) { - if (serverSocket != null && !serverSocket!!.isClosed) { - callback.run() - return - } - Thread { - try { - debug("started web server on 127.0.0.1:$port") - serverSocket = ServerSocket(port) - callback.run() - while (!serverSocket!!.isClosed) { - try { - val socket = serverSocket!!.accept() - Thread { handleRequest(socket) }.start() - } catch (e: Throwable) { - Logger.xposedLog(e) - } - } - } catch (e: Throwable) { - Logger.xposedLog(e) - } - }.start() - } - - fun putDownloadableContent(key: String, data: ByteArray) { - cachedData[key] = data - } - - private fun handleRequest(socket: Socket) { - val reader = BufferedReader(InputStreamReader(socket.getInputStream())) - val outputStream = socket.getOutputStream() - val writer = PrintWriter(outputStream) - val line = reader.readLine() ?: return - val close = Runnable { - try { - reader.close() - writer.close() - outputStream.close() - socket.close() - } catch (e: Throwable) { - Logger.xposedLog(e) - } - } - val parse = StringTokenizer(line) - val method = parse.nextToken().uppercase(Locale.getDefault()) - var fileRequested = parse.nextToken().lowercase(Locale.getDefault()) - if (method != "GET") { - writer.println("HTTP/1.1 501 Not Implemented") - writer.println("Content-type: " + "application/octet-stream") - writer.println("Content-length: " + 0) - writer.println() - writer.flush() - close.run() - return - } - if (fileRequested.startsWith("/")) { - fileRequested = fileRequested.substring(1) - } - if (!cachedData.containsKey(fileRequested)) { - writer.println("HTTP/1.1 404 Not Found") - writer.println("Content-type: " + "application/octet-stream") - writer.println("Content-length: " + 0) - writer.println() - writer.flush() - close.run() - return - } - val data = cachedData[fileRequested]!! - writer.println("HTTP/1.1 200 OK") - writer.println("Content-type: " + "application/octet-stream") - writer.println("Content-length: " + data.size) - writer.println() - writer.flush() - outputStream.write(data, 0, data.size) - outputStream.flush() - close.run() - cachedData.remove(fileRequested) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/download/RemoteMediaResolver.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/download/RemoteMediaResolver.kt deleted file mode 100644 index 75e46fa3ee..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/util/download/RemoteMediaResolver.kt +++ /dev/null @@ -1,51 +0,0 @@ -package me.rhunk.snapenhance.util.download - -import me.rhunk.snapenhance.Constants -import me.rhunk.snapenhance.Logger -import okhttp3.OkHttpClient -import okhttp3.Request -import java.io.ByteArrayInputStream -import java.io.InputStream -import java.util.Base64 - -object RemoteMediaResolver { - private const val BOLT_HTTP_RESOLVER_URL = "https://aws.api.snapchat.com/bolt-http" - const val CF_ST_CDN_D = "https://cf-st.sc-cdn.net/d/" - - private val urlCache = mutableMapOf<String, String>() - - private val okHttpClient = OkHttpClient.Builder() - .followRedirects(true) - .addInterceptor { chain -> - val request = chain.request() - val requestUrl = request.url.toString() - - if (urlCache.containsKey(requestUrl)) { - val cachedUrl = urlCache[requestUrl]!! - return@addInterceptor chain.proceed(request.newBuilder().url(cachedUrl).build()) - } - - chain.proceed(request).apply { - val responseUrl = this.request.url.toString() - if (responseUrl.startsWith("https://cf-st.sc-cdn.net")) { - urlCache[requestUrl] = responseUrl - } - } - } - .build() - - fun downloadBoltMedia(protoKey: ByteArray): InputStream? { - val request = Request.Builder() - .url(BOLT_HTTP_RESOLVER_URL + "/resolve?co=" + Base64.getUrlEncoder().encodeToString(protoKey)) - .addHeader("User-Agent", Constants.USER_AGENT) - .build() - - okHttpClient.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - Logger.log("Unexpected code $response") - return null - } - return ByteArrayInputStream(response.body.bytes()) - } - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/protobuf/ProtoEditor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/protobuf/ProtoEditor.kt deleted file mode 100644 index 8d331ef7b8..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/util/protobuf/ProtoEditor.kt +++ /dev/null @@ -1,41 +0,0 @@ -package me.rhunk.snapenhance.util.protobuf - -class ProtoEditor( - private var buffer: ByteArray -) { - fun edit(vararg path: Int, callback: ProtoWriter.() -> Unit) { - val writer = ProtoWriter() - callback(writer) - buffer = writeAtPath(path, 0, ProtoReader(buffer), writer.toByteArray()) - } - - private fun writeAtPath(path: IntArray, currentIndex: Int, rootReader: ProtoReader, bufferToWrite: ByteArray): ByteArray { - if (currentIndex == path.size) { - return bufferToWrite - } - val id = path[currentIndex] - val output = ProtoWriter() - val wires = mutableListOf<Pair<Int, ByteArray>>() - - rootReader.list { tag, value -> - if (tag == id) { - val childReader = rootReader.readPath(id) - if (childReader == null) { - wires.add(Pair(tag, value)) - return@list - } - wires.add(Pair(tag, writeAtPath(path, currentIndex + 1, childReader, bufferToWrite))) - return@list - } - wires.add(Pair(tag, value)) - } - - wires.forEach { (tag, value) -> - output.writeBuffer(tag, value) - } - - return output.toByteArray() - } - - fun toByteArray() = buffer -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/protobuf/ProtoReader.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/protobuf/ProtoReader.kt deleted file mode 100644 index ece75e8a05..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/util/protobuf/ProtoReader.kt +++ /dev/null @@ -1,130 +0,0 @@ -package me.rhunk.snapenhance.util.protobuf - -data class Wire(val type: Int, val value: Any) - -class ProtoReader(private val buffer: ByteArray) { - private var offset: Int = 0 - private val values = mutableMapOf<Int, MutableList<Wire>>() - - init { - read() - } - - fun getBuffer() = buffer - - private fun readByte() = buffer[offset++] - - private fun readVarInt(): Long { - var result = 0L - var shift = 0 - while (true) { - val b = readByte() - result = result or ((b.toLong() and 0x7F) shl shift) - if (b.toInt() and 0x80 == 0) { - break - } - shift += 7 - } - return result - } - - private fun read() { - while (offset < buffer.size) { - val tag = readVarInt().toInt() - val id = tag ushr 3 - val type = tag and 0x7 - try { - val value = when (type) { - 0 -> readVarInt().toString().toByteArray() - 2 -> { - val length = readVarInt().toInt() - val value = buffer.copyOfRange(offset, offset + length) - offset += length - value - } - else -> break - } - values.getOrPut(id) { mutableListOf() }.add(Wire(type, value)) - } catch (t: Throwable) { - values.clear() - break - } - } - } - - fun readPath(vararg ids: Int, reader: (ProtoReader.() -> Unit)? = null): ProtoReader? { - var thisReader = this - ids.forEach { id -> - if (!thisReader.exists(id)) { - return null - } - thisReader = ProtoReader(thisReader.get(id) as ByteArray) - } - if (reader != null) { - thisReader.reader() - } - return thisReader - } - - fun pathExists(vararg ids: Int): Boolean { - var thisReader = this - ids.forEach { id -> - if (!thisReader.exists(id)) { - return false - } - thisReader = ProtoReader(thisReader.get(id) as ByteArray) - } - return true - } - - fun getByteArray(id: Int) = values[id]?.first()?.value as ByteArray? - fun getByteArray(vararg ids: Int): ByteArray? { - if (ids.isEmpty() || ids.size < 2) { - return null - } - val lastId = ids.last() - var value: ByteArray? = null - readPath(*(ids.copyOfRange(0, ids.size - 1))) { - value = getByteArray(lastId) - } - return value - } - - fun getString(id: Int) = getByteArray(id)?.toString(Charsets.UTF_8) - fun getString(vararg ids: Int) = getByteArray(*ids)?.toString(Charsets.UTF_8) - - fun getInt(id: Int) = getString(id)?.toInt() - fun getInt(vararg ids: Int) = getString(*ids)?.toInt() - - fun getLong(id: Int) = getString(id)?.toLong() - fun getLong(vararg ids: Int) = getString(*ids)?.toLong() - - fun exists(id: Int) = values.containsKey(id) - - fun get(id: Int) = values[id]!!.first().value - - fun isValid() = values.isNotEmpty() - - fun getCount(id: Int) = values[id]!!.size - - fun each(id: Int, reader: ProtoReader.(index: Int) -> Unit) { - values[id]!!.forEachIndexed { index, _ -> - ProtoReader(values[id]!![index].value as ByteArray).reader(index) - } - } - - fun list(reader: (id: Int, data: ByteArray) -> Unit) { - values.forEach { (id, wires) -> - wires.forEachIndexed { index, _ -> - reader(id, wires[index].value as ByteArray) - } - } - } - - fun eachExists(id: Int, reader: ProtoReader.(index: Int) -> Unit) { - if (!exists(id)) { - return - } - each(id, reader) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/protobuf/ProtoWriter.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/protobuf/ProtoWriter.kt deleted file mode 100644 index f12eaa1a85..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/util/protobuf/ProtoWriter.kt +++ /dev/null @@ -1,66 +0,0 @@ -package me.rhunk.snapenhance.util.protobuf - -import java.io.ByteArrayOutputStream - -class ProtoWriter { - private val stream: ByteArrayOutputStream = ByteArrayOutputStream() - - private fun writeVarInt(value: Int) { - var v = value - while (v and -0x80 != 0) { - stream.write(v and 0x7F or 0x80) - v = v ushr 7 - } - stream.write(v) - } - - private fun writeVarLong(value: Long) { - var v = value - while (v and -0x80L != 0L) { - stream.write((v and 0x7FL or 0x80L).toInt()) - v = v ushr 7 - } - stream.write(v.toInt()) - } - - fun writeBuffer(id: Int, value: ByteArray) { - writeVarInt(id shl 3 or 2) - writeVarInt(value.size) - stream.write(value) - } - - fun writeConstant(id: Int, value: Int) { - writeVarInt(id shl 3) - writeVarInt(value) - } - - fun writeConstant(id: Int, value: Long) { - writeVarInt(id shl 3) - writeVarLong(value) - } - - fun writeString(id: Int, value: String) = writeBuffer(id, value.toByteArray()) - - fun write(id: Int, writer: ProtoWriter.() -> Unit) { - val writerStream = ProtoWriter() - writer(writerStream) - writeBuffer(id, writerStream.stream.toByteArray()) - } - - fun write(vararg ids: Int, writer: ProtoWriter.() -> Unit) { - val writerStream = ProtoWriter() - writer(writerStream) - var stream = writerStream.stream.toByteArray() - ids.reversed().forEach { id -> - with(ProtoWriter()) { - writeBuffer(id, stream) - stream = this.stream.toByteArray() - } - } - stream.let(this.stream::write) - } - - fun toByteArray(): ByteArray { - return stream.toByteArray() - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/snap/SnapUUID.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/snap/SnapUUID.kt deleted file mode 100644 index a4c9508b72..0000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/util/snap/SnapUUID.kt +++ /dev/null @@ -1,2 +0,0 @@ -package me.rhunk.snapenhance.util.snap - diff --git a/app/src/main/res/drawable/bitmoji_blank.xml b/app/src/main/res/drawable/bitmoji_blank.xml new file mode 100644 index 0000000000..beb7a87fc6 --- /dev/null +++ b/app/src/main/res/drawable/bitmoji_blank.xml @@ -0,0 +1,11 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="90dp" + android:height="90dp" + android:viewportWidth="90" + android:viewportHeight="90"> + <path + android:pathData="M45,90.1c10.8,0 20.8,-3.8 28.6,-10.2c-1.4,-2.1 -3,-3.6 -4.7,-5c-5.2,-4.1 -12.6,-5.6 -17.7,-6.5l-0.2,-2c7.8,-4.6 9.7,-9.5 12.8,-19.8l0.1,-0.7c0,0 2.7,-1.1 3.1,-6.1c0.6,-6.8 -2.2,-4.8 -2.2,-5.3C65.1,31 65,26.4 64,23c-2.1,-7.3 -9.2,-13.1 -19,-13.1S28.1,15.6 26,23c-1,3.4 -1.1,8 -0.8,11.6c0,0.5 -2.7,-1.5 -2.2,5.3c0.4,5 3.1,6.1 3.1,6.1l0.1,0.7c3.1,10.3 5,15.2 12.8,19.8l-0.2,2c-5,0.9 -12.5,2.4 -17.7,6.5c-1.7,1.4 -3.3,2.9 -4.7,5C24.2,86.3 34.2,90.1 45,90.1z" + android:strokeWidth="1.5" + android:fillColor="#979797" + android:strokeColor="#000000"/> +</vector> diff --git a/app/src/main/res/drawable/ic_codeberg.xml b/app/src/main/res/drawable/ic_codeberg.xml new file mode 100644 index 0000000000..12833d58ef --- /dev/null +++ b/app/src/main/res/drawable/ic_codeberg.xml @@ -0,0 +1,11 @@ +<vector xmlns:aapt="http://schemas.android.com/aapt" xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:viewportHeight="4.233" android:viewportWidth="4.233" android:width="24dp"> + <path android:pathData="M2.1744,1.1041c-0.0055,0 -0.0106,0.0019 -0.0141,0.0051s-0.0049,0.0073 -0.0038,0.0113l0.8154,3.0565a2.1166,2.1166 135,0 0,0.9561 -0.8198l-1.7375,-2.2463c-0.0034,-0.0042 -0.0095,-0.0068 -0.0161,-0.0068z" android:strokeColor="#00000000" android:strokeWidth="1"> + <aapt:attr name="android:fillColor"> + <gradient android:endX="3.5352" android:endY="3.8199" android:startX="2.1744" android:startY="1.1041" android:type="linear"> + <item android:color="#00000000" android:offset="0"/> + <item android:color="#4C000000" android:offset="0.495"/> + </gradient> + </aapt:attr> + </path> + <path android:fillColor="#ffffff" android:pathData="M2.113,0.12C0.944,0.12 -0.004,1.067 -0.004,2.236c0,0.398 0.112,0.787 0.323,1.124l1.765,-2.282c0.013,-0.016 0.045,-0.016 0.057,0l1.765,2.282c0.211,-0.337 0.323,-0.727 0.323,-1.124C4.23,1.067 3.282,0.12 2.113,0.12z"/> +</vector> diff --git a/app/src/main/res/drawable/ic_github.xml b/app/src/main/res/drawable/ic_github.xml new file mode 100644 index 0000000000..daa9e10181 --- /dev/null +++ b/app/src/main/res/drawable/ic_github.xml @@ -0,0 +1,11 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FF000000" + android:pathData="M12,0c-6.626,0 -12,5.373 -12,12 0,5.302 3.438,9.8 8.207,11.387 0.599,0.111 0.793,-0.261 0.793,-0.577v-2.234c-3.338,0.726 -4.033,-1.416 -4.033,-1.416 -0.546,-1.387 -1.333,-1.756 -1.333,-1.756 -1.089,-0.745 0.083,-0.729 0.083,-0.729 1.205,0.084 1.839,1.237 1.839,1.237 1.07,1.834 2.807,1.304 3.492,0.997 0.107,-0.775 0.418,-1.305 0.762,-1.604 -2.665,-0.305 -5.467,-1.334 -5.467,-5.931 0,-1.311 0.469,-2.381 1.236,-3.221 -0.124,-0.303 -0.535,-1.524 0.117,-3.176 0,0 1.008,-0.322 3.301,1.23 0.957,-0.266 1.983,-0.399 3.003,-0.404 1.02,0.005 2.047,0.138 3.006,0.404 2.291,-1.552 3.297,-1.23 3.297,-1.23 0.653,1.653 0.242,2.874 0.118,3.176 0.77,0.84 1.235,1.911 1.235,3.221 0,4.609 -2.807,5.624 -5.479,5.921 0.43,0.372 0.823,1.102 0.823,2.222v3.293c0,0.319 0.192,0.694 0.801,0.576 4.765,-1.589 8.199,-6.086 8.199,-11.386 0,-6.627 -5.373,-12 -12,-12z" + tools:ignore="VectorPath" /> +</vector> diff --git a/app/src/main/res/drawable/ic_telegram.xml b/app/src/main/res/drawable/ic_telegram.xml new file mode 100644 index 0000000000..ab02ef56f4 --- /dev/null +++ b/app/src/main/res/drawable/ic_telegram.xml @@ -0,0 +1,4 @@ +<vector android:height="24dp" android:viewportHeight="32" + android:viewportWidth="32" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> + <path android:fillColor="#FF000000" android:pathData="m16,0.5c-8.563,0 -15.5,6.938 -15.5,15.5s6.938,15.5 15.5,15.5c8.563,0 15.5,-6.938 15.5,-15.5s-6.938,-15.5 -15.5,-15.5zM23.613,11.119 L21.069,23.107c-0.188,0.85 -0.694,1.056 -1.4,0.656l-3.875,-2.856 -1.869,1.8c-0.206,0.206 -0.381,0.381 -0.781,0.381l0.275,-3.944 7.181,-6.488c0.313,-0.275 -0.069,-0.431 -0.482,-0.156l-8.875,5.587 -3.825,-1.194c-0.831,-0.262 -0.85,-0.831 0.175,-1.231l14.944,-5.763c0.694,-0.25 1.3,0.169 1.075,1.219z"/> +</vector> diff --git a/app/src/main/res/drawable/launcher_icon_monochrome.xml b/app/src/main/res/drawable/launcher_icon_monochrome.xml new file mode 100644 index 0000000000..d98ea0b70a --- /dev/null +++ b/app/src/main/res/drawable/launcher_icon_monochrome.xml @@ -0,0 +1,26 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:width="48dp" + android:height="48dp" + android:viewportWidth="1000" + android:viewportHeight="1000"> + + <group + android:scaleX="0.65" + android:scaleY="0.65" + android:translateX="175" + android:translateY="175"> + + <path + android:fillColor="#ffffffff" + android:pathData="m397.9,491.5h-55.1c-10.1,0 -18.4,8.2 -18.4,18.4h0c0,10.1 8.2,18.4 18.4,18.4h55.1c15.3,0 27.8,13.7 27.8,30.6s-12.5,30.6 -27.8,30.6h-55.1c-10.1,0 -18.4,8.2 -18.4,18.4h0c0,10.1 8.2,18.4 18.4,18.4h55.1c33.8,0 61.2,-30.1 61.2,-67.3s-27.4,-67.3 -61.2,-67.3Z"/> + <path + android:fillColor="#ffffffff" + android:pathData="m814.2,491.5h-62.6v-49.7h-0.1c0,-2 0.1,-4.1 0.1,-6.1 0,-152.1 -123.3,-275.5 -275.5,-275.5s-275.5,123.3 -275.5,275.5c0,2 0,4.1 0.1,6.1h-0.1v302.1c0,29.6 13.5,57.6 36.7,76l11.9,9.4c18.3,13.9 47,13.8 65.2,0l18.5,-14c7.1,-5.4 21,-5.4 28.1,0l18.5,14c18.3,13.9 46.9,13.9 65.2,0l18.5,-14c3.3,-2.5 8.2,-3.8 13.1,-4 4.9,0.2 9.7,1.5 13,4l18.5,14c18.3,13.8 46.8,13.8 65.1,0l18.5,-14c7.1,-5.4 20.9,-5.4 28,0l18.5,14c18.2,13.8 46.8,13.8 65.1,0l11.9,-9.4c23.2,-18.4 36.7,-46.4 36.7,-75.9v-117.8h62.6c33.8,0 61.2,-30.1 61.2,-67.3s-27.4,-67.3 -61.2,-67.3ZM714.8,441.9v310.2c0,16.4 -7.7,31.9 -20.7,41.8l-9.6,7.3c-7.1,5.4 -20.9,5.4 -28,0l-18.5,-14c-18.2,-13.8 -46.8,-13.8 -65.1,0l-18.5,14c-7.1,5.4 -20.9,5.4 -28,0l-18.5,-14c-8.8,-6.7 -20.1,-10.1 -31.4,-10.3v-0c-0.1,0 -0.1,0 -0.2,0 -0,0 -0.1,0 -0.1,0h0c-11.4,0.2 -22.6,3.7 -31.5,10.4l-18.5,14c-7.1,5.4 -21,5.4 -28.1,0l-18.5,-14c-18.3,-13.8 -46.9,-13.8 -65.2,0l-18.5,14c-7.1,5.4 -21,5.4 -28,0l-9.7,-7.4c-13.1,-9.9 -20.8,-25.4 -20.8,-41.8v-310.1h0.1c-0.1,-2 -0.1,-4.1 -0.1,-6.1 0,-131.9 106.9,-238.7 238.7,-238.7s238.7,106.9 238.7,238.7c0,2 -0,4.1 -0.1,6.1h0.1ZM814.2,589.5h-62.6v-61.2h62.6c15.3,0 27.8,13.7 27.8,30.6s-12.5,30.6 -27.8,30.6Z" + tools:ignore="VectorPath" /> + <path + android:fillColor="#ffffffff" + android:pathData="m711.1,336.1c-6.6,-1.6 -10.6,-1.7 -17.4,-2.8 -33.1,-5.4 -65.4,-0.8 -97.3,8.2 -7,2 -13.9,3.4 -20.9,3.5 -7,-0.2 -13.9,-1.5 -20.9,-3.5 -31.9,-9 -64.2,-13.6 -97.3,-8.2 -6.8,1.1 -10.8,1.1 -17.4,2.8 -6,1.5 -7.5,11.2 -5.5,29.1 0.9,8.2 3.3,16.1 4.9,24.2 1.6,8.1 3.6,16.1 7.9,23.3 4.1,6.8 10.1,11.6 17.5,13.9 16.9,5.4 34.1,6.2 51.3,1.4 14.5,-4.1 26.1,-12.6 33.4,-25.9 5.4,-9.9 9.7,-20.4 14.5,-30.7 0.7,-1.6 1.3,-3.2 2.1,-4.8 2.2,-4.4 5,-6.3 9.6,-6.3 4.5,-0 7.3,1.8 9.6,6.3 0.8,1.6 1.4,3.2 2.1,4.8 4.8,10.3 9.1,20.8 14.5,30.7 7.2,13.4 18.9,21.9 33.4,25.9 17.1,4.8 34.4,4 51.3,-1.4 7.4,-2.3 13.4,-7.1 17.5,-13.9 4.3,-7.2 6.3,-15.1 7.9,-23.3 1.5,-8.1 4,-16 4.9,-24.2 2,-17.9 0.5,-27.6 -5.5,-29.1Z"/> + + </group> +</vector> diff --git a/app/src/main/res/drawable/streak_icon.xml b/app/src/main/res/drawable/streak_icon.xml new file mode 100644 index 0000000000..6bdc6fa970 --- /dev/null +++ b/app/src/main/res/drawable/streak_icon.xml @@ -0,0 +1,11 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:width="48dp" + android:height="48dp" + android:viewportWidth="960" + android:viewportHeight="960"> + <path + android:fillColor="#FF000000" + android:pathData="M224.78,560q0,59.18 26.11,111.45 26.11,52.27 73.28,87.6 -4,-10.99 -6,-22.68 -2,-11.69 -2,-22.97 1.2,-30.8 13.31,-57.61 12.12,-26.8 34.4,-49.09L480,492.35l116.35,114.35q22.28,22.28 34.4,49.09 12.12,26.81 13.08,57.61 0,11.28 -2,22.97 -2,11.69 -5.76,22.68 46.7,-35.33 73.04,-87.6Q735.46,619.18 735.46,560q0,-52.09 -22.07,-102.06 -22.07,-49.97 -63.35,-91.96 -21,14.28 -43.84,22.42 -22.84,8.14 -45.21,8.14 -60.27,0 -100.63,-39.95Q420,316.65 417.37,254.61v-20q-44.36,32.26 -79.91,71.32 -35.55,39.05 -60.59,81.36 -25.04,42.3 -38.57,86.61 -13.52,44.31 -13.52,86.11ZM480,587.83l-68.31,67.56q-13.82,13.57 -20.96,29.92 -7.14,16.34 -7.14,35.64 0,39.5 28.09,66.89 28.09,27.38 68.37,27.38 40.28,0 68.33,-27.44 28.04,-27.44 28.04,-66.97 0,-19.05 -7.13,-35.4 -7.13,-16.35 -20.63,-29.93L480,587.83ZM483.59,114.26L483.59,252q0,32.48 22.45,54.44 22.45,21.97 54.94,21.97 17.21,0 32.04,-7.14 14.83,-7.14 26.35,-21.66l19.67,-24.39q76.21,43.41 120.38,119.74 44.17,76.33 44.17,164.97 0,135.53 -94.05,229.59 -94.05,94.06 -229.56,94.06 -135.51,0 -229.54,-94.04 -94.03,-94.04 -94.03,-229.55 0,-129.19 87.79,-249.61Q332,189.98 483.59,114.26Z" + tools:ignore="VectorPath" /> +</vector> diff --git a/app/src/main/res/font/avenir_next_medium.ttf b/app/src/main/res/font/avenir_next_medium.ttf new file mode 100644 index 0000000000..41f5d1ea02 Binary files /dev/null and b/app/src/main/res/font/avenir_next_medium.ttf differ diff --git a/app/src/main/res/layout/map.xml b/app/src/main/res/layout/map.xml deleted file mode 100644 index b2a2999cfb..0000000000 --- a/app/src/main/res/layout/map.xml +++ /dev/null @@ -1,44 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <org.osmdroid.views.MapView - android:id="@+id/mapView" - android:layout_width="match_parent" - android:layout_height="match_parent" > - - </org.osmdroid.views.MapView> - - <FrameLayout - android:layout_width="match_parent" - android:layout_height="wrap_content"> - - <Button - android:id="@+id/set_precise_location_button" - android:layout_width="wrap_content" - android:layout_height="match_parent" - android:layout_gravity="left" - android:layout_marginStart="20dp" - android:layout_marginTop="20dp" - android:padding="10dp" - android:background="@android:color/white" - android:text="Set Precise Location" - android:textSize="20sp" - tools:ignore="HardcodedText,RtlHardcoded" /> - - <Button - android:id="@+id/apply_location_button" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_gravity="right" - android:layout_marginTop="20dp" - android:layout_marginRight="20dp" - android:background="@android:color/white" - android:text="Apply" - android:textSize="20sp" - tools:ignore="HardcodedText,RtlHardcoded" /> - </FrameLayout> - -</FrameLayout> diff --git a/app/src/main/res/layout/precise_location_dialog.xml b/app/src/main/res/layout/precise_location_dialog.xml deleted file mode 100644 index c9607a8d3c..0000000000 --- a/app/src/main/res/layout/precise_location_dialog.xml +++ /dev/null @@ -1,28 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:padding="20dp" - android:orientation="vertical" - tools:ignore="HardcodedText"> - - <EditText - android:id="@+id/dialog_latitude" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:ems="10" - android:inputType="numberDecimal" - android:hint="Latitude" - android:autofillHints="" /> - - <EditText - android:id="@+id/dialog_longitude" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:ems="10" - android:inputType="numberDecimal" - android:hint="Longitude" - android:autofillHints="" /> - -</LinearLayout> \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml b/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml index ae6391b6d7..0a1cdd2d39 100644 --- a/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml +++ b/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml @@ -2,4 +2,5 @@ <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <background android:drawable="@color/launcher_icon_background"/> <foreground android:drawable="@mipmap/launcher_icon_foreground"/> + <monochrome android:drawable="@drawable/launcher_icon_monochrome"/> </adaptive-icon> \ No newline at end of file diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml deleted file mode 100644 index 3ef365ea1a..0000000000 --- a/app/src/main/res/values/arrays.xml +++ /dev/null @@ -1,6 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string-array name="sc_scope"> - <item>com.snapchat.android</item> - </string-array> -</resources> \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000000..0fa72d3527 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <color name="primaryText">#DEDEDE</color> + <color name="primaryBackground">#121212</color> + <color name="borderColor">#424242</color> +</resources> \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2a6df44580..432b7bb865 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,3 @@ <resources> - <string name="app_name" translatable="false">Snap Enhance</string> + <string name="app_name" translatable="false">SnapEnhance</string> </resources> \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000000..a428b71563 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <style name="FullscreenOverlayDialog"> + <item name="android:windowBackground">@android:color/transparent</item> + <item name="android:windowFrame">@null</item> + <item name="android:windowIsFloating">false</item> + <item name="android:windowNoTitle">true</item> + <item name="android:windowContentOverlay">@null</item> + <item name="android:windowAnimationStyle">@android:style/Animation.Dialog</item> + <item name="android:windowSoftInputMode">stateUnspecified|adjustPan</item> + <item name="android:windowActionBar">false</item> + <item name="android:windowActionModeOverlay">true</item> + <item name="android:navigationBarColor">@android:color/transparent</item> + <item name="android:statusBarColor">@android:color/transparent</item> + </style> +</resources> diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000000..1a9dbb6bf3 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<resources> + <style name="AppTheme"> + <item name="android:fontFamily">@font/avenir_next_medium</item> + <item name="android:windowNoTitle">true</item> + <item name="android:windowContentOverlay">@null</item> + <item name="android:navigationBarColor">@color/primaryBackground</item> + <item name="android:textColor">@color/primaryText</item> + <item name="android:editTextColor">@color/primaryText</item> + <item name="android:alertDialogTheme">@android:style/Theme.DeviceDefault.Dialog.Alert</item> + </style> + <style name="BiometricPromptTheme" parent="AppTheme"> + <item name="android:windowBackground">@android:color/transparent</item> + <item name="android:windowIsTranslucent">true</item> + <item name="android:windowTranslucentStatus">true</item> + <item name="android:windowContentOverlay">@null</item> + <item name="android:backgroundDimEnabled">false</item> + </style> +</resources> \ No newline at end of file diff --git a/app/src/main/res/xml/provider_paths.xml b/app/src/main/res/xml/provider_paths.xml new file mode 100644 index 0000000000..8d13fa1778 --- /dev/null +++ b/app/src/main/res/xml/provider_paths.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<paths> + <external-path name="external_files" path="."/> +</paths> diff --git a/build.gradle b/build.gradle deleted file mode 100644 index 03d5df0ec9..0000000000 --- a/build.gradle +++ /dev/null @@ -1,10 +0,0 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. -plugins { - id 'com.android.application' version '8.0.2' apply false - id 'com.android.library' version '8.0.2' apply false - id 'org.jetbrains.kotlin.android' version '1.8.21' apply false -} - -tasks.register('clean', Delete) { - delete rootProject.buildDir -} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000000..93857e8380 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,27 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + alias(libs.plugins.androidApplication) apply false + alias(libs.plugins.androidLibrary) apply false + alias(libs.plugins.kotlinAndroid) apply false + alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.rust.android) apply false +} + +var versionName = "2.1.0" +var versionCode = 210 + +rootProject.ext.set("appVersionName", versionName) +rootProject.ext.set("appVersionCode", versionCode) +rootProject.ext.set("applicationId", "me.rhunk.snapenhance") +rootProject.ext.set("buildHash", properties["debug_build_hash"] ?: java.security.SecureRandom().nextLong(Long.MAX_VALUE / 1000L, Long.MAX_VALUE).toString(16)) + +tasks.register("getVersion") { + doLast { + val versionFile = File("app/build/version.txt") + versionFile.parentFile.mkdirs() + if (!versionFile.exists()) { + versionFile.createNewFile() + } + versionFile.writeText(versionName) + } +} diff --git a/common/.gitignore b/common/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/common/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/common/build.gradle.kts b/common/build.gradle.kts new file mode 100644 index 0000000000..dfa84e0a58 --- /dev/null +++ b/common/build.gradle.kts @@ -0,0 +1,65 @@ +import java.io.ByteArrayOutputStream + +plugins { + alias(libs.plugins.androidLibrary) + alias(libs.plugins.kotlinAndroid) + alias(libs.plugins.compose.compiler) + id("kotlin-parcelize") +} + +android { + namespace = rootProject.ext["applicationId"].toString() + ".common" + compileSdk = 34 + + buildFeatures { + aidl = true + buildConfig = true + compose = true + } + + defaultConfig { + minSdk = 28 + buildConfigField("String", "VERSION_NAME", "\"${rootProject.ext["appVersionName"]}\"") + buildConfigField("int", "VERSION_CODE", "${rootProject.ext["appVersionCode"]}") + buildConfigField("String", "APPLICATION_ID", "\"${rootProject.ext["applicationId"]}\"") + buildConfigField("long", "BUILD_TIMESTAMP", "${System.currentTimeMillis()}L") + buildConfigField("String", "BUILD_HASH", "\"${rootProject.ext["buildHash"]}\".toString()") + val gitHash = ByteArrayOutputStream() + exec { + commandLine("git", "rev-parse", "HEAD") + standardOutput = gitHash + } + buildConfigField("String", "GIT_HASH", "\"${gitHash.toString(Charsets.UTF_8).trim()}\"") + buildConfigField("String", "SIF_ENDPOINT", "\"${properties["debug_sif_endpoint"]?.toString() ?: "https://github.com/SnapEnhance/resources/raw/refs/heads/main/sif"}\"") + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + kotlinOptions { + jvmTarget = "21" + } +} + +dependencies { + implementation(libs.coroutines) + implementation(libs.gson) + implementation(libs.okhttp) + implementation(libs.androidx.documentfile) + implementation(libs.rhino) + implementation(libs.rhino.android) { + exclude(group = "org.mozilla", module = "rhino-runtime") + } + + compileOnly(libs.androidx.activity.ktx) + compileOnly(platform(libs.androidx.compose.bom)) + compileOnly(libs.androidx.navigation.compose) + compileOnly(libs.androidx.material.icons.core) + compileOnly(libs.androidx.material.ripple) + compileOnly(libs.androidx.material.icons.extended) + compileOnly(libs.androidx.material3) + + implementation(project(":mapper")) +} \ No newline at end of file diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/AccountStorage.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/AccountStorage.aidl new file mode 100644 index 0000000000..521ec344c5 --- /dev/null +++ b/common/src/main/aidl/me/rhunk/snapenhance/bridge/AccountStorage.aidl @@ -0,0 +1,10 @@ +package me.rhunk.snapenhance.bridge; + + +interface AccountStorage { + Map<String, String> getAccounts(); // userId -> username + void addAccount(String userId, String username, in ParcelFileDescriptor data); + void removeAccount(String userId); + boolean isAccountExists(String userId); + @nullable ParcelFileDescriptor getAccountData(String userId); +} \ No newline at end of file diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl new file mode 100644 index 0000000000..185734917a --- /dev/null +++ b/common/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl @@ -0,0 +1,101 @@ +package me.rhunk.snapenhance.bridge; + +import java.util.List; +import me.rhunk.snapenhance.bridge.DownloadCallback; +import me.rhunk.snapenhance.bridge.SyncCallback; +import me.rhunk.snapenhance.bridge.scripting.IScripting; +import me.rhunk.snapenhance.bridge.e2ee.E2eeInterface; +import me.rhunk.snapenhance.bridge.logger.LoggerInterface; +import me.rhunk.snapenhance.bridge.logger.TrackerInterface; +import me.rhunk.snapenhance.bridge.ConfigStateListener; +import me.rhunk.snapenhance.bridge.snapclient.MessagingBridge; +import me.rhunk.snapenhance.bridge.AccountStorage; +import me.rhunk.snapenhance.bridge.storage.FileHandleManager; +import me.rhunk.snapenhance.bridge.location.LocationManager; + +interface BridgeInterface { + /** + * Get the SnapEnhance APK path (used in LSPatch updater and for auto bridge restart) + */ + String getApplicationApkPath(); + + /** + * broadcast a log message + */ + oneway void broadcastLog(String tag, String level, String message); + + /** + * Enqueue a download + */ + oneway void enqueueDownload(in Intent intent, DownloadCallback callback); + + /** + * File conversation + */ + @nullable ParcelFileDescriptor convertMedia(in ParcelFileDescriptor input, String inputExtension, String outputExtension, @nullable String audioCodec, @nullable String videoCodec); + + /** + * Get rules for a given user or conversation + * @return list of rules (MessagingRuleType) + */ + List<String> getRules(String uuid); + + /** + * Get all ids for a specific rule + * @param type rule type (MessagingRuleType) + * @return list of ids + */ + List<String> getRuleIds(String type); + + /** + * Update rule for a giver user or conversation + * + * @param type rule type (MessagingRuleType) + */ + oneway void setRule(String uuid, String type, boolean state); + + /** + * Sync groups and friends + */ + oneway void sync(SyncCallback callback); + + /** + * Trigger sync for an id + */ + oneway void triggerSync(String scope, String id); + + /** + * Pass all groups and friends to be able to add them to the database + * @param groups list of groups (MessagingGroupInfo as parcelable) + * @param friends list of friends (MessagingFriendInfo as parcelable) + */ + oneway void passGroupsAndFriends(in List<String> groups, in List<String> friends); + + @nullable String getScopeNotes(String id); + + oneway void setScopeNotes(String id, String content); + + IScripting getScriptingInterface(); + + E2eeInterface getE2eeInterface(); + + LoggerInterface getLogger(); + + TrackerInterface getTracker(); + + AccountStorage getAccountStorage(); + + FileHandleManager getFileHandleManager(); + + LocationManager getLocationManager(); + + oneway void registerMessagingBridge(MessagingBridge bridge); + + oneway void openOverlay(String type); + + oneway void closeOverlay(); + + oneway void registerConfigStateListener(in ConfigStateListener listener); + + @nullable String getDebugProp(String key, @nullable String defaultValue); +} \ No newline at end of file diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/ConfigStateListener.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/ConfigStateListener.aidl new file mode 100644 index 0000000000..85edf8c9c5 --- /dev/null +++ b/common/src/main/aidl/me/rhunk/snapenhance/bridge/ConfigStateListener.aidl @@ -0,0 +1,7 @@ +package me.rhunk.snapenhance.bridge; + +oneway interface ConfigStateListener { + void onConfigChanged(); + void onRestartRequired(); + void onCleanCacheRequired(); +} \ No newline at end of file diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/DownloadCallback.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/DownloadCallback.aidl new file mode 100644 index 0000000000..473a31a4ec --- /dev/null +++ b/common/src/main/aidl/me/rhunk/snapenhance/bridge/DownloadCallback.aidl @@ -0,0 +1,7 @@ +package me.rhunk.snapenhance.bridge; + +oneway interface DownloadCallback { + void onSuccess(String outputPath); + void onProgress(String message); + void onFailure(String message, @nullable String throwable); +} diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/SyncCallback.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/SyncCallback.aidl new file mode 100644 index 0000000000..6326a1623a --- /dev/null +++ b/common/src/main/aidl/me/rhunk/snapenhance/bridge/SyncCallback.aidl @@ -0,0 +1,17 @@ +package me.rhunk.snapenhance.bridge; + +interface SyncCallback { + /** + * Called when the friend data has been synced + * @param uuid The uuid of the friend to sync + * @return The serialized friend data + */ + @nullable String syncFriend(String uuid); + + /** + * Called when the conversation data has been synced + * @param uuid The uuid of the conversation to sync + * @return The serialized conversation data + */ + @nullable String syncGroup(String uuid); +} \ No newline at end of file diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/e2ee/E2eeInterface.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/e2ee/E2eeInterface.aidl new file mode 100644 index 0000000000..18fc171aa9 --- /dev/null +++ b/common/src/main/aidl/me/rhunk/snapenhance/bridge/e2ee/E2eeInterface.aidl @@ -0,0 +1,46 @@ +package me.rhunk.snapenhance.bridge.e2ee; + +import me.rhunk.snapenhance.bridge.e2ee.EncryptionResult; + +interface E2eeInterface { + /** + * Start a new pairing process with a friend + * @param friendId + * @return the pairing public key + */ + @nullable byte[] createKeyExchange(String friendId); + + /** + * Accept a pairing request from a friend + * @param friendId + * @param publicKey the public key received from the friend + * @return the encapsulated secret to send to the friend + */ + @nullable byte[] acceptPairingRequest(String friendId, in byte[] publicKey); + + /** + * Accept a pairing response from a friend + * @param friendId + * @param encapsulatedSecret the encapsulated secret received from the friend + * @return true if the pairing was successful + */ + boolean acceptPairingResponse(String friendId, in byte[] encapsulatedSecret); + + /** + * Check if a friend key exists + * @param friendId + * @return true if the friend key exists + */ + boolean friendKeyExists(String friendId); + + /** + * Get the fingerprint of a secret key + * @param friendId + * @return the fingerprint of the secret key + */ + @nullable String getSecretFingerprint(String friendId); + + @nullable EncryptionResult encryptMessage(String friendId, in byte[] message); + + @nullable byte[] decryptMessage(String friendId, in byte[] message, in byte[] iv); +} \ No newline at end of file diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/e2ee/EncryptionResult.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/e2ee/EncryptionResult.aidl new file mode 100644 index 0000000000..3e9e24b99c --- /dev/null +++ b/common/src/main/aidl/me/rhunk/snapenhance/bridge/e2ee/EncryptionResult.aidl @@ -0,0 +1,6 @@ +package me.rhunk.snapenhance.bridge.e2ee; + +parcelable EncryptionResult { + byte[] ciphertext; + byte[] iv; +} \ No newline at end of file diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/location/FriendLocation.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/location/FriendLocation.aidl new file mode 100644 index 0000000000..40076e721f --- /dev/null +++ b/common/src/main/aidl/me/rhunk/snapenhance/bridge/location/FriendLocation.aidl @@ -0,0 +1,13 @@ +package me.rhunk.snapenhance.bridge.location; + +parcelable FriendLocation { + String username; + @nullable String displayName; + @nullable String bitmojiId; + @nullable String bitmojiSelfieId; + double latitude; + double longitude; + long lastUpdated; + String locality; + List<String> localityPieces; +} diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/location/LocationCoordinates.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/location/LocationCoordinates.aidl new file mode 100644 index 0000000000..aab90195c8 --- /dev/null +++ b/common/src/main/aidl/me/rhunk/snapenhance/bridge/location/LocationCoordinates.aidl @@ -0,0 +1,10 @@ +package me.rhunk.snapenhance.bridge.location; + + +parcelable LocationCoordinates { + int id; + String name; + double latitude; + double longitude; + double radius; +} \ No newline at end of file diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/location/LocationManager.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/location/LocationManager.aidl new file mode 100644 index 0000000000..715e726534 --- /dev/null +++ b/common/src/main/aidl/me/rhunk/snapenhance/bridge/location/LocationManager.aidl @@ -0,0 +1,7 @@ +package me.rhunk.snapenhance.bridge.location; + +import me.rhunk.snapenhance.bridge.location.FriendLocation; + +interface LocationManager { + void provideFriendsLocation(in List<FriendLocation> friendsLocation); +} \ No newline at end of file diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/logger/BridgeLoggedMessage.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/logger/BridgeLoggedMessage.aidl new file mode 100644 index 0000000000..91d1c0643e --- /dev/null +++ b/common/src/main/aidl/me/rhunk/snapenhance/bridge/logger/BridgeLoggedMessage.aidl @@ -0,0 +1,11 @@ +package me.rhunk.snapenhance.bridge.logger; + +parcelable BridgeLoggedMessage { + long messageId; + String conversationId; + String userId; + String username; + long sendTimestamp; + @nullable String groupTitle; + byte[] messageData; +} \ No newline at end of file diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/logger/LoggedChatEdit.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/logger/LoggedChatEdit.aidl new file mode 100644 index 0000000000..9abb5d509c --- /dev/null +++ b/common/src/main/aidl/me/rhunk/snapenhance/bridge/logger/LoggedChatEdit.aidl @@ -0,0 +1,6 @@ +package me.rhunk.snapenhance.bridge.logger; + +parcelable LoggedChatEdit { + long timestamp; + String message; +} \ No newline at end of file diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/logger/LoggerInterface.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/logger/LoggerInterface.aidl new file mode 100644 index 0000000000..f80570c118 --- /dev/null +++ b/common/src/main/aidl/me/rhunk/snapenhance/bridge/logger/LoggerInterface.aidl @@ -0,0 +1,44 @@ +package me.rhunk.snapenhance.bridge.logger; + +import me.rhunk.snapenhance.bridge.logger.BridgeLoggedMessage; +import me.rhunk.snapenhance.bridge.logger.LoggedChatEdit; + +interface LoggerInterface { + /** + * Get the ids of the messages that are logged + * @return message ids that are logged + */ + long[] getLoggedIds(in String[] conversationIds, int limit); + + /** + * Get the content of a logged message from the database + */ + @nullable byte[] getMessage(String conversationId, long id); + + /** + * Add a message to the message logger database if it is not already there + */ + oneway void addMessage(in BridgeLoggedMessage message); + + /** + * Delete a message from the message logger database + */ + oneway void deleteMessage(String conversationId, long id); + + /** + * Add a story to the message logger database if it is not already there + */ + boolean addStory(String userId, String url, long postedAt, long createdAt, in byte[] key, in byte[] iv); + + oneway void logTrackerEvent( + String conversationId, + String conversationTitle, + boolean isGroup, + String username, + String userId, + String eventType, + String data + ); + + List<LoggedChatEdit> getChatEdits(String conversationId, long messageId); +} \ No newline at end of file diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/logger/TrackerInterface.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/logger/TrackerInterface.aidl new file mode 100644 index 0000000000..a83af4a907 --- /dev/null +++ b/common/src/main/aidl/me/rhunk/snapenhance/bridge/logger/TrackerInterface.aidl @@ -0,0 +1,7 @@ +package me.rhunk.snapenhance.bridge.logger; + +interface TrackerInterface { + String getTrackedEvents(String eventType); // returns serialized TrackerEventsResult + + long updateFriendScore(String userId, long score); // returns old score (-1 if not found) +} \ No newline at end of file diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/scripting/AutoReloadListener.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/scripting/AutoReloadListener.aidl new file mode 100644 index 0000000000..09cdb09202 --- /dev/null +++ b/common/src/main/aidl/me/rhunk/snapenhance/bridge/scripting/AutoReloadListener.aidl @@ -0,0 +1,5 @@ +package me.rhunk.snapenhance.bridge.scripting; + +interface AutoReloadListener { + oneway void restartApp(); +} \ No newline at end of file diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/scripting/IPCListener.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/scripting/IPCListener.aidl new file mode 100644 index 0000000000..b817ff1fb8 --- /dev/null +++ b/common/src/main/aidl/me/rhunk/snapenhance/bridge/scripting/IPCListener.aidl @@ -0,0 +1,6 @@ +package me.rhunk.snapenhance.bridge.scripting; + + +interface IPCListener { + void onMessage(in String[] args); +} \ No newline at end of file diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/scripting/IScripting.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/scripting/IScripting.aidl new file mode 100644 index 0000000000..e2b5c1802e --- /dev/null +++ b/common/src/main/aidl/me/rhunk/snapenhance/bridge/scripting/IScripting.aidl @@ -0,0 +1,18 @@ +package me.rhunk.snapenhance.bridge.scripting; + +import me.rhunk.snapenhance.bridge.scripting.IPCListener; +import me.rhunk.snapenhance.bridge.scripting.AutoReloadListener; + +interface IScripting { + List<String> getEnabledScripts(); + + @nullable ParcelFileDescriptor getScriptContent(String path); + + oneway void registerIPCListener(String channel, String eventName, IPCListener listener); + + int sendIPCMessage(String channel, String eventName, in String[] args); + + @nullable String configTransaction(String module, String action, @nullable String key, @nullable String value, boolean save); + + oneway void registerAutoReloadListener(in AutoReloadListener listener); +} \ No newline at end of file diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/snapclient/MessagingBridge.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/snapclient/MessagingBridge.aidl new file mode 100644 index 0000000000..32c1573cea --- /dev/null +++ b/common/src/main/aidl/me/rhunk/snapenhance/bridge/snapclient/MessagingBridge.aidl @@ -0,0 +1,23 @@ +package me.rhunk.snapenhance.bridge.snapclient; + +import java.util.List; +import me.rhunk.snapenhance.bridge.snapclient.types.Message; +import me.rhunk.snapenhance.bridge.snapclient.SessionStartListener; + +interface MessagingBridge { + boolean isSessionStarted(); + + void registerSessionStartListener(in SessionStartListener listener); + + String getMyUserId(); + + @nullable Message fetchMessage(String conversationId, String clientMessageId); + + @nullable Message fetchMessageByServerId(String conversationId, String serverMessageId); + + @nullable List<Message> fetchConversationWithMessagesPaginated(String conversationId, int limit, long beforeMessageId); + + @nullable String updateMessage(String conversationId, long clientMessageId, String messageUpdate); + + @nullable String getOneToOneConversationId(String userId); +} \ No newline at end of file diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/snapclient/SessionStartListener.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/snapclient/SessionStartListener.aidl new file mode 100644 index 0000000000..d0f78e0422 --- /dev/null +++ b/common/src/main/aidl/me/rhunk/snapenhance/bridge/snapclient/SessionStartListener.aidl @@ -0,0 +1,5 @@ +package me.rhunk.snapenhance.bridge.snapclient; + +oneway interface SessionStartListener { + void onConnected(); +} \ No newline at end of file diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/snapclient/types/Message.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/snapclient/types/Message.aidl new file mode 100644 index 0000000000..b15e6fda0e --- /dev/null +++ b/common/src/main/aidl/me/rhunk/snapenhance/bridge/snapclient/types/Message.aidl @@ -0,0 +1,11 @@ +package me.rhunk.snapenhance.bridge.snapclient.types; + +parcelable Message { + String conversationId; + String senderId; + int contentType; + long clientMessageId; + long serverMessageId; + byte[] content; + List<String> mediaReferences; +} \ No newline at end of file diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/storage/FileHandle.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/storage/FileHandle.aidl new file mode 100644 index 0000000000..2158b983c7 --- /dev/null +++ b/common/src/main/aidl/me/rhunk/snapenhance/bridge/storage/FileHandle.aidl @@ -0,0 +1,9 @@ +package me.rhunk.snapenhance.bridge.storage; + +interface FileHandle { + boolean exists(); + boolean create(); + boolean delete(); + + @nullable ParcelFileDescriptor open(int mode); +} \ No newline at end of file diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/storage/FileHandleManager.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/storage/FileHandleManager.aidl new file mode 100644 index 0000000000..b9a3aa7de5 --- /dev/null +++ b/common/src/main/aidl/me/rhunk/snapenhance/bridge/storage/FileHandleManager.aidl @@ -0,0 +1,7 @@ +package me.rhunk.snapenhance.bridge.storage; + +import me.rhunk.snapenhance.bridge.storage.FileHandle; + +interface FileHandleManager { + @nullable FileHandle getFileHandle(String scope, String name); +} \ No newline at end of file diff --git a/common/src/main/assets/lang/bn.json b/common/src/main/assets/lang/bn.json new file mode 100644 index 0000000000..4e77d6d0f5 --- /dev/null +++ b/common/src/main/assets/lang/bn.json @@ -0,0 +1,1463 @@ +{ + "conversation_preview": { + "unknown_user": "অজানা ব্যবহাকারী", + "streak_expiration": "{day} দিন {hour} ঘন্টা {minute} মিনিটে মেয়াদ শেষ হবে", + "title": "পূর্বরূপ", + "total_messages": "মোট পাঠানো/প্রাপ্ত বার্তা: {count}" + }, + "profile_info": { + "title": "প্রোফাইল তথ্য", + "display_name": "প্রদর্শন নাম", + "added_date": "যুক্ত হওয়ার তারিখ", + "birthday": "জন্মদিন : {month}{day}", + "hidden_birthday": "জন্মদিন : লুকানো", + "add_source": "উৎস যোগ করুন", + "snapchat_plus": "স্ন্যাপচ্যাট প্লাস", + "snapchat_plus_state": { + "subscribed": "সদস্যতা", + "not_subscribed": "সাবস্ক্রাইব করা হয়নি" + }, + "friendship": "বন্ধুত্ব", + "mutable_username": "পরিবর্তনযোগ্য ব্যবহারকারী নাম", + "first_created_username": "প্রথম তৈরি ইউজারনেম" + }, + "chat_export": { + "dialog_negative_button": "বাতিল করুন", + "dialog_positive_button": "রপ্তানি করুন", + "exported_to": "রপ্তানি করা হয়েছে {path}", + "exporting_chats": "চ্যাট রপ্তানি করা হচ্ছে...", + "processing_chats": "{amount} গুলো আলাপ প্রসেসিং করা হচ্ছে...", + "export_fail": "{conversation} আলাপ রপ্তানি করতে সক্ষম হয়নি", + "writing_output": "আউটপুট লিখা হচ্ছে...", + "finished": "সম্পন্ন! আপনি এখন ডায়ালগটি বন্ধ করে দিতে পারেন।.", + "no_messages_found": "কোন বার্তা পাওয়া যায়নি!", + "exporting_message": "{conversation} রপ্তানি করা হচ্ছে...", + "exporter_dialog": { + "select_conversations_title": "কথোপকথন নির্বাচন করুন", + "text_field_selection_all": "সকল", + "export_file_format_title": "ফাইল বিন্যাস রপ্তানি করুন", + "message_type_filter_title": "বার্তাগুলি ধরন অনুযায়ী ফিল্টার করুন", + "amount_of_messages_title": "বার্তার পরিমাণ (সবের জন্য খালি রাখুন)", + "download_medias_title": "মিডিয়াগুলি ডাউনলোড করুন", + "text_field_selection": "{amount} নির্বাচিত" + } + }, + "button": { + "ok": "ঠিক আছে", + "positive": "হ্যাঁ", + "negative": "না", + "cancel": "বাতিল করুন", + "open": "খুলুন", + "download": "ডাউনলোড" + }, + "download_processor": { + "download_started_toast": "ডাউনলোড শুরু হয়েছে", + "unsupported_content_type_toast": "অসমর্থিত কনটেন্ট ধরণ!", + "failed_no_longer_available_toast": "মিডিয়া আর পাওয়া যাচ্ছে না", + "already_queued_toast": "মিডিয়া ইতিমধ্যে কিউতে আছে!", + "already_downloaded_toast": "মিডিয়া ইতোমধ্যে ডাউনলোড হয়েছে!", + "download_toast": "ডাউনলোড হচ্ছে {path}...", + "processing_toast": "প্রসেসিং হচ্ছে {path}...", + "failed_generic_toast": "ডাউনলোড করতে সক্ষম হয়নি", + "failed_to_create_preview_toast": "পুর্বরূপ তৈরি করতে সক্ষম হয়নি", + "select_attachments_title": "সংযুক্তি নির্বাচন করুন", + "attachment_type": { + "original_story": "মূল কাহিনী", + "snap": "স্ন্যাপ", + "sticker": "স্টিকার", + "external_media": "বহিঃপ্রচার মাধ্যম", + "note": "নোট", + "gif": "GIF" + }, + "dash_dialog": { + "segment_text": "সেগমেন্ট {from} - {to}", + "title": "ড্যাশ মিডিয়া ডাউনলোড করুন", + "download_all": "সব ডাউনলোড করুন5" + }, + "failed_processing_toast": "প্রক্রিয়াকরণ ব্যর্থ হয়েছে {error}", + "failed_gallery_toast": "গ্যালারিতে সংরক্ষণ ব্যর্থ হয়েছে {error}", + "no_attachments_toast": "কোনও সংযুক্তি খুঁজে পাওয়া যায়নি !", + "dash_no_chapter": "কোন অধ্যায় খুঁজে পাওয়া যায়নি" + }, + "setup": { + "mappings": { + "dialog": "ম্যাপিংগুলি তৈরি করতে এটি কিছুটা সময় নিতে পারে ..।", + "generate_failure": "ম্যাপিং তৈরি করার চেষ্টা করতে গিয়ে একটি ত্রুটি ঘটেছে, অনুগ্রহ করে আবার চেষ্টা করুন।", + "generate_failure_no_snapchat": "স্ন্যাপএনহান্স স্ন্যাপচ্যাট সনাক্ত করতে অক্ষম ছিল, অনুগ্রহ করে স্ন্যাপচ্যাট পুনরায় ইনস্টল করার চেষ্টা করুন।" + }, + "dialogs": { + "select_language": "নির্বাচন ভাষা", + "save_folder": "স্ন্যাপএনহান্স-এর স্ন্যাপচ্যাট থেকে মিডিয়া ডাউনলোড এবং সংরক্ষণ করার জন্য স্টোরেজ অনুমতি প্রয়োজন।\nঅনুগ্রহ করে নির্বাচন করুন যে মিডিয়া কোথায় ডাউনলোড করা উচিত।", + "select_save_folder_button": "ফোল্ডার নির্বাচন করুন" + }, + "permissions": { + "dialog": "চালিয়ে যেতে আপনাকে নিম্নলিখিত শর্তগুলি মেনে চলতে হবে:", + "notification_access": "বিজ্ঞপ্তি অ্যাক্সেস", + "battery_optimization": "ব্যাটারি অপটিমাইজেশন", + "display_over_other_apps": "অন্যান্য অ্যাপের উপরে প্রদর্শন করুন", + "request_button": "অনুরোধ" + } + }, + "manager": { + "routes": { + "tasks": "কার্যসমূহ", + "home": "হোম", + "home_settings": "সেটিংস", + "scripts": "স্ক্রিপ্টস্", + "features": "বৈশিষ্ট্য", + "home_logs": "লগ", + "social": "সামাজিক", + "logged_stories": "লগ করা গল্প", + "manage_scope": "সুযোগ পরিচালনা করুন", + "messaging_preview": "পূর্বরূপ", + "logger_history": "লগার ইতিহাস", + "friend_tracker": "বন্ধু ট্র্যাকার", + "edit_rule": "সম্পাদনার নিয়ম", + "file_imports": "ফাইল আমদানি" + }, + "sections": { + "features": { + "disabled": "অক্ষম", + "export_option": "রপ্তানি", + "import_option": "আমদানি", + "reset_option": "রিসেট", + "config_export_success_toast": "কনফিগার সফলভাবে রপ্তানি করা হয়েছে", + "config_import_success_toast": "কনফিগ সফলভাবে আমদানি করা হয়েছে", + "saved_config_snackbar": "কনফিগার সংরক্ষণ করা হয়েছে", + "config_import_failure_toast": "কনফিগ আমদানি করতে ব্যর্থ হয়েছে {error}", + "config_export_failure_toast": "কনফিগ রপ্তানি করতে ব্যর্থ {error}" + }, + "social": { + "streaks_expiration_short": "{hours}h", + "friends_tab": "বন্ধুরা", + "groups_tab": "গোষ্ঠীগুলি", + "empty_hint": "(খালি)" + }, + "tasks": { + "no_tasks": "কোন কাজ নেই", + "delete_files_option": "ফাইলগুলিও মুছে ফেলুন", + "remove_selected_tasks_confirm": "{count} কাজ সরান?", + "remove_selected_tasks_title": "নিশ্চিত আপনি নির্বাচিত কাজগুলি সরাতে চান?", + "remove_all_tasks_title": "নিশ্চিত আপনি সব কাজ অপসারণ করতে চান?", + "remove_all_tasks_confirm": "সমস্ত কাজ মুছে ফেলুন?", + "merge_files_toast": "{count} ফাইল মিশ্রণ" + }, + "home_settings": { + "export_button": "রপ্তানি", + "message_logger_summary": "{messageCount} বার্তা\n{storyCount} গল্পগুলি", + "actions_title": "অ্যাকশন", + "message_logger_title": "বার্তা লগার", + "debug_title": "ডিবাগ", + "success_toast": "হয়ে গেছে!", + "clear_button": "পরিষ্কার", + "view_logger_history_button": "লগার ইতিহাস দেখুন" + }, + "manage_scope": { + "e2ee_title": "এন্ড-টু-এন্ড এনক্রিপশন", + "logged_stories_button": "লগ করা গল্পগুলি দেখান", + "rules_title": "বিধিগুলি", + "not_found": "পাওয়া যায়নি", + "streaks_title": "রেখাগুলি", + "participants_text": "{count} অংশগ্রহণকারী", + "streaks_length_text": "দৈর্ঘ্য: {length}", + "streaks_expiration_text": "{eta}-এ মেয়াদ শেষ হবে", + "streaks_expiration_text_expired": "মেয়াদোত্তীর্ণ", + "reminder_button": "অনুস্মারক সেট করুন", + "delete_scope_confirm_dialog_title": "নিশ্চিত আপনি একটি {scope} মুছতে চান?" + }, + "home": { + "update_content": "সংস্করণ {version} উপলব্ধ!", + "update_title": "স্ন্যাপএনহ্যান্স আপডেট", + "update_button": "ডাউনলোড" + }, + "home_logs": { + "no_logs_hint": "কোনও লগ উপলব্ধ নেই", + "clear_logs_button": "লগ পরিষ্কার করুন", + "export_logs_button": "রপ্তানি লগ", + "saving_logs_toast": "লগগুলি সংরক্ষণ করা এটি কিছু সময় নিতে পারে ..।", + "saved_logs_success_toast": "লগগুলি সফলভাবে সংরক্ষণ করা হয়েছে", + "saved_logs_failure_toast": "লগ সংরক্ষণ করতে ব্যর্থ" + }, + "logged_stories": { + "story_failed_to_load": "লোড করতে ব্যর্থ হয়েছে", + "no_stories": "কোনো গল্প খুঁজে পাওয়া যায়নি", + "save_from_cache_button": "ক্যাশ থেকে সংরক্ষণ করুন" + }, + "messaging_preview": { + "bridge_init_failed": "মেসেজিং ব্রিজ চালু করা যাচ্ছে না। নিশ্চিত করুন যে স্ন্যাপচ্যাট পটভূমিতে চলছে", + "message_fetch_failed": "বার্তা আনতে ব্যর্থ হয়েছে", + "save_selection_option": "সেভ সিলেকশন", + "unsave_selection_option": "আনসেভ সিলেকশন", + "unsave_all_option": "সব আনসেভ করুন", + "mark_selection_as_seen_option": "নির্বাচিত স্ন্যাপটি দেখা হিসেবে চিহ্নিত করুন", + "delete_selection_option": "ডিলিট সিলেকশন", + "delete_all_option": "সব মুছে ফেলুন", + "bridge_connection_failed": "ব্রিজে সংযোগ করতে ব্যর্থ হয়েছে। নিশ্চিত করুন যে স্ন্যাপচ্যাট ব্যাকগ্রাউন্ডে চলছে", + "no_message_hint": "কোন বার্তা নেই", + "save_all_option": "সব সংরক্ষণ করুন", + "mark_all_as_seen_option": "সমস্ত স্ন্যাপস যেমন দেখা যায় চিহ্নিত করুন" + }, + "logger_history": { + "list_friend_format": "বন্ধু {name}", + "list_group_format": "গ্রুপ {name}", + "no_more_messages": "আর কোনো বার্তা নেই", + "reverse_order_checkbox": "বিপরীত ক্রম", + "chat_attachment": "সংযুক্তি {index}", + "empty_message": "খালি চ্যাট বার্তা", + "unknown_sender": "অজানা প্রেরক", + "download_attachment_failed_toast": "সংযুক্তি ডাউনলোড করতে ব্যর্থ", + "message_parse_failed": "বার্তা পার্স করতে ব্যর্থ" + }, + "file_imports": { + "file_imported": "ফাইল সফলভাবে আমদানি করা হয়েছে", + "no_files_hint": "এখানে আপনি স্ন্যাপচ্যাটে ব্যবহারের জন্য ফাইল আমদানি করতে পারেন। ফাইল আমদানি করতে নীচের বোতামটি চাপুন।", + "import_file_button": "ফাইল আমদানি করুন", + "file_not_found": "ফাইল পাওয়া যায়নি", + "file_import_failed": "ফাইল আমদানি ব্যর্থ হয়েছে: {error}", + "file_delete_failed": "ফাইল মুছে ফেলতে ব্যর্থ হয়েছে" + } + }, + "dialogs": { + "add_friend": { + "title": "বন্ধু বা গ্রুপ যোগ করুন", + "category_groups": "গোষ্ঠীগুলি", + "search_hint": "সন্ধান", + "fetch_error": "তথ্য আনতে ব্যর্থ হয়েছে", + "category_friends": "বন্ধুরা" + }, + "scripting_warning": { + "title": "সতর্কতা", + "content": "স্ন্যাপএনহান্স একটি স্ক্রিপ্টিং টুল অন্তর্ভুক্ত করে, যা আপনার ডিভাইসে ব্যবহারকারী-নির্ধারিত কোডের নির্বাহ সম্ভব করে। অত্যন্ত সাবধানতা অবলম্বন করুন এবং কেবল পরিচিত, নির্ভরযোগ্য উৎস থেকে মডিউল ইনস্টল করুন। অননুমোদিত বা যাচাই না করা মডিউলগুলি আপনার সিস্টেমের জন্য নিরাপত্তা ঝুঁকি তৈরি করতে পারে।" + }, + "reset_config": { + "content": "আপনি কি নিশ্চিত যে আপনি কনফিগ রিসেট করতে চান?", + "success_toast": "কনফিগ সফলভাবে রিসেট হয়েছে", + "title": "কনফিগ রিসেট করুন" + }, + "messaging_action": { + "title": "প্রক্রিয়া করতে সামগ্রীর ধরণগুলি চয়ন করুন", + "select_all_button": "সব নির্বাচন করুন" + }, + "file_imports": { + "no_files_settings_hint": "কোনো ফাইল পাওয়া যায়নি। নিশ্চিত করুন যে আপনি ফাইল আমদানি বিভাগে প্রয়োজনীয় ফাইলগুলি আমদানি করেছেন", + "settings_select_file_hint": "আমদানি করা একটি ফাইল নির্বাচন করুন" + }, + "export_config": { + "title": "সংবেদনশীল তথ্য রপ্তানি করুন?", + "content": "আপনি কি সংবেদনশীল ডেটা সহ কনফিগারেশন রপ্তানি করতে চান? (যেমন অবস্থানের স্থানাঙ্ক, ইত্যাদি)" + } + } + }, + "scopes": { + "friend": "বন্ধু", + "group": "গোষ্ঠী" + }, + "streaks_reminder": { + "notification_title": "রেখা", + "notification_text": "আপনি {friend} এর সাথে আপনার রেখা {hoursLeft} ঘন্টার মধ্যে হারাবেন" + }, + "rules": { + "properties": { + "stealth": { + "name": "স্টিলথ মোড", + "options": { + "whitelist": "স্টেলথ মোড", + "blacklist": "স্টিলথ মোড থেকে বাদ দিন" + }, + "description": "কারও জানার থেকে বিরত রাখে যে আপনি তাদের স্ন্যাপ/চ্যাট এবং কথোপকথন খুলেছেন" + }, + "auto_save": { + "options": { + "blacklist": "অটো সেভ থেকে বাদ দিন", + "whitelist": "স্বয়ংক্রিয় সংরক্ষণ" + }, + "name": "অটো সেভ", + "description": "চ্যাট মেসেজ দেখার সময় সেভ করে" + }, + "pin_conversation": { + "name": "কনভারসেশন পিন করুন" + }, + "auto_download": { + "options": { + "blacklist": "স্বতঃ ডাউনলোড থেকে বহির্ভূত করুন", + "whitelist": "স্বয়ংক্রিয় ডাউনলোড" + }, + "name": "স্বতঃ ডাউনলোড", + "description": "দেখার সময় স্ন্যাপগুলি স্বয়ংক্রিয়ভাবে ডাউনলোড করুন" + }, + "unsaveable_messages": { + "description": "অন্যান্য লোকের দ্বারা চ্যাটে মেসেজ সংরক্ষণ প্রতিরোধ করে", + "options": { + "blacklist": "অসংরক্ষণযোগ্য মেসেজ থেকে বাদ দিন", + "whitelist": "অসংরক্ষণযোগ্য মেসেজ" + }, + "name": "অসংরক্ষণযোগ্য মেসেজ" + }, + "hide_friend_feed": { + "name": "ফ্রেন্ড ফিড থেকে লুকান" + }, + "e2e_encryption": { + "name": "E2E এনক্রিপশন ব্যবহার করুন" + }, + "auto_open_snaps": { + "options": { + "blacklist": "অটো ওপেন স্ন্যাপস থেকে বাদ দিন", + "whitelist": "অটো ওপেন স্ন্যাপস" + }, + "name": "অটো ওপেন স্ন্যাপস", + "description": "স্ন্যাপস পাওয়ার সময় স্বয়ংক্রিয়ভাবে খোলে" + } + }, + "modes": { + "whitelist": "হোয়াইটলিস্ট মোড", + "blacklist": "ব্ল্যাকলিস্ট মোড" + }, + "toasts": { + "enabled": "{ruleName} সক্ষম", + "disabled": "{ruleName} অক্ষম" + } + }, + "actions": { + "clean_snapchat_cache": { + "description": "স্ন্যাপচ্যাট ক্যাশে পরিষ্কার করে", + "name": "স্ন্যাপচ্যাট ক্যাশে পরিষ্কার করুন" + }, + "manage_friend_list": { + "name": "বন্ধু তালিকা পরিচালনা করুন", + "description": "ব্যাক আপ করার সময় বন্ধু তালিকা আমদানি/রপ্তানি করুন" + }, + "regen_mappings": { + "name": "ম্যাপিং পুনরুত্পাদন করুন", + "description": "ম্যানুয়ালি ম্যাপিং পুনরুত্পাদন করুন" + }, + "bulk_messaging_action": { + "description": "বন্ধু মুছে ফেলা বা কথোপকথনের গণ মুছে ফেলার মতো অপারেশন সম্পাদন করে", + "name": "বাল্ক মেসেজিং অ্যাকশন" + }, + "export_chat_messages": { + "name": "চ্যাট মেসেজ রপ্তানি করুন", + "description": "কথোপকথনের মেসেজগুলি একটি JSON/HTML/TXT ফাইলে রপ্তানি করে" + }, + "export_memories": { + "name": "স্মৃতি রপ্তানি করুন", + "description": "স্মৃতিগুলি একটি ZIP ফাইলে রপ্তানি করে" + }, + "change_language": { + "name": "ভাষা পরিবর্তন করুন", + "description": "স্ন্যাপএনহ্যান্সের ভাষা পরিবর্তন করুন" + } + }, + "features": { + "properties": { + "downloader": { + "description": "স্ন্যাপচ্যাট মিডিয়া ডাউনলোড করুন", + "properties": { + "path_format": { + "name": "পাথ ফরম্যাট", + "description": "ফাইল পাথ ফরম্যাট নির্দিষ্ট করুন" + }, + "auto_download_sources": { + "description": "সেই সোর্সগুলি নির্বাচন করুন যেখান থেকে স্বয়ংক্রিয়ভাবে ডাউনলোড হবে", + "name": "অটো ডাউনলোড সোর্স" + }, + "prevent_self_auto_download": { + "name": "নিজের স্ন্যাপস স্বয়ংক্রিয়ভাবে ডাউনলোড হওয়া থেকে বিরত রাখুন", + "description": "নিজের স্ন্যাপসগুলি স্বয়ংক্রিয়ভাবে ডাউনলোড হওয়া থেকে বিরত রাখে" + }, + "force_image_format": { + "name": "ছবির ফরম্যাট জোর করে নির্ধারণ করুন", + "description": "ছবিগুলিকে নির্দিষ্ট ফরম্যাটে সংরক্ষণ করতে বাধ্য করে" + }, + "force_voice_note_format": { + "description": "ভয়েস নোটগুলিকে নির্দিষ্ট ফরম্যাটে সংরক্ষণ করতে বাধ্য করে", + "name": "ভয়েস নোটের ফরম্যাট জোর করে নির্ধারণ করুন" + }, + "opera_download_button": { + "description": "একটি স্ন্যাপ দেখার সময় উপরের ডান কোণে একটি ডাউনলোড বোতাম যোগ করে।\nবোতামে দীর্ঘ চাপ ডাউনলোড জোর করে করবে", + "name": "অপেরা ডাউনলোড বোতাম" + }, + "ffmpeg_options": { + "description": "অতিরিক্ত FFmpeg অপশনস নির্দিষ্ট করুন", + "properties": { + "custom_video_codec": { + "name": "কাস্টম ভিডিও কোডেক", + "description": "একটি কাস্টম ভিডিও কোডেক নির্ধারণ করুন (যেমন libx264)" + }, + "video_bitrate": { + "description": "ভিডিও বিটরেট নির্ধারণ করুন (kbps)", + "name": "ভিডিও বিটরেট" + }, + "audio_bitrate": { + "name": "অডিও বিটরেট", + "description": "অডিও বিটরেট নির্ধারণ করুন (kbps)" + }, + "threads": { + "description": "ব্যবহারের জন্য থ্রেডের পরিমাণ", + "name": "থ্রেডস" + }, + "custom_audio_codec": { + "description": "একটি কাস্টম অডিও কোডেক নির্ধারণ করুন (যেমন AAC)", + "name": "কাস্টম অডিও কোডেক" + }, + "preset": { + "name": "প্রিসেট", + "description": "রূপান্তরের গতি নির্ধারণ করুন" + }, + "constant_rate_factor": { + "name": "স্থির হার ফ্যাক্টর", + "description": "ভিডিও এনকোডারের জন্য স্থির হার ফ্যাক্টর নির্ধারণ করুন\nlibx264 এর জন্য 0 থেকে 51 পর্যন্ত" + } + }, + "name": "FFmpeg অপশনস" + }, + "logging": { + "name": "লগিং", + "description": "মিডিয়া ডাউনলোড হচ্ছে এমন সময় টোস্ট দেখায়" + }, + "custom_path_format": { + "description": "ডাউনলোডেড মিডিয়ার জন্য একটি কাস্টম পাথ ফরম্যাট নির্দিষ্ট করুন\n\nউপলব্ধ ভেরিয়েবল:\n - %username%\n - %source%\n - %hash%\n - %date_time%", + "name": "কাস্টম পাথ ফরম্যাট" + }, + "save_folder": { + "name": "সেভ ফোল্ডার", + "description": "সেই ডিরেক্টরি নির্বাচন করুন যেখানে সমস্ত মিডিয়া ডাউনলোড করা হবে" + }, + "allow_duplicate": { + "description": "একই মিডিয়া একাধিকবার ডাউনলোড করার অনুমতি দেয়", + "name": "একই মিডিয়া একাধিকবার ডাউনলোড করা অনুমোদন করুন" + }, + "merge_overlays": { + "name": "একটি স্ন্যাপের টেক্সট এবং মিডিয়াকে একটি একক ফাইলে মিশ্রিত করুন", + "description": "একটি স্ন্যাপের টেক্সট এবং মিডিয়াকে একটি একক ফাইলে মিশ্রিত করে" + }, + "download_profile_pictures": { + "name": "প্রোফাইল ছবি ডাউনলোড করুন", + "description": "প্রোফাইল পৃষ্ঠা থেকে প্রোফাইল ছবি ডাউনলোড করতে অনুমতি দেয়" + }, + "download_context_menu": { + "name": "ডাউনলোড কনটেক্সট মেনু", + "description": "কনটেক্সট মেনু ব্যবহার করে কথোপকথন বা গল্প থেকে মেসেজ ডাউনলোড/প্রিভিউ করতে অনুমতি দেয়।\nবোতামে দীর্ঘ চাপ ডাউনলোড জোর করে করবে" + } + }, + "name": "ডাউনলোডার" + }, + "user_interface": { + "description": "স্ন্যাপচ্যাটের চেহারা এবং অনুভূতি পরিবর্তন করুন", + "properties": { + "friend_feed_message_preview": { + "properties": { + "amount": { + "name": "পরিমাণ", + "description": "প্রিভিউ পাওয়ার জন্য বার্তাগুলির পরিমাণ" + } + }, + "name": "বন্ধু ফিড মেসেজ প্রিভিউ", + "description": "বন্ধু ফিডে শেষ বার্তাগুলির একটি প্রিভিউ দেখায়" + }, + "hide_ui_components": { + "name": "UI উপাদান লুকান", + "description": "কোন UI উপাদান লুকাতে চান তা নির্বাচন করুন" + }, + "enable_friend_feed_menu_bar": { + "description": "নতুন বন্ধু ফিড মেনু বার সক্রিয় করে", + "name": "বন্ধু ফিড মেনু বার" + }, + "old_bitmoji_selfie": { + "name": "পুরানো বিটমোজি সেলফি", + "description": "পুরানো স্ন্যাপচ্যাট সংস্করণ থেকে বিটমোজি সেলফিগুলি ফিরিয়ে আনে" + }, + "disable_spotlight": { + "name": "স্পটলাইট অক্ষম করুন", + "description": "স্পটলাইট পৃষ্ঠা অক্ষম করে" + }, + "enable_app_appearance": { + "description": "লুকানো অ্যাপ চেহারা সেটিং সক্রিয় করে\nনতুন স্ন্যাপচ্যাট সংস্করণে এটি প্রয়োজন নাও হতে পারে", + "name": "অ্যাপ চেহারা সেটিংস সক্রিয় করুন" + }, + "snap_preview": { + "name": "স্ন্যাপ প্রিভিউ", + "description": "চ্যাটে অদেখা স্ন্যাপের পাশে একটি ছোট প্রিভিউ দেখায়" + }, + "bootstrap_override": { + "description": "ব্যবহারকারীর ইন্টারফেস বুটস্ট্র্যাপ সেটিংস ওভাররাইড করে", + "name": "বুটস্ট্র্যাপ ওভাররাইড", + "properties": { + "app_appearance": { + "name": "অ্যাপের চেহারা", + "description": "একটি স্থায়ী অ্যাপের চেহারা সেট করে" + }, + "home_tab": { + "name": "হোম ট্যাব", + "description": "স্ন্যাপচ্যাট খুললে স্টার্টআপ ট্যাব ওভাররাইড করে" + } + } + }, + "hide_friend_feed_entry": { + "name": "বন্ধু ফিড এন্ট্রি লুকান", + "description": "বন্ধু ফিড থেকে নির্দিষ্ট একজন বন্ধুকে লুকায়\nএই বৈশিষ্ট্য পরিচালনা করতে সামাজিক ট্যাব ব্যবহার করুন" + }, + "prevent_message_list_auto_scroll": { + "name": "বার্তা তালিকা অটো স্ক্রোল প্রতিরোধ করে", + "description": "বার্তা পাঠানো/গ্রহণ করার সময় বার্তা তালিকাকে নীচে স্ক্রোল করা থেকে প্রতিরোধ করে" + }, + "streak_expiration_info": { + "name": "স্ট্রিক মেয়াদ শেষের তথ্য দেখায়", + "description": "স্ট্রিকস কাউন্টারের পাশে স্ট্রিক মেয়াদ শেষের টাইমার দেখায়" + }, + "hide_streak_restore": { + "name": "স্ট্রিক পুনরুদ্ধার লুকান", + "description": "বন্ধু ফিডে পুনরুদ্ধার বোতাম লুকায়" + }, + "friend_feed_menu_buttons": { + "name": "বন্ধু ফিড মেনু বোতাম", + "description": "বন্ধু ফিড মেনুতে কোন বোতামগুলি দেখানো হবে তা নির্বাচন করুন" + }, + "vertical_story_viewer": { + "name": "উল্লম্ব গল্প দর্শক", + "description": "সমস্ত গল্পের জন্য উল্লম্ব গল্প দর্শক সক্রিয় করে" + }, + "hide_story_suggestions": { + "description": "গল্পের পৃষ্ঠা থেকে পরামর্শ সরায়", + "name": "গল্পের পরামর্শ লুকান" + }, + "map_friend_nametags": { + "name": "উন্নত বন্ধু মানচিত্র নামফলক", + "description": "স্ন্যাপম্যাপে বন্ধুদের নামফলক উন্নত করে" + }, + "edit_text_override": { + "name": "টেক্সট সম্পাদনা ওভাররাইড", + "description": "টেক্সট ফিল্ডের আচরণ ওভাররাইড করে" + }, + "stealth_mode_indicator": { + "name": "স্টেলথ মোড সূচক", + "description": "স্টেলথ মোডে কথোপকথনের পাশে একটি 👻 ইমোজি যোগ করে" + }, + "opera_media_quick_info": { + "name": "অপেরা মিডিয়া দ্রুত তথ্য", + "description": "অপেরা দর্শক কনটেক্সট মেনুতে মিডিয়ার সৃষ্টির তারিখের মতো উপকারী তথ্য দেখায়" + }, + "message_indicators": { + "name": "বার্তা সূচক", + "description": "বার্তাগুলিতে নির্দিষ্ট সূচক আইকন যোগ করে\nনোট: সূচকগুলি ১০০% সঠিক নাও হতে পারে" + } + }, + "name": "ব্যবহারকারীর ইন্টারফেস" + }, + "messaging": { + "properties": { + "disable_replay_in_ff": { + "description": "বন্ধু ফিড থেকে দীর্ঘ চাপ দিয়ে পুনরায় চালানোর ক্ষমতা অক্ষম করে", + "name": "এফএফ-এ পুনরায় চালানো অক্ষম করুন" + }, + "half_swipe_notifier": { + "properties": { + "max_duration": { + "name": "সর্বাধিক সময়কাল", + "description": "অর্ধেক সোয়াইপের সর্বাধিক সময়কাল (সেকেন্ডে)" + }, + "min_duration": { + "description": "অর্ধেক সোয়াইপের সর্বনিম্ন সময়কাল (সেকেন্ডে)", + "name": "সর্বনিম্ন সময়কাল" + } + }, + "name": "অর্ধেক সোয়াইপ সূচক", + "description": "কেউ যখন কথোপকথনে অর্ধেক সোয়াইপ করে তখন আপনাকে জানায়" + }, + "remove_groups_locked_status": { + "description": "আপনাকে গ্রুপের তথ্য দেখতে দেয়, যদিও আপনাকে গ্রুপ থেকে বের করা হয়েছে", + "name": "গ্রুপের লক অবস্থা সরান" + }, + "loop_media_playback": { + "name": "মিডিয়া প্লেব্যাক পুনরাবৃত্তি", + "description": "স্ন্যাপ/গল্প দেখার সময় মিডিয়া প্লেব্যাক পুনরাবৃত্তি করে" + }, + "message_logger": { + "properties": { + "message_filter": { + "name": "বার্তা ফিল্টার", + "description": "নির্বাচন করুন কোন বার্তা লগ হবে (সব বার্তার জন্য খালি)" + }, + "keep_my_own_messages": { + "description": "আপনার নিজের বার্তা মুছে ফেলা থেকে প্রতিরোধ করে", + "name": "আমার নিজের বার্তা রাখুন" + }, + "auto_purge": { + "name": "অটো পার্জ", + "description": "নির্দিষ্ট সময়ের চেয়ে পুরানো ক্যাশে করা বার্তা স্বয়ংক্রিয়ভাবে মুছে ফেলে" + } + }, + "name": "বার্তা লগার", + "description": "বার্তাগুলি মুছে ফেলা থেকে প্রতিরোধ করে" + }, + "hide_peek_a_peek": { + "description": "চ্যাটে অর্ধেক সোয়াইপ করার সময় নোটিফিকেশন পাঠানো থেকে বিরত রাখে", + "name": "পিক-এ-পিক লুকান" + }, + "hide_typing_notifications": { + "name": "টাইপিং নোটিফিকেশন লুকান", + "description": "কেউ যেন জানতে না পারে আপনি একটি বার্তা টাইপ করছেন" + }, + "prevent_message_sending": { + "description": "নির্দিষ্ট ধরনের বার্তা পাঠানো প্রতিরোধ করে", + "name": "বার্তা পাঠানো প্রতিরোধ করুন" + }, + "better_notifications": { + "name": "উন্নত নোটিফিকেশন", + "description": "প্রাপ্ত নোটিফিকেশনে আরও তথ্য যোগ করে", + "properties": { + "media_caption": { + "name": "মিডিয়া ক্যাপশন", + "description": "নোটিফিকেশনে মিডিয়ার সংযুক্ত ক্যাপশন দেখায়" + }, + "friend_add_source": { + "name": "বন্ধু যোগ উৎস", + "description": "নোটিফিকেশনে বন্ধু অনুরোধের উৎস দেখায়" + }, + "reply_button": { + "description": "নোটিফিকেশনে একটি উত্তর বোতাম যোগ করে", + "name": "উত্তর বোতাম" + }, + "mark_as_read_button": { + "name": "পড়া হিসেবে চিহ্নিত বোতাম", + "description": "নোটিফিকেশন থেকে একটি বার্তা পড়া হিসেবে চিহ্নিত করতে দেয়" + }, + "group_notifications": { + "name": "গ্রুপ নোটিফিকেশন", + "description": "একক নোটিফিকেশনে গ্রুপ নোটিফিকেশন একত্রিত করে" + }, + "chat_preview": { + "name": "চ্যাট প্রিভিউ", + "description": "নোটিফিকেশনে প্রাপ্ত বার্তাগুলির প্রিভিউ দেখায়" + }, + "media_preview": { + "name": "মিডিয়া প্রিভিউ", + "description": "নোটিফিকেশনে নির্বাচিত মিডিয়া ধরনের প্রিভিউ দেখায়" + }, + "stacked_media_messages": { + "name": "স্তূপীকৃত মিডিয়া বার্তা", + "description": "প্রিভিউ করা না গেলে একাধিক মিডিয়া বার্তাকে একটি টেক্সট নোটিফিকেশনে একত্রিত করে। চ্যাট প্রিভিউ এর সাথে ব্যবহার করুন" + }, + "download_button": { + "name": "ডাউনলোড বোতাম", + "description": "নোটিফিকেশন থেকে মিডিয়া ডাউনলোড করতে দেয়" + }, + "mark_as_read_and_save_in_chat": { + "name": "পড়া হিসেবে চিহ্নিত এবং চ্যাটে সংরক্ষণ করুন", + "description": "নোটিফিকেশনে পড়া হিসেবে চিহ্নিত এবং চ্যাটে সংরক্ষণ করার বোতাম যোগ করে" + } + } + }, + "auto_save_messages_in_conversations": { + "description": "কথোপকথনে প্রতিটি বার্তা স্বয়ংক্রিয়ভাবে সংরক্ষণ করে", + "name": "অটো সেভ বার্তা" + }, + "auto_mark_as_read": { + "name": "অটো পড়া হিসেবে চিহ্নিত করুন", + "description": "স্টেলথ মোড সক্রিয় থাকলেও বার্তা/স্ন্যাপগুলি স্বয়ংক্রিয়ভাবে পড়া হিসাবে চিহ্নিত করে" + }, + "bypass_message_action_restrictions": { + "description": "একটি স্ন্যাপ খোলা ছাড়াই প্রতিক্রিয়া জানাতে দেয় বা একটি অসংরক্ষণযোগ্য বার্তা সংরক্ষণ করতে দেয়", + "name": "বার্তা ক্রিয়া সীমাবদ্ধতা এড়িয়ে চলুন" + }, + "anonymous_story_viewing": { + "description": "কেউ যেন জানতে না পারে আপনি তাদের গল্প দেখেছেন", + "name": "অজ্ঞাতসারে গল্প দেখা" + }, + "unlimited_snap_view_time": { + "description": "স্ন্যাপ দেখার সময়সীমা সরিয়ে দেয়", + "name": "স্ন্যাপ দেখার সময়সীমা অসীম" + }, + "call_start_confirmation": { + "name": "কল শুরু নিশ্চিতকরণ", + "description": "কল শুরু করার সময় একটি নিশ্চিতকরণ ডায়ালগ দেখায়" + }, + "unlimited_conversation_pinning": { + "name": "অসীম কথোপকথন পিনিং", + "description": "স্থানীয়ভাবে অসীম পরিমাণে কথোপকথন পিন করতে দেয়" + }, + "notification_blacklist": { + "name": "নোটিফিকেশন ব্ল্যাকলিস্ট", + "description": "সেই নোটিফিকেশনগুলি নির্বাচন করুন যা ব্লক করা উচিত" + }, + "strip_media_metadata": { + "description": "বার্তা হিসেবে পাঠানোর আগে মিডিয়ার মেটাডেটা সরায়", + "name": "মিডিয়া মেটাডেটা সরান" + }, + "prevent_story_rewatch_indicator": { + "name": "গল্প পুনরায় দেখার সূচক প্রতিরোধ করুন", + "description": "কেউ যেন জানতে না পারে আপনি তাদের গল্প পুনরায় দেখেছেন" + }, + "hide_bitmoji_presence": { + "name": "বিটমোজি উপস্থিতি লুকান", + "description": "চ্যাটে থাকাকালীন আপনার বিটমোজি পপ আপ হওয়া থেকে বিরত রাখে" + }, + "friend_mutation_notifier": { + "name": "বন্ধু পরিবর্তন সূচক", + "description": "বন্ধুর প্রোফাইলে কিছু পরিবর্তন হলে আপনাকে জানায়" + }, + "bypass_screenshot_detection": { + "name": "স্ক্রিনশট সনাক্তকরণ এড়িয়ে চলুন", + "description": "স্ন্যাপচ্যাট যখন আপনি স্ক্রিনশট নেন তা সনাক্ত করা থেকে বিরত রাখে" + }, + "gallery_media_send_override": { + "name": "গ্যালারি মিডিয়া পাঠানো ওভাররাইড", + "description": "গ্যালারি থেকে পাঠানোর সময় মিডিয়া উৎস নকল করে" + }, + "bypass_message_retention_policy": { + "name": "বার্তা ধরে রাখার নীতি এড়িয়ে চলুন", + "description": "বার্তা দেখার পর তা মুছে ফেলা থেকে প্রতিরোধ করে" + } + }, + "description": "বন্ধুদের সাথে মিথস্ক্রিয়া পরিবর্তন করুন", + "name": "মেসেজিং" + }, + "global": { + "properties": { + "disable_confirmation_dialogs": { + "name": "নিশ্চিতকরণ ডায়ালগ অক্ষম করুন", + "description": "নির্বাচিত ক্রিয়াকলাপগুলি স্বয়ংক্রিয়ভাবে নিশ্চিত করে" + }, + "snapchat_plus": { + "description": "স্ন্যাপচ্যাট প্লাস বৈশিষ্ট্যগুলি সক্রিয় করে\nকিছু সার্ভার-সাইডেড বৈশিষ্ট্য কাজ নাও করতে পারে", + "name": "স্ন্যাপচ্যাট প্লাস" + }, + "video_playback_rate_slider": { + "name": "ভিডিও প্লেব্যাক হার স্লাইডার", + "description": "অপেরা কনটেক্সট মেনুতে একটি স্লাইডার যোগ করে যা ভিডিও প্লেব্যাক হার পরিবর্তন করে\nনোট: পরিবর্তনগুলি কেবল পরবর্তী ভিডিওগুলিতে প্রযোজ্য" + }, + "disable_metrics": { + "name": "মেট্রিক্স অক্ষম করুন", + "description": "স্ন্যাপচ্যাটে নির্দিষ্ট বিশ্লেষণাত্মক ডেটা পাঠানো ব্লক করে" + }, + "default_video_playback_rate": { + "description": "ভিডিও প্লেব্যাকের জন্য ডিফল্ট গতি সেট করে\nমান 0.1 থেকে 4.0 এর মধ্যে হতে হবে", + "name": "ডিফল্ট ভিডিও প্লেব্যাক হার" + }, + "default_volume_controls": { + "name": "ডিফল্ট ভলিউম নিয়ন্ত্রণ", + "description": "স্ন্যাপচ্যাটকে সিস্টেম ভলিউম নিয়ন্ত্রণ ব্যবহার করতে বাধ্য করে" + }, + "better_location": { + "properties": { + "suspend_location_updates": { + "name": "অবস্থান আপডেট স্থগিত করুন", + "description": "মানচিত্র সেটিংসে একটি বোতাম যোগ করে যা অবস্থান আপডেট স্থগিত করে" + }, + "spoof_battery_level": { + "name": "ব্যাটারি লেভেল নকল করুন", + "description": "মানচিত্রে আপনার ডিভাইসের ব্যাটারি লেভেল নকল করে\nমান 0 থেকে 100 এর মধ্যে হতে হবে" + }, + "coordinates": { + "description": "নকল অবস্থানের স্থানাঙ্ক সেট করুন", + "name": "স্থানাঙ্ক" + }, + "walk_radius": { + "description": "এই ব্যাসার্ধের মধ্যে এলোমেলোভাবে হাঁটুন (ft)", + "name": "হাঁটার ব্যাসার্ধ" + }, + "spoof_location": { + "name": "অবস্থান নকল করুন", + "description": "আপনার অবস্থানকে নির্দিষ্ট একটিতে নকল করে" + }, + "always_update_location": { + "name": "সবসময় অবস্থান আপডেট করুন", + "description": "জিপিএস ডেটা না পেলেও স্ন্যাপচ্যাটকে অবস্থান আপডেট করতে বাধ্য করুন" + }, + "spoof_headphones": { + "name": "হেডফোন নকল করুন", + "description": "মানচিত্রে সঙ্গীত শোনার অবস্থা নকল করে" + } + }, + "description": "স্ন্যাপচ্যাটের অবস্থান উন্নত করে", + "name": "উন্নত অবস্থান" + }, + "block_ads": { + "name": "বিজ্ঞাপন ব্লক করুন", + "description": "বিজ্ঞাপন প্রদর্শন থেকে প্রতিরোধ করে" + }, + "disable_story_sections": { + "name": "গল্পের অংশগুলি অক্ষম করুন", + "description": "গল্পের পৃষ্ঠা থেকে অংশগুলি সরায়\nঠিকভাবে কাজ করতে রিফ্রেশ করা প্রয়োজন হতে পারে" + }, + "disable_google_play_dialogs": { + "description": "গুগল প্লে সার্ভিসেস উপলব্ধতা ডায়ালগ প্রদর্শন থেকে প্রতিরোধ করে", + "name": "গুগল প্লে সার্ভিসেস ডায়ালগ অক্ষম করুন" + }, + "disable_snap_splitting": { + "name": "স্ন্যাপ বিভক্তি অক্ষম করুন", + "description": "স্ন্যাপগুলিকে একাধিক অংশে বিভক্ত হওয়া থেকে প্রতিরোধ করে\nআপনি যে ছবিগুলি পাঠাবেন তা ভিডিওতে পরিণত হবে" + }, + "disable_permission_requests": { + "description": "স্ন্যাপচ্যাটকে নির্দিষ্ট অনুমতি চাওয়া থেকে প্রতিরোধ করে", + "name": "অনুমতি অনুরোধ অক্ষম করুন" + }, + "media_upload_quality": { + "properties": { + "disable_image_compression": { + "name": "ইমেজ কম্প্রেশন অক্ষম করুন", + "description": "মিডিয়া আপলোড করার সময় ইমেজ কম্প্রেশন অক্ষম করে" + }, + "custom_image_upload_format": { + "name": "কাস্টম ইমেজ আপলোড ফরম্যাট", + "description": "একটি কাস্টম ইমেজ আপলোড ফরম্যাট সেট করে\nসেরা গুণমানের জন্য একটি লসলেস ফরম্যাট (যেমন PNG) নির্বাচন করুন" + }, + "force_video_upload_source_quality": { + "name": "ভিডিও আপলোড সোর্স গুণমান জোর করে ব্যবহার করুন", + "description": "ভিডিও আপলোড করার সময় স্ন্যাপচ্যাটকে সোর্স গুণমান ব্যবহার করতে বাধ্য করে\nদয়া করে মনে রাখবেন এটি মিডিয়া থেকে মেটাডেটা সরাতে নাও পারে" + } + }, + "name": "মিডিয়া আপলোডের গুণমান", + "description": "মিডিয়া আপলোডের গুণমান ওভাররাইড করে" + }, + "disable_custom_tabs": { + "name": "কাস্টম ট্যাব অক্ষম করুন", + "description": "ওয়েব ব্রাউজারের পরিবর্তে সমর্থিত অ্যাপ্লিকেশনগুলিতে লিঙ্ক খোলে" + }, + "bypass_video_length_restriction": { + "name": "ভিডিও দৈর্ঘ্যের সীমাবদ্ধতা এড়িয়ে চলুন", + "description": "একক: একটি একক ভিডিও পাঠায়\nবিভক্ত: সম্পাদনার পর ভিডিওগুলি বিভক্ত করে" + }, + "spotlight_comments_username": { + "description": "স্পটলাইট মন্তব্যে লেখকের ব্যবহারকারীর নাম দেখায়", + "name": "স্পটলাইট মন্তব্যের ব্যবহারকারীর নাম" + }, + "hide_active_music": { + "name": "সক্রিয় সঙ্গীত লুকান", + "description": "স্ন্যাপচ্যাটকে আপনি সঙ্গীত শুনছেন তা জানা থেকে প্রতিরোধ করে\nএটি আপনাকে সঙ্গীত শোনার সময় বোতাম ব্যবহার করে স্ন্যাপ তুলতে দেবে" + }, + "auto_updater": { + "name": "অটো আপডেটার", + "description": "স্বয়ংক্রিয়ভাবে নতুন আপডেটগুলি পরীক্ষা করে" + }, + "disable_memories_snap_feed": { + "name": "মেমোরিজ স্ন্যাপ ফিড অক্ষম করুন", + "description": "ক্যামেরায় উপরে সোয়াইপ করলে স্ন্যাপচ্যাটকে সাম্প্রতিক মেমোরিজ দেখানো থেকে প্রতিরোধ করে" + } + }, + "description": "গ্লোবাল স্ন্যাপচ্যাট সেটিংস মোড়ান", + "name": "গ্লোবাল" + }, + "experimental": { + "properties": { + "spoof": { + "properties": { + "play_store_installer_package_name": { + "name": "প্লে স্টোর ইনস্টলার প্যাকেজের নাম", + "description": "ইনস্টলার প্যাকেজের নামকে com.android.vending এ ওভাররাইড করে" + }, + "remove_vpn_transport_flag": { + "name": "ভিপিএন ট্রান্সপোর্ট ফ্ল্যাগ সরান", + "description": "স্ন্যাপচ্যাটকে ভিপিএন সনাক্ত করা থেকে প্রতিরোধ করে" + }, + "remove_mock_location_flag": { + "name": "মক লোকেশন ফ্ল্যাগ সরান", + "description": "স্ন্যাপচ্যাটকে মক লোকেশন সনাক্ত করা থেকে প্রতিরোধ করে" + } + }, + "description": "আপনার সম্পর্কে বিভিন্ন তথ্য নকল করে", + "name": "নকল" + }, + "best_friend_pinning": { + "name": "সেরা বন্ধু পিনিং", + "description": "আপনাকে আপনার নম্বর ওয়ান সেরা বন্ধু হিসেবে একজন বন্ধুকে পিন করতে দেয়। নোট: কেবল আপনিই আপনার পিন করা সেরা বন্ধুটি দেখতে পাবেন" + }, + "hidden_snapchat_plus_features": { + "description": "অপ্রকাশিত/বেটা স্ন্যাপচ্যাট প্লাস বৈশিষ্ট্যগুলি সক্রিয় করে\nপুরানো স্ন্যাপচ্যাট সংস্করণে কাজ নাও করতে পারে", + "name": "লুকানো স্ন্যাপচ্যাট প্লাস বৈশিষ্ট্যগুলি" + }, + "native_hooks": { + "properties": { + "disable_bitmoji": { + "description": "বন্ধুর প্রোফাইল বিটমোজি অক্ষম করে", + "name": "বিটমোজি অক্ষম করুন" + }, + "composer_hooks": { + "properties": { + "composer_console": { + "description": "কম্পোজারে (শুধুমাত্র arm64) জাভাস্ক্রিপ্ট কোড চালানোর অনুমতি দেয়", + "name": "কম্পোজার কনসোল" + }, + "show_first_created_username": { + "name": "প্রথম তৈরি ব্যবহারকারীর নাম দেখান", + "description": "প্রোফাইল পৃষ্ঠায় বর্তমান ব্যবহারকারীর নামের পাশে প্রথম তৈরি ব্যবহারকারীর নাম দেখায়" + }, + "bypass_camera_roll_limit": { + "name": "ক্যামেরা রোল সীমা এড়িয়ে চলুন", + "description": "ক্যামেরা রোল থেকে পাঠানো যায় এমন মিডিয়ার সর্বাধিক পরিমাণ বাড়ায়" + }, + "composer_logs": { + "name": "কম্পোজার লগস", + "description": "কম্পোজারের কনসোল লগগুলি স্ন্যাপএনহ্যান্সে পুনঃনির্দেশ করে" + } + }, + "description": "কম্পোজার ক্রস-প্ল্যাটফর্ম UI ফ্রেমওয়ার্কে কোড ইনজেক্ট করে", + "name": "কম্পোজার হুকস" + }, + "custom_emoji_font": { + "description": "আপনাকে কাস্টম ইমোজি ফন্ট ব্যবহার করতে দেয়। শুধুমাত্র .ttf ফন্টগুলির সাথে কাজ করে", + "name": "কাস্টম ইমোজি ফন্ট" + } + }, + "name": "নেটিভ হুকস", + "description": "স্ন্যাপচ্যাটের নেটিভ কোডে যুক্ত অনিরাপদ বৈশিষ্ট্য" + }, + "e2ee": { + "properties": { + "encrypted_message_indicator": { + "name": "এনক্রিপ্টেড বার্তা সূচক", + "description": "এনক্রিপ্টেড বার্তার পাশে একটি 🔒 ইমোজি যোগ করে" + }, + "force_message_encryption": { + "name": "বার্তা এনক্রিপশন জোর করে", + "description": "E2E এনক্রিপশন সক্রিয় না থাকা ব্যক্তিদের কাছে এনক্রিপ্টেড বার্তা পাঠানো বন্ধ করে কেবল যখন একাধিক কথোপকথন নির্বাচিত হয়" + } + }, + "name": "End-To-End এনক্রিপশন", + "description": "একটি ভাগ করা গোপন কী ব্যবহার করে আপনার বার্তাগুলি AES দিয়ে এনক্রিপ্ট করে\nনিশ্চিত করুন যে আপনার কীটি কোথাও নিরাপদে সংরক্ষণ করেছেন!" + }, + "no_friend_score_delay": { + "name": "কোনও বন্ধু স্কোর বিলম্ব করেনি", + "description": "বন্ধুর স্কোর দেখার সময় বিলম্ব অপসারণ করে" + }, + "account_switcher": { + "name": "অ্যাকাউন্ট স্যুইচার", + "properties": { + "auto_backup_current_account": { + "description": "লগ আউট করা বা অ্যাকাউন্ট পরিবর্তন করার সময় বর্তমান অ্যাকাউন্ট স্বয়ংক্রিয়ভাবে ব্যাকআপ করে", + "name": "অটো ব্যাকআপ বর্তমান অ্যাকাউন্ট" + } + }, + "description": "লগ আউট না করে অ্যাকাউন্টের মধ্যে স্যুইচ করতে দেয়\nমেনু খুলতে আপনার বিটমোজি প্রোফাইলের পাশের সার্চ আইকনে দীর্ঘ চাপ দিন\nনোট: এই বৈশিষ্ট্যটি পরীক্ষামূলক এবং ভবিষ্যতে সম্ভবত পরিবর্তন হবে" + }, + "custom_streaks_expiration_format": { + "description": "স্ট্রিকস মেয়াদ শেষের ফরম্যাট কাস্টমাইজ করে\n\nউপলব্ধ ভেরিয়েবল:\n - %c: স্ট্রিকস গণনা\n - %e: আওয়ারগ্লাস ইমোজি\n - %d: দিন\n - %h: ঘন্টা\n - %m: মিনিট\n - %s: সেকেন্ড\n - %w: অবশিষ্ট সময়", + "name": "কাস্টম স্ট্রিকস মেয়াদ শেষের ফরম্যাট" + }, + "prevent_forced_logout": { + "name": "জোর করে লগআউট প্রতিরোধ করে", + "description": "অন্য ডিভাইসে লগইন করলে স্ন্যাপচ্যাট আপনাকে লগআউট করা থেকে বিরত রাখে" + }, + "story_logger": { + "description": "বন্ধুদের গল্পের ইতিহাস প্রদান করে", + "name": "গল্প লগার" + }, + "edit_message": { + "description": "কথোপকথনে বার্তা সম্পাদনা করতে দেয়", + "name": "বার্তাগুলি সম্পাদনা করুন" + }, + "convert_message_locally": { + "name": "স্থানীয়ভাবে বার্তা রূপান্তর করুন", + "description": "স্থানীয়ভাবে স্ন্যাপগুলিকে চ্যাট বাহ্যিক মিডিয়ায় রূপান্তর করে | এটি চ্যাট ডাউনলোড কনটেক্সট মেনুতে প্রদর্শিত হয়" + }, + "call_recorder": { + "description": "স্বয়ংক্রিয়ভাবে অডিও কল রেকর্ড করে", + "name": "কল রেকর্ডার" + }, + "infinite_story_boost": { + "name": "অনন্ত গল্প বৃদ্ধি", + "description": "স্টোরি বুস্ট লিমিট বিলম্ব বাইপাস করুন" + }, + "meo_passcode_bypass": { + "name": "আমার চোখ শুধু পাসকোড বাইপাস", + "description": "আমার চোখে কেবল পাসকোড বাইপাস করুন\nএটি কেবল তখনই কাজ করবে যদি পাসকোড আগে সঠিকভাবে প্রবেশ করানো হয়" + }, + "add_friend_source_spoof": { + "name": "বন্ধু উৎস স্পুফ যোগ করুন", + "description": "একটি বন্ধু অনুরোধের উৎস স্পুফ করে" + }, + "media_file_picker": { + "name": "মিডিয়া ফাইল পিকার", + "description": "গ্যালারি থেকে যেকোনো ভিডিও/অডিও ফাইল নির্বাচন করতে দেয়" + }, + "app_lock": { + "name": "অ্যাপ লক", + "description": "পাসকোড ছাড়া স্ন্যাপচ্যাটে প্রবেশ আটকায়", + "properties": { + "lock_on_resume": { + "name": "রিজুমে লক", + "description": "অ্যাপটি পুনরায় খোলার সময় লক করে" + } + } + }, + "cof_experiments": { + "name": "COF পরীক্ষা-নিরীক্ষা", + "description": "অপ্রকাশিত/বেটা স্ন্যাপচ্যাট বৈশিষ্ট্যগুলি সক্রিয় করে" + } + }, + "description": "পরীক্ষামূলক বৈশিষ্ট্য", + "name": "পরীক্ষামূলক" + }, + "scripting": { + "properties": { + "auto_reload": { + "name": "অটো রিলোড", + "description": "স্ক্রিপ্টগুলি পরিবর্তন হলে স্বয়ংক্রিয়ভাবে রিলোড করে" + }, + "module_folder": { + "name": "মডিউল ফোল্ডার", + "description": "স্ক্রিপ্টগুলি যে ফোল্ডারে অবস্থিত" + }, + "disable_log_anonymization": { + "name": "লগ অ্যানোনিমাইজেশন অক্ষম করুন", + "description": "লগগুলির বেনামী নিষ্ক্রিয় করে" + }, + "integrated_ui": { + "name": "ইন্টিগ্রেটেড UI", + "description": "স্ক্রিপ্টগুলিকে স্ন্যাপচ্যাটে কাস্টম UI উপাদান যোগ করতে দেয়" + }, + "developer_mode": { + "name": "ডেভেলপার মোড", + "description": "স্ন্যাপচ্যাটের UI-এ ডিবাগ তথ্য দেখায়" + } + }, + "description": "স্ন্যাপএনহ্যান্স প্রসারিত করতে কাস্টম স্ক্রিপ্ট চালান", + "name": "স্ক্রিপ্টিং" + }, + "camera": { + "properties": { + "black_photos": { + "description": "ধারণ করা ছবিগুলিকে কালো পটভূমির সাথে প্রতিস্থাপন করে\nভিডিওগুলি প্রভাবিত হয় না", + "name": "কালো ছবি" + }, + "front_custom_frame_rate": { + "name": "ফ্রন্ট কাস্টম ফ্রেম রেট", + "description": "ফ্রন্ট ক্যামেরার ফ্রেম রেট ওভাররাইড করে" + }, + "immersive_camera_preview": { + "description": "ক্যামেরা প্রিভিউ ক্রপ করা থেকে স্ন্যাপচ্যাটকে প্রতিরোধ করে\nএটি কিছু ডিভাইসে ক্যামেরা ঝলসানোর কারণ হতে পারে", + "name": "ইমারসিভ প্রিভিউ" + }, + "disable_cameras": { + "description": "নির্বাচিত ক্যামেরা ব্যবহার থেকে স্ন্যাপচ্যাটকে প্রতিরোধ করে", + "name": "ক্যামেরা অক্ষম করুন" + }, + "custom_resolution": { + "name": "কাস্টম রেজোলিউশন", + "description": "একটি কাস্টম ক্যামেরা রেজোলিউশন সেট করে, প্রস্থ x উচ্চতা (যেমন 1920x1080)\nকাস্টম রেজোলিউশনটি আপনার ডিভাইস দ্বারা সমর্থিত হতে হবে" + }, + "override_back_resolution": { + "name": "ব্যাক রেজোলিউশন ওভাররাইড করুন", + "description": "ব্যাক ক্যামেরার জন্য ক্যামেরা রেজোলিউশন ওভাররাইড করে" + }, + "back_custom_frame_rate": { + "name": "ব্যাক কাস্টম ফ্রেম রেট", + "description": "ব্যাক ক্যামেরার ফ্রেম রেট ওভাররাইড করে" + }, + "hevc_recording": { + "name": "HEVC রেকর্ডিং", + "description": "ভিডিও রেকর্ডিংয়ের জন্য HEVC (H.265) কোডেক ব্যবহার করে" + }, + "override_front_resolution": { + "name": "ফ্রন্ট রেজোলিউশন ওভাররাইড করুন", + "description": "ফ্রন্ট ক্যামেরার জন্য ক্যামেরা রেজোলিউশন ওভাররাইড করে" + }, + "force_camera_source_encoding": { + "name": "ক্যামেরা সোর্স এনকোডিং জোর করে ব্যবহার করুন", + "description": "ক্যামেরা সোর্স এনকোডিং জোর করে ব্যবহার করে" + } + }, + "name": "ক্যামেরা", + "description": "নিখুঁত স্ন্যাপের জন্য সঠিক সেটিংস সামঞ্জস্য করুন" + }, + "rules": { + "description": "প্রতিটি ব্যক্তির জন্য স্বয়ংক্রিয় বৈশিষ্ট্যগুলি পরিচালনা করুন", + "name": "নিয়মাবলী" + }, + "streaks_reminder": { + "properties": { + "group_notifications": { + "name": "গ্রুপ নোটিফিকেশন", + "description": "একক নোটিফিকেশনে গ্রুপ নোটিফিকেশন একত্রিত করে" + }, + "remaining_hours": { + "description": "নোটিফিকেশন দেখানোর আগে অবশিষ্ট সময়ের পরিমাণ (ঘন্টা)", + "name": "অবশিষ্ট সময়" + }, + "interval": { + "name": "বিরতি", + "description": "প্রতিটি অনুস্মারকের মধ্যে বিরতি (ঘন্টা)" + } + }, + "name": "স্ট্রিকস অনুস্মারক", + "description": "নিয়মিত আপনাকে আপনার স্ট্রিকস সম্পর্কে জানায়" + }, + "friend_tracker": { + "description": "স্ন্যাপচ্যাটে বন্ধুর ক্রিয়াকলাপ রেকর্ড করে", + "properties": { + "record_messaging_events": { + "name": "মেসেজিং ইভেন্টগুলি রেকর্ড করুন", + "description": "স্ন্যাপ খোলা, বার্তা পড়া ইত্যাদি মেসেজিং ইভেন্টগুলি রেকর্ড করে ।" + }, + "allow_running_in_background": { + "name": "ব্যাকগ্রাউন্ডে দৌড়ানোর অনুমতি দিন", + "description": "ট্র্যাকারকে ব্যাকগ্রাউন্ডে চালানোর অনুমতি দেয়। নোট: এটি আপনার ব্যাটারির চার্জ অনেক কমিয়ে দেবে" + }, + "auto_purge": { + "description": "নির্দিষ্ট সময়ের চেয়ে পুরানো ক্যাশে করা ইভেন্টগুলি স্বয়ংক্রিয়ভাবে মুছে ফেলে", + "name": "স্বয়ংক্রিয় শুদ্ধি" + } + }, + "name": "বন্ধু ট্র্যাকার" + } + }, + "options": { + "message_indicators": { + "location_indicator": "লোকেশন সক্রিয় করে পাঠানো স্ন্যাপগুলির পাশে একটি 📍 আইকন যোগ করে", + "director_mode_indicator": "ডিরেক্টর মোড ব্যবহার করে পাঠানো স্ন্যাপগুলির পাশে একটি ✏️ আইকন যোগ করে, যা গ্যালারির চিত্রগুলি স্ন্যাপ হিসেবে পাঠানোর জন্য ব্যবহৃত হতে পারে", + "encryption_indicator": "যে বার্তাগুলি শুধুমাত্র আপনাকে পাঠানো হয়েছে তার পাশে একটি 🔒 আইকন যোগ করে", + "platform_indicator": "যে প্ল্যাটফর্ম থেকে মিডিয়া পাঠানো হয়েছে তার আইকন যোগ করে (যেমন অ্যান্ড্রয়েড, আইওএস, ওয়েব)", + "ovf_editor_indicator": "যদি কোনো স্ন্যাপ OVF এডিটর ব্যবহার করে পাঠানো হয় তাহলে তা নির্দেশ করে" + }, + "friend_feed_menu_buttons": { + "conversation_info": "👤 কথোপকথনের তথ্য", + "mark_snaps_as_seen": "👀 দেখা হিসেবে স্ন্যাপস চিহ্নিত করুন", + "mark_stories_as_seen_locally": "👀 স্থানীয়ভাবে দেখা হিসেবে গল্পগুলি চিহ্নিত করুন", + "auto_save": "💬 বার্তাগুলি স্বতঃ সংরক্ষণ করুন", + "stealth": "👻 স্টিলথ মোড", + "auto_open_snaps": "📷 স্বতঃ খুলুন স্ন্যাপস", + "auto_download": "⬇️ স্বয়ংক্রিয় ডাউনলোড", + "unsaveable_messages": "⬇️ অসংরক্ষণযোগ্য বার্তা", + "e2e_encryption": "🔒 E2E এনক্রিপশন ব্যবহার করুন" + }, + "path_format": { + "create_source_folder": "প্রতিটি মিডিয়া উৎসের ধরন অনুযায়ী ফোল্ডার তৈরি করুন", + "append_hash": "ফাইলের নামে একটি অনন্য হ্যাশ যোগ করুন", + "create_author_folder": "প্রতিটি লেখকের জন্য ফোল্ডার তৈরি করুন", + "append_date_time": "ফাইলের নামে তারিখ ও সময় যোগ করুন", + "append_source": "ফাইলের নামে মিডিয়া উৎস যোগ করুন", + "append_username": "ফাইলের নামে ব্যবহারকারীর নাম যোগ করুন" + }, + "add_friend_source_spoof": { + "added_by_community": "সম্প্রদায় অনুসারে", + "added_by_username": "ব্যবহারকারীর নাম অনুসারে", + "added_by_qr_code": "QR কোড দ্বারা", + "added_by_mention": "উল্লেখ করে", + "added_by_group_chat": "গ্রুপ চ্যাট দ্বারা", + "added_by_quick_add": "কুইক অ্যাড দ্বারা (ব্যান হওয়ার উচ্চ ঝুঁকি)" + }, + "disable_story_sections": { + "discover": "আবিষ্কার", + "friends": "বন্ধুরা", + "following": "অনুসরণ" + }, + "old_bitmoji_selfie": { + "3d": "৩ডি বিটমোজি", + "2d": "২ডি বিটমোজি" + }, + "auto_mark_as_read": { + "conversation_read": "বার্তা পাঠানোর সময় কথোপকথনকে পড়া হিসেবে চিহ্নিত করুন", + "snap_reply": "তাদের উত্তর দেওয়ার সময় স্ন্যাপগুলিকে পড়া হিসেবে চিহ্নিত করুন" + }, + "notifications": { + "speaking": "স্পিকিং", + "abandon_video": "মিসড ভিডিও কল", + "stories": "গল্পগুলি", + "typing": "টাইপিং", + "snap_replay": "স্ন্যাপ রিপ্লে", + "chat_reaction": "DM প্রতিক্রিয়া", + "group_chat_reaction": "গ্রুপ প্রতিক্রিয়া", + "initiate_video": "আসন্ন ভিডিও কল", + "initiate_audio": "আসন্ন অডিও কল", + "snap": "স্ন্যাপ", + "chat_reply": "চ্যাটের উত্তর", + "chat_screenshot": "স্ক্রিনশট", + "chat_screen_record": "স্ক্রিন রেকর্ড", + "camera_roll_save": "ক্যামেরা রোল সংরক্ষণ করুন", + "chat": "চ্যাট", + "abandon_audio": "মিসড অডিও কল" + }, + "gallery_media_send_override": { + "always_ask": "সর্বদা জিজ্ঞাসা করুন", + "NOTE": "অডিও নোট", + "SNAP": "স্ন্যাপ", + "ORIGINAL": "মূল" + }, + "hide_ui_components": { + "hide_profile_call_buttons": "প্রোফাইল কল বোতামগুলি অপসারণ করুন", + "hide_chat_call_buttons": "চ্যাট কল বোতামগুলি অপসারণ করুন", + "hide_live_location_share_button": "লাইভ লোকেশন শেয়ার বোতাম অপসারণ করুন", + "hide_stickers_button": "স্টিকার বোতাম অপসারণ করুন", + "hide_voice_record_button": "ভয়েস রেকর্ড বোতাম অপসারণ করুন", + "hide_unread_chat_hint": "অপঠিত চ্যাটের সংকেত অপসারণ করুন" + }, + "strip_media_metadata": { + "remove_audio_note_transcript_capability": "অডিও নোট ট্রান্সক্রিপ্ট ক্ষমতা অপসারণ করুন", + "hide_extras": "অতিরিক্ত লুকান", + "remove_audio_note_duration": "অডিও নোটের সময়কাল অপসারণ করুন", + "hide_snap_filters": "স্ন্যাপ ফিল্টারগুলি লুকান", + "hide_caption_text": "ক্যাপশন পাঠ্য লুকান" + }, + "bypass_video_length_restriction": { + "single": "একক মিডিয়া", + "split": "বিভক্ত মিডিয়া" + }, + "disable_confirmation_dialogs": { + "ignore_friend": "বন্ধুকে উপেক্ষা করুন", + "hide_friend": "বন্ধুকে লুকান", + "erase_message": "বার্তা মুছে ফেলুন", + "hide_conversation": "কথোপকথন লুকান", + "remove_friend": "বন্ধুকে সরান", + "block_friend": "বন্ধুকে ব্লক করুন", + "clear_conversation": "বন্ধু ফিড থেকে কথোপকথন পরিষ্কার করুন" + }, + "auto_purge": { + "1_month": "১ মাস", + "6_months": "৬ মাস", + "6_hours": "৬ ঘন্টা", + "3_days": "৩ দিন", + "1_day": "১ দিন", + "2_weeks": "২ সপ্তাহ", + "never": "কখনও না", + "1_hour": "১ ঘন্টা", + "3_hours": "৩ ঘন্টা", + "12_hours": "১২ ঘন্টা", + "1_week": "১ সপ্তাহ", + "3_months": "৩ মাস" + }, + "auto_download_sources": { + "friend_snaps": "বন্ধু স্ন্যাপস", + "spotlight": "স্পটলাইট", + "friend_stories": "বন্ধুর গল্প", + "public_stories": "পাবলিক গল্প" + }, + "friend_mutation_notifier": { + "birthday_changes": "কেউ যখন তাদের জন্মদিন পরিবর্তন করে তখন বিজ্ঞপ্তি দিন", + "bitmoji_scene_changes": "কেউ যখন তাদের বিটমোজি দৃশ্য পরিবর্তন করে তখন বিজ্ঞপ্তি দিন", + "remove_friend": "কেউ যখন আপনাকে বন্ধু হিসেবে সরিয়ে দেয় তখন বিজ্ঞপ্তি দিন", + "bitmoji_avatar_changes": "কেউ যখন তাদের বিটমোজি অবতার পরিবর্তন করে তখন বিজ্ঞপ্তি দিন", + "bitmoji_background_changes": "কেউ যখন তাদের বিটমোজি পটভূমি পরিবর্তন করে তখন বিজ্ঞপ্তি দিন", + "bitmoji_selfie_changes": "কেউ যখন তাদের বিটমোজি সেলফি পরিবর্তন করে তখন বিজ্ঞপ্তি দিন" + }, + "app_appearance": { + "always_light": "সর্বদা লাইট", + "always_dark": "সর্বদা ডার্ক" + }, + "hide_story_suggestions": { + "hide_suggested_friend_stories": "প্রস্তাবিত বন্ধুর গল্পগুলি লুকান", + "hide_my_stories": "আমার গল্প লুকান" + }, + "home_tab": { + "chat": "চ্যাট", + "map": "মানচিত্র", + "camera": "ক্যামেরা", + "discover": "আবিষ্কার", + "spotlight": "স্পটলাইট" + }, + "edit_text_override": { + "bypass_text_input_limit": "টেক্সট ইনপুট সীমা বাইপাস করুন", + "multi_line_chat_input": "মাল্টি লাইন চ্যাট ইনপুট" + }, + "disable_permission_requests": { + "notifications": "বিজ্ঞপ্তিগুলি", + "microphone": "মাইক্রোফোন", + "read_media_images": "মিডিয়া চিত্র পড়ুন", + "read_media_video": "মিডিয়া ভিডিও পড়ুন", + "camera": "ক্যামেরা", + "location": "অবস্থান", + "read_contacts": "পরিচিতিগুলি পড়ুন", + "nearby_devices": "আশেপাশের ডিভাইসগুলি", + "phone_calls": "ফোন কল" + }, + "auto_reload": { + "all": "সব (স্ন্যাপচ্যাট + স্ন্যাপএনহ্যান্স)", + "snapchat_only": "শুধুমাত্র স্ন্যাপচ্যাট" + }, + "logging": { + "started": "শুরু হয়েছে", + "success": "সাফল্য", + "progress": "অগ্রগতি", + "failure": "ব্যর্থতা" + }, + "disable_cameras": { + "back": "পিছনের ক্যামেরা", + "front": "সামনের ক্যামেরা" + } + }, + "notices": { + "unstable": "⚠ অস্থির", + "internal_behavior": "⚠ এটি স্ন্যাপচ্যাটের অভ্যন্তরীণ আচরণ ভেঙে দিতে পারে", + "ban_risk": "⚠ এই বৈশিষ্ট্যটি নিষেধাজ্ঞা ঘটাতে পারে" + } + }, + "content_type": { + "FAMILY_CENTER_INVITE": "পরিবার কেন্দ্রের আমন্ত্রণ", + "LIVE_LOCATION_SHARE": "লাইভ লোকেশন শেয়ার করুন", + "CREATIVE_TOOL_ITEM": "ক্রিয়েটিভ টুল আইটেম", + "NOTE": "অডিও নোট", + "CHAT": "চ্যাট", + "STATUS_CONVERSATION_CAPTURE_SCREENSHOT": "স্ক্রীনশট", + "STATUS_CONVERSATION_CAPTURE_RECORD": "স্ক্রিন রেকর্ড", + "STATUS_CALL_MISSED_VIDEO": "মিসড ভিডিও কল", + "STATUS_CALL_MISSED_AUDIO": "মিসড অডিও কল", + "FAMILY_CENTER_ACCEPT": "ফ্যামিলি সেন্টার গ্রহণ", + "SNAP": "স্ন্যাপ", + "STICKER": "স্টিকার", + "STATUS": "স্থিতি", + "LOCATION": "অবস্থান", + "TINY_SNAP": "টিনি স্ন্যাপ", + "EXTERNAL_MEDIA": "বাহ্যিক মিডিয়া", + "STATUS_SAVE_TO_CAMERA_ROLL": "ক্যামেরা রোলে সংরক্ষণ করা হয়েছে", + "SHARE": "শেয়ার", + "STATUS_COUNTDOWN": "কাউন্টডাউন", + "MAP_REACTION": "ম্যাপ প্রতিক্রিয়া", + "FAMILY_CENTER_LEAVE": "ফ্যামিলি সেন্টার ছুটি", + "STATUS_PLUS_GIFT": "স্ট্যাটাস প্লাস উপহার" + }, + "media_download_source": { + "message_logger": "বার্তা লগার", + "pending": "মুলতুবি", + "story": "গল্প", + "profile_picture": "প্রোফাইল ছবি", + "story_logger": "গল্প লগার", + "public_story": "পাবলিক স্টোরি", + "spotlight": "স্পটলাইট", + "voice_call": "ভয়েস কল", + "none": "কিছু না", + "chat_media": "চ্যাট মিডিয়া", + "merged": "মার্জ করা হয়েছে" + }, + "end_to_end_encryption": { + "incoming_secret_message": "আপনার বন্ধু সবেমাত্র আপনার সর্বজনীন কী গ্রহণ করেছেন। গোপন কথা গ্রহণ করতে নীচে ক্লিক করুন।", + "unencrypted_conversation_send_failure_toast": "আপনি এনক্রিপ্টেড এবং অ-এনক্রিপ্টেড উভয় ধরনের কথোপকথনে এনক্রিপ্টেড সামগ্রী পাঠাতে পারবেন না!", + "native_hooks_send_failure_toast": "পাঠানো ব্যর্থ হয়েছে! অনুগ্রহ করে সেটিংসে নেটিভ হুকস সক্রিয় করুন।", + "no_participants_to_encrypt_toast": "এই কথোপকথনে আপনার কোনো বন্ধু নেই যাদের সাথে বার্তা এনক্রিপ্ট করা যায়!", + "encryption_failed_toast": "বার্তা এনক্রিপ্ট করা ব্যর্থ হয়েছে! আরও বিস্তারিত জানতে লগক্যাট চেক করুন।", + "accept_secret_key_success_toast": "সম্পন্ন! আপনি এখন এই বন্ধুর সাথে এনক্রিপ্টেড বার্তা পাঠাতে এবং গ্রহণ করতে পারবেন।", + "accept_public_key_success_toast": "সর্বজনীন কী সফলভাবে স্বীকৃত!", + "accept_public_key_failure_toast": "সর্বজনীন কী স্বীকার করতে ব্যর্থ হয়েছে", + "accept_public_key_button": "সর্বজনীন কী মেনে নিন", + "outgoing_pk_message": "কী বিনিময়ের অনুরোধ", + "confirmation_dialogs": { + "title": "End-to-end এনক্রিপশন", + "confirmation_2": "আপনি কি সত্যিই নিশ্চিত যে আপনি চালিয়ে যেতে চান? এটি পিছু হটার শেষ সুযোগ।", + "confirmation_1": "সতর্কীকরণ: এটি আপনার বিদ্যমান কী ওভাররাইট করবে। আপনি এই বন্ধুর সমস্ত এনক্রিপ্টেড বার্তার অ্যাক্সেস হারাবেন। আপনি কি নিশ্চিত যে আপনি চালিয়ে যেতে চান?" + }, + "outgoing_secret_message": "কী বিনিময়ের প্রতিক্রিয়া", + "toolbox": { + "shared_key_fingerprint": "আপনার ফিঙ্গারপ্রিন্ট হল:\n\n{fingerprint}\n\nনিশ্চিত করুন যে এটি আপনার বন্ধুর ফিঙ্গারপ্রিন্টের সাথে মিলে যায়!", + "no_shared_key": "আপনার এই বন্ধুর সাথে এখনও কোনো গোপন শেয়ার নেই। নতুন একটি শুরু করতে নীচে ক্লিক করুন।", + "initiate_exchange_button": "কী এক্সচেঞ্জ শুরু করুন" + }, + "accept_secret_key_failure_toast": "গোপন কী স্বীকার করতে ব্যর্থ হয়েছে", + "incoming_pk_message": "আপনি সবেমাত্র একটি সর্বজনীন কী অনুরোধ পেয়েছেন। এটি গ্রহণ করতে নীচে ক্লিক করুন।", + "accept_secret_button": "গোপন গ্রহণ" + }, + "friend_mutation_observer": { + "bitmoji_background_changed": "{username} তাদের বিটমোজি পটভূমি পরিবর্তন করেছেন", + "bitmoji_scene_changed": "{username} তাদের বিটমোজি দৃশ্য পরিবর্তন করেছেন", + "birthday_added": "{username} তাদের জন্মদিন ({birthday}) যোগ করেছেন", + "birthday_changed": "{username} তাদের জন্মদিন {oldBirthday} থেকে {newBirthday} পরিবর্তন করেছেন", + "bitmoji_selfie_changed": "{username} তাদের বিটমোজি সেলফি পরিবর্তন করেছেন", + "bitmoji_avatar_changed": "{username} তাদের বিটমোজি অবতার পরিবর্তন করেছেন", + "birthday_removed": "{username} তাদের জন্মদিন ({birthday}) সরিয়ে ফেলেছেন", + "notification_channel_name": "বন্ধু মিউটেশন পর্যবেক্ষক", + "friend_removed": "{username} আপনাকে বন্ধু হিসেবে সরিয়ে দিয়েছেন" + }, + "modal_option": { + "profile_info": "প্রোফাইলের তথ্য", + "close": "বন্ধ" + }, + "bulk_messaging_action": { + "actions": { + "remove_friends": "বন্ধুদের সরান", + "clear_conversations": "পরিষ্কার কথোপকথন" + }, + "progress_status": "প্রক্রিয়াকরণ {index} এর {total}", + "choose_action_title": "একটি ক্রিয়া নির্বাচন করুন", + "selection_dialog_continue_button": "অবিরত", + "confirmation_dialog": { + "title": "তুমি কি নিশ্চিত?", + "message": "এটি সমস্ত নির্বাচিত বন্ধুদের প্রভাবিত করবে। এই ক্রিয়াটি পূর্বাবস্থায় ফেরানো যাবে না।" + } + }, + "profile_picture_downloader": { + "avatar_option": "অবতার", + "title": "প্রোফাইল ছবি ডাউনলোডার", + "button": "ডাউনলোড প্রোফাইল পিকচার", + "background_option": "প্রেক্ষাপট" + }, + "material3_strings": { + "date_range_picker_scroll_to_next_month": "পরের মাসে", + "date_input_invalid_for_pattern": "অকার্যকর তারিখ", + "date_range_picker_day_in_range": "নির্বাচিত", + "date_picker_switch_to_calendar_mode": "ক্যালেন্ডার", + "date_input_invalid_not_allowed": "অকার্যকর তারিখ", + "date_range_picker_title": "তারিখের সীমা নির্বাচন করুন", + "date_range_picker_start_headline": "থেকে", + "date_range_picker_end_headline": "প্রতি", + "date_picker_today_description": "আজ", + "date_input_invalid_year_range": "অবৈধ বছর", + "date_picker_switch_to_input_mode": "ইনপুট", + "date_range_picker_scroll_to_previous_month": "পূর্ববর্তী মাসে", + "date_range_input_invalid_range_input": "তারিখের সীমা অবৈধ" + }, + "friendship_link_type": { + "following": "অনুসরণ", + "suggested": "প্রস্তাবিত", + "incoming_follower": "আসন্ন অনুসারী", + "mutual": "পারস্পরিক", + "outgoing": "বহির্গামী", + "blocked": "অবরুদ্ধ", + "deleted": "বিলোপ", + "incoming": "আসন্ন" + }, + "call_start_confirmation": { + "dialog_message": "আপনি কি নিশ্চিত যে আপনি কল শুরু করতে চান?", + "dialog_title": "কল শুরু করুন" + }, + "better_notifications": { + "button": { + "reply": "উত্তর", + "mark_as_read": "পঠিত হিসাবে চিহ্নিত করুন", + "download": "ডাউনলোড" + } + }, + "half_swipe_notifier": { + "notification_content_dm": "{friend} সবেমাত্র আপনার চ্যাটে {duration} সেকেন্ডের জন্য হাফ সোয়াইপ করেছেন", + "notification_content_group": "{friend} সবেমাত্র {group}-এ {duration} সেকেন্ডের জন্য হাফ সোয়াইপ করেছেন", + "notification_channel_name": "হাফ সোয়াইপ" + }, + "friend_menu_option": { + "preview": "পূর্বরূপ", + "mark_snaps_as_seen": "যেমন দেখা যায় তেমন স্ন্যাপগুলি চিহ্নিত করুন", + "anti_auto_save": "অ্যান্টি অটো সেভ", + "mark_stories_as_seen_locally": "স্থানীয়ভাবে দেখা হিসেবে গল্পগুলি চিহ্নিত করুন", + "auto_download_blacklist": "অটো ডাউনলোড ব্ল্যাকলিস্ট", + "stealth_mode": "স্টিলথ মোড" + }, + "chat_action_menu": { + "preview_button": "পূর্বরূপ", + "edit_message": "বার্তা সম্পাদনা করুন", + "convert_message": "বার্তা রূপান্তর করুন", + "download_button": "ডাউনলোড", + "delete_logged_message_button": "লগ করা বার্তা মুছে ফেলুন" + }, + "mark_as_seen": { + "seen_toast": "দেখা হিসেবে চিহ্নিত!", + "already_unseen_toast": "ইতিমধ্যে অদেখা হিসেবে চিহ্নিত!", + "unseen_toast": "অদেখা হিসেবে চিহ্নিত!", + "no_unseen_snaps_toast": "কোনো অদেখা স্ন্যাপ পাওয়া যায়নি!", + "already_seen_toast": "ইতিমধ্যে দেখা হিসেবে চিহ্নিত!" + }, + "biometric_auth": { + "title": "স্ন্যাপচ্যাট আনলক করুন", + "subtitle": "স্ন্যাপচ্যাট আনলক করতে অনুগ্রহ করে প্রমাণীকরণ করুন", + "unlock_button": "আনলক করুন" + }, + "gallery_media_send_override": { + "multiple_media_toast": "একসময়ে আপনি শুধু একটি মিডিয়া পাঠাতে পারেন" + }, + "opera_context_menu": { + "media_duration": "মিডিয়ার সময়কাল: {duration} ms", + "sent_at": "{date} এ পাঠানো হয়েছে", + "created_at": "{date} তারিখে তৈরি করা হয়েছে", + "expires_at": "{date} তারিখে মেয়াদ শেষ হয়", + "show_debug_info": "ডিবাগ তথ্য দেখান", + "download": "মিডিয়া ডাউনলোড করুন", + "media_size": "মিডিয়ার আকার: {size}" + }, + "auto_open_snaps": { + "title": "স্বয়ংক্রিয়ভাবে স্ন্যাপ খুলুন", + "notification_content": "{count} স্ন্যাপ খোলা হয়েছে" + } +} diff --git a/common/src/main/assets/lang/da.json b/common/src/main/assets/lang/da.json new file mode 100644 index 0000000000..7871451a86 --- /dev/null +++ b/common/src/main/assets/lang/da.json @@ -0,0 +1,1436 @@ +{ + "setup": { + "dialogs": { + "select_language": "Vælg sprog", + "save_folder": "SnapEnhance kræver Lagringstilladelser til at downloade og gemme medier fra Snapchat.\nVælg venligst det sted, hvor medierne skal downloades til.", + "select_save_folder_button": "Vælg Mappe" + }, + "mappings": { + "dialog": "Generering af maps, dette kan tage et stykke tid, overvej at bruge tiden fornuftigt, drik noget eller lav en toast...", + "generate_failure_no_snapchat": "SnapEnhance kunne ikke detektere Snapchat, prøv at geninstallere Snapchat.", + "generate_failure": "Der opstod en fejl under forsøget på at generere mappings, prøv venligst igen." + }, + "permissions": { + "dialog": "For at fortsætte skal du passe til følgende krav:", + "notification_access": "Adgang til notifikationer", + "battery_optimization": "Batterioptimering", + "display_over_other_apps": "Vis over andre apps", + "request_button": "Forespørgsel" + } + }, + "manager": { + "routes": { + "features": "Funktioner", + "home": "Hjem", + "home_settings": "Indstillinger", + "home_logs": "Logfiler", + "social": "Social", + "scripts": "Scripts", + "tasks": "Opgaver", + "logger_history": "Log historik", + "logged_stories": "Loggede historier", + "manage_scope": "Administrer omfang", + "messaging_preview": "Forhåndsvisning", + "friend_tracker": "Ven Tracker", + "edit_rule": "Rediger regel" + }, + "sections": { + "features": { + "disabled": "Slået fra", + "config_import_success_toast": "Konfigurationen blev importeret", + "export_option": "Eksport", + "import_option": "Importere", + "reset_option": "Nulstil", + "config_export_success_toast": "Konfigurationen blev eksporteret", + "config_import_failure_toast": "Kunne ikke importere konfiguration {error}", + "saved_config_snackbar": "Konfiguration gemt" + }, + "social": { + "streaks_expiration_short": "{hours} timer", + "friends_tab": "Venner", + "groups_tab": "Grupper", + "empty_hint": "(tom)" + }, + "tasks": { + "no_tasks": "Ingen opgaver", + "remove_all_tasks_confirm": "Vil du fjerne alle opgaver?", + "merge_files_toast": "Fletter {count} filer", + "remove_selected_tasks_title": "Er du sikker på, at du vil fjerne valgte opgaver?", + "remove_all_tasks_title": "Er du sikker på, at du vil fjerne alle opgaver?", + "delete_files_option": "Slet også filer", + "remove_selected_tasks_confirm": "Vil du fjerne {count} opgaver?" + }, + "logger_history": { + "chat_attachment": "Vedhæftet {index}", + "empty_message": "Tom chat besked", + "message_parse_failed": "Kunne ikke parse meddelelsen", + "unknown_sender": "Ukendt afsender", + "download_attachment_failed_toast": "Kunne ikke downloade den vedhæftede fil", + "list_friend_format": "Ven {name}", + "list_group_format": "Gruppe {name}", + "no_more_messages": "Ikke flere beskeder", + "reverse_order_checkbox": "Omvendt rækkefølge" + }, + "manage_scope": { + "streaks_title": "Streaks", + "reminder_button": "Indstil påmindelse", + "logged_stories_button": "Vis loggede historier", + "e2ee_title": "End-to-End Kryptering", + "rules_title": "Regler", + "participants_text": "{count} deltagere", + "not_found": "Ikke fundet", + "streaks_length_text": "Længde: {length}", + "streaks_expiration_text": "Udløber om {eta}", + "streaks_expiration_text_expired": "Udløbet", + "delete_scope_confirm_dialog_title": "Er du sikker på, at du vil slette et {scope}?" + }, + "logged_stories": { + "no_stories": "Ingen historier fundet", + "story_failed_to_load": "Kunne ikke indlæses", + "save_from_cache_button": "Gem fra cachen" + }, + "messaging_preview": { + "delete_selection_option": "Slet markering", + "bridge_connection_failed": "Kunne ikke oprette forbindelse til Snapchat via bridge-tjenesten", + "bridge_init_failed": "Kunne ikke initialisere meddelelses broen", + "message_fetch_failed": "Kunne ikke hente beskeder", + "no_message_hint": "Ingen besked", + "save_selection_option": "Gem valg", + "save_all_option": "Gem alle", + "unsave_selection_option": "Fjern markering", + "unsave_all_option": "Fjern gem alle", + "mark_selection_as_seen_option": "Markér valgt Snap som set", + "mark_all_as_seen_option": "Markér alle snaps som set", + "delete_all_option": "Slet alt" + }, + "home": { + "update_title": "SnapEnhance Opdatering", + "update_content": "Version {version} er tilgængelig!", + "update_button": "Hent" + }, + "home_logs": { + "no_logs_hint": "Ingen logfiler tilgængelige", + "clear_logs_button": "Ryd logfiler", + "export_logs_button": "Eksporter logs", + "saving_logs_toast": "Gemmer logfiler, dette kan tage et stykke tid ...", + "saved_logs_success_toast": "Logfiler blev gemt", + "saved_logs_failure_toast": "Kunne ikke gemme logfiler" + }, + "home_settings": { + "actions_title": "Handlinger", + "message_logger_title": "Meddelelses logger", + "debug_title": "Fejlfinde", + "success_toast": "Færdig!", + "message_logger_summary": "{messageCount} beskeder\n{storyCount} historier", + "export_button": "Eksport", + "clear_button": "Ryd", + "view_logger_history_button": "Se logger historik" + } + }, + "dialogs": { + "add_friend": { + "title": "Tilføj ven eller gruppe", + "search_hint": "Søg", + "fetch_error": "Dataene blev ikke hentet", + "category_groups": "Grupper", + "category_friends": "Venner" + }, + "scripting_warning": { + "title": "Advarsel", + "content": "SnapEnhance inkluderer et scriptværktøj, der tillader udførelse af brugerdefineret kode på din enhed. Vær ekstrem forsigtig og installer kun moduler fra kendte, pålidelige kilder. Uautoriserede eller uverificerede moduler kan udgøre sikkerhedsrisici for dit system." + }, + "reset_config": { + "title": "Nulstil konfiguration", + "content": "Er du sikker på, at du vil nulstille konfigurationen?", + "success_toast": "Konfigurations nulstilling lykkedes" + }, + "messaging_action": { + "title": "Vælg indholdstyper, der skal behandles", + "select_all_button": "Vælg alle" + } + } + }, + "rules": { + "modes": { + "blacklist": "Sortlistetilstand", + "whitelist": "Whitelist tilstand" + }, + "properties": { + "auto_download": { + "name": "Auto-download", + "description": "Download automatisk snaps, når du ser dem", + "options": { + "blacklist": "Udeluk fra automatisk download", + "whitelist": "Auto-hentning" + } + }, + "stealth": { + "name": "Stealth Tilstand", + "description": "Forhindrer alle i at vide, at du har åbnet deres Snaps/Chats og samtaler", + "options": { + "blacklist": "Udeluk fra Stealth Mode", + "whitelist": "Stealth Tilstand" + } + }, + "auto_save": { + "name": "Gem Automatisk", + "description": "Gemmer chatbeskeder når de vises", + "options": { + "blacklist": "Udeluk fra automatisk lagring", + "whitelist": "Auto-gem" + } + }, + "hide_friend_feed": { + "name": "Skjul fra venskabsfeed" + }, + "e2e_encryption": { + "name": "Brug E2E Kryptering" + }, + "pin_conversation": { + "name": "Fastgør samtale" + }, + "unsaveable_messages": { + "name": "Meddelelser, der ikke kan gemmes", + "description": "Forhindrer beskeder i at blive gemt i chat af andre personer", + "options": { + "blacklist": "Udelad fra meddelelser, der ikke kan gemmes", + "whitelist": "Meddelelser, der ikke kan gemmes" + } + }, + "auto_open_snaps": { + "name": "Auto åbn snaps", + "description": "Åbner automatisk Snaps, når de modtages", + "options": { + "blacklist": "Udeluk fra automatisk åbning af Snaps", + "whitelist": "Auto-åbn Snaps" + } + } + }, + "toasts": { + "enabled": "{ruleName} aktiveret", + "disabled": "{ruleName} deaktiveret" + } + }, + "features": { + "notices": { + "unstable": "⚠️ Ustabil", + "ban_risk": "⚠️ Denne funktion kan medføre forbud", + "internal_behavior": "⚠️ Dette kan ødelægge Snapchat intern opførsel" + }, + "properties": { + "downloader": { + "name": "Henter", + "description": "Download Snapchat Medie", + "properties": { + "save_folder": { + "name": "Gem Mappe", + "description": "Vælg den mappe som alle medier skal downloades til" + }, + "auto_download_sources": { + "name": "Download Kilder Automatisk", + "description": "Vælg de kilder, der skal downloades automatisk fra" + }, + "prevent_self_auto_download": { + "name": "Forhindr Automatisk Download", + "description": "Forhindrer dine egne Snaps i automatisk at blive hentet" + }, + "path_format": { + "name": "Sti Format", + "description": "Angiv filstiens format" + }, + "allow_duplicate": { + "name": "Tillad Dupliker", + "description": "Tillader, at de samme medier downloades flere gange" + }, + "merge_overlays": { + "name": "Flet Overlejringer", + "description": "Kombinerer teksten og medierne for en Snap i en enkelt fil" + }, + "force_image_format": { + "name": "Gennemtving Billedformat", + "description": "Tving billeder til at blive gemt i et angivet format" + }, + "force_voice_note_format": { + "name": "Gennemtving Stemme Note Format", + "description": "Tving billeder til at blive gemt i et angivet format" + }, + "download_profile_pictures": { + "name": "Download Profilbilleder", + "description": "Tillader dig at downloade profilbilleder fra profilsiden" + }, + "ffmpeg_options": { + "name": "FFmpeg-indstillinger", + "description": "Angiv yderligere FFmpeg indstillinger", + "properties": { + "threads": { + "name": "Tråde", + "description": "Mængden af tråde der skal bruges" + }, + "preset": { + "name": "Forvalg", + "description": "Indstil hastigheden for konverteringen" + }, + "constant_rate_factor": { + "name": "Konstant Rate Faktor", + "description": "Indstil den konstante hastighedsfaktor for video-encoder\nFra 0 til 51 for libx264" + }, + "video_bitrate": { + "name": "Videobit-hastighed", + "description": "Indstil videoens bitrate (kbps)" + }, + "audio_bitrate": { + "name": "Audiobit-hastighed", + "description": "Indstil videoens bitrate (kbps)" + }, + "custom_video_codec": { + "name": "Brugerdefineret Lydkode", + "description": "Angiv en brugerdefineret Video Codec (f.eks. libx264)" + }, + "custom_audio_codec": { + "name": "Brugerdefineret Lydkode", + "description": "Angiv en brugerdefineret Video Codec (f.eks. libx264)" + } + } + }, + "logging": { + "name": "Logging", + "description": "Viser toasts, når mediet downloades" + }, + "download_context_menu": { + "name": "Download kontekst menu", + "description": "Giver dig mulighed for at downloade/forhåndsvise beskeder fra en samtale eller en historie ved hjælp af kontekstmenuen.\nLangt tryk på knapperne vil tvinge download" + }, + "opera_download_button": { + "name": "Opera download knap", + "description": "Tilføjer en downloadknap i øverste højre hjørne, når du ser et Snap.\nLangt tryk på knapperne vil tvinge download" + }, + "custom_path_format": { + "name": "Brugerdefineret sti", + "description": "Angiv et brugerdefineret stiformat for downloadede medier\n\nTilgængelige variabler:\n- %brugernavn%\n- %kilde%\n- %hash%\n- %dato_tid%" + } + } + }, + "user_interface": { + "name": "Brugergrænseflade", + "description": "Skift udseendet og fornemmelsen af Snapchat", + "properties": { + "enable_app_appearance": { + "name": "Aktiver App-udseende Indstillinger", + "description": "Aktiverer den skjulte App Udseende Indstilling\nKan ikke kræves i nyere Snapchat versioner" + }, + "friend_feed_message_preview": { + "name": "Forhåndsvisning Af Venne Feed Besked", + "description": "Viser en forhåndsvisning af de sidste beskeder i venskabsfeedet", + "properties": { + "amount": { + "name": "Mængde", + "description": "Antallet af beskeder der skal forhåndsvises" + } + } + }, + "bootstrap_override": { + "name": "Bootstrap Overskriv", + "description": "Tilsidesætter indstillinger for brugergrænseflade bootstrap", + "properties": { + "app_appearance": { + "name": "App Udseende", + "description": "Indstiller en vedvarende app-udseende" + }, + "home_tab": { + "name": "Fanebladet Hjem", + "description": "Tilsidesætter fanen opstart, når du åbner Snapchat" + } + } + }, + "map_friend_nametags": { + "name": "Forbedrede Vennekortnavne", + "description": "Forbedrer Nametags af venner på Snapmap" + }, + "streak_expiration_info": { + "name": "Vis Streak Udløbsinfo", + "description": "Viser en Streak- udløbstimer ved siden af Streaks tælleren" + }, + "hide_friend_feed_entry": { + "name": "Skjul Venne Feed Post", + "description": "Skjuler en bestemt ven fra venskabsfeed\nBrug fanen social til at håndtere denne funktion" + }, + "hide_streak_restore": { + "description": "Skjuler knappen Gendan i vennefeedet", + "name": "Skjul Streak Gendan" + }, + "hide_ui_components": { + "name": "Skjul UI Komponenter", + "description": "Vælg hvilke brugergrænsefladekomponenter der skal skjules" + }, + "disable_spotlight": { + "name": "Deaktivér Spotlight", + "description": "Deaktiverer Spotlight siden" + }, + "friend_feed_menu_buttons": { + "name": "Ven Feed Menu-Knapper", + "description": "Vælg hvilke knapper der skal vises i menulinjen Venne Feed" + }, + "enable_friend_feed_menu_bar": { + "name": "Ven Feed Menu-Knapper", + "description": "Aktiverer den nye vennefeed menulinjen" + }, + "vertical_story_viewer": { + "description": "Aktiverer den lodrette historiefremviser for alle historier", + "name": "Lodret historiefremviser" + }, + "stealth_mode_indicator": { + "name": "Stealth Tilstand Indikator", + "description": "Tilføjer en 👻 emoji ved siden af samtaler i stealth tilstand" + }, + "hide_story_suggestions": { + "name": "Skjul historieforslag", + "description": "Fjerner forslag fra siden Historier" + }, + "message_indicators": { + "name": "Meddelelsesindikatorer", + "description": "Tilføjer specifikke indikatorikoner til beskeder\nBemærk: indikatorer er muligvis ikke 110% nøjagtige" + }, + "edit_text_override": { + "name": "Rediger tekst tilsidesættelse", + "description": "Tilsidesætter tekstfelt adfærd" + }, + "snap_preview": { + "name": "Snap-forhåndsvisning", + "description": "Viser en lille forhåndsvisning ud for usete snaps i chat" + }, + "prevent_message_list_auto_scroll": { + "name": "Forhindre meddelelse chat i automatisk rulning", + "description": "Forhindrer beskedlisten i at rulle til bunden, når du sender/modtager en besked" + }, + "old_bitmoji_selfie": { + "name": "Gammel Bitmoji Selfie", + "description": "Giver Bitmoji-selfies fra ældre Snapchat-versioner tilbage" + }, + "opera_media_quick_info": { + "description": "Viser nyttig information om medier såsom oprettelsesdato i opera fremviser kontekstmenu", + "name": "Opera Media Hurtig info" + } + } + }, + "messaging": { + "name": "Meddelelser", + "description": "Skift hvordan du interagerer med venner", + "properties": { + "anonymous_story_viewing": { + "name": "Anonym Historievisning", + "description": "Forhindrer alle i at kende du har set deres historie" + }, + "hide_bitmoji_presence": { + "name": "Skjul Bitmoji Tilstedeværelse", + "description": "Forhindrer din Bitmoji i at dukke op, mens du er i Chat" + }, + "hide_typing_notifications": { + "name": "Skjul Skrivenotifikationer", + "description": "Forhindrer alle i at vide, at du skriver en besked" + }, + "unlimited_snap_view_time": { + "name": "Ubegrænset Snap Visningstid", + "description": "Fjerner tidsgrænsen for visning af Snaps" + }, + "disable_replay_in_ff": { + "name": "Deaktivér genafspilning i FF", + "description": "Deaktiverer evnen til at genspille med et langt tryk fra Vennefeed" + }, + "prevent_message_sending": { + "name": "Forhindre Besked Afsendelse", + "description": "Forhindrer afsendelse af visse typer beskeder" + }, + "better_notifications": { + "name": "Bedre Notifikationer", + "description": "Tilføjer mere information i modtagne notifikationer", + "properties": { + "media_preview": { + "name": "Medie forhåndsvisning", + "description": "Viser en forhåndsvisning af de valgte medietyper i notifikationen" + }, + "friend_add_source": { + "name": "Ven tilføjnings kilde", + "description": "Viser kilden til en venneanmodning i notifikationer" + }, + "reply_button": { + "name": "Svar knap", + "description": "Tilføjer en svarknap til notifikationen" + }, + "download_button": { + "name": "Hent knap", + "description": "Giver dig mulighed for at hente medier fra notifikationer" + }, + "mark_as_read_button": { + "name": "Markér som læst knap", + "description": "Giver dig mulighed for at markere en besked som læst fra notifikationen" + }, + "mark_as_read_and_save_in_chat": { + "description": "Tilføjer et mærke som læst og gem i chat-knap til notifikationen", + "name": "Markér som læst og gem i chat" + }, + "group_notifications": { + "name": "Gruppe notifikationer", + "description": "Gruppér notifikationer i en enkelt" + }, + "chat_preview": { + "name": "Chat forhåndsvisning", + "description": "Viser en forhåndsvisning af modtagne beskeder i notifikationen" + }, + "media_caption": { + "name": "Medie tekst", + "description": "Viser den vedhæftede billedtekst af medier i notifikationen" + }, + "stacked_media_messages": { + "name": "Stablede medie beskeder", + "description": "Kombinerer flere mediebeskeder i én tekstmeddelelse, når de ikke kan forhåndsvises. Brug i kombination med Chat forhåndsvisning" + } + } + }, + "notification_blacklist": { + "name": "Notifikation Sortliste", + "description": "Vælg notifikationer som skal blive blokeret" + }, + "message_logger": { + "name": "Besked Logger", + "description": "Forhindrer beskeder i at blive slettet", + "properties": { + "message_filter": { + "name": "Meddelelses filter", + "description": "Vælg, hvilke meddelelser der skal logges (tom for alle meddelelser)" + }, + "keep_my_own_messages": { + "name": "Behold mine egne beskeder", + "description": "Forhindrer dine egne beskeder i at blive slettet" + }, + "auto_purge": { + "name": "Automatisk udrensning", + "description": "Sletter automatisk cachelagrede meddelelser, der er ældre end det angivne tidsrum" + } + } + }, + "auto_save_messages_in_conversations": { + "name": "Gem Automatisk Beskeder", + "description": "Gem automatisk alle beskeder i samtaler" + }, + "gallery_media_send_override": { + "name": "Galleri Medier Send Overskriv", + "description": "Spoofs mediekilden, når du sender fra Galleri" + }, + "prevent_story_rewatch_indicator": { + "name": "Indikator for at forhindre gense af historie", + "description": "Forhindrer nogen i at vide, at du har genset deres historie" + }, + "half_swipe_notifier": { + "name": "Halv Swipe Meddeler", + "description": "Giver dig besked, når nogen stryger halvt ind i en samtale", + "properties": { + "min_duration": { + "name": "Minimum varighed", + "description": "Minimumsvarigheden af det halve swipe (i sekunder)" + }, + "max_duration": { + "name": "Maksimal varighed", + "description": "Den maksimale varighed af det halve swipe (i sekunder)" + } + } + }, + "loop_media_playback": { + "name": "Løkke medie afspilning", + "description": "Løkke medie afspilning, når du ser Snaps / Stories" + }, + "bypass_message_retention_policy": { + "name": "Omgå meddelelses opbevarings politik", + "description": "Forhindrer beskeder i at blive slettet efter at have set dem" + }, + "bypass_screenshot_detection": { + "name": "Omgå skærmbillede detektion", + "description": "Forhindrer Snapchat i at registrere, når du tager et skærmbillede" + }, + "hide_peek_a_peek": { + "name": "Skjul Kig-et-Kig", + "description": "Forhindrer besked i at blive sendt, når du stryger halvt ind i en chat" + }, + "call_start_confirmation": { + "name": "Bekræftelse af opkalds start", + "description": "Viser en bekræftelsesdialog, når du starter et opkald" + }, + "strip_media_metadata": { + "name": "Fjern Media Metadata", + "description": "Fjerner metadata fra medier før afsendelse af besked" + }, + "bypass_message_action_restrictions": { + "name": "Omgå Begrænsninger for Beskedhandling", + "description": "Giver dig mulighed for at reagere på et snap uden at have åbnet det eller at gemme en besked, der ikke kan gemmes" + }, + "remove_groups_locked_status": { + "name": "Fjern gruppers låst status", + "description": "Giver dig mulighed for at se gruppeoplysninger efter at være blevet sparket ud" + }, + "auto_mark_as_read": { + "name": "Automatisk markér som læst", + "description": "Automatisk markerer beskeder/snaps som læst, selv når Stealth-tilstand er aktiveret" + }, + "friend_mutation_notifier": { + "description": "Giver dig besked, når noget ændrer sig i en vens profil", + "name": "Ven Mutation Notifier" + }, + "unlimited_conversation_pinning": { + "description": "Giver dig mulighed for at fastgøre et ubegrænset antal samtaler lokalt", + "name": "Ubegrænset samtale fastgørelse" + } + } + }, + "global": { + "name": "Global", + "description": "Tweak Globale Snapchat-Indstillinger", + "properties": { + "snapchat_plus": { + "name": "Snapchat Plus", + "description": "Aktiverer Snapchat Plus-funktioner\nNogle server-sidede funktioner fungerer muligvis ikke" + }, + "auto_updater": { + "name": "Automatisk Opdatering", + "description": "Søg automatisk efter opdateringer" + }, + "disable_metrics": { + "name": "Deaktivér Metrics", + "description": "Blokerer afsendelse af specifikke analytiske data til Snapchat" + }, + "block_ads": { + "name": "Bloker reklamer", + "description": "Forhindrer reklamer i at blive vist" + }, + "bypass_video_length_restriction": { + "name": "Bypass Videolængde Begrænsninger", + "description": "Single: sender en enkelt video\nSplit: split videoer efter redigering" + }, + "disable_google_play_dialogs": { + "name": "Deaktiver Google Play-tjenester Dialoger", + "description": "Forhindre, at Google Play-tjenesternes tilgængelighedsdialoger vises" + }, + "disable_snap_splitting": { + "name": "Deaktivér Snap Opdeling", + "description": "Forhindrer snaps i at blive opdelt i flere dele\nBilleder, du sender, vil blive til videoer" + }, + "better_location": { + "description": "Forbedrer Snapchat-placeringen", + "name": "Bedre placeringen", + "properties": { + "spoof_location": { + "name": "Spoof placering", + "description": "Spoof din placering til et bestemt sted" + }, + "coordinates": { + "name": "Koordinater", + "description": "Indstil koordinaterne for den spoofed placering" + }, + "always_update_location": { + "name": "Opdater altid placering", + "description": "Tving Snapchat til at opdatere placering, selvom der ikke modtages GPS-data" + }, + "suspend_location_updates": { + "name": "Suspender placerings opdateringer", + "description": "Tilføjer en knap i kortindstillingerne for at suspendere placerings opdateringer" + }, + "spoof_battery_level": { + "name": "Spoof batteriniveau", + "description": "Spoofer batteriniveauet på din enhed på kortet\nVærdien skal være mellem 0 og 100" + }, + "spoof_headphones": { + "name": "Spoof hovedtelefoner", + "description": "Spoof status for at lytte til musik på kortet" + }, + "walk_radius": { + "name": "Gå radius", + "description": "Gå tilfældigt rundt inden for denne radius (ft)" + } + } + }, + "disable_story_sections": { + "name": "Deaktiver historie sektioner", + "description": "Fjerner sektioner fra siden Historier\nKan kræve en opdatering for at fungere korrekt" + }, + "disable_permission_requests": { + "name": "Deaktiver tilladelsesanmodninger", + "description": "Forhindrer Snapchat i at bede om specifikke tilladelser" + }, + "spotlight_comments_username": { + "name": "Spotlight Kommentarer Brugernavn", + "description": "Viser forfatterens brugernavn i Spotlight-kommentarer" + }, + "default_volume_controls": { + "description": "Tvinger Snapchat til at bruge systemets lydstyrke kontroller", + "name": "Standard lydstyrke kontroller" + }, + "disable_memories_snap_feed": { + "name": "Deaktiver minder Snap Feed", + "description": "Forhindrer Snapchat i at vise seneste minder, når du stryger op i kameraet" + }, + "default_video_playback_rate": { + "name": "Standard videoafspilningshastighed", + "description": "Indstil standardhastigheden for afspilning af videoer\nVærdien skal være mellem 0,1 og 4,0" + }, + "video_playback_rate_slider": { + "name": "Skyder for videoafspilningshastighed", + "description": "Tilføjer en skyder i opera kontekstmenu for at ændre videoafspilningshastigheden\nBemærk: Ændringer gælder kun for efterfølgende videoer" + }, + "disable_confirmation_dialogs": { + "name": "Deaktiver bekræftelsesdialoger", + "description": "Bekræfter automatisk valgte handlinger" + }, + "hide_active_music": { + "name": "Skjul aktiv musik", + "description": "Forhindrer Snapchat i at vide, at du lytter til musik\nDette giver dig mulighed for at tage snaps ved hjælp af kontrollydstyrkeknapperne, mens du lytter til musik" + }, + "disable_custom_tabs": { + "name": "Deaktiver bruger tilpassede faner", + "description": "Åbner links i understøttede programmer i stedet for i webbrowseren" + }, + "media_upload_quality": { + "properties": { + "force_video_upload_source_quality": { + "name": "Tving video upload kilde kvalitet", + "description": "Tvinger Snapchat til at bruge kilde kvaliteten, når du uploader videoer\nBemærk, at dette muligvis ikke fjerner metadata fra medier" + }, + "custom_image_upload_format": { + "description": "Indstiller et brugerdefineret billedoverførselsformat\nVælg et tabsfrit format (som PNG) for den bedste kvalitet", + "name": "Brugerdefineret billed overførsels format" + }, + "disable_image_compression": { + "name": "Deaktiver billed komprimering", + "description": "Deaktiverer billed komprimering ved upload af medier" + } + }, + "name": "Medie upload kvalitet", + "description": "Tilsidesætter medie overførsel kvaliteten" + } + } + }, + "rules": { + "name": "Regler", + "description": "Administrer automatiske funktioner for individuelle personer" + }, + "camera": { + "name": "Kamera", + "description": "Juster de rigtige indstillinger for den perfekte snap", + "properties": { + "immersive_camera_preview": { + "name": "Omfattende Forhåndsvisning", + "description": "Forhindrer Snapchat i at beskære kamera forhåndsvisning\nDette kan få kameraet til at flimre på nogle enheder" + }, + "force_camera_source_encoding": { + "name": "Tving Kameraets Kildekodning", + "description": "Tving Kameraets Kildekodning" + }, + "disable_cameras": { + "description": "Forhindrer Snapchat i at bruge de valgte kameraer", + "name": "Deaktiver kameraer" + }, + "front_custom_frame_rate": { + "description": "Tilsidesætter frontkameraets billedhastighed", + "name": "Frontkamera brugerdefineret billedhastighed" + }, + "override_front_resolution": { + "description": "Tilsidesætter kameraopløsningen for frontkameraet", + "name": "Tilsidesæt frontkamera opløsning" + }, + "override_back_resolution": { + "name": "Tilsidesæt bagkamera opløsning", + "description": "Tilsidesætter kameraopløsningen for bagkameraet" + }, + "back_custom_frame_rate": { + "description": "Tilsidesætter bagkameraets billedhastighed", + "name": "Bagkamera brugerdefineret billedhastighed" + }, + "custom_resolution": { + "description": "Indstiller en brugerdefineret kameraopløsning, bredde x højde (f.eks. 1920x1080).\nDen brugerdefinerede opløsning skal understøttes af din enhed", + "name": "Brugerdefineret opløsning" + }, + "black_photos": { + "name": "Sorte fotos", + "description": "Erstatter optagne fotos med sort baggrund\nVideoer påvirkes ikke" + }, + "hevc_recording": { + "description": "Bruger HEVC (H.265) codec til videooptagelse", + "name": "HEVC optagelse" + } + } + }, + "streaks_reminder": { + "name": "Streaks Påmindelse", + "description": "Periodisk giver dig besked om dine streaks", + "properties": { + "interval": { + "description": "Intervallet mellem hver påmindelse (timer)", + "name": "Tidsrum" + }, + "remaining_hours": { + "name": "Resterende tid", + "description": "Det resterende tid før meddelelsen vises (timer)" + }, + "group_notifications": { + "name": "Gruppe Notifikationer", + "description": "Grupper notifikationer til en enkelt" + } + } + }, + "experimental": { + "name": "Eksperimental", + "description": "Eksperimentelle funktioner", + "properties": { + "native_hooks": { + "name": "Native Kroge", + "description": "Usikre funktioner, der hookes ind i Snapchat's native kode", + "properties": { + "disable_bitmoji": { + "name": "Deaktivér Bitmoji", + "description": "Deaktiverer Venner Profil Bitmoji" + }, + "composer_hooks": { + "properties": { + "composer_console": { + "description": "Giver dig mulighed for at udføre JavaScript-kode i Composer (kun arm64)", + "name": "Komponistkonsol" + }, + "composer_logs": { + "name": "Komponistlogfiler", + "description": "Omdirigerer konsollogfiler fra Composer til SnapEnhance" + }, + "bypass_camera_roll_limit": { + "name": "Undgå grænsen for kamerarulle", + "description": "Øger det maksimale antal medier, du kan sende fra kamerarullen" + }, + "show_first_created_username": { + "name": "Vis første oprettede brugernavn", + "description": "Viser det første oprettede brugernavn ud for det aktuelle brugernavn på profilsiden" + } + }, + "name": "Komponistkroge", + "description": "Injicerer kode i Composer-grænsefladen på tværs af platforme" + } + } + }, + "spoof": { + "name": "Spoof", + "description": "Spoof forskellige oplysninger om dig", + "properties": { + "remove_vpn_transport_flag": { + "name": "Fjern VPN-transportflag", + "description": "Forhindrer Snapchat i at opdage VPN'er" + }, + "remove_mock_location_flag": { + "description": "Forhindrer Snapchat i at registrere falsk placering", + "name": "Fjern Falsk Location Flag" + }, + "play_store_installer_package_name": { + "name": "Play Butik Installer Pakkenavn", + "description": "Tilsidesætter installations pakkenavnet til com.android.vending" + } + } + }, + "infinite_story_boost": { + "name": "Uendelig Historie Boost", + "description": "Bypass Story Boost Limit delay" + }, + "meo_passcode_bypass": { + "name": "Mine Øjne Kun Kode Bypass", + "description": "Bypass My Eyes Only Passcode\nDette vil kun fungere, hvis adgangskoden er indtastet korrekt før" + }, + "no_friend_score_delay": { + "name": "Ingen Vennescore Forsinkelse", + "description": "Fjerner forsinkelsen, når du ser en Venners Score" + }, + "e2ee": { + "name": "Ende-til-Ende kryptering", + "description": "Krypterer dine beskeder med AES ved hjælp af en delt hemmelig nøgle\nSørg for at gemme din nøgle et sikkert sted!", + "properties": { + "encrypted_message_indicator": { + "name": "Krypteret Meddelelsesindikator", + "description": "Tilføjer en 🔒 emoji ved siden af krypterede beskeder" + }, + "force_message_encryption": { + "name": "Tving besked Kryptering", + "description": "Forhindrer afsendelse af krypterede beskeder til personer, der ikke har E2E Kryptering aktiveret kun, når flere samtaler er valgt" + } + } + }, + "add_friend_source_spoof": { + "name": "Tilføj Ven Kilde Spoof", + "description": "Spoofs kilden til en venneanmodning" + }, + "hidden_snapchat_plus_features": { + "name": "Skjult Snapchat Plus-funktioner", + "description": "Aktiverer ikke-frigivet/beta Snapchat Plus funktioner\nKan ikke virke på ældre snapchat versioner" + }, + "story_logger": { + "name": "Historie logger", + "description": "Giver en historie med venners historier" + }, + "convert_message_locally": { + "name": "Konverter besked lokalt", + "description": "Konverterer snaps til at chatte eksterne medier lokalt. Dette vises i chat download kontekstmenu" + }, + "call_recorder": { + "name": "Opkaldsoptager", + "description": "Optager automatisk lydopkald" + }, + "account_switcher": { + "properties": { + "auto_backup_current_account": { + "description": "Sikkerhedskopierer automatisk den aktuelle konto, når du logger ud eller skifter konto", + "name": "Automatisk backup af nuværende konto" + } + }, + "name": "Konto skifter", + "description": "Giver dig mulighed for at skifte mellem konti uden at logge ud\nTryk længe på søgeikonet ved siden af din Bitmoji-profil for at åbne menuen\nBemærk: Denne funktion er eksperimentel og vil sandsynligvis ændre sig i fremtiden" + }, + "media_file_picker": { + "name": "Mediefilvælger", + "description": "Giver dig mulighed for at vælge en hvilken som helst video-/lydfil fra galleriet" + }, + "prevent_forced_logout": { + "description": "Forhindrer Snapchat i at logge dig ud, når du logger på en anden enhed", + "name": "Undgå tvungen log ud" + }, + "edit_message": { + "name": "Rediger beskeder", + "description": "Giver dig mulighed for at redigere beskeder i samtaler" + }, + "app_lock": { + "name": "Applås", + "description": "Forhindrer adgang til Snapchat uden en adgangskode", + "properties": { + "lock_on_resume": { + "name": "Lås ved genoptagelse", + "description": "Låser appen, når den genåbnes" + } + } + }, + "custom_streaks_expiration_format": { + "name": "Tilpasset format til udløb af Streaks", + "description": "Tilpasser formatet for udløbet af Streaks\n\nTilgængelige variabler:\n- %c: Antal Streaks\n- %e: Timeglas Emoji\n- %d: Dage\n- %h: Timer\n- %m: Minutter\n- %s: Sekunder\n- %w: Resterende tid" + }, + "best_friend_pinning": { + "description": "Tillader dig at fastgøre en ven som din bedste ven nummer ét. Bemærk: Kun du kan se din fastgjorte bedste ven", + "name": "Pinning af bedste ven" + } + } + }, + "scripting": { + "name": "Scripting", + "description": "Kør brugerdefinerede scripts for at udvide SnapEnhance", + "properties": { + "developer_mode": { + "name": "Udviklertilstand", + "description": "Viser debug info på Snapchats brugerflade" + }, + "module_folder": { + "name": "Modul Mappe", + "description": "Mappen hvor scripterne er placeret" + }, + "auto_reload": { + "name": "Automatisk genindlæsning", + "description": "Genindlæser automatisk scripts, når de ændres" + }, + "integrated_ui": { + "description": "Tillader scripts at tilføje brugerdefinerede UI komponenter til Snapchat", + "name": "Integreret UI" + }, + "disable_log_anonymization": { + "name": "Deaktiver log anonymisering", + "description": "Deaktiverer anonymisering af logfiler" + } + } + }, + "friend_tracker": { + "properties": { + "allow_running_in_background": { + "name": "Tillad kørsel i baggrunden", + "description": "Tillader trackeren at køre i baggrunden. Bemærk: Dette vil dræne dit batteri betydeligt, det anbefales ikke at se porno i mens" + }, + "record_messaging_events": { + "description": "Registrerer besked begivenheder såsom åbning af et snap, læsning af en besked osv.", + "name": "Optag meddelelses begivenheder" + }, + "auto_purge": { + "name": "Automatisk udrensning", + "description": "Sletter automatisk cachelagrede hændelser, der er ældre end det angivne tidsrum" + } + }, + "name": "Ven Tracker", + "description": "Registrerer venners aktivitet på Snapchat" + } + }, + "options": { + "app_appearance": { + "always_light": "Altid Lys", + "always_dark": "Altid Mørk" + }, + "friend_feed_menu_buttons": { + "auto_download": "⬇️ Auto-download", + "auto_save": "💬 Gem Automatisk Beskeder", + "stealth": "👻 Stealth Tilstand", + "conversation_info": "👤 Samtaleinfo", + "e2e_encryption": "🔒 Brug E2E Kryptering", + "mark_stories_as_seen_locally": "👀 Markér historier som set lokalt", + "mark_snaps_as_seen": "👀 Markér Snaps som set", + "unsaveable_messages": "⬇️ Beskeder, der ikke kan gemmes", + "auto_open_snaps": "📷 Automatisk åbn Snaps" + }, + "path_format": { + "create_author_folder": "Opret mappe for hver forfatter", + "create_source_folder": "Opret mappe for hver mediekildetype", + "append_hash": "Tilføj et unikt hash til filnavnet", + "append_source": "Tilføj mediekilden til filnavnet", + "append_username": "Tilføj mediekilden til filnavnet", + "append_date_time": "Tilføj dato og tid til filnavnet" + }, + "auto_download_sources": { + "friend_snaps": "Ven Snaps", + "friend_stories": "Vennehistorier", + "public_stories": "Offentlige Historier", + "spotlight": "Spotlight" + }, + "logging": { + "started": "Startet", + "success": "Succes", + "progress": "Fremskridt", + "failure": "Fejl" + }, + "notifications": { + "chat_screenshot": "Skærmbillede", + "chat_screen_record": "Skærmoptagelse", + "snap_replay": "Fastgør Genafspilning", + "camera_roll_save": "Gem Kamera Rulle", + "chat": "Chat", + "chat_reply": "Chat Svar", + "snap": "Snap", + "typing": "Indtastning", + "stories": "Historier", + "chat_reaction": "DM Reaktion", + "group_chat_reaction": "Gruppe Reaktion", + "initiate_audio": "Indgående Lydopkald", + "abandon_audio": "Ubesvaret Lydopkald", + "initiate_video": "Indgående videoopkald", + "abandon_video": "Ubesvarede videoopkald", + "speaking": "Taler" + }, + "gallery_media_send_override": { + "ORIGINAL": "Original", + "NOTE": "Lyd Note", + "SNAP": "Snap", + "always_ask": "Spørg altid for at være sikker" + }, + "hide_ui_components": { + "hide_profile_call_buttons": "Fjern Profilopkaldsknapper", + "hide_chat_call_buttons": "Fjern Profilopkaldsknapper", + "hide_live_location_share_button": "Fjern Knappen Live Placeringsdeling", + "hide_stickers_button": "Fjern Klistermærker Knap", + "hide_voice_record_button": "Fjern Stemmeoptagelsesknap", + "hide_unread_chat_hint": "Fjern ulæst chat tip" + }, + "home_tab": { + "map": "Kort", + "chat": "Chat", + "camera": "Kamera", + "discover": "Opdag", + "spotlight": "Spotlight" + }, + "add_friend_source_spoof": { + "added_by_username": "Efter Brugernavn", + "added_by_mention": "Efter Omtale", + "added_by_group_chat": "Efter Gruppechat", + "added_by_qr_code": "Via QR-kode", + "added_by_community": "Af Fællesskabet" + }, + "bypass_video_length_restriction": { + "single": "Enkelt medie", + "split": "Opdel medier" + }, + "strip_media_metadata": { + "remove_audio_note_duration": "Fjern lyd note varighed", + "hide_caption_text": "Skjul billedtekst", + "remove_audio_note_transcript_capability": "Fjern lydnote-transskriptions kapacitet", + "hide_snap_filters": "Skjul snap filtre", + "hide_extras": "Skjul ekstramateriale (f.eks. omtaler)" + }, + "disable_confirmation_dialogs": { + "remove_friend": "Fjern ven 😢", + "clear_conversation": "Ryd samtale fra vennefeed", + "block_friend": "Bloker ven 🚫", + "ignore_friend": "Ignorer ven", + "hide_friend": "Skjul ven", + "hide_conversation": "Skjul samtale", + "erase_message": "Slet besked" + }, + "disable_permission_requests": { + "camera": "Kamera", + "read_contacts": "Læs kontakter", + "phone_calls": "Telefonopkald", + "nearby_devices": "Enheder i nærheden", + "notifications": "Meddelelser", + "read_media_images": "Læs medie billeder", + "read_media_video": "Læs medie video", + "microphone": "Mikrofon", + "location": "Placering" + }, + "old_bitmoji_selfie": { + "3d": "3D Bitmoji", + "2d": "To-dimensionelle Bitmoji" + }, + "auto_purge": { + "6_hours": "6 Timer", + "12_hours": "12 Timer", + "1_month": "1 Måned", + "3_days": "3 Dage", + "never": "ALDRIG", + "3_hours": "3 Timer", + "1_day": "1 Dag", + "1_week": "1 Uge", + "2_weeks": "2 Uger", + "3_months": "3 Måneder", + "1_hour": "1 Time", + "6_months": "6 Måneder" + }, + "disable_cameras": { + "front": "Front kamera", + "back": "Bag kamera" + }, + "auto_reload": { + "snapchat_only": "Kun Snapchat", + "all": "Alle (Snapchat + SnapEnhance)" + }, + "edit_text_override": { + "multi_line_chat_input": "Extra mange linjer chat input", + "bypass_text_input_limit": "Omgå grænse for tekst input" + }, + "disable_story_sections": { + "discover": "Opdage", + "friends": "Venner", + "following": "Følge" + }, + "hide_story_suggestions": { + "hide_my_stories": "Skjul mine historier", + "hide_suggested_friend_stories": "Skjul foreslåede vennehistorier" + }, + "message_indicators": { + "ovf_editor_indicator": "Angiver, om en snap er blevet sendt ved hjælp af OVF Editor", + "location_indicator": "Tilføjer et 📍 ikon til snaps, når de er blevet sendt med placering aktiveret", + "encryption_indicator": "Tilføjer et 🔒 ikon ud for beskeder, der kun er blevet sendt til dig", + "platform_indicator": "Tilføjer platforms ikonet, hvorfra et medie blev sendt (f.eks. Android, iOS, Web)", + "director_mode_indicator": "Tilføjer et ✏️ ikon til snaps, når de er blevet sendt ved hjælp af Director Mode, som kan bruges til at sende galleribilleder som snaps" + }, + "auto_mark_as_read": { + "conversation_read": "Marker samtalen som læst, når du sender en besked", + "snap_reply": "Marker snaps som læst, når du svarer på dem" + }, + "friend_mutation_notifier": { + "remove_friend": "Giv besked, når nogen fjerner dig som ven", + "birthday_changes": "Giv besked, når nogen ændrer deres fødselsdag", + "bitmoji_selfie_changes": "Giv besked, når nogen ændrer deres Bitmoji selfie", + "bitmoji_avatar_changes": "Giv besked, når nogen ændrer deres Bitmoji avatar", + "bitmoji_background_changes": "Giv besked, når nogen ændrer deres Bitmoji baggrund", + "bitmoji_scene_changes": "Giv besked, når nogen ændrer deres Bitmoji scene" + } + } + }, + "friend_menu_option": { + "preview": "Forhåndsvisning", + "stealth_mode": "Stealth Tilstand", + "anti_auto_save": "Anti Automatisk Gem", + "mark_snaps_as_seen": "Markér Snaps som set", + "mark_stories_as_seen_locally": "Markér historier som set lokalt", + "auto_download_blacklist": "Automatisk download af sortliste" + }, + "chat_action_menu": { + "preview_button": "Forhåndsvisning", + "download_button": "Hent", + "delete_logged_message_button": "Slet Logget Besked", + "convert_message": "Konverter besked", + "edit_message": "Rediger besked" + }, + "opera_context_menu": { + "download": "Hent medie", + "sent_at": "Sendt den {dato}", + "show_debug_info": "Vis fejlretnings oplysninger", + "media_duration": "Medievarighed: {duration} ms", + "expires_at": "Udløber {date}", + "created_at": "Oprettet {date}", + "media_size": "Mediestørrelse: {size}" + }, + "modal_option": { + "profile_info": "Profiloplysninger", + "close": "Luk" + }, + "gallery_media_send_override": { + "multiple_media_toast": "Du kan kun sende et medie ad gangen" + }, + "conversation_preview": { + "streak_expiration": "udløber på {day} dage {hour} timer {minute} minutter", + "total_messages": "Total sendt/modtagne meddelelser: {count}", + "title": "Forhåndsvisning", + "unknown_user": "Ukendt bruger" + }, + "profile_info": { + "title": "Profiloplysninger", + "first_created_username": "Første Oprettet Brugernavn", + "mutable_username": "Mutabelt Brugernavn", + "display_name": "Visningsnavn", + "added_date": "Tilføjet Dato", + "birthday": "Fødselsdag : {month} {day}", + "friendship": "Venskab", + "add_source": "Tilføj Kilde", + "snapchat_plus": "Snapchat Plus", + "snapchat_plus_state": { + "subscribed": "Abonnement bekræftet", + "not_subscribed": "Ikke abonneret" + }, + "hidden_birthday": "Fødselsdag: Skjult" + }, + "chat_export": { + "dialog_negative_button": "Annuller", + "dialog_positive_button": "Eksportér", + "exported_to": "Eksporteret til {path}", + "exporting_chats": "Eksporterer Chats...", + "processing_chats": "Behandler {amount} samtaler...", + "export_fail": "Kunne ikke eksportere samtale {conversation}", + "writing_output": "Skriver output...", + "finished": "Færdig! Du kan nu lukke denne dialog.", + "no_messages_found": "Ingen beskeder fundet!", + "exporting_message": "Eksporterer {conversation}...", + "exporter_dialog": { + "text_field_selection": "{amount} valgt", + "message_type_filter_title": "Filtrer meddelelser efter type", + "download_medias_title": "Hent medier", + "text_field_selection_all": "Alle", + "select_conversations_title": "Vælg Samtaler", + "export_file_format_title": "Eksporter fil format", + "amount_of_messages_title": "Antal meddelelser (lad det stå tomt for alle)" + } + }, + "button": { + "ok": "OK", + "positive": "Ja", + "negative": "Nej", + "cancel": "Annuller", + "open": "Åbn", + "download": "Hent" + }, + "profile_picture_downloader": { + "button": "Download Profilbillede", + "title": "Profil Billede Downloader", + "avatar_option": "Avatar", + "background_option": "Baggrund" + }, + "download_processor": { + "attachment_type": { + "snap": "Snap", + "sticker": "Klistermærke", + "external_media": "Eksterne medier", + "note": "Note", + "original_story": "Oprindelig Historie", + "gif": "GIF" + }, + "select_attachments_title": "Vælg vedhæftede filer", + "download_started_toast": "Download startet", + "unsupported_content_type_toast": "Ikke-understøttet indholdstype!", + "failed_no_longer_available_toast": "Medier er ikke længere tilgængelige", + "no_attachments_toast": "Ingen vedhæftning fundet!", + "already_queued_toast": "Medie allerede i kø!", + "already_downloaded_toast": "Medier er allerede downloadet!", + "download_toast": "Downloader {path}...", + "processing_toast": "Behandler {path}...", + "failed_generic_toast": "Kunne ikke downloade", + "failed_to_create_preview_toast": "Kunne ikke oprette forhåndsvisning", + "failed_processing_toast": "Mislykkedes at behandle {error}", + "failed_gallery_toast": "Kunne ikke gemme i galleri {error}", + "dash_dialog": { + "download_all": "Hent alle", + "title": "Download dash-medier", + "segment_text": "Segment {from} - {to}" + }, + "dash_no_chapter": "Intet kapitel fundet" + }, + "streaks_reminder": { + "notification_title": "Streaks", + "notification_text": "Du vil miste din Streak med {friend} på {hoursLeft} timer" + }, + "actions": { + "clean_snapchat_cache": { + "name": "Rens Snapchat-cachen", + "description": "Renser Snapchat-cachen" + }, + "manage_friend_list": { + "name": "Administrer venneliste", + "description": "Importer/eksporter din venneliste, når du sikkerhedskopierer" + }, + "export_chat_messages": { + "name": "Eksporter chatbeskeder", + "description": "Eksporterer samtalebeskeder til en JSON/HTML/TXT fil" + }, + "export_memories": { + "name": "Eksporter hukommelser", + "description": "Eksporterer minder til en ZIP fil" + }, + "bulk_messaging_action": { + "name": "Massemeddelelseshandling", + "description": "Udfører handlinger såsom sletning af venner eller massesletning af samtaler" + }, + "regen_mappings": { + "name": "Gendan kortlægninger", + "description": "Regenerer kortlægninger manuelt" + }, + "change_language": { + "name": "Skift sprog", + "description": "Skift sproget for SnapEnhance" + } + }, + "material3_strings": { + "date_range_input_invalid_range_input": "Ugyldigt datointerval", + "date_input_invalid_for_pattern": "Ugyldig dato", + "date_input_invalid_year_range": "Ugyldigt år", + "date_range_picker_start_headline": "Fra", + "date_range_picker_end_headline": "Til", + "date_range_picker_title": "Vælg dato interval", + "date_picker_switch_to_calendar_mode": "Kalender", + "date_range_picker_day_in_range": "Valgte", + "date_input_invalid_not_allowed": "Ugyldigt dato", + "date_range_picker_scroll_to_previous_month": "Forrige måned", + "date_range_picker_scroll_to_next_month": "Næste måned", + "date_picker_today_description": "I dag", + "date_picker_switch_to_input_mode": "Input" + }, + "friendship_link_type": { + "mutual": "Gensidig", + "deleted": "Slettet", + "outgoing": "Udgående", + "blocked": "Blokeret", + "suggested": "Foreslået", + "incoming": "Indkommende", + "incoming_follower": "Indgående følger", + "following": "Følge" + }, + "content_type": { + "SNAP": "Snap", + "STATUS_CALL_MISSED_AUDIO": "Mistet lydopkald", + "CHAT": "Chat", + "TINY_SNAP": "Lille Snap", + "STATUS_PLUS_GIFT": "Status Plus gave", + "EXTERNAL_MEDIA": "Eksterne medier", + "NOTE": "Lyd note", + "STICKER": "Klistermærke", + "CREATIVE_TOOL_ITEM": "Kreativt værktøjselement", + "FAMILY_CENTER_INVITE": "Invitation til Familiecenter", + "FAMILY_CENTER_ACCEPT": "Familiecenter Accepter", + "FAMILY_CENTER_LEAVE": "Familiecenter forlad", + "STATUS_COUNTDOWN": "Nedtælling", + "MAP_REACTION": "Kort reaktion", + "LIVE_LOCATION_SHARE": "Live placeringsdeling", + "STATUS": "Status", + "STATUS_CONVERSATION_CAPTURE_RECORD": "Skærmoptagelse", + "STATUS_CALL_MISSED_VIDEO": "Ubesvaret videoopkald", + "LOCATION": "Placering", + "STATUS_SAVE_TO_CAMERA_ROLL": "Gemt i kamerarulle", + "STATUS_CONVERSATION_CAPTURE_SCREENSHOT": "Skærmbillede", + "SHARE": "Del" + }, + "media_download_source": { + "none": "Ingen", + "merged": "Fusioneret", + "public_story": "Offentlig historie", + "story": "Historie", + "story_logger": "Historielogger", + "pending": "Afventende", + "spotlight": "Spotlight", + "chat_media": "Chat medier", + "profile_picture": "Profilbillede", + "message_logger": "Meddelelseslogger", + "voice_call": "Taleopkald" + }, + "half_swipe_notifier": { + "notification_channel_name": "Halvt swipe", + "notification_content_dm": "{friend} har lige stryget halvt ind i din chat i {duration} sekunder", + "notification_content_group": "{friend} har lige stryget halvt ind i {group} i {duration} sekunder" + }, + "bulk_messaging_action": { + "actions": { + "remove_friends": "Fjern venner 😢", + "clear_conversations": "Ryd samtaler" + }, + "confirmation_dialog": { + "message": "Dette vil påvirke alle udvalgte venner. Denne handling kan ikke fortrydes.", + "title": "Forsatan! Er du nu helt sikker?" + }, + "choose_action_title": "Vælg en handling", + "progress_status": "Behandler {index} af {total}", + "selection_dialog_continue_button": "Forsæt" + }, + "better_notifications": { + "button": { + "reply": "Svar", + "mark_as_read": "Marker som læst", + "download": "Hent" + } + }, + "mark_as_seen": { + "already_unseen_toast": "Allerede markeret som uset!", + "no_unseen_snaps_toast": "Ingen usete snaps fundet!", + "seen_toast": "Mærket som set!", + "unseen_toast": "Markeret som uset!", + "already_seen_toast": "Allerede markeret som set!" + }, + "call_start_confirmation": { + "dialog_title": "Start opkald", + "dialog_message": "Er du sikker på, at du gider at snakke?" + }, + "end_to_end_encryption": { + "toolbox": { + "no_shared_key": "Du har ikke en delt hemmelighed med denne ven endnu. Klik nedenfor for at starte en ny.", + "shared_key_fingerprint": "Dit fingeraftryk er:\n\n{fingerprint}\n\nSørg for at tjekke, om det matcher din vens fingeraftryk!", + "initiate_exchange_button": "Start nøgleudveksling" + }, + "confirmation_dialogs": { + "confirmation_1": "ADVARSEL: Dette vil overskrive din eksisterende nøgle. Du mister adgangen til alle krypterede beskeder fra denne ven. Er du sikker på, at du vil fortsætte?", + "title": "End-to-end kryptering", + "confirmation_2": "Dit fjols er du VIRKELIG sikker på, at du vil fortsætte? Dette er din sidste chance for at fortryde." + }, + "accept_secret_key_success_toast": "Færdig! Du kan nu sende og modtage krypterede beskeder med denne ven.", + "accept_secret_key_failure_toast": "Kunne ikke acceptere hemmelig nøgle", + "accept_secret_button": "Accepter hemmelighed", + "accept_public_key_button": "Accepter offentlig nøgle", + "outgoing_pk_message": "Anmodning om nøgleudveksling", + "outgoing_secret_message": "Nøgle udvekslings svar", + "unencrypted_conversation_send_failure_toast": "Du kan ikke sende krypteret indhold til både krypterede og ukrypterede samtaler!", + "native_hooks_send_failure_toast": "Afsendelse mislykkedes! Aktiver venligst Native Hooks i indstillingerne.", + "no_participants_to_encrypt_toast": "Du har ikke nogen venner i denne samtale at kryptere beskeder med!", + "encryption_failed_toast": "Kunne ikke kryptere meddelelsen! Tjek logfil for flere detaljer.", + "accept_public_key_success_toast": "Offentlig nøgle blev accepteret!", + "accept_public_key_failure_toast": "Kunne ikke acceptere offentlig nøgle", + "incoming_pk_message": "Du har lige modtaget en offentlig nøgleanmodning. Klik nedenfor for at acceptere det.", + "incoming_secret_message": "Din ven har lige accepteret din offentlige nøgle. Klik nedenfor for at acceptere hemmeligheden." + }, + "scopes": { + "friend": "Ven", + "group": "Gruppe" + }, + "biometric_auth": { + "unlock_button": "Lås op", + "title": "Lås Snapchat op", + "subtitle": "Godkend venligst for at låse Snapchat op" + }, + "auto_open_snaps": { + "title": "Auto Åbn Snaps", + "notification_content": "{count} Snaps åbnet" + }, + "friend_mutation_observer": { + "friend_removed": "{username} har fjernet dig som ven", + "birthday_removed": "{username} har fjernet deres fødselsdag ({birthday})", + "birthday_added": "{username} har tilføjet deres fødselsdag ({birthday})", + "birthday_changed": "{username} har ændret deres fødselsdag fra {oldBirthday} til {newBirthday}", + "bitmoji_selfie_changed": "{username} har ændret deres Bitmoji selfie", + "bitmoji_avatar_changed": "{username} har ændret deres Bitmoji avatar", + "bitmoji_scene_changed": "{username} har ændret deres Bitmoji scene", + "bitmoji_background_changed": "{username} har ændret deres Bitmoji baggrund", + "notification_channel_name": "Venmutationobserver" + } +} diff --git a/common/src/main/assets/lang/de_DE.json b/common/src/main/assets/lang/de_DE.json new file mode 100644 index 0000000000..9add98f242 --- /dev/null +++ b/common/src/main/assets/lang/de_DE.json @@ -0,0 +1,1564 @@ +{ + "setup": { + "dialogs": { + "select_language": "Sprache auswählen", + "save_folder": "SnapEnhance benötigt Zugriff auf den Gerätespeicher, um Medien von Snapchat herunterzuladen und zu sichern.\nBitte wähle einen Ziel-Ordner für die Downloads aus.", + "select_save_folder_button": "Ordner auswählen" + }, + "mappings": { + "dialog": "Mappings werden generiert, dies könnte eine Weile dauern ...", + "generate_failure_no_snapchat": "SnapEnhance konnte Snapchat nicht finden, bitte versuche Snapchat neu zu installieren.", + "generate_failure": "Beim Generieren der Zuordnungen ist ein Fehler aufgetreten. Bitte versuche es erneut." + }, + "permissions": { + "dialog": "Um fortfahren zu können, musst du diese Anforderungen erfüllen:", + "notification_access": "Benachrichtigungszugriff", + "battery_optimization": "Batterieoptimierung", + "display_over_other_apps": "Über anderen Apps anzeigen", + "request_button": "Anfordern" + } + }, + "manager": { + "routes": { + "features": "Funktionen", + "home": "Startseite", + "home_settings": "Einstellungen", + "home_logs": "Protokolle", + "social": "Sozial", + "scripts": "Skripte", + "tasks": "Aufgaben", + "logger_history": "Logs Verlauf", + "logged_stories": "Geloggte Storys", + "messaging_preview": "Vorschau", + "manage_scope": "Verwalte Scope", + "friend_tracker": "Freund:in-tracker", + "edit_rule": "Regel bearbeiten", + "better_location": "Besserer Standort", + "file_imports": "Datei Importe", + "manage_rule_feature": "Regelfunktion verwalten", + "manage_repos": "Repositorien verwalten", + "theming": "Personalisierung", + "edit_theme": "Theme bearbeiten" + }, + "sections": { + "features": { + "disabled": "Deaktiviert", + "export_option": "Exportieren", + "import_option": "Importieren", + "reset_option": "Zurücksetzen", + "config_export_success_toast": "Einstellungen erfolgreich exportiert", + "config_import_success_toast": "Einstellungen erfolgreich importiert", + "config_import_failure_toast": "Einstellungen konnten nicht importiert werden {error}", + "saved_config_snackbar": "Einstellungen gespeichert", + "config_export_failure_toast": "Der Export der Konfiguration ist fehlgeschlagen {error}", + "older_required": "Diese Funktion erfordert Snapchat v{version} oder älter um korrekt zu funktionieren", + "search_button": "Suche", + "newer_required": "Diese Funktion erfordert Snapchat v{version} oder neuer um korrekt zu funktionieren" + }, + "social": { + "streaks_expiration_short": "{hours}h", + "empty_hint": "(leer)", + "friends_tab": "Freunde", + "groups_tab": "Gruppen" + }, + "tasks": { + "no_tasks": "Keine Aufgaben", + "merge_files_toast": "Zusammenführen von {count} Dateien", + "remove_selected_tasks_title": "Sind Sie sicher, dass Sie die ausgewählten Aufgaben entfernen möchten?", + "remove_all_tasks_title": "Sind Sie sicher, dass Sie alle Aufgaben entfernen möchten?", + "delete_files_option": "Auch Dateien löschen", + "remove_selected_tasks_confirm": "{count} Aufgaben entfernen?", + "remove_all_tasks_confirm": "Alle Aufgaben entfernen?", + "failed_to_open_file": "Datei konnte nicht geöffnet werden", + "merge_button": "Zusammenführen" + }, + "home": { + "update_title": "SnapEnhance Update", + "update_content": "Version {version} ist verfügbar!", + "update_button": "Herunterladen", + "version_title": "v{versionName} · von rhunk", + "debug_build_summary_title": "Du führst einen Debug-Build von SnapEnhance aus", + "quick_actions_title": "Schnelle aktionen", + "debug_build_summary_date": "Erstellungsdatum: {date} ({days} Tage her)", + "debug_build_summary_content": "Version {versionName} ({versionCode})" + }, + "home_logs": { + "saving_logs_toast": "Speichern von Protokollen, das kann eine Weile dauern ...", + "saved_logs_failure_toast": "Speichern von Protokollen fehlgeschlagen", + "saved_logs_success_toast": "Protokolle erfolgreich gespeichert", + "no_logs_hint": "Keine Protokolle verfügbar", + "clear_logs_button": "Protokolle löschen", + "export_logs_button": "Protokolle exportieren" + }, + "home_settings": { + "actions_title": "Aktionen", + "message_logger_title": "Nachrichtenaufzeichner", + "debug_title": "Debuggen", + "message_logger_summary": "{messageCount} Nachrichten\n{storyCount} Stories", + "success_toast": "Erledigt!", + "export_button": "Exportieren", + "clear_button": "Löschen", + "view_logger_history_button": "Historie des Nachrichtenaufzeichners ansehen" + }, + "logged_stories": { + "story_failed_to_load": "Laden fehlgeschlagen", + "no_stories": "Keine Stories gefunden", + "save_from_cache_button": "Aus Zwischenspeicher speichern" + }, + "messaging_preview": { + "bridge_connection_failed": "Verbindung zu Snapchat über den Brückendienst fehlgeschlagen. Stellen Sie sicher das Snapchat im Hintergrund läuft", + "bridge_init_failed": "Initialisierung der Nachrichtenbrücke fehlgeschlagen. Stelle sicher, dass Snapchat im Hintergrund läuft", + "message_fetch_failed": "Fehler beim Abrufen der Nachrichten", + "no_message_hint": "Keine Nachricht", + "save_selection_option": "Auswahl speichern", + "save_all_option": "Alles speichern", + "unsave_selection_option": "Auswahl nicht mehr speichern", + "unsave_all_option": "Alles nicht mehr speichern", + "mark_selection_as_seen_option": "Ausgewählten Snap als gesehen markieren", + "mark_all_as_seen_option": "Alle Snaps als gesehen markieren", + "delete_selection_option": "Auswahl löschen", + "delete_all_option": "Alles löschen" + }, + "logger_history": { + "list_friend_format": "Freund {name}", + "list_group_format": "Gruppe {name}", + "no_more_messages": "Keine weiteren Nachrichten", + "reverse_order_checkbox": "Umgekehrte Reihenfolge", + "chat_attachment": "Anhang {index}", + "empty_message": "Leere Chatnachricht", + "message_parse_failed": "Nachricht konnte nicht verarbeitet werden", + "unknown_sender": "Unbekannter Absender", + "download_attachment_failed_toast": "Fehler beim Herunterladen des Anhangs" + }, + "manage_scope": { + "delete_scope_confirm_dialog_title": "Sind Sie sicher, dass Sie ein {scope} löschen wollen?", + "logged_stories_button": "Aufgezeichnete Stories anschauen", + "e2ee_title": "Ende-zu-Ende Verschlüsselung", + "rules_title": "Regeln", + "participants_text": "{count} Teilnehmer", + "not_found": "Nicht gefunden", + "streaks_title": "Streaks", + "streaks_length_text": "Länge: {length}", + "streaks_expiration_text": "Läuft ab in {eta}", + "streaks_expiration_text_expired": "Abgelaufen", + "reminder_button": "Erinnerung erstellen", + "notes_placeholder": "Klicken um eine Notiz hinzuzufügen" + }, + "manage_rule_feature": { + "disable_state_option": "Deaktiviert", + "disable_state_subtext": "Keine Freunde/Gruppen werden betroffen sein", + "whitelist_state_option": "Keiner ausser ...", + "whitelist_state_button": "Erlaubte Freunde/Gruppen auswählen", + "blacklist_state_subtext": "Alle ausser {count} Freunde/Gruppen sind von dieser Regel betroffen", + "clear_list_button": "Liste der Freunde/Gruppen löschen", + "dialog_clear_confirmation_text": "Bist du sicher, dass du die Liste löschen willst?", + "whitelist_state_subtext": "Nur {count} Freunde/Gruppen sind von dieser Regel betroffen", + "blacklist_state_option": "Jeder ausser ...", + "blacklist_state_button": "Ausgeschlossene Freunde/Gruppen auswählen" + }, + "better_location": { + "saved_name_dialog_hint": "Gespeicherter Name", + "latitude_dialog_hint": "Breitengrad", + "longitude_dialog_hint": "Längengrad", + "save_dialog_button": "Speichern", + "spoof_location_toggle": "Standort fälschen", + "no_saved_coordinates_hint": "Keine gespeicherten Koordinaten", + "delete_dialog_title": "Lösche gespeicherte Koordinaten", + "delete_dialog_message": "Bist du sicher, dass du diese gespeicherte Koordinate löschen willst?", + "teleport_to_friend_title": "Zu Freund teleportieren", + "no_friends_map": "Keine Freunde auf der Karte", + "no_friends_found": "Keine Freunde gefunden", + "save_coordinates_dialog_title": "Koordinaten speichern", + "choose_location_button": "Standort auswählen", + "teleport_to_friend_button": "Zu Freund teleportieren", + "suspend_location_updates": "Standortaktualisierungen aussetzen", + "saved_coordinates_title": "Gespeicherte Koordinaten", + "search_bar": "Suchen", + "spoofed_coordinates_title": "Breitengrad {latitude}, Längengrad {longitude}" + }, + "file_imports": { + "file_imported": "Datei erfolgreich importiert", + "file_delete_failed": "Löschen der Datei fehlgeschlagen", + "no_files_hint": "Hier kannst du Dateien zur Verwendung in Snapchat importieren. Drücken Sie auf die Schaltfläche unten, um eine Datei zu importieren.", + "import_file_button": "Datei importieren", + "file_not_found": "Datei nicht gefunden", + "file_import_failed": "Importieren der Datei fehlgeschlagen: {error}" + }, + "theming": { + "no_themes_hint": "Keine Personalisierungen gefunden" + } + }, + "dialogs": { + "add_friend": { + "title": "Freund oder Gruppe hinzufügen", + "search_hint": "Suchen", + "fetch_error": "Fehler beim Abrufen der Daten", + "category_groups": "Gruppen", + "category_friends": "Freunde", + "participants_text": "{count} Teilnehmer" + }, + "scripting_warning": { + "content": "SnapEnhance enthält ein Skripting-Tool, das die Ausführung von benutzerdefinierten Code auf Ihrem Gerät ermöglicht. Seien Sie äußerst vorsichtig und installieren Sie nur Module aus bekannten, zuverlässigen Quellen. Unautorisierte oder ungeprüfte Module können Sicherheitsrisiken für Ihr System darstellen.", + "title": "Warnung" + }, + "reset_config": { + "success_toast": "Einstellungen erfolgreich zurückgesetzt", + "title": "Einstellungen zurücksetzen", + "content": "Bist Du sicher, dass Du die Einstellungen zurücksetzen möchtest?" + }, + "messaging_action": { + "title": "Wählen Sie die zu verarbeitenden Inhaltstypen um fortzufahren", + "select_all_button": "Alle auswählen" + }, + "file_imports": { + "no_files_settings_hint": "Keine Dateien gefunden. Vergewissere dich, dass du die erforderlichen Dateien im Abschnitt „Datei-Import“ importiert hast", + "settings_select_file_hint": "Eine importierte Datei auswählen" + }, + "export_config": { + "title": "Sensitive Daten exportieren?", + "content": "Willst du die Konfiguration mit sensiblen Daten exportieren? (z. B. Standortkoordinaten usw.)" + } + } + }, + "rules": { + "modes": { + "blacklist": "Blacklist Modus", + "whitelist": "Whitlist Modus" + }, + "properties": { + "auto_download": { + "name": "Auto-Download", + "description": "Snaps beim Ansehen automatisch herunterladen", + "options": { + "blacklist": "Vom Auto-Download ausschließen", + "whitelist": "Auto-Download" + } + }, + "stealth": { + "name": "Heimlicher Modus", + "description": "Verhindert, dass jemand weiß, dass du seine Snaps/Chats oder Konversationen geöffnet hast", + "options": { + "blacklist": "Vom Heimlichen Modus ausschließen", + "whitelist": "Heimlicher Modus" + } + }, + "auto_save": { + "name": "Automatisches speichern", + "description": "Speichert Chat-Nachrichten beim Ansehen", + "options": { + "blacklist": "Vom automatischen Speichern ausschließen", + "whitelist": "Automatisch speichern" + } + }, + "hide_friend_feed": { + "name": "Vom Freundes-Feed ausblenden" + }, + "e2e_encryption": { + "name": "E2E-Verschlüsselung verwenden" + }, + "pin_conversation": { + "name": "Unterhaltung anheften" + }, + "unsaveable_messages": { + "name": "Nicht speicherbare Nachrichten", + "options": { + "blacklist": "Von nicht speicherbaren Nachrichten ausschließen", + "whitelist": "Nicht speicherbare Nachrichten" + }, + "description": "Verhindert, dass Nachrichten im Chat von anderen Personen gespeichert werden können" + }, + "auto_open_snaps": { + "name": "Öffne Snaps automatisch", + "description": "Öffnet Snaps selbstständig beim Empfangen", + "options": { + "blacklist": "Von der automatischen Snap-Öffnung ausschließen", + "whitelist": "Automatisches Öffnen von Snaps" + } + } + }, + "toasts": { + "enabled": "{ruleName} aktiviert", + "disabled": "{ruleName} deaktiviert" + } + }, + "features": { + "notices": { + "unstable": "⚠ Instabil", + "ban_risk": "⚠ Dieses Feature kann zu Bans führen", + "internal_behavior": "⚠ Dies kann das interne Verhalten von Snapchat stören" + }, + "properties": { + "downloader": { + "name": "Downloader", + "description": "Snapchat Medien herunterladen", + "properties": { + "save_folder": { + "name": "Speicherverzeichnis", + "description": "Wähle das Verzeichnis, in das alle Medien heruntergeladen werden sollen" + }, + "auto_download_sources": { + "name": "Quellen automatisch herunterladen", + "description": "Wähle die Quellen, von denen automatisch herunterzuladen ist" + }, + "prevent_self_auto_download": { + "name": "Selbst-Auto-Download verhindern", + "description": "Verhindert, dass eigene Snaps automatisch heruntergeladen werden" + }, + "path_format": { + "name": "Pfadformat", + "description": "Gib das Dateiformat an" + }, + "allow_duplicate": { + "name": "Duplikate erlauben", + "description": "Ermöglicht es, dass dieselben Medien mehrmals heruntergeladen werden" + }, + "merge_overlays": { + "name": "Overlays zusammenführen", + "description": "Kombiniert den Text und die Medien eines Snaps in eine Datei" + }, + "force_image_format": { + "name": "Bildformat erzwingen", + "description": "Erzwingt das Speichern von Bildern in einem bestimmten Format" + }, + "force_voice_note_format": { + "name": "Sprachnotiz Format erzwingen", + "description": "Erzwingt das Speichern von Sprachnotizen in einem bestimmten Format" + }, + "download_profile_pictures": { + "name": "Profilbilder herunterladen", + "description": "Ermöglicht das Herunterladen von Profilbildern von einer Profilseite" + }, + "ffmpeg_options": { + "name": "FFmpeg-Optionen", + "description": "Zusätzliche FFmpeg-Optionen angeben", + "properties": { + "threads": { + "name": "Threads", + "description": "Die Anzahl Threads, welche zu gebrauchen ist" + }, + "preset": { + "name": "Voreinstellungen", + "description": "Geschwindigkeit der Konvertierung festlegen" + }, + "constant_rate_factor": { + "description": "Setze den Constant Rate Factor für den Video-Encoder\nvon 0 bis 51 für libx264", + "name": "Konstanter Rate-Faktor" + }, + "video_bitrate": { + "name": "Videobitrate", + "description": "Video-Bitrate (kbps) festlegen" + }, + "audio_bitrate": { + "name": "Audiobitrate", + "description": "Audio-Bitrate (kbps) festlegen" + }, + "custom_video_codec": { + "name": "Benutzerdefinierter Video-Codec", + "description": "Wähle einen benutzerdefinierten Video-Codec (z.B. libx264)" + }, + "custom_audio_codec": { + "name": "Benutzerdefinierter Audio-Codec", + "description": "Wähle einen benutzerdefinierten Audio-Codec (z.B. AAC)" + } + } + }, + "logging": { + "name": "Protokollierung", + "description": "Zeigt Toasts, wenn Medien heruntergeladen werden" + }, + "custom_path_format": { + "description": "Legen Sie ein benutzerdefiniertes Pfadformat für heruntergeladene Medien fest\n\nVerfügbare Variablen:\n - %username%\n - %source%\n - %hash%\n - %date_time%", + "name": "Benutzerdefiniertes Pfadformat" + }, + "opera_download_button": { + "description": "Fügt einen Download-Button in der oberen rechten Ecke hinzu, wenn ein Snap angezeigt wird.\nGedrückt halten um einen Download zu starten", + "name": "Schwebender Download Button" + }, + "download_context_menu": { + "name": "Download Kontext Menü", + "description": "Ermöglicht es, Nachrichten oder eine Story herunterzuladen/vorher anzuschauen mithilfe des Kontext Menüs.\nLanges Drücken des Knopfes erzwingt den Download" + }, + "auto_download_voice_notes": { + "name": "Sprachnotizen automatisch herunterladen", + "description": "Sprachnotizen automatisch herunterladen, wenn sie abgespielt werden" + } + } + }, + "user_interface": { + "name": "Benutzeroberfläche", + "description": "Ändere das Aussehen von Snapchat", + "properties": { + "enable_app_appearance": { + "name": "Aktiviert die App Darstellungseinstellungen", + "description": "Aktiviert die versteckte App-Erscheinungsbild Einstellung,\nbei neueren Snapchat-Versionen möglicherweise nicht erforderlich" + }, + "friend_feed_message_preview": { + "name": "Freund Feed Nachrichten Vorschau", + "description": "Zeigt eine Vorschau der letzten Nachrichten im Freundes-Feed", + "properties": { + "amount": { + "name": "Anzahl", + "description": "Die Anzahl der Nachrichten, die in der Vorschau angezeigt werden" + } + } + }, + "bootstrap_override": { + "name": "Bootstrap Überschreibung", + "description": "Bootstrap-Einstellungen der Benutzeroberfläche überschreiben", + "properties": { + "app_appearance": { + "name": "App-Erscheinungsbild", + "description": "Legt eine dauerhafte App-Darstellung fest" + }, + "home_tab": { + "name": "Home Registerkarte", + "description": "Überschreibt den Start-Tab beim Öffnen von Snapchat" + }, + "simple_snapchat": { + "description": "Aktiviert eine vereinfachte Version von Snapchat", + "name": "Einfaches Snapchat" + } + } + }, + "map_friend_nametags": { + "name": "Verbesserte Karten-Namensschilder von Freunden", + "description": "Verbessert die Namensschilder von Freunden auf der Snapmap" + }, + "streak_expiration_info": { + "name": "Informationen zum Flammen-Ablauf anzeigen", + "description": "Zeigt einen Flammen-Ablauf-Timer neben dem Flammen-Zähler" + }, + "hide_friend_feed_entry": { + "name": "Freund Feed Eintrag ausblenden", + "description": "Versteckt einen bestimmten Freund aus dem Freundes-Feed,\nBenutze den sozialen Tab um diese Funktion zu verwalten" + }, + "hide_streak_restore": { + "name": "Flammen-Wiederherstellung verstecken", + "description": "Versteckt den Wiederherstellen-Button im Freundesfeed" + }, + "hide_ui_components": { + "name": "UI-Komponenten ausblenden", + "description": "Wähle aus welche UI-Elemente ausgeblendet werden sollen" + }, + "disable_spotlight": { + "name": "Spotlight deaktivieren", + "description": "Deaktiviert die Spotlight Seite" + }, + "friend_feed_menu_buttons": { + "name": "Schaltflächen für das Freunde-Feed Menü", + "description": "Wähle aus welche Schaltflächen in der Freunde Feed Menüleiste angezeigt werden sollen" + }, + "enable_friend_feed_menu_bar": { + "name": "Freunde Feed Menüleiste", + "description": "Aktiviert die neue Freunde Feed Menüleiste" + }, + "opera_media_quick_info": { + "description": "Zeigt nützliche Informationen zu Medien wie das Erstellungsdatum im Kontextmenü der Snap-Ansicht an", + "name": "Medien Schnellinfo" + }, + "vertical_story_viewer": { + "name": "Vertikale Story Ansicht", + "description": "Aktiviert die vertikale Story Ansicht für alle Storys" + }, + "old_bitmoji_selfie": { + "name": "Altes Bitmoji-Selfie", + "description": "Bringt die Bitmoji-Selfies aus früheren Snapchat-Versionen zurück" + }, + "prevent_message_list_auto_scroll": { + "name": "Automatisches Scrollen der Nachrichtenliste verhindern", + "description": "Verhindert, dass die Nachrichtenliste beim Senden/Empfangen einer Nachricht nach unten scrollt" + }, + "edit_text_override": { + "name": "Textfeld-Verhalten überschreiben", + "description": "Überschreibt das Verhalten von Textfeldern" + }, + "snap_preview": { + "name": "Snap-Vorschau", + "description": "Zeigt eine kleine Vorschau neben ungesehenen Snaps im Chat an" + }, + "hide_story_suggestions": { + "name": "Story Vorschläge ausblenden", + "description": "Entfernt Empfehlungen von der Story‐Seite" + }, + "stealth_mode_indicator": { + "name": "Diebstahl Modus Indikator", + "description": "Fügt den Konversationen im Stealth-Modus ein 👻-Emoji hinzu" + }, + "message_indicators": { + "name": "Nachrichtenindikatoren", + "description": "Fügt Nachrichten spezifische Anzeigesymbole hinzu\nHinweis: Die Symbole sind möglicherweise nicht 100 % genau" + }, + "custom_theme": { + "description": "Individualisiere Snapchat's Farben\nHinweis: Wenn du einen dunkles Thema (wie Amoled) wählst, musst du möglicherweise den dunklen Modus in den Einstellungen von Snapchat aktivieren für ein besseres Ergebnis", + "name": "Benutzerdefiniertes Thema" + }, + "auto_close_friend_feed_menu": { + "description": "Schließt das Freunde Feed Menü automatisch nachdem du einen Knopf für die Einstellungen drückst" + }, + "hide_quick_add_suggestions": { + "name": "Verstecke Vorschläge" + } + } + }, + "messaging": { + "name": "Mitteilungen", + "description": "Ändern wie mit Freunden interagiert wird", + "properties": { + "anonymous_story_viewing": { + "name": "Anonyme Story Ansicht", + "description": "Verhindert, dass jemand erfährt, dass du seine Story gesehen hast" + }, + "hide_bitmoji_presence": { + "name": "Bitmoji Präsenz verstecken", + "description": "Verhindert, dass dein Bitmoji im Chat auftaucht" + }, + "hide_typing_notifications": { + "name": "Tippen-Benachrichtigungen verbergen", + "description": "Verhindert, dass jemand erfährt, dass du eine Nachricht tippst" + }, + "unlimited_snap_view_time": { + "name": "Unbegrenzte Zeit zum Ansehen von Snaps", + "description": "Entfernt das Zeitlimit für die Anzeige von Snaps" + }, + "disable_replay_in_ff": { + "name": "Replay in FF deaktivieren", + "description": "Deaktiviert die Möglichkeit, mit einem langen Drücken vom Freundes-Feed zu wiederholen" + }, + "prevent_message_sending": { + "name": "Nachrichtenversand verhindern", + "description": "Verhindert das Versenden bestimmter Nachrichten" + }, + "better_notifications": { + "name": "Bessere Benachrichtigungen", + "description": "Zeige weitere Informationen in Benachrichtigungen an", + "properties": { + "chat_preview": { + "name": "Chat-Vorschau", + "description": "Zeigt eine Vorschau der empfangenen Nachrichten in der Benachrichtigung an" + }, + "reply_button": { + "name": "Antwortknopf", + "description": "Fügt der Benachrichtigung eine Antwortschaltfläche hinzu" + }, + "group_notifications": { + "name": "Gruppenbenachrichtigungen", + "description": "Gruppieren Sie Benachrichtigungen zu einer einzigen" + }, + "media_preview": { + "name": "Medienvorschau", + "description": "Zeigt eine Vorschau der ausgewählten Medientypen in der Benachrichtigung an" + }, + "media_caption": { + "name": "Medienuntertitel", + "description": "Zeigt die angehängte Bildunterschrift der Medien in der Benachrichtigung an" + }, + "stacked_media_messages": { + "name": "Gestapelte Mediennachrichten", + "description": "Kombiniert mehrere Mediennachrichten in einer Textbenachrichtigung, wenn sie nicht in der Vorschau angezeigt werden können. In Kombination mit der Chat-Vorschau verwenden" + }, + "friend_add_source": { + "name": "Quelle neuer Freunde", + "description": "Zeigt die Quelle einer Freundschaftsanfrage in der Benachrichtigung an" + }, + "download_button": { + "name": "Download-Button", + "description": "Ermöglicht das Herunterladen von Medien aus der Benachrichtigung" + }, + "mark_as_read_button": { + "name": "Schaltfläche „Als gelesen markieren“", + "description": "Ermöglicht es dir, eine Nachricht aus der Benachrichtigung als gelesen zu markieren" + }, + "mark_as_read_and_save_in_chat": { + "name": "Als gelesen markieren und im Chat speichern", + "description": "Fügt der Benachrichtigung eine Schaltfläche „Als gelesen markieren und im Chat speichern“ hinzu" + }, + "smart_replies": { + "description": "Fügt empfohlene Antworten zu Benachrichtigungen hinzu (Android 10+). In Kombination mit Antworten Knopf zu verwenden", + "name": "Smarte Antworten" + } + } + }, + "notification_blacklist": { + "name": "Benachrichtigungs Blacklist", + "description": "Wählen Sie Benachrichtigungen aus, die blockiert werden sollen" + }, + "message_logger": { + "name": "Nachrichten Logger", + "description": "Verhindert, dass Nachrichten gelöscht werden", + "properties": { + "message_filter": { + "name": "Nachrichtenfilter", + "description": "Wählen Sie aus, welche Nachrichten behalten werden sollen (leer für alle Nachrichten)" + }, + "auto_purge": { + "description": "Löscht automatisch zwischengespeicherte Nachrichten, die älter als die angegebene Zeit sind", + "name": "Automatische Bereinigung" + }, + "keep_my_own_messages": { + "name": "Eigene Nachrichten behalten", + "description": "Verhindert, dass Ihre eigenen Nachrichten gelöscht werden" + }, + "deleted_message_color": { + "name": "Gelöschte Nachrichten Farbe", + "description": "Setzt die Farbe von gelöschten Nachrichten" + } + } + }, + "auto_save_messages_in_conversations": { + "name": "Automatisches Speichern von Nachrichten", + "description": "Speichert automatisch jede Nachricht in Unterhaltungen" + }, + "gallery_media_send_override": { + "name": "Galerie-Medien senden Überschreiben", + "description": "Fälscht die Medienquelle, wenn etwas von der Galerie gesendet wird" + }, + "bypass_screenshot_detection": { + "description": "Verhindert, dass Snapchat erkennt, wenn du einen Screenshot machst", + "name": "Umgehen der Screenshot-Erkennung" + }, + "half_swipe_notifier": { + "name": "Über Half-Swipes informieren", + "description": "Benachrichtigt Sie, wenn jemand halb in ihren Chat swiped", + "properties": { + "min_duration": { + "name": "Mindestdauer", + "description": "Die Mindestdauer der halben Swipes (in Sekunden)" + }, + "max_duration": { + "description": "Die maximale Dauer des halben Swipes (in Sekunden)", + "name": "Maximale Dauer" + } + } + }, + "prevent_story_rewatch_indicator": { + "name": "Wiederholungs-Indikator bei Stories verhindern", + "description": "Verhindert, dass andere wissen, dass Sie ihre Story noch einmal angeschaut haben" + }, + "hide_peek_a_peek": { + "description": "Verhindert, dass eine Benachrichtigung gesendet wird, wenn Sie halb in einen Chat swipen", + "name": "Vorschau Benachrichtigung verhindern" + }, + "strip_media_metadata": { + "description": "Entfernt Metadaten von Medien vor dem Versand als Nachricht", + "name": "Medien-Metadaten entfernen" + }, + "bypass_message_retention_policy": { + "name": "Umgehung der Richtlinie zur Aufbewahrung von Nachrichten", + "description": "Verhindert, dass Nachrichten nach dem Anzeigen gelöscht werden" + }, + "call_start_confirmation": { + "name": "Bestätigung des Starts eines Anrufs", + "description": "Zeigt einen Bestätigungsdialog beim Starten eines Anrufs an" + }, + "loop_media_playback": { + "name": "Medien Wiedergabe Wiederholen", + "description": "Wiederholt Snaps & Stories beim ansehen in einer Schleife" + }, + "bypass_message_action_restrictions": { + "description": "Ermöglicht es Ihnen, auf einen Snap zu reagieren, ohne ihn geöffnet zu haben, oder eine nicht speicherbare Nachricht zu speichern", + "name": "Umgehung von Nachrichtenaktionsbeschränkungen" + }, + "remove_groups_locked_status": { + "name": "Entfernen des Gruppen-Sperrstatus", + "description": "Ermöglicht es Ihnen, nach dem Rauswurf Gruppeninformationen anzuzeigen" + }, + "auto_mark_as_read": { + "name": "Automatisch als gelesen markieren", + "description": "Markiert Nachrichten bzw. Snaps automatisch als gelesen wenn der Stealth Mode aktiviert ist" + }, + "friend_mutation_notifier": { + "name": "Freund:innen-Änderungsbenachrichigung", + "description": "Benachrichtigt Sie, wenn sich etwas im Profil eines Freundes ändert" + }, + "unlimited_conversation_pinning": { + "name": "Unlimitiertes Anpinnen von Konversationen", + "description": "Erlaubt dir eine unlimitierte Anzahl von Konversationen lokal anzupinnen" + }, + "skip_when_marking_as_seen": { + "name": "Überspringen wenn als gelesen markiert", + "description": "Springt automatisch zum nächsten Snap, wenn ein Snap als gelesen markiert wird\nIn Kombination mit Snap als gelesen markieren Knopf verwenden" + }, + "mark_snap_as_seen_button": { + "name": "Snap als gelesen markieren Knopf", + "description": "Fügt einen Knopf hinzu, um einen Snap als gelesen zu markieren, wenn er angeschaut wurde.\nDas funktioniert sogar wenn Stealth Modus aktiviert ist" + }, + "double_tap_chat_action": { + "name": "Doppelt Tippen Chat Aktion" + } + } + }, + "global": { + "name": "Global", + "description": "Globale Snapchat-Einstellungen anpassen", + "properties": { + "snapchat_plus": { + "name": "Snapchat Plus", + "description": "Aktiviert Snapchat Plus Funktionen\nEinige serverseitige Funktionen funktionieren möglicherweise nicht" + }, + "auto_updater": { + "name": "Automatische Aktualisierung", + "description": "Automatisch auf Updates prüfen" + }, + "disable_metrics": { + "name": "Metriken deaktivieren", + "description": "Verhindert das Senden von analytischen Daten an Snapchat" + }, + "block_ads": { + "name": "Werbung blockieren", + "description": "Verhindert die Anzeige von Werbung" + }, + "bypass_video_length_restriction": { + "name": "Umgeht Videolängenbeschränkungen", + "description": "Einzel: sendet ein einzelnes Video\nSplitt: Videos nach Bearbeitung aufteilen" + }, + "disable_google_play_dialogs": { + "name": "Google Play-Service Dialog deaktivieren", + "description": "Verfügbarkeitsdialog für Google Play Services nicht anzeigen" + }, + "disable_snap_splitting": { + "name": "Snap-Aufteilung deaktivieren", + "description": "Verhindert, dass Snaps in mehrere Teile aufgeteilt werden\nBilder werden in Videos umgewandelt" + }, + "disable_confirmation_dialogs": { + "name": "Bestätigungsdialoge deaktivieren", + "description": "Bestätigt automatisch ausgewählte Aktionen" + }, + "spotlight_comments_username": { + "name": "Spotlight Kommentare Benutzername", + "description": "Zeigt den Benutzernamen des Autors in Spotlight-Kommentaren an" + }, + "disable_story_sections": { + "name": "Story Sektion deaktivieren", + "description": "Entfernt Sektionen von der Story-Seite\nErfordert möglicherweise eine Aktualisierung, um richtig zu funktionieren" + }, + "better_location": { + "name": "Snapmap Plus", + "description": "Verbessert die Snapmap", + "properties": { + "spoof_location": { + "name": "Standort simulieren", + "description": "Ändert deinen Standort auf einen bestimmten Ort" + }, + "coordinates": { + "name": "Koordinaten", + "description": "Setze Koordinaten, deren Ort simuliert werden soll" + }, + "always_update_location": { + "name": "Dauerhafte Standort-Updates", + "description": "Zwingt Snapchat dazu, die den falschen Standort beizubehalten, auch wenn es kein GPS Signal erhält" + }, + "spoof_battery_level": { + "name": "­Akkuladung simulieren", + "description": "Simuliert einen falschen Akkustand auf der Snapmap\nDer Wert sollte zwischen 0% und 100% liegen" + }, + "spoof_headphones": { + "name": "Kopfhörer", + "description": "Simuliert auf der Snapmap, dass du gerade Musik hörst" + }, + "suspend_location_updates": { + "name": "Button zum Deaktivieren", + "description": "Fügt in den Karteneinstellungen einen Knopf hinzu, um Standortaktualisierungen zu pausieren" + }, + "walk_radius": { + "name": "Radius", + "description": "Laufe zufälligerweise in diesem Radius (ft) herum" + } + } + }, + "default_volume_controls": { + "name": "Standard Lautstärkekontrolle", + "description": "Zwinge Snapchat die Systemlautstärke zu nutzen" + }, + "disable_memories_snap_feed": { + "name": "Memories Snap Feed deaktivieren", + "description": "Verhindert, dass Snapchat aktuelle Erinnerungen anzeigt, wenn Sie in der Kamera nach oben wischen" + }, + "disable_permission_requests": { + "name": "Berechtigungsanfragen deaktivieren", + "description": "Verhindert, dass Snapchat nach bestimmten Berechtigungen fragt" + }, + "default_video_playback_rate": { + "name": "Standardmäßige Videowiedergaberate", + "description": "Legt die Standardgeschwindigkeit für die Wiedergabe von Videos fest\n\tDer Wert muss zwischen 0,1 und 4,0 liegen" + }, + "video_playback_rate_slider": { + "name": "Schieberegler für die Videowiedergaberate", + "description": "Fügt einen Schieberegler im Opera-Kontextmenü hinzu, um die Videowiedergabegeschwindigkeit zu ändern\nHinweis: Änderungen gelten nur für nachfolgende Videos" + }, + "hide_active_music": { + "name": "Aktive Musik ausblenden", + "description": "Verhindert, dass Snapchat erkennt, dass Sie Musik hören.\nSo können Sie mithilfe der Lautstärketasten Snaps aufnehmen, während Sie Musik hören" + }, + "media_upload_quality": { + "properties": { + "disable_image_compression": { + "name": "Deaktiviert Bildkompression", + "description": "Deaktiviert Bildkompression, wenn Medien hochgeladen werden" + }, + "custom_image_upload_format": { + "description": "Setzt ein benutzerdefiniertes Bildhochladformat\nWähle ein verlustfreies Format (wie PNG) für die beste Qualität", + "name": "Benutzerdefiniertes Bild-Upload-Format" + }, + "force_video_upload_source_quality": { + "name": "Erzwingen Sie die Qualität der Video-Upload-Quelle", + "description": "Erzwing Snapchat die Quellenqualität zu nutzen, wenn Videos hochgelden werden\nBitte merke, dass dies eventuell die Metadaten von Medien nicht entfernt" + } + }, + "name": "Qualität", + "description": "Überschreibt die Medienuploadqualität" + }, + "disable_custom_tabs": { + "name": "Deaktivieren Sie benutzerdefinierte Registerkarten", + "description": "Öffnet Links in unterstützen Applikationen, anstatt dem Webbrowser" + } + } + }, + "rules": { + "name": "Regeln", + "description": "Automatische Funktionen für einzelne Personen verwalten" + }, + "camera": { + "name": "Kamera", + "description": "Pass die richtigen Einstellungen für den perfekten Snap an", + "properties": { + "immersive_camera_preview": { + "name": "Immersive Vorschau", + "description": "Verhindert das Beschneiden der Kameravorschau\nDas kann dazu führen, dass die Kamera auf einigen Geräten flickert" + }, + "force_camera_source_encoding": { + "name": "Kodierung der Kameraquelle erzwingen", + "description": "Erzwingt die Kodierung der Kameraquelle" + }, + "hevc_recording": { + "name": "HEVC Aufnahme", + "description": "Verwendet HEVC (H.265) Codec für die Videoaufzeichnung" + }, + "black_photos": { + "description": "Ersetzt die aufgenommenen Fotos durch einen schwarzen Hintergrund\nVideos sind davon nicht betroffen", + "name": "Schwarze Fotos" + }, + "override_front_resolution": { + "name": "Überschreiben der Frontauflösung", + "description": "Überschreibt die Kameraauflösung für die Selfie-Kamera" + }, + "override_back_resolution": { + "name": "Hauptkamera Auflösung überschreiben", + "description": "Überschreibt die Kameraauflösung der Hauptkamera" + }, + "custom_resolution": { + "name": "Benutzerdefinierte Auflösung", + "description": "Legt eine benutzerdefinierte Kameraauflösung (Breite x Höhe) fest (z. B. 1920x1080).\nDie benutzerdefinierte Auflösung muss von Ihrem Gerät unterstützt werden" + }, + "disable_cameras": { + "name": "Kameras deaktivieren", + "description": "Verhindert, dass Snapchat die gewählten Kameras nutzt" + }, + "front_custom_frame_rate": { + "name": "Benutzerdefinierte Frame-Rate Selfie-Kamera", + "description": "Überschreibt die Frame-Rate der Selfie-Kamera" + }, + "back_custom_frame_rate": { + "description": "Überschreibt die Frame-Rate der Haupt-Kamera", + "name": "Benutzerdefinierte Frame-Rate Haupt-Kamera" + } + } + }, + "streaks_reminder": { + "name": "Flammen-Erinnerung", + "description": "Benachrichtigt dich regelmäßig über deine Flammen", + "properties": { + "interval": { + "name": "Intervall", + "description": "Das Intervall zwischen jeder Erinnerung (Stunden)" + }, + "remaining_hours": { + "name": "Verbleibende Zeit", + "description": "Die verbleibende Zeit, bevor die Benachrichtigung angezeigt wird (in Stunden)" + }, + "group_notifications": { + "name": "Gruppierte Benachrichtigungen", + "description": "Benachrichtigungen in eine einzelne gruppieren" + } + } + }, + "experimental": { + "name": "Experimentell", + "description": "Experimentelle Funktionen", + "properties": { + "native_hooks": { + "name": "Native Hooks", + "description": "Unsichere Funktionen, welche sich in Snapchats nativen Code einhängen", + "properties": { + "disable_bitmoji": { + "name": "Bitmojis deaktivieren", + "description": "Deaktiviert das Bitmoji des Freund:innenprofil" + }, + "composer_hooks": { + "properties": { + "bypass_camera_roll_limit": { + "name": "Umgeht das Kamerarollenlimit", + "description": "Erhöht die maximale Anzahl von Medien, die Sie aus der Kamerarolle senden können" + }, + "composer_console": { + "name": "Composer-Konsole", + "description": "Ermöglicht das Ausführen von JavaScript-Code in Composer (nur arm64)" + }, + "composer_logs": { + "name": "Composer-Protokolle", + "description": "Leitet Konsolenprotokolle von Composer zu SnapEnhance um" + }, + "show_first_created_username": { + "name": "Zeige erstgewählten Benutzernamen", + "description": "Zeige den erstgewählten Benutzernamen neben dem jetzigen Benutzernamen in der Profileseite" + } + }, + "name": "Composer Haken", + "description": "Injiziert Code in das Composer UI-Framework (nur arm64)" + } + } + }, + "spoof": { + "name": "Simulieren", + "description": "Verschiedene Informationen über dich vortäuschen", + "properties": { + "remove_mock_location_flag": { + "name": "Kennzeichnung für den gefälschten Standort entfernen", + "description": "Verhindert, dass Snapchat gefälschte Standorte erkennt" + }, + "remove_vpn_transport_flag": { + "description": "Hindert Snapchat daran, VPNs zu erkennen", + "name": "VPN-Transport-Flagge entfernen" + }, + "play_store_installer_package_name": { + "description": "Überschreibt den Namen des Installationspakets auf com.android.vending_machine", + "name": "Goolge Play Installationsname des Paketes" + } + } + }, + "infinite_story_boost": { + "name": "Unendlicher Story Boost", + "description": "Story Boost Limit Verzögerung umgehen" + }, + "meo_passcode_bypass": { + "name": "Passwortumgehung für privaten Bereich", + "description": "Umgeht das Passwort für den privaten Bereich\nFunktioniert nur, wenn das korrekte Passwort schon einmal eingegeben wurde" + }, + "no_friend_score_delay": { + "name": "Keine Friend Score Verzögerung", + "description": "Entfernt die Verzögerung beim Betrachten einer Friends Score" + }, + "e2ee": { + "name": "Ende-zu-Ende-Verschlüsselung", + "description": "Verschlüsselt deine Nachrichten mit AES unter Verwendung eines freigegebenen geheimen Schlüssels\nAchte darauf, dass du deinen Schlüssel an einem sicheren Ort aufbewahrst!", + "properties": { + "encrypted_message_indicator": { + "name": "Anzeige für verschlüsselte Nachrichten", + "description": "Fügt einen 🔒 Emoji neben verschlüsselten Nachrichten hinzu" + }, + "force_message_encryption": { + "name": "Nachrichtenverschlüsselung erzwingen", + "description": "Verhindert das Senden von verschlüsselten Nachrichten an Personen, die keine E2E-Verschlüsselung aktiviert haben, wenn mehrere Unterhaltungen ausgewählt sind" + } + } + }, + "add_friend_source_spoof": { + "name": "Freundesquellen-Änderung hinzufügen", + "description": "Verfälscht die Quelle einer Freundschaftsanfrage" + }, + "hidden_snapchat_plus_features": { + "name": "Verborgene Snapchat Plus-Funktionen", + "description": "Aktiviert unveröffentlichte/beta Snapchat Plus Funktionen\nKönnte auf älteren Snapchat-Versionen nicht funktionieren" + }, + "prevent_forced_logout": { + "name": "Erzwungenen Logout verhindern", + "description": "Verhindert, dass Snapchat dich abmeldet, wenn du dich auf einem anderen Gerät anmeldest" + }, + "convert_message_locally": { + "description": "Konvertiert Snaps lokal in externe Chat-Medien. Dies erscheint im Kontextmenü für den Chat-Download", + "name": "Nachricht lokal umwandeln" + }, + "story_logger": { + "description": "Liefert eine Historie der Stories von Freund:innen", + "name": "Geschichtenprotokollierer" + }, + "call_recorder": { + "name": "Anrufaufzeichner", + "description": "Zeichnet automatisch Audioanrufe auf" + }, + "media_file_picker": { + "name": "Mediendatei-Auswahl", + "description": "Ermöglicht es beliebige Video und Audio Dateien von der Gallerie auszuwählen" + }, + "account_switcher": { + "name": "­Accountwechsler", + "description": "Ermöglicht es Ihnen, zwischen Konten zu wechseln, ohne sich auszuloggen\nHalten Sie lange auf das Suchsymbol neben Ihrem Bitmoji-Profil, um das Menü zu öffnen\nHinweis: Diese Funktion ist experimentell und wird wahrscheinlich in Zukunft geändert", + "properties": { + "auto_backup_current_account": { + "name": "Automatisches Backup des aktuellen Accounts", + "description": "Automatisch wird das aktuelle Konto gesichert, wenn Sie sich ausloggen oder zwischen Konten wechseln" + } + } + }, + "edit_message": { + "description": "Ermöglicht es Nachrichten in Konversationen zu bearbeiten", + "name": "Nachrichten bearbeiten" + }, + "app_lock": { + "name": "App-Sperre", + "description": "Verhindert den Zugriff auf Snapchat ohne dein Passwort", + "properties": { + "lock_on_resume": { + "description": "Sperrt die App, sobald sie wieder geöffnet wird", + "name": "Sperren beim Fortsetzen" + } + } + }, + "custom_streaks_expiration_format": { + "name": "Eigenes Ablaufdatum für Flammen", + "description": "Passt das Ablaufdatumformat für Flammen an\n\nVerfügbare Variablen:\n- %c: Anzahl der Serien\n- %e: Sanduhr-Emoji\n- %d: Tage\n- %h: Stunden\n- %m: Minuten\n- %s: Sekunden\n- %w: Verbleibende Zeit" + }, + "best_friend_pinning": { + "name": "Bester Freund Pinning", + "description": "Erlaubt einen Freund als besten Freund Nummer 1 anzupinnen. Notiz: Nur du kannst deinen gepinnten besten Freund sehen" + } + } + }, + "scripting": { + "name": "Scripting", + "description": "Benutzerdefinierte Skripte ausführen, um SnapEnhance zu erweitern", + "properties": { + "developer_mode": { + "name": "Entwickler:innenmodus", + "description": "Zeigt Debug-Informationen auf Snapchat's UI" + }, + "module_folder": { + "name": "Modulordner", + "description": "Der Ordner, in dem sich die Skripte befinden" + }, + "integrated_ui": { + "name": "Integrierte Benutzeroberfläche", + "description": "Erlaubt Skripten, benutzerdefinierte UI-Komponenten zu Snapchat hinzuzufügen" + }, + "disable_log_anonymization": { + "description": "Deaktiviert die Anonymisierung von Logs", + "name": "Log-Anonymisierung deaktivieren" + }, + "auto_reload": { + "description": "Automatisches Neuladen von Skripten, wenn diese sich ändern", + "name": "Automatisches Neuladen" + } + } + }, + "friend_tracker": { + "properties": { + "allow_running_in_background": { + "name": "Hintergrundnutzung erlauben", + "description": "Ermöglicht die Ausführung des Trackers im Hintergrund. Hinweis: Dadurch wird Ihr Akku erheblich entladen" + }, + "record_messaging_events": { + "name": "Nachrichtenevents aufzeichnen", + "description": "Zeichnet Nachrichtenereignisse wie das Öffnen eines Snaps, das Lesen einer Nachricht usw. auf." + }, + "auto_purge": { + "description": "Löscht zwischengespeicherte Ereignisse automatisch, wenn sie älter als die spezifizierte Dauer sind", + "name": "Automatisch Löschen" + } + }, + "name": "Freundestracker", + "description": "Die Aktivität der Freunde aufzeichnen" + } + }, + "options": { + "app_appearance": { + "always_light": "Immer hell", + "always_dark": "Immer dunkel" + }, + "friend_feed_menu_buttons": { + "auto_download": "⬇️ Auto-Download", + "auto_save": "💬 Auto-Nachricht-Speichern", + "stealth": "👻 Heimlicher Modus", + "conversation_info": "👤 Gesprächsinformationen", + "e2e_encryption": "🔒 E2E-Verschlüsselung verwenden", + "mark_stories_as_seen_locally": "👀 Stories als lokal gesehen markieren", + "mark_snaps_as_seen": "👀 Snaps als gesehen markieren", + "unsaveable_messages": "⬇️ Nicht speicherbare Nachrichten", + "auto_open_snaps": "📷 Automatisches Öffnen von Snaps" + }, + "path_format": { + "create_author_folder": "Erzeuge ein Verzeichnis für jeden Benutzer", + "create_source_folder": "Ordner für jeden Medienquellentyp erstellen", + "append_hash": "Fügt jedem Dateinamen einen einzigartigen Hash hinzu", + "append_source": "Füge die Medienquelle zum Dateinamen hinzu", + "append_username": "Füge den Benutzernamen zum Dateinamen hinzu", + "append_date_time": "Füge Datum und Uhrzeit zum Dateinamen hinzu" + }, + "auto_download_sources": { + "friend_snaps": "Freund-Snaps", + "friend_stories": "Freund-Stories", + "public_stories": "Öffentliche Stories", + "spotlight": "Spotlight" + }, + "logging": { + "started": "Gestartet", + "success": "Erfolgreich", + "progress": "Fortschritt", + "failure": "Fehler" + }, + "notifications": { + "chat_screenshot": "Screenshot", + "chat_screen_record": "Bildschirmaufnahme", + "snap_replay": "Snap Wiederholung", + "camera_roll_save": "In Aufnahmen gespeichert", + "chat": "Chat", + "chat_reply": "Chat Antwort", + "snap": "Snap", + "typing": "Schreiben", + "stories": "Stories", + "chat_reaction": "DM-Reaktion", + "group_chat_reaction": "Gruppenreaktion", + "initiate_audio": "Eingehender Audioanruf", + "abandon_audio": "Verpasster Audioanruf", + "initiate_video": "Eingehender Videoanruf", + "abandon_video": "Verpasster Videoanruf", + "speaking": "Sprache" + }, + "gallery_media_send_override": { + "ORIGINAL": "Originale Medien", + "NOTE": "Audio-Notiz", + "SNAP": "Snap", + "always_ask": "Immer Fragen" + }, + "hide_ui_components": { + "hide_profile_call_buttons": "Entferne Anruf Tasten", + "hide_chat_call_buttons": "Entferne Anruf Tasten im Chat", + "hide_live_location_share_button": "Schaltfläche Live-Standortfreigabe entfernen", + "hide_stickers_button": "Entferne Stickers Taste", + "hide_voice_record_button": "Knopf für Sprachaufzeichnung entfernen", + "hide_unread_chat_hint": "Hinweis auf ungelesene Chats entfernen" + }, + "home_tab": { + "map": "Karte", + "chat": "Chat", + "camera": "Kamera", + "discover": "Entdecken", + "spotlight": "Spotlight" + }, + "add_friend_source_spoof": { + "added_by_username": "Nach Benutzername", + "added_by_mention": "Durch Erwähnung", + "added_by_group_chat": "Per Gruppenchat", + "added_by_qr_code": "Per QR-Code", + "added_by_community": "Per Community", + "added_by_quick_add": "Von Schnell Hinzufügen ->!!HOHES RISIKO, GEBANNT ZU WERDEN!!<-" + }, + "bypass_video_length_restriction": { + "single": "Einzelnes Medium", + "split": "Medien aufteilen" + }, + "auto_reload": { + "snapchat_only": "Nur Snapchat", + "all": "Alles (Snapchat + SnapEnhance)" + }, + "strip_media_metadata": { + "remove_audio_note_duration": "Dauer der Sprachnachricht entfernen", + "remove_audio_note_transcript_capability": "Sprachnachricht-Transkriptionsfunktion entfernen", + "hide_extras": "Extras ausblenden (z. B. Erwähnungen)", + "hide_caption_text": "Bildunterschriftstext ausblenden", + "hide_snap_filters": "Snap Filter ausblenden" + }, + "auto_purge": { + "1_day": "1 Tag", + "1_week": "1 Woche", + "1_month": "1 Monat", + "2_weeks": "2 Wochen", + "never": "Nie", + "1_hour": "1 Stunde", + "3_hours": "3 Stunden", + "6_months": "6 Monate", + "3_days": "3 Tage", + "6_hours": "6 Stunden", + "3_months": "3 Monate", + "12_hours": "12 Stunden" + }, + "disable_confirmation_dialogs": { + "hide_conversation": "Konversation ausblenden", + "clear_conversation": "Konversation aus dem Freundes-Feed löschen", + "remove_friend": "Freund entfernen", + "hide_friend": "Freund ausblenden", + "ignore_friend": "Freund ignorieren", + "block_friend": "Freund blockieren", + "erase_message": "Nachricht löschen" + }, + "edit_text_override": { + "bypass_text_input_limit": "Umgehen des Limits für die Texteingabe", + "multi_line_chat_input": "Mehrzeiliges Chat-Eingabefeld" + }, + "old_bitmoji_selfie": { + "2d": "2D Bitmoji", + "3d": "3D Bitmoji" + }, + "hide_story_suggestions": { + "hide_suggested_friend_stories": "Empfohlene Stories von Freunden ausblenden", + "hide_my_stories": "Meine Stories verbergen" + }, + "disable_story_sections": { + "friends": "Freunde", + "following": "Folge Ich", + "discover": "Entdecken" + }, + "disable_cameras": { + "front": "Selfie Kamera", + "back": "Hauptkamera" + }, + "disable_permission_requests": { + "notifications": "Benachrichtigungen", + "read_media_images": "Medienbilder lesen", + "camera": "Kamera", + "microphone": "Mikrofon", + "read_media_video": "Medienvideos lesen", + "read_contacts": "Kontakte lesen", + "location": "Standort", + "nearby_devices": "In der Nähe befindliche Geräte", + "phone_calls": "Telefonanrufe" + }, + "message_indicators": { + "encryption_indicator": "Fügt neben Nachrichten, die nur an Sie gesendet wurden, ein 🔒-Symbol hinzu", + "location_indicator": "Fügt Snaps ein 📍-Symbol hinzu, wenn sie mit aktivierter Standortfunktion gesendet wurden", + "platform_indicator": "Fügt das Plattformsymbol hinzu, von der aus ein Medium gesendet wurde (z. B. Android, iOS, Web)", + "director_mode_indicator": "Fügt Snaps ein ✏️-Symbol hinzu, wenn sie mit dem Director-Modus gesendet wurden, der verwendet werden kann, um Galeriebilder als Snaps zu senden", + "ovf_editor_indicator": "Kennzeichnet, ob ein Snap mit dem OVF-Editor gesendet wurde" + }, + "auto_mark_as_read": { + "conversation_read": "Markiert eine Konversation als gelesen sobald eine Narchicht gesendet wird", + "snap_reply": "Markiert Snaps als gelesen, sobald auf sie geantwortet wird" + }, + "friend_mutation_notifier": { + "remove_friend": "Benachrichtige, wenn dich jemand als Freund entfernt", + "bitmoji_avatar_changes": "Benachrichtige, wenn jemand sein Bitmoji-Avatar ändert", + "bitmoji_background_changes": "Benachrichtige, wenn jemand den Hintergrund seines Bitmoji ändert", + "birthday_changes": "Benachrichtige, wenn jemand sein Geburtsdatum ändert", + "bitmoji_selfie_changes": "Benachrichtigen, wenn jemand sein Bitmoji-Selfie ändert", + "bitmoji_scene_changes": "Benachrichtige, wenn jemand seine Bitmoji-Szene ändert" + } + } + }, + "friend_menu_option": { + "preview": "Vorschau", + "stealth_mode": "Inkognitomodus", + "auto_download_blacklist": "Blacklist für automatische Downloads", + "anti_auto_save": "Anti-Auto-Speichern", + "mark_snaps_as_seen": "Snaps als gesehen markieren", + "mark_stories_as_seen_locally": "Stories als lokal gesehen markieren" + }, + "chat_action_menu": { + "preview_button": "Vorschau", + "download_button": "Download", + "delete_logged_message_button": "Gespeicherte Nachrichten löschen", + "convert_message": "Nachricht konvertieren", + "edit_message": "Nachricht bearbeiten" + }, + "opera_context_menu": { + "download": "Medien herunterladen", + "media_duration": "Mediendauer: {duration} ms", + "show_debug_info": "Debug-Informationen anzeigen", + "expires_at": "Läuft am {date} ab", + "created_at": "Erstellt am {date}", + "sent_at": "Gesendet am {date}", + "media_size": "Mediengröße: {size}" + }, + "modal_option": { + "profile_info": "Profil Info", + "close": "Schließen" + }, + "gallery_media_send_override": { + "multiple_media_toast": "Du kannst nur eine Datei auf einmal senden" + }, + "conversation_preview": { + "streak_expiration": "läuft in {day} Tagen, {hour} Stunden, {minute} Minuten ab", + "total_messages": "Insgesamt gesendete/empfangene Nachrichten: {count}", + "title": "Vorschau", + "unknown_user": "Unbekannter Benutzer" + }, + "profile_info": { + "title": "Profil Info", + "first_created_username": "Erster Benutzername", + "mutable_username": "Änderbarer Benutzername", + "display_name": "Anzeigename", + "added_date": "Datum hinzugefügt", + "birthday": "Geburtstag: {month} {day}", + "friendship": "Freundschaft", + "add_source": "Quelle hinzufügen", + "snapchat_plus": "Snapchat Plus", + "snapchat_plus_state": { + "subscribed": "Abonniert", + "not_subscribed": "Nicht abonniert" + }, + "hidden_birthday": "Geburtstag : Versteckt" + }, + "chat_export": { + "dialog_negative_button": "Abbrechen", + "dialog_positive_button": "Exportieren", + "exported_to": "Exportiert zu {path}", + "exporting_chats": "Chats exportieren...", + "processing_chats": "{amount} Konversationen werden verarbeitet...", + "export_fail": "Konversation {conversation} konnte nicht exportiert werden", + "writing_output": "Ausgabe schreiben...", + "finished": "Fertig! Du kannst diesen Dialog jetzt schließen.", + "no_messages_found": "Keine Nachrichten gefunden!", + "exporting_message": "{conversation} wird exportiert...", + "exporter_dialog": { + "text_field_selection_all": "Alle", + "export_file_format_title": "Dateiformat für den Export", + "download_medias_title": "Medien herunterladen", + "amount_of_messages_title": "Anzahl der Nachrichten (für alle leer lassen)", + "message_type_filter_title": "Nachrichten nach Typ filtern", + "text_field_selection": "{amount} ausgewählt", + "select_conversations_title": "Konversationen auswählen" + } + }, + "button": { + "ok": "OK", + "positive": "Ja", + "negative": "Nein", + "cancel": "Abbrechen", + "open": "Öffnen", + "download": "Download" + }, + "profile_picture_downloader": { + "button": "Profilbilder herunterladen", + "title": "Profilbild-Downloader", + "avatar_option": "Avatar", + "background_option": "Hintergrund" + }, + "download_processor": { + "attachment_type": { + "snap": "Snap", + "sticker": "Sticker", + "external_media": "Externe Medien", + "note": "Notiz", + "original_story": "Originale Story", + "gif": "GIF" + }, + "select_attachments_title": "Anhänge auswählen", + "download_started_toast": "Download gestartet", + "unsupported_content_type_toast": "Nicht unterstützter Content-Typ!", + "failed_no_longer_available_toast": "Datei ist nicht mehr verfügbar", + "no_attachments_toast": "Keine Anhänge gefunden!", + "already_queued_toast": "Datei wird bereits bearbeitet!", + "already_downloaded_toast": "Datei wurde bereits heruntergeladen!", + "download_toast": "{path} wird heruntergeladen...", + "processing_toast": "Verarbeite {path}...", + "failed_generic_toast": "Download fehlgeschlagen", + "failed_to_create_preview_toast": "Fehler beim Erstellen der Vorschau", + "failed_processing_toast": "Fehler beim Verarbeiten {error}", + "failed_gallery_toast": "Speichern in der Galerie fehlgeschlagen {error}", + "dash_no_chapter": "Kein Abschnitt gefunden", + "dash_dialog": { + "title": "Dash-Medium herunterladen", + "download_all": "Alle herunterladen", + "segment_text": "Segment {from} - {to}" + } + }, + "streaks_reminder": { + "notification_title": "Flammen", + "notification_text": "Du wirst deine Flammen mit {friend} in {hoursLeft} Stunden verlieren" + }, + "content_type": { + "FAMILY_CENTER_INVITE": "Family Center einladen", + "STATUS_CONVERSATION_CAPTURE_RECORD": "Bildschirmaufnahme", + "STATUS_CALL_MISSED_VIDEO": "Verpasster Videoanruf", + "CREATIVE_TOOL_ITEM": "Kreativ-Werkzeug Element", + "STICKER": "Sticker", + "TINY_SNAP": "Winziger Snap", + "STATUS_SAVE_TO_CAMERA_ROLL": "In Camera Roll gespeichert", + "EXTERNAL_MEDIA": "Externe Medien", + "SNAP": "Snap", + "LOCATION": "Standort", + "CHAT": "Chat", + "STATUS_PLUS_GIFT": "Status Plus Geschenk", + "STATUS_COUNTDOWN": "Countdown", + "LIVE_LOCATION_SHARE": "Live-Standort teilen", + "STATUS": "Status", + "STATUS_CONVERSATION_CAPTURE_SCREENSHOT": "Screenshot", + "FAMILY_CENTER_ACCEPT": "Family Center akzeptieren", + "FAMILY_CENTER_LEAVE": "Family Center verlassen", + "STATUS_CALL_MISSED_AUDIO": "Verpasster Sprachanruf", + "NOTE": "Sprachnachricht", + "MAP_REACTION": "Kartenreaktion", + "SHARE": "Teilen" + }, + "better_notifications": { + "button": { + "download": "Herunterladen", + "reply": "Antwort", + "mark_as_read": "Als gelesen markieren" + } + }, + "half_swipe_notifier": { + "notification_content_group": "{friend} hat gerade halb in {group} für {duration} Sekunden geswiped", + "notification_channel_name": "Halb-Swipe", + "notification_content_dm": "{friend} hat gerade für {duration} Sekunden halb in deinen Chat geswiped" + }, + "friendship_link_type": { + "mutual": "Gegenseitig", + "deleted": "Gelöscht", + "following": "Folge Ich", + "incoming_follower": "Eingehender Follower", + "incoming": "Eingehend", + "blocked": "Blockiert", + "suggested": "Empfohlen", + "outgoing": "Ausgehend" + }, + "call_start_confirmation": { + "dialog_message": "Sind Sie sicher, dass Sie einen Anruf starten wollen?", + "dialog_title": "Anruf starten" + }, + "bulk_messaging_action": { + "choose_action_title": "Wähle eine Aktion", + "progress_status": "Verarbeite {index} von {total}", + "actions": { + "clear_conversations": "Lösche Konversationen", + "remove_friends": "Freunde entfernen" + }, + "selection_dialog_continue_button": "Weiter", + "confirmation_dialog": { + "message": "Das betrifft alle ausgewählten Freunde. Diese Aktion kann nicht rückgängig gemacht werden.", + "title": "Sind Sie sicher?" + } + }, + "media_download_source": { + "none": "Keine", + "profile_picture": "Profilbild", + "chat_media": "Chat-Medium", + "public_story": "Öffentliche Story", + "spotlight": "Spotlight", + "pending": "Ausstehend", + "story": "Story", + "story_logger": "Story Logger", + "merged": "Zusammengeführt", + "message_logger": "Nachrichtenprotokollierung", + "voice_call": "Sprachanruf" + }, + "material3_strings": { + "date_input_invalid_not_allowed": "Invalides Datum", + "date_range_picker_scroll_to_previous_month": "Vorheriger Monat", + "date_input_invalid_for_pattern": "Invalides Datum", + "date_picker_today_description": "Heute", + "date_picker_switch_to_calendar_mode": "Kalender", + "date_range_picker_start_headline": "Von", + "date_range_picker_end_headline": "Bis", + "date_range_picker_scroll_to_next_month": "Nächster Monat", + "date_input_invalid_year_range": "Invalides Jahr", + "date_range_input_invalid_range_input": "Ungültiger Zeitraum", + "date_picker_switch_to_input_mode": "Eingabe", + "date_range_picker_day_in_range": "Gewählt", + "date_range_picker_title": "Wähle einen Zeitraum" + }, + "actions": { + "clean_snapchat_cache": { + "name": "Leere den Snapchat Cache", + "description": "Leert den Snapchat Cache" + }, + "manage_friend_list": { + "name": "Freundesliste verwalten", + "description": "Im-/ex- portiere deine Freundesliste beim Backup" + }, + "export_chat_messages": { + "name": "Exportiere die Chatnachrichten", + "description": "Exportiert Chat-Nachrichten in eine JSON/HTML/TXT Datei" + }, + "export_memories": { + "name": "Exportiere die Memories", + "description": "Exportiere die Memories in eine ZIP-Datei" + }, + "bulk_messaging_action": { + "name": "Massen Nachrichten Aktion", + "description": "Führt Operationen wie das Löschen von Freunden oder amassenlöschung von Konversationen durch" + }, + "regen_mappings": { + "name": "Erneuere die Mappings", + "description": "Erneuere die Mappings manuell" + }, + "change_language": { + "name": "Sprache ändern", + "description": "Ändere die Sprache von SnapEnhance" + }, + "file_imports": { + "description": "Importieren von Dateien zur Verwendung in Snapchat", + "name": "Dateiimporte" + }, + "logger_history": { + "name": "Logger Verlauf", + "description": "Historie der protokollierten Nachrichten anzeigen" + }, + "friend_tracker": { + "name": "Freund Tracker", + "description": "Verfolge deine Freunde auf Snapchat" + }, + "security_features": { + "name": "Sicherheitsfunktionen", + "description": "Einstellungen für Sicherheitsfunktionen ändern" + }, + "theming": { + "name": "Personalisierung", + "description": "Passe das Erscheinungsbild von Snapchat an" + } + }, + "mark_as_seen": { + "already_unseen_toast": "Schon als ungelesen markiert!", + "no_unseen_snaps_toast": "Es wurden keine ungesehenen Snaps gefunden!", + "seen_toast": "Markiert als gesehen!", + "unseen_toast": "Markiert als ungesehen!", + "already_seen_toast": "Bereits markiert als gesehen!" + }, + "scopes": { + "friend": "Freund:in", + "group": "Gruppe" + }, + "end_to_end_encryption": { + "toolbox": { + "no_shared_key": "Sie haben noch kein gemeinsames Geheimnis mit diesem Freund. Klicken Sie unten, um ein neues zu erstellen.", + "shared_key_fingerprint": "Dein Fingerabdruck ist:\n\n{fingerprint}\n\nSieh nach, ob er mit dem Fingerabdruck deines Freundes übereinstimmt!", + "initiate_exchange_button": "Schlüsselaustausch einleiten" + }, + "confirmation_dialogs": { + "title": "Ende-zu-Ende Verschlüsselung", + "confirmation_1": "WARNUNG: Dadurch wird Ihr vorhandener Schlüssel überschrieben. Sie werden den Zugang zu allen verschlüsselten Nachrichten dieses Freundes verlieren. Sind Sie sicher, dass Sie fortfahren möchten?", + "confirmation_2": "Sind Sie WIRKLICH sicher, dass Sie weitermachen wollen? Dies ist Ihre letzte Chance, einen Rückzieher zu machen." + }, + "native_hooks_send_failure_toast": "Senden fehlgeschlagen! Bitte aktivieren Sie die nativen Hooks in den Einstellungen.", + "no_participants_to_encrypt_toast": "Sie haben in diesem Gespräch keine Freunde, mit denen Sie Nachrichten verschlüsseln können!", + "encryption_failed_toast": "Nachricht konnte nicht verschlüsselt werden! Prüfen Sie logcat für weitere Details.", + "accept_public_key_success_toast": "Öffentlicher Schlüssel erfolgreich akzeptiert!", + "accept_secret_key_success_toast": "Geschafft! Sie können nun verschlüsselte Nachrichten an diesen Freund senden und empfangen.", + "accept_secret_key_failure_toast": "Akzeptieren des geheimen Schlüssels fehlgeschlagen", + "outgoing_secret_message": "Schlüsselaustausch-Antwort", + "accept_secret_button": "Geheimnis akzeptieren", + "accept_public_key_button": "Öffentlichen Schlüssel akzeptieren", + "incoming_pk_message": "Sie haben gerade eine Anfrage für einen öffentlichen Schlüssel erhalten. Klicken Sie unten, um sie anzunehmen.", + "outgoing_pk_message": "Schlüsselaustausch-Anfrage", + "accept_public_key_failure_toast": "Akzeptieren des öffentlichen Schlüssels fehlgeschlagen", + "unencrypted_conversation_send_failure_toast": "Sie können keine verschlüsselten Inhalte zu verschlüsselten und unverschlüsselten Gesprächen gleichzeitig senden!", + "incoming_secret_message": "Ihr Freund hat gerade Ihren öffentlichen Schlüssel akzeptiert. Klicken Sie unten, um das Passwort zu akzeptieren." + }, + "biometric_auth": { + "unlock_button": "Entsperren", + "title": "Entsperre Snapchat", + "subtitle": "Bestätigen, dass du es bist, um Snapchat zu öffnen" + }, + "auto_open_snaps": { + "title": "Auto-Öffnen von Snaps", + "notification_content": "{count} Snaps geöffnet" + }, + "friend_mutation_observer": { + "birthday_removed": "{username} hat sein/ ihr Geburtsdatum ({birthday}) entfernt", + "birthday_changed": "{username} hat sein/ ihr Geburtsdatum von {oldBirthday} auf {newBirthday} geändert", + "bitmoji_background_changed": "{username} hat den Hintergrund seines/ ihres Bitmojis geändert", + "birthday_added": "{username} hat sein/ ihr Geburtsdatum ({birthday}) hinzugefügt", + "bitmoji_scene_changed": "{username} hat seine/ ihre Bitmoji-Szene geändert", + "bitmoji_selfie_changed": "{username} hat sein/ ihr Bitmoji-Selfie geändert", + "bitmoji_avatar_changed": "{username} hat sein/ ihr Bitmoji-Avatar geändert", + "notification_channel_name": "Freund:innen-Veränderungs-Stalker", + "friend_removed": "{username} hat dich als Freund:in entfernt" + } +} diff --git a/common/src/main/assets/lang/en_UK.json b/common/src/main/assets/lang/en_UK.json new file mode 100644 index 0000000000..16c2ef98b1 --- /dev/null +++ b/common/src/main/assets/lang/en_UK.json @@ -0,0 +1,1543 @@ +{ + "setup": { + "dialogs": { + "select_language": "Select Language", + "select_save_folder_button": "Select Folder", + "save_folder": "SnapEnhance requires Storage permissions to download and Save Media from Snapchat.\nPlease choose the location where media should be downloaded to." + }, + "mappings": { + "generate_failure_no_snapchat": "SnapEnhance was unable to detect Snapchat, please try reinstalling Snapchat.", + "generate_failure": "An error occurred while trying to generate mappings, please try again.", + "dialog": "Generating Mappings, this may take a while, please be patient..." + }, + "permissions": { + "notification_access": "Notification Access", + "request_button": "Request", + "dialog": "To continue you need to fit the following requirements:", + "battery_optimization": "Battery Optimization", + "display_over_other_apps": "Display Over Other Apps" + } + }, + "manager": { + "routes": { + "tasks": "Tasks", + "features": "Features", + "home_logs": "Logs", + "home": "Home", + "home_settings": "Settings", + "logged_stories": "Logged Stories", + "social": "Social", + "manage_scope": "Manage Scope", + "scripts": "Scripts", + "logger_history": "Logger History", + "messaging_preview": "Preview", + "friend_tracker": "Friend Tracker", + "edit_rule": "Edit Rule", + "better_location": "Better Location", + "file_imports": "File Imports" + }, + "sections": { + "features": { + "disabled": "Disabled", + "export_option": "Export", + "import_option": "Import", + "config_import_success_toast": "Config imported Successfully", + "config_import_failure_toast": "Failed to import Config {error}", + "reset_option": "Reset", + "config_export_success_toast": "Config exported Successfully", + "saved_config_snackbar": "Config saved", + "config_export_failure_toast": "Failed to export config {error}" + }, + "tasks": { + "no_tasks": "No Tasks", + "merge_files_toast": "Merging {count} Files", + "remove_selected_tasks_title": "Are you sure you want to remove selected tasks?", + "remove_all_tasks_title": "Are you sure you want to remove all tasks?", + "delete_files_option": "Also Delete Files", + "remove_selected_tasks_confirm": "Remove {count} Tasks?", + "remove_all_tasks_confirm": "Remove All Tasks?", + "failed_to_open_file": "Failed to open file" + }, + "social": { + "streaks_expiration_short": "{hours}h", + "friends_tab": "Friends", + "groups_tab": "Groups", + "empty_hint": "(blank)" + }, + "home": { + "update_title": "SnapEnhance Update", + "update_button": "Download", + "update_content": "Version {version} is Available!", + "debug_build_summary_title": "You are running a debug build of SnapEnhance", + "version_title": "v{versionName} · by rhunk", + "debug_build_summary_date": "Build date: {date} ({days} days ago)", + "debug_build_summary_content": "Version {versionName} ({versionCode})", + "quick_actions_title": "Quick Actions" + }, + "home_logs": { + "no_logs_hint": "No Logs Available", + "clear_logs_button": "Clear Logs", + "export_logs_button": "Export Logs", + "saving_logs_toast": "Saving logs, This may take a while ...", + "saved_logs_success_toast": "Logs saved successfully", + "saved_logs_failure_toast": "Failed to save logs" + }, + "home_settings": { + "debug_title": "Debug", + "success_toast": "Done!", + "message_logger_summary": "{messageCount} Messages\n{storyCount} Stories", + "export_button": "Export", + "clear_button": "Clear", + "view_logger_history_button": "View Logger History", + "actions_title": "Actions", + "message_logger_title": "Message Logger" + }, + "manage_scope": { + "logged_stories_button": "Show Logged Stories", + "e2ee_title": "End-to-End Encryption", + "participants_text": "{count} Participants", + "not_found": "Not Found", + "streaks_title": "Streaks", + "streaks_length_text": "Length: {length}", + "streaks_expiration_text": "Expires in {eta}", + "delete_scope_confirm_dialog_title": "Are you sure You want to Delete a {scope}?", + "rules_title": "Rules", + "streaks_expiration_text_expired": "Expired", + "reminder_button": "Set Reminder" + }, + "logged_stories": { + "story_failed_to_load": "Failed to load", + "save_from_cache_button": "Save From Cache", + "no_stories": "No stories found" + }, + "messaging_preview": { + "bridge_connection_failed": "Failed to connect to bridge. Make sure Snapchat is running in the background", + "message_fetch_failed": "Failed to fetch Messages", + "save_all_option": "Save All", + "unsave_all_option": "Unsave All", + "mark_selection_as_seen_option": "Mark Selected Snap As Seen", + "mark_all_as_seen_option": "Mark all Snaps As Seen", + "delete_selection_option": "Delete Selection", + "delete_all_option": "Delete All", + "bridge_init_failed": "Failed to initialize messaging bridge. Make sure Snapchat is running in the background", + "no_message_hint": "No Message", + "save_selection_option": "Save Selection", + "unsave_selection_option": "Unsave Selection" + }, + "logger_history": { + "list_friend_format": "Friend {name}", + "list_group_format": "Group {name}", + "no_more_messages": "No more Messages", + "reverse_order_checkbox": "Reverse Order", + "chat_attachment": "Attachment {index}", + "empty_message": "Empty Chat Message", + "message_parse_failed": "Failed to Parse Message", + "unknown_sender": "Unknown Sender", + "download_attachment_failed_toast": "Failed to Download Attachment" + }, + "file_imports": { + "file_import_failed": "Failed to import file: {error}", + "import_file_button": "Import File", + "file_not_found": "File not found", + "file_imported": "File imported successfully", + "file_delete_failed": "Failed to delete file", + "no_files_hint": "Here you can import files for use in Snapchat. Press the button below to import a file." + }, + "better_location": { + "spoofed_coordinates_title": "Lat {latitude}, Long {longitude}", + "spoof_location_toggle": "Spoof Location", + "no_saved_coordinates_hint": "No saved coordinates", + "teleport_to_friend_title": "Teleport to Friend", + "saved_name_dialog_hint": "Saved Name", + "save_dialog_button": "Save", + "saved_coordinates_title": "Saved Coordinates", + "delete_dialog_message": "Are you sure you want to delete this saved coordinate?", + "delete_dialog_title": "Delete Saved Coordinate", + "search_bar": "Search", + "no_friends_map": "No friends on the map", + "no_friends_found": "No friends found", + "save_coordinates_dialog_title": "Save Coordinates", + "choose_location_button": "Choose Location", + "teleport_to_friend_button": "Teleport to Friend", + "latitude_dialog_hint": "Latitude", + "longitude_dialog_hint": "Longitude", + "suspend_location_updates": "Suspend Location Updates" + } + }, + "dialogs": { + "add_friend": { + "title": "Add Friend or Group", + "search_hint": "Search", + "fetch_error": "Failed to fetch data", + "category_groups": "Groups", + "category_friends": "Friends" + }, + "scripting_warning": { + "title": "Warning", + "content": "SnapEnhance includes a scripting tool, allowing the execution of user-defined code on your device. Use extreme caution and only install modules from known, reliable sources. Unauthorised or unverified modules may pose security risks to your system." + }, + "reset_config": { + "title": "Reset config", + "content": "Are you sure you want to reset the config?", + "success_toast": "Config reset successfully" + }, + "messaging_action": { + "title": "Choose content types to process", + "select_all_button": "Select All" + }, + "export_config": { + "content": "Do you want to export the config with sensitive data? (Such as location coordinates, etc.)", + "title": "Export Sensitive Data?" + }, + "file_imports": { + "settings_select_file_hint": "Select an imported file", + "no_files_settings_hint": "No files found. Make sure you have imported the required files in the File Imports section" + } + } + }, + "scopes": { + "friend": "Friend", + "group": "Group" + }, + "rules": { + "toasts": { + "enabled": "{ruleName} Enabled", + "disabled": "{ruleName} Disabled" + }, + "modes": { + "blacklist": "Blacklist mode", + "whitelist": "Whitelist mode" + }, + "properties": { + "auto_download": { + "name": "Auto Download", + "description": "Automatically download Snaps when viewing them", + "options": { + "blacklist": "Exclude from Auto Download", + "whitelist": "Auto Download" + } + }, + "stealth": { + "description": "Prevents anyone from knowing you've opened their Snaps/Chats and conversations", + "options": { + "blacklist": "Exclude from Stealth Mode", + "whitelist": "Stealth Mode" + }, + "name": "Stealth Mode" + }, + "auto_save": { + "name": "Auto Save", + "description": "Saves Chat Messages when viewing them", + "options": { + "blacklist": "Exclude from Auto save", + "whitelist": "Auto Save" + } + }, + "unsaveable_messages": { + "name": "Unsaveable Messages", + "description": "Prevents messages from being saved in chat by other people", + "options": { + "blacklist": "Exclude from Unsaveable Messages", + "whitelist": "Unsaveable Messages" + } + }, + "hide_friend_feed": { + "name": "Hide from Friend Feed" + }, + "e2e_encryption": { + "name": "Use E2E Encryption" + }, + "pin_conversation": { + "name": "Pin Conversation" + }, + "auto_open_snaps": { + "name": "Automatically Opens Snaps", + "description": "Automatically opens Snaps upon receiving them", + "options": { + "blacklist": "Exclude from Automatic Snap Opening", + "whitelist": "Automatically Opens Snaps" + } + } + } + }, + "actions": { + "export_memories": { + "name": "Export Memories", + "description": "Exports Memories into a ZIP file" + }, + "clean_snapchat_cache": { + "name": "Clean Snapchat Cache", + "description": "Cleans the Snapchat Cache" + }, + "manage_friend_list": { + "name": "Manage Friend List", + "description": "Import/export your friends list when backing up" + }, + "export_chat_messages": { + "description": "Exports conversation messages into a JSON/HTML/TXT file", + "name": "Export Chat Messages" + }, + "bulk_messaging_action": { + "name": "Bulk Messaging Action", + "description": "Performs operations such as deleting friends or mass deletion of conversations" + }, + "regen_mappings": { + "name": "Regenerate Mappings", + "description": "Manually Regenerate Mappings" + }, + "change_language": { + "name": "Change Language", + "description": "Change the Language of SnapEnhance" + }, + "logger_history": { + "name": "Logger History", + "description": "View the history of logged messages" + }, + "file_imports": { + "name": "File Imports", + "description": "Import files for use in Snapchat" + }, + "friend_tracker": { + "name": "Friend Tracker", + "description": "Track your friends on Snapchat" + } + }, + "features": { + "notices": { + "ban_risk": "⚠ This feature may cause bans", + "unstable": "⚠ Unstable", + "internal_behavior": "⚠ This may break Snapchat's internal behavior" + }, + "properties": { + "downloader": { + "properties": { + "path_format": { + "name": "Path Format", + "description": "Specify the File Path Format" + }, + "merge_overlays": { + "name": "Merge Overlays", + "description": "Combines the Text and the media of a Snap into a single file" + }, + "download_profile_pictures": { + "name": "Download Profile Pictures", + "description": "Allows you to download Profile Pictures from the profile page" + }, + "download_context_menu": { + "description": "Allows you to download/preview messages from a conversation or a story using the context menu.\nLong press on buttons will force download", + "name": "Download Context Menu" + }, + "ffmpeg_options": { + "name": "FFmpeg Options", + "description": "Specify additional FFmpeg options", + "properties": { + "threads": { + "name": "Threads", + "description": "The amount of threads to use" + }, + "preset": { + "name": "Preset", + "description": "Set the speed of the conversion" + }, + "constant_rate_factor": { + "name": "Constant Rate Factor", + "description": "Set the constant rate factor for the video encoder\nFrom 0 to 51 for libx264" + }, + "video_bitrate": { + "name": "Video Bitrate", + "description": "Set the video bitrate (kbps)" + }, + "audio_bitrate": { + "description": "Set the audio bitrate (kbps)", + "name": "Audio Bitrate" + }, + "custom_video_codec": { + "name": "Custom Video Codec", + "description": "Set a custom Video Codec (e.g. libx264)" + }, + "custom_audio_codec": { + "name": "Custom Audio Codec", + "description": "Set a custom Audio Codec (e.g. AAC)" + } + } + }, + "save_folder": { + "name": "Save Folder", + "description": "Select the directory to which all media should be downloaded to" + }, + "auto_download_sources": { + "name": "Auto Download Sources", + "description": "Select the sources to automatically download from" + }, + "prevent_self_auto_download": { + "name": "Prevent Self Auto Download", + "description": "Prevents your own Snaps from being downloaded automatically" + }, + "allow_duplicate": { + "name": "Allow Duplicate", + "description": "Allows the same media to be downloaded multiple times" + }, + "force_voice_note_format": { + "name": "Force Voice Note Format", + "description": "Forces Voice Notes to be saved in a specified Format" + }, + "opera_download_button": { + "name": "Opera Download Button", + "description": "Adds a download button on the top right corner when viewing a Snap.\nLong press on buttons will force download" + }, + "force_image_format": { + "name": "Force Image Format", + "description": "Forces images to be saved in a specified Format" + }, + "logging": { + "name": "Logging", + "description": "Shows toasts when media is downloading" + }, + "custom_path_format": { + "name": "Custom Path Format", + "description": "Specify a custom path format for downloaded media\n\nAvailable variables:\n - %username%\n - %source%\n - %hash%\n - %date_time%" + } + }, + "name": "Downloader", + "description": "Download Snapchat Media" + }, + "user_interface": { + "properties": { + "friend_feed_message_preview": { + "properties": { + "amount": { + "description": "The amount of messages to get previewed", + "name": "Amount" + } + }, + "description": "Shows a preview of the last messages in the Friend Feed", + "name": "Friend Feed Message Preview" + }, + "snap_preview": { + "name": "Snap Preview", + "description": "Displays a small preview next to unseen Snaps in chat" + }, + "bootstrap_override": { + "description": "Overrides user interface bootstrap settings", + "properties": { + "app_appearance": { + "name": "App Appearance", + "description": "Sets a persistent App Appearance" + }, + "home_tab": { + "name": "Home Tab", + "description": "Overrides the startup tab when opening Snapchat" + } + }, + "name": "Bootstrap Override" + }, + "streak_expiration_info": { + "name": "Show Streak Expiration Info", + "description": "Shows a Streak Expiration timer next to the Streaks counter" + }, + "map_friend_nametags": { + "name": "Enhanced Friend Map Nametags", + "description": "Improves the Nametags of friends on the Snapmap" + }, + "prevent_message_list_auto_scroll": { + "name": "Prevent Message List Auto Scroll", + "description": "Prevents the message list from scrolling to the bottom when sending/receiving a message" + }, + "opera_media_quick_info": { + "name": "Opera Media Quick Info", + "description": "Shows useful information of media such as creation date in opera viewer context menu" + }, + "vertical_story_viewer": { + "description": "Enables the vertical story viewer for all stories", + "name": "Vertical Story Viewer" + }, + "friend_feed_menu_buttons": { + "name": "Friend Feed Menu Buttons", + "description": "Select which buttons to show in the Friend Feed Menu" + }, + "message_indicators": { + "name": "Message Indicators", + "description": "Adds specific indicators icons to messages\nNote: Indicators might not be 100% accurate" + }, + "stealth_mode_indicator": { + "name": "Stealth Mode Indicator", + "description": "Adds a 👻 emoji next to conversations in Stealth Mode" + }, + "enable_app_appearance": { + "name": "Enable App Appearance Settings", + "description": "Enables the hidden App Appearance Setting\nMay not be required on newer Snapchat versions" + }, + "hide_friend_feed_entry": { + "name": "Hide Friend Feed Entry", + "description": "Hides a specific friend from the Friend Feed\nUse the social tab to manage this feature" + }, + "hide_streak_restore": { + "description": "Hides the Restore button in the friend feed", + "name": "Hide Streak Restore" + }, + "hide_story_suggestions": { + "name": "Hide Story Suggestions", + "description": "Removes suggestions from the Stories page" + }, + "hide_ui_components": { + "description": "Select which UI components to hide", + "name": "Hide UI Components" + }, + "old_bitmoji_selfie": { + "name": "Old Bitmoji Selfie", + "description": "Brings back the Bitmoji selfies from older Snapchat versions" + }, + "disable_spotlight": { + "name": "Disable Spotlight", + "description": "Disables the Spotlight page" + }, + "enable_friend_feed_menu_bar": { + "name": "Friend Feed Menu Bar", + "description": "Enables the new Friend Feed Menu Bar" + }, + "edit_text_override": { + "name": "Edit Text Override", + "description": "Overrides text field behavior" + } + }, + "name": "User Interface", + "description": "Change the look and feel of Snapchat" + }, + "messaging": { + "name": "Messaging", + "properties": { + "bypass_screenshot_detection": { + "description": "Prevents Snapchat from detecting when you take a screenshot", + "name": "Bypass Screenshot Detection" + }, + "anonymous_story_viewing": { + "name": "Anonymous Story Viewing", + "description": "Prevents anyone from knowing you've seen their story" + }, + "hide_peek_a_peek": { + "description": "Prevents notification from being sent when you half swipe into a chat", + "name": "Hide Peek-a-Peek" + }, + "prevent_story_rewatch_indicator": { + "description": "Prevents anyone from knowing you've rewatched their story", + "name": "Prevent Story Rewatch Indicator" + }, + "hide_bitmoji_presence": { + "name": "Hide Bitmoji Presence", + "description": "Prevents your Bitmoji from popping up while in Chat" + }, + "hide_typing_notifications": { + "name": "Hide Typing Notifications", + "description": "Prevents anyone from knowing you're typing a message" + }, + "half_swipe_notifier": { + "name": "Half Swipe Notifier", + "description": "Notifies you when someone half swipes into a conversation", + "properties": { + "min_duration": { + "name": "Minimum Duration", + "description": "The minimum duration of the half swipe (in seconds)" + }, + "max_duration": { + "name": "Maximum Duration", + "description": "The maximum duration of the half swipe (in seconds)" + } + } + }, + "disable_replay_in_ff": { + "name": "Disable Replay in FF", + "description": "Disables the ability to replay with a long press from the Friend Feed" + }, + "call_start_confirmation": { + "name": "Call Start Confirmation", + "description": "Shows a confirmation dialog when starting a call" + }, + "auto_save_messages_in_conversations": { + "name": "Auto Save Messages", + "description": "Automatically saves every message in conversations" + }, + "remove_groups_locked_status": { + "name": "Remove Groups Locked Status", + "description": "Allows you to view group information after being kicked" + }, + "unlimited_snap_view_time": { + "name": "Unlimited Snap View Time", + "description": "Removes the Time Limit for viewing Snaps" + }, + "loop_media_playback": { + "name": "Loop Media Playback", + "description": "Loops media playback when viewing Snaps / Stories" + }, + "prevent_message_sending": { + "name": "Prevent Message Sending", + "description": "Prevents sending certain types of messages" + }, + "better_notifications": { + "name": "Better Notifications", + "description": "Adds more information in received notifications", + "properties": { + "media_preview": { + "name": "Media Preview", + "description": "Shows a preview of the selected media types in the notification" + }, + "media_caption": { + "name": "Media Caption", + "description": "Shows the attached caption of media in the notification" + }, + "stacked_media_messages": { + "name": "Stacked Media Messages", + "description": "Combines multiple media messages into one text notification when they cannot be previewed. Use in combination with Chat Preview" + }, + "friend_add_source": { + "name": "Friend Add Source", + "description": "Shows the source of a friend request in the notification" + }, + "reply_button": { + "name": "Reply Button", + "description": "Adds a reply button to the notification" + }, + "download_button": { + "name": "Download Button", + "description": "Allows you to download media from the notification" + }, + "mark_as_read_button": { + "name": "Mark as Read Button", + "description": "Allows you to mark a message as read from the notification" + }, + "group_notifications": { + "name": "Group Notifications", + "description": "Group notifications into a single one" + }, + "chat_preview": { + "name": "Chat Preview", + "description": "Shows a preview of received messages in the notification" + }, + "mark_as_read_and_save_in_chat": { + "name": "Mark as Read and Save in Chat", + "description": "Adds a mark as read and save in chat button to the notification" + }, + "smart_replies": { + "name": "Smart Replies", + "description": "Adds suggested replies to notifications (Android 10+). Use in combination with Reply Button" + } + } + }, + "notification_blacklist": { + "name": "Notification Blacklist", + "description": "Select notifications which should get blocked" + }, + "message_logger": { + "name": "Message Logger", + "description": "Prevents messages from being deleted", + "properties": { + "auto_purge": { + "name": "Auto Purge", + "description": "Automatically deletes cached messages that are older than the specified amount of time" + }, + "message_filter": { + "name": "Message Filter", + "description": "Select which messages should get logged (empty for all messages)" + }, + "keep_my_own_messages": { + "name": "Keep My Own Messages", + "description": "Prevents your own messages from being deleted" + } + } + }, + "gallery_media_send_override": { + "name": "Gallery Media Send Override", + "description": "Spoofs the media source when sending from the Gallery" + }, + "strip_media_metadata": { + "name": "Strip Media Metadata", + "description": "Removes metadata of media before sending as a message" + }, + "bypass_message_retention_policy": { + "name": "Bypass Message Retention Policy", + "description": "Prevents messages from being deleted after viewing them" + }, + "bypass_message_action_restrictions": { + "name": "Bypass Message Action Restrictions", + "description": "Allows you to react to a snap without having opened it or to save an unsaveable message" + }, + "auto_mark_as_read": { + "name": "Automatically Marks as Read", + "description": "Automatically marks messages/snaps as read even when Stealth Mode is enabled" + }, + "friend_mutation_notifier": { + "description": "Notifies you when something changes on a friend's profile", + "name": "Friend Mutation Notifier" + }, + "unlimited_conversation_pinning": { + "description": "Allows you to pin an unlimited amount of conversations locally", + "name": "Unlimited Conversation Pinning" + }, + "mark_snap_as_seen_button": { + "name": "Mark Snap as Seen Button", + "description": "Adds a button to mark a Snap as seen when viewing it.\nThis will work even when Stealth Mode is enabled" + }, + "skip_when_marking_as_seen": { + "name": "Skip When Marking as Seen", + "description": "Automatically skips to the next Snap when marking a Snap as seen.\nUse in combination with Mark Snap as Seen Button" + } + }, + "description": "Change how you interact with friends" + }, + "global": { + "name": "Global", + "description": "Tweak Global Snapchat Settings", + "properties": { + "better_location": { + "name": "Better Location", + "description": "Enhances the Snapchat Location", + "properties": { + "spoof_location": { + "name": "Spoof Location", + "description": "Spoofs your location to a specified one" + }, + "coordinates": { + "name": "Coordinates", + "description": "Set the coordinates of the spoofed location" + }, + "always_update_location": { + "name": "Always Update Location", + "description": "Force Snapchat to update location even if no GPS data is received" + }, + "suspend_location_updates": { + "description": "Prevents your location from being updated", + "name": "Suspend Location Updates" + }, + "spoof_battery_level": { + "description": "Spoofs the battery level of your device on map\nValue must be between 0 and 100", + "name": "Spoof Battery Level" + }, + "spoof_headphones": { + "name": "Spoof Headphones", + "description": "Spoofs the status of listening to music on map" + }, + "walk_radius": { + "name": "Walk Radius", + "description": "Randomly walk around within this radius (ft)" + } + } + }, + "disable_confirmation_dialogs": { + "name": "Disable Confirmation Dialogs", + "description": "Automatically confirms selected actions" + }, + "disable_metrics": { + "name": "Disable Metrics", + "description": "Blocks sending specific analytic data to Snapchat" + }, + "block_ads": { + "name": "Block Ads", + "description": "Prevents Advertisements from being displayed" + }, + "spotlight_comments_username": { + "description": "Shows author username in Spotlight comments", + "name": "Spotlight Comments Username" + }, + "bypass_video_length_restriction": { + "name": "Bypass Video Length Restrictions", + "description": "Single: sends a single video\nSplit: split videos after editing" + }, + "default_volume_controls": { + "name": "Default Volume Controls", + "description": "Forces Snapchat to use system volume controls" + }, + "snapchat_plus": { + "description": "Enables Snapchat Plus features\nSome Server-sided features may not work", + "name": "Snapchat Plus" + }, + "auto_updater": { + "name": "Auto Updater", + "description": "Automatically checks for new updates" + }, + "disable_story_sections": { + "name": "Disable Story Sections", + "description": "Removes sections from the Stories page\nMay require a refresh to work properly" + }, + "disable_permission_requests": { + "name": "Disable Permission Requests", + "description": "Prevents Snapchat from asking for specific permissions" + }, + "disable_memories_snap_feed": { + "name": "Disable Memories Snap Feed", + "description": "Prevents Snapchat from showing recent memories when you swipe up in camera" + }, + "default_video_playback_rate": { + "name": "Default Video Playback Rate", + "description": "Sets the default speed for the playback of videos\nValue must be between 0.1 and 4.0" + }, + "video_playback_rate_slider": { + "name": "Video Playback Rate Slider", + "description": "Adds a slider in opera context menu to change the video playback rate\nNote: Changes only apply to subsequent videos" + }, + "disable_snap_splitting": { + "name": "Disable Snap Splitting", + "description": "Prevents Snaps from being split into multiple parts\nPictures you send will turn into videos" + }, + "disable_google_play_dialogs": { + "name": "Disable Google Play Services Dialogs", + "description": "Prevent Google Play Services availability dialogs from being shown" + }, + "hide_active_music": { + "name": "Hide Active Music", + "description": "Prevents Snapchat from knowing you're listening to music\nThis will allow you to take snaps using control volume buttons while listening to music" + }, + "disable_custom_tabs": { + "name": "Disable Custom Tabs", + "description": "Opens links in supported applications rather than in the Web Browser" + }, + "media_upload_quality": { + "name": "Media Upload Quality", + "description": "Overrides the media upload quality", + "properties": { + "force_video_upload_source_quality": { + "name": "Force Video Upload Source Quality", + "description": "Forces Snapchat to use the source quality when uploading videos\nPlease note that this may not remove metadata from media" + }, + "disable_image_compression": { + "name": "Disable Image Compression", + "description": "Disables image compression when uploading media" + }, + "custom_image_upload_format": { + "name": "Custom Image Upload Format", + "description": "Sets a custom image upload format\nSelect a lossless format (like PNG) for the best quality" + } + } + }, + "disable_telecom_framework": { + "name": "Disable Telecom Framework", + "description": "Prevents Snapchat from using the Android Telecom framework\nThis allows you to listen to music while on a call" + } + } + }, + "camera": { + "name": "Camera", + "description": "Adjust the right settings for the perfect snap", + "properties": { + "immersive_camera_preview": { + "description": "Prevents Snapchat from Cropping the Camera preview\nThis might cause the camera to flicker on some devices", + "name": "Immersive Preview" + }, + "override_front_resolution": { + "name": "Override Front Resolution", + "description": "Overrides the camera resolution for the front camera" + }, + "disable_cameras": { + "name": "Disable Cameras", + "description": "Prevents Snapchat from using the selected cameras" + }, + "black_photos": { + "name": "Black Photos", + "description": "Replaces captured photos with a black background\nVideos are not affected" + }, + "override_back_resolution": { + "description": "Overrides the camera resolution for the back camera", + "name": "Override Back Resolution" + }, + "custom_resolution": { + "description": "Sets a custom camera resolution, width x height (e.g. 1920x1080).\nThe custom resolution must be supported by your device", + "name": "Custom Resolution" + }, + "front_custom_frame_rate": { + "name": "Front Custom Frame Rate", + "description": "Overrides the front camera frame rate" + }, + "back_custom_frame_rate": { + "name": "Back Custom Frame Rate", + "description": "Overrides the back camera frame rate" + }, + "force_camera_source_encoding": { + "name": "Force Camera Source Encoding", + "description": "Forces the camera source encoding" + }, + "hevc_recording": { + "name": "HEVC Recording", + "description": "Uses HEVC (H.265) codec for video recording" + } + } + }, + "streaks_reminder": { + "properties": { + "group_notifications": { + "description": "Group notifications into a single one", + "name": "Group Notifications" + }, + "interval": { + "name": "Interval", + "description": "The interval between each reminder (hours)" + }, + "remaining_hours": { + "name": "Remaining Time", + "description": "The remaining amount of time before the notification is shown (hours)" + } + }, + "name": "Streaks Reminder", + "description": "Periodically notifies you about your Streaks" + }, + "experimental": { + "description": "Experimental features", + "name": "Experimental", + "properties": { + "native_hooks": { + "name": "Native Hooks", + "description": "Unsafe Features that hook into Snapchat's native code", + "properties": { + "disable_bitmoji": { + "name": "Disable Bitmoji", + "description": "Disables Friends Profile Bitmoji" + }, + "composer_hooks": { + "properties": { + "composer_console": { + "name": "Composer Console", + "description": "Allows you to execute JavaScript code in Composer (arm64 only)" + }, + "bypass_camera_roll_limit": { + "name": "Bypass Camera Roll Limit", + "description": "Increases the maximum amount of media you can send from the camera roll" + }, + "composer_logs": { + "name": "Composer Logs", + "description": "Redirects console logs of Composer to SnapEnhance" + }, + "show_first_created_username": { + "description": "Shows the first created username next to the current username in the profile page", + "name": "Show First Created Username" + } + }, + "name": "Composer Hooks", + "description": "Injects code into the Composer cross-platform UI framework" + }, + "custom_emoji_font": { + "description": "Allows you to use a custom emoji font. Only works with .ttf fonts", + "name": "Custom Emoji Font" + } + } + }, + "spoof": { + "properties": { + "remove_mock_location_flag": { + "name": "Remove Mock Location Flag", + "description": "Prevents Snapchat from detecting Mock location" + }, + "play_store_installer_package_name": { + "name": "Play Store Installer Package Name", + "description": "Overrides the installer package name to com.android.vending" + }, + "remove_vpn_transport_flag": { + "name": "Remove VPN Transport Flag", + "description": "Prevents Snapchat from detecting VPNs" + } + }, + "description": "Spoof various information about you", + "name": "Spoof" + }, + "media_file_picker": { + "name": "Media File Picker", + "description": "Allows you to pick any video/audio file from the gallery" + }, + "story_logger": { + "name": "Story Logger", + "description": "Provides a history of friends stories" + }, + "account_switcher": { + "name": "Account Switcher", + "description": "Allows you to switch between accounts without logging out\nLong press on the search icon next to your Bitmoji profile to open the menu\nNote: this feature is experimental and will likely change in the future", + "properties": { + "auto_backup_current_account": { + "description": "Automatically backs up the current account when logging out or switching accounts", + "name": "Auto Backup Current Account" + } + } + }, + "call_recorder": { + "name": "Call Recorder", + "description": "Automatically records audio calls" + }, + "convert_message_locally": { + "name": "Convert Message Locally", + "description": "Converts snaps to chat external media locally. This appears in chat download context menu" + }, + "edit_message": { + "name": "Edit Messages", + "description": "Allows you to edit messages in conversations" + }, + "infinite_story_boost": { + "description": "Bypass the Story Boost Limit delay", + "name": "Infinite Story Boost" + }, + "meo_passcode_bypass": { + "description": "Bypass the My Eyes Only passcode\nThis will only work if the passcode has been entered correctly before", + "name": "My Eyes Only Passcode Bypass" + }, + "no_friend_score_delay": { + "name": "No Friend Score Delay", + "description": "Removes the delay when viewing a Friends Score" + }, + "e2ee": { + "name": "End-To-End Encryption", + "description": "Encrypts your messages with AES using a shared secret key\nMake sure to save your key somewhere safe!", + "properties": { + "encrypted_message_indicator": { + "name": "Encrypted Message Indicator", + "description": "Adds a 🔒 emoji next to encrypted messages" + }, + "force_message_encryption": { + "name": "Force Message Encryption", + "description": "Prevents sending encrypted messages to people who don't have E2E Encryption enabled only when multiple conversations are selected" + } + } + }, + "add_friend_source_spoof": { + "description": "Spoofs the source of a Friend Request", + "name": "Add Friend Source Spoof" + }, + "hidden_snapchat_plus_features": { + "description": "Enables unreleased/beta Snapchat Plus features\nMight not work on older Snapchat versions", + "name": "Hidden Snapchat Plus Features" + }, + "prevent_forced_logout": { + "name": "Prevent Forced Logout", + "description": "Prevents Snapchat from logging you out when you login on another device" + }, + "app_lock": { + "name": "App Lock", + "description": "Prevents access to Snapchat without a passcode", + "properties": { + "lock_on_resume": { + "name": "Lock On Resume", + "description": "Locks the app when it's reopened" + } + } + }, + "custom_streaks_expiration_format": { + "name": "Bespoke Streak Expiry Format", + "description": "Customises the Streaks Expiry format\n\nAvailable variables:\n- %c: Streaks Count\n- %e: Hourglass Emoji\n- %d: Days\n- %h: Hours\n- %m: Minutes\n- %s: Seconds\n- %w: Remaining Time" + }, + "best_friend_pinning": { + "name": "Best Friend Pinning", + "description": "Allows you to pin a friend as you're number one best friend. Note: Only you can see you're pinned best friend" + }, + "cof_experiments": { + "description": "Enables unreleased/beta Snapchat features", + "name": "COF Experiments" + }, + "context_menu_fix": { + "name": "Context Menu Fix", + "description": "Attempts to repair the Friend Feed Menu when the device is offline, it cannot be displayed correctly" + }, + "better_transcript": { + "name": "Better Transcript", + "properties": { + "preferred_transcription_lang": { + "name": "Preferred Transcription Language", + "description": "The preferred language for the voice note transcript (e.g. EN, ES, FR)" + }, + "enhanced_transcript": { + "name": "Enhanced Transcript", + "description": "Improves the voice note transcript using DeepL.\nBefore using this feature, please ensure that you have read their privacy policy." + }, + "force_transcription": { + "description": "Allows all voice notes to be transcribed", + "name": "Force Voice Note Transcription" + } + }, + "description": "Improves the voice note transcript" + } + } + }, + "rules": { + "name": "Rules", + "description": "Manage Automatic Features for individual people" + }, + "scripting": { + "name": "Scripting", + "description": "Run custom scripts to extend SnapEnhance", + "properties": { + "developer_mode": { + "name": "Developer Mode", + "description": "Shows debug info on Snapchat's UI" + }, + "module_folder": { + "name": "Module Folder", + "description": "The folder where the scripts are located" + }, + "integrated_ui": { + "name": "Integrated UI", + "description": "Allows scripts to add custom UI components to Snapchat" + }, + "disable_log_anonymization": { + "name": "Disable Log Anonymization", + "description": "Disables the anonymization of logs" + }, + "auto_reload": { + "name": "Auto Reload", + "description": "Automatically reloads scripts when they change" + } + } + }, + "friend_tracker": { + "description": "Records friend's activity on Snapchat", + "properties": { + "allow_running_in_background": { + "name": "Allow Running in Background", + "description": "Allows the tracker to run in the background. Note: This will significantly drain your battery" + }, + "record_messaging_events": { + "description": "Records messaging events such as opening a snap, reading a message, etc.", + "name": "Record Messaging Events" + }, + "auto_purge": { + "name": "Auto Purge", + "description": "Automatically deletes cached events that are older than the specified amount of time" + } + }, + "name": "Friend Tracker" + } + }, + "options": { + "add_friend_source_spoof": { + "added_by_group_chat": "By Group Chat", + "added_by_qr_code": "By QR Code", + "added_by_community": "By Community", + "added_by_username": "By Username", + "added_by_mention": "By Mention", + "added_by_quick_add": "By Quick Add (high risk of being banned)" + }, + "bypass_video_length_restriction": { + "single": "Single media", + "split": "Split media" + }, + "old_bitmoji_selfie": { + "2d": "2D Bitmoji", + "3d": "3D Bitmoji" + }, + "disable_permission_requests": { + "read_media_images": "Read Media Images", + "read_media_video": "Read Media Video", + "camera": "Camera", + "microphone": "Microphone", + "location": "Location", + "read_contacts": "Read Contacts", + "notifications": "Notifications", + "nearby_devices": "Nearby Devices", + "phone_calls": "Phone Calls" + }, + "app_appearance": { + "always_light": "Always Light", + "always_dark": "Always Dark" + }, + "friend_feed_menu_buttons": { + "auto_download": "⬇️ Auto Download", + "auto_save": "💬 Auto Save Messages", + "unsaveable_messages": "⬇️ Unsaveable Messages", + "e2e_encryption": "🔒 Use E2E Encryption", + "conversation_info": "👤 Conversation Info", + "stealth": "👻 Stealth Mode", + "mark_snaps_as_seen": "👀 Mark Snaps as seen", + "mark_stories_as_seen_locally": "👀 Mark Stories as seen locally", + "auto_open_snaps": "📷 Auto Open Snaps" + }, + "path_format": { + "create_author_folder": "Create folder for each author", + "append_username": "Add the username to the file name", + "append_date_time": "Add the date and time to the file name", + "create_source_folder": "Create folder for each media source type", + "append_source": "Add the media source to the file name", + "append_hash": "Add a unique hash to the file name" + }, + "auto_download_sources": { + "friend_stories": "Friend Stories", + "public_stories": "Public Stories", + "spotlight": "Spotlight", + "friend_snaps": "Friend Snaps" + }, + "logging": { + "started": "Started", + "success": "Success", + "progress": "Progress", + "failure": "Failure" + }, + "notifications": { + "chat_screenshot": "Screenshot", + "chat_screen_record": "Screen Record", + "snap_replay": "Snap Replay", + "camera_roll_save": "Camera Roll Save", + "chat": "Chat", + "chat_reply": "Chat Reply", + "snap": "Snap", + "typing": "Typing", + "stories": "Stories", + "chat_reaction": "DM Reaction", + "group_chat_reaction": "Group Reaction", + "initiate_audio": "Incoming Audio Call", + "abandon_audio": "Missed Audio Call", + "initiate_video": "Incoming Video Call", + "abandon_video": "Missed Video Call", + "speaking": "Talking" + }, + "gallery_media_send_override": { + "ORIGINAL": "Original", + "NOTE": "Audio Note", + "SNAP": "Snap", + "always_ask": "Always Ask" + }, + "strip_media_metadata": { + "hide_caption_text": "Hide Caption Text", + "hide_snap_filters": "Hide Snap Filters", + "hide_extras": "Hide Extras (e.g. mentions)", + "remove_audio_note_duration": "Remove Audio Note Duration", + "remove_audio_note_transcript_capability": "Remove Audio Note Transcript Capability" + }, + "hide_ui_components": { + "hide_profile_call_buttons": "Remove Profile Call Buttons", + "hide_chat_call_buttons": "Remove Chat Call Buttons", + "hide_live_location_share_button": "Remove Live Location Share Button", + "hide_stickers_button": "Remove Stickers Button", + "hide_voice_record_button": "Remove Voice Record Button", + "hide_unread_chat_hint": "Remove Unread Chat Hint", + "hide_post_to_story_buttons": "Remove Post to Story buttons before sending a Snap" + }, + "hide_story_suggestions": { + "hide_suggested_friend_stories": "Hide suggested friend stories", + "hide_my_stories": "Hide My Stories" + }, + "home_tab": { + "map": "Map", + "chat": "Chat", + "camera": "Camera", + "discover": "Discover", + "spotlight": "Spotlight" + }, + "disable_confirmation_dialogs": { + "remove_friend": "Remove Friend", + "block_friend": "Block Friend", + "ignore_friend": "Ignore Friend", + "hide_friend": "Hide Friend", + "hide_conversation": "Hide Conversation", + "clear_conversation": "Clear Conversation from Friend Feed", + "erase_message": "Erase Message" + }, + "auto_reload": { + "snapchat_only": "Snapchat Only", + "all": "All (Snapchat + SnapEnhance)" + }, + "edit_text_override": { + "multi_line_chat_input": "Multi Line Chat Input", + "bypass_text_input_limit": "Bypass Text Input Limit" + }, + "auto_purge": { + "never": "Never", + "1_hour": "1 Hour", + "3_hours": "3 Hours", + "6_hours": "6 Hours", + "12_hours": "12 Hours", + "1_day": "1 Day", + "3_days": "3 Days", + "1_week": "1 Week", + "2_weeks": "2 Weeks", + "1_month": "1 Month", + "3_months": "3 Months", + "6_months": "6 Months" + }, + "disable_story_sections": { + "friends": "Friends", + "discover": "Discover", + "following": "Following" + }, + "disable_cameras": { + "back": "Back Camera", + "front": "Front Camera" + }, + "message_indicators": { + "encryption_indicator": "Adds a 🔒 icon next to messages that have been sent only to you", + "location_indicator": "Adds a 📍 icon to snaps when they have been sent with location enabled", + "platform_indicator": "Adds the platform icon from which a media was sent (e.g. Android, iOS, Web)", + "ovf_editor_indicator": "Indicates if a snap has been sent using OVF Editor", + "director_mode_indicator": "Adds a ✏️ icon to snaps when they have been sent using Director Mode, which can be used to send gallery images as snaps" + }, + "auto_mark_as_read": { + "conversation_read": "Mark conversation as read upon sending a message", + "snap_reply": "Mark snaps as read when replying to them" + }, + "friend_mutation_notifier": { + "remove_friend": "Notify when someone removes you as a friend", + "birthday_changes": "Notify when someone changes their birthday", + "bitmoji_selfie_changes": "Notify when someone changes their Bitmoji selfie", + "bitmoji_scene_changes": "Notify when someone changes their Bitmoji scene", + "bitmoji_avatar_changes": "Notify when someone changes their Bitmoji avatar", + "bitmoji_background_changes": "Notify when someone changes their Bitmoji background" + } + } + }, + "friend_menu_option": { + "preview": "Preview", + "mark_snaps_as_seen": "Mark Snaps as seen", + "mark_stories_as_seen_locally": "Mark Stories as seen locally", + "stealth_mode": "Stealth Mode", + "auto_download_blacklist": "Auto Download Blacklist", + "anti_auto_save": "Anti Auto Save" + }, + "content_type": { + "FAMILY_CENTER_INVITE": "Family Center Invite", + "CREATIVE_TOOL_ITEM": "Creative Tool Item", + "FAMILY_CENTER_ACCEPT": "Family Center Accept", + "FAMILY_CENTER_LEAVE": "Family Center Leave", + "STATUS_PLUS_GIFT": "Status Plus Gift", + "MAP_REACTION": "Map Reaction", + "TINY_SNAP": "­Tiny Snap", + "STATUS_COUNTDOWN": "Countdown", + "LOCATION": "Location", + "STATUS_SAVE_TO_CAMERA_ROLL": "Saved to Camera Roll", + "STATUS_CONVERSATION_CAPTURE_RECORD": "Screen Record", + "STATUS_CALL_MISSED_VIDEO": "Missed Video Call", + "STATUS_CONVERSATION_CAPTURE_SCREENSHOT": "Screenshot", + "STATUS_CALL_MISSED_AUDIO": "Missed Audio Call", + "LIVE_LOCATION_SHARE": "Live Location Share", + "CHAT": "Chat", + "SNAP": "Snap", + "EXTERNAL_MEDIA": "External Media", + "NOTE": "Audio Note", + "STICKER": "Sticker", + "STATUS": "Status", + "SHARE": "Share" + }, + "better_notifications": { + "button": { + "reply": "Reply", + "download": "Download", + "mark_as_read": "Mark as Read" + } + }, + "profile_picture_downloader": { + "button": "Download Profile Picture", + "title": "Profile Picture Downloader", + "avatar_option": "Avatar", + "background_option": "Background" + }, + "call_start_confirmation": { + "dialog_title": "Start Call", + "dialog_message": "Are you sure you want to start a call?" + }, + "download_processor": { + "attachment_type": { + "snap": "Snap", + "sticker": "Sticker", + "external_media": "External Media", + "note": "Note", + "original_story": "Original Story", + "gif": "GIF" + }, + "processing_toast": "Processing {path}...", + "failed_generic_toast": "Failed to download", + "failed_to_create_preview_toast": "Failed to create preview", + "failed_processing_toast": "Failed processing {error}", + "failed_gallery_toast": "Failed saving to gallery {error}", + "select_attachments_title": "Select attachments", + "download_started_toast": "Download started", + "unsupported_content_type_toast": "Unsupported content type!", + "failed_no_longer_available_toast": "Media no longer available", + "no_attachments_toast": "No attachments found!", + "already_queued_toast": "Media already in queue!", + "already_downloaded_toast": "Media already downloaded!", + "dash_no_chapter": "No chapter found", + "dash_dialog": { + "title": "Download dash media", + "segment_text": "Segment {from} - {to}", + "download_all": "Download All" + }, + "download_toast": "Downloading {path}..." + }, + "streaks_reminder": { + "notification_title": "Streaks", + "notification_text": "You will lose your Streak with {friend} in {hoursLeft} hours" + }, + "material3_strings": { + "date_range_picker_scroll_to_next_month": "Next Month", + "date_picker_today_description": "Today", + "date_range_picker_day_in_range": "Selected", + "date_input_invalid_for_pattern": "Invalid Date", + "date_input_invalid_year_range": "Invalid Year", + "date_input_invalid_not_allowed": "Invalid Date", + "date_range_input_invalid_range_input": "Invalid Date Range", + "date_range_picker_scroll_to_previous_month": "Previous month", + "date_range_picker_start_headline": "From", + "date_range_picker_end_headline": "To", + "date_picker_switch_to_input_mode": "Input", + "date_range_picker_title": "Select date range", + "date_picker_switch_to_calendar_mode": "Calendar" + }, + "media_download_source": { + "none": "None", + "pending": "Pending", + "chat_media": "Chat Media", + "story": "Story", + "public_story": "Public Story", + "spotlight": "Spotlight", + "profile_picture": "Profile Picture", + "story_logger": "Story Logger", + "message_logger": "Message Logger", + "merged": "Merged", + "voice_call": "Voice Call" + }, + "chat_action_menu": { + "preview_button": "Preview", + "download_button": "Download", + "delete_logged_message_button": "Delete Logged Message", + "convert_message": "Convert Message", + "edit_message": "Edit Message", + "show_chat_edit_history": "Show Chat Edit History" + }, + "opera_context_menu": { + "download": "Download Media", + "show_debug_info": "Show Debug Info", + "sent_at": "Sent at {date}", + "expires_at": "Expires at {date}", + "created_at": "Created at {date}", + "media_size": "Media size: {size}", + "media_duration": "Media duration: {duration} ms" + }, + "modal_option": { + "profile_info": "Profile Info", + "close": "Close" + }, + "gallery_media_send_override": { + "multiple_media_toast": "You can only send one media at a time" + }, + "mark_as_seen": { + "no_unseen_snaps_toast": "No unseen Snaps found!", + "seen_toast": "Marked as seen!", + "unseen_toast": "Marked as unseen!", + "already_seen_toast": "Already marked as seen!", + "already_unseen_toast": "Already marked as unseen!" + }, + "conversation_preview": { + "streak_expiration": "expires in {day} days {hour} hours {minute} minutes", + "total_messages": "Total sent/received messages: {count}", + "title": "Preview", + "unknown_user": "Unknown User", + "no_messages": "No messages found!" + }, + "profile_info": { + "title": "Profile Info", + "first_created_username": "First Created Username", + "mutable_username": "Mutable Username", + "display_name": "Display Name", + "added_date": "Added Date", + "birthday": "Birthday : {month} {day}", + "hidden_birthday": "Birthday : Hidden", + "friendship": "Friendship", + "add_source": "Add Source", + "snapchat_plus_state": { + "subscribed": "Subscribed", + "not_subscribed": "Not Subscribed" + }, + "snapchat_plus": "Snapchat Plus" + }, + "friendship_link_type": { + "mutual": "Mutual", + "outgoing": "Outgoing", + "blocked": "Blocked", + "deleted": "Deleted", + "following": "Following", + "suggested": "Suggested", + "incoming": "Incoming", + "incoming_follower": "Incoming Follower" + }, + "bulk_messaging_action": { + "choose_action_title": "Choose an action", + "progress_status": "Processing {index} of {total}", + "selection_dialog_continue_button": "Continue", + "confirmation_dialog": { + "title": "Are you sure?", + "message": "This will affect all selected friends. This action cannot be undone." + }, + "actions": { + "remove_friends": "Remove Friends", + "clear_conversations": "Clear Conversations" + } + }, + "chat_export": { + "exporter_dialog": { + "select_conversations_title": "Select Conversations", + "text_field_selection": "{amount} Selected", + "text_field_selection_all": "All", + "export_file_format_title": "Export File Format", + "message_type_filter_title": "Filter Messages By Type", + "amount_of_messages_title": "Amount of Messages (leave it blank for all)", + "download_medias_title": "Download Media's" + }, + "dialog_negative_button": "Cancel", + "dialog_positive_button": "Export", + "exported_to": "Exported to {path}", + "exporting_chats": "Exporting Chats...", + "processing_chats": "Processing {amount} conversations...", + "export_fail": "Failed To Export Conversation {conversation}", + "writing_output": "Writing Output...", + "finished": "Done! You Can Now Close This Dialog.", + "no_messages_found": "No Messages Found!", + "exporting_message": "Exporting {conversation}..." + }, + "button": { + "ok": "OK", + "positive": "Yes", + "negative": "No", + "cancel": "Cancel", + "open": "Open", + "download": "Download" + }, + "half_swipe_notifier": { + "notification_channel_name": "Half Swipe", + "notification_content_dm": "{friend} Just Half-Swiped Into Your Chat For {duration} Seconds", + "notification_content_group": "{friend} Just Half-Swiped Into {group} For {duration} Seconds" + }, + "end_to_end_encryption": { + "confirmation_dialogs": { + "title": "End-to-end encryption", + "confirmation_1": "WARNING: This will overwrite your existing key. You will loose access to all encrypted messages from this friend. Are you sure you want to continue?", + "confirmation_2": "Are you REALLY sure you want to continue? This is your last chance to back out." + }, + "accept_secret_key_failure_toast": "Failed to accept secret key", + "outgoing_secret_message": "Key exchange response", + "toolbox": { + "no_shared_key": "You don't have a shared secret with this friend yet. Click below to initiate a new one.", + "shared_key_fingerprint": "Your fingerprint is:\n\n{fingerprint}\n\nMake sure to check if it matches your friend's fingerprint!", + "initiate_exchange_button": "Initiate Key Exchange" + }, + "unencrypted_conversation_send_failure_toast": "You can't send encrypted content to both encrypted and unencrypted conversations!", + "native_hooks_send_failure_toast": "Failed to send! Please enable Native Hooks in the settings.", + "no_participants_to_encrypt_toast": "You don't have any friends in this conversation to encrypt messages with!", + "encryption_failed_toast": "Failed to encrypt message! Check logcat for more details.", + "accept_public_key_success_toast": "Public key successfully accepted!", + "accept_secret_key_success_toast": "Done! You can now send and receive encrypted messages with this friend.", + "accept_public_key_failure_toast": "Failed to accept public key", + "accept_secret_button": "Accept Secret", + "accept_public_key_button": "Accept Public Key", + "outgoing_pk_message": "Key exchange request", + "incoming_pk_message": "You just received a public key request. Click below to accept it.", + "incoming_secret_message": "Your friend just accepted your public key. Click below to accept the secret." + }, + "biometric_auth": { + "subtitle": "Please authenticate to unlock Snapchat", + "unlock_button": "Unlock", + "title": "Unlock Snapchat" + }, + "auto_open_snaps": { + "title": "Auto Open Snaps", + "notification_content": "{count} Snaps opened" + }, + "friend_mutation_observer": { + "notification_channel_name": "Friend Mutation Observer", + "bitmoji_scene_changed": "{username} has changed their Bitmoji scene", + "friend_removed": "{username} has removed you as a friend", + "birthday_removed": "{username} has removed their birthday ({birthday})", + "birthday_added": "{username} has added their birthday ({birthday})", + "birthday_changed": "{username} has changed their birthday from {oldBirthday} to {newBirthday}", + "bitmoji_selfie_changed": "{username} has changed their Bitmoji selfie", + "bitmoji_avatar_changed": "{username} has changed their Bitmoji avatar", + "bitmoji_background_changed": "{username} has changed their Bitmoji background" + } +} diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json new file mode 100644 index 0000000000..a0ea27caa7 --- /dev/null +++ b/common/src/main/assets/lang/en_US.json @@ -0,0 +1,1719 @@ +{ + "setup": { + "dialogs": { + "select_language": "Select Language", + "save_folder": "SnapEnhance requires Storage permissions to download and Save Media from Snapchat.\nPlease choose the location where media should be downloaded to.", + "select_save_folder_button": "Select Folder" + }, + "mappings": { + "dialog": "Generating Mappings, this may take a while ...", + "generate_failure_no_snapchat": "SnapEnhance was unable to detect Snapchat, please try reinstalling Snapchat.", + "generate_failure": "An error occurred while trying to generate mappings, please try again." + }, + "permissions": { + "dialog": "To continue you need to fit the following requirements:", + "notification_access": "Notification Access", + "battery_optimization": "Battery Optimization", + "display_over_other_apps": "Display Over Other Apps", + "request_button": "Request" + } + }, + + "scopes": { + "friend": "Friend", + "group": "Group" + }, + + "manager": { + "routes": { + "tasks": "Tasks", + "features": "Features", + "manage_rule_feature": "Manage Rule Feature", + "home": "Home", + "home_settings": "Settings", + "home_logs": "Logs", + "logger_history": "Logger History", + "logged_stories": "Logged Stories", + "friend_tracker": "Friend Tracker", + "edit_rule": "Edit Rule", + "file_imports": "File Imports", + "manage_repos": "Manage Repositories", + "social": "Social", + "manage_scope": "Manage Scope", + "messaging_preview": "Preview", + "scripts": "Scripts", + "better_location": "Better Location" + }, + "sections": { + "home": { + "version_title": "v{versionName} · by rhunk", + "update_title": "SnapEnhance Update", + "update_content": "Version {version} is available!", + "update_button": "Download", + "debug_build_summary_title": "You are running a debug build of SnapEnhance", + "debug_build_summary_content": "Version {versionName} ({versionCode})", + "debug_build_summary_date": "Build date: {date} ({days} days ago)", + "quick_actions_title": "Quick Actions" + }, + "home_logs": { + "no_logs_hint": "No logs available", + "clear_logs_button": "Clear Logs", + "export_logs_button": "Export Logs", + "saving_logs_toast": "Saving logs, this may take a while ...", + "saved_logs_success_toast": "Logs saved successfully", + "saved_logs_failure_toast": "Failed to save logs" + }, + "home_settings": { + "actions_title": "Actions", + "message_logger_title": "Message Logger", + "debug_title": "Debug", + "success_toast": "Done!", + "message_logger_summary": "{messageCount} messages\n{storyCount} stories", + "export_button": "Export", + "clear_button": "Clear", + "view_logger_history_button": "View Logger History" + }, + "tasks": { + "no_tasks": "No tasks", + "merge_button": "Merge", + "failed_to_open_file": "Failed to open file", + "merge_files_toast": "Merging {count} files", + "remove_selected_tasks_title": "Are you sure you want to remove selected tasks?", + "remove_all_tasks_title": "Are you sure you want to remove all tasks?", + "delete_files_option": "Also delete files", + "remove_selected_tasks_confirm": "Remove {count} tasks?", + "remove_all_tasks_confirm": "Remove all tasks?" + }, + "features": { + "disabled": "Disabled", + "export_option": "Export", + "import_option": "Import", + "reset_option": "Reset", + "config_export_success_toast": "Config exported successfully", + "config_import_success_toast": "Config imported successfully", + "config_import_failure_toast": "Failed to import config {error}", + "config_export_failure_toast": "Failed to export config {error}", + "saved_config_snackbar": "Config saved", + "older_required": "This feature requires Snapchat v{version} or older to work correctly", + "newer_required": "This feature requires Snapchat v{version} or newer to work correctly", + "search_button": "Search" + }, + "manage_rule_feature": { + "disable_state_option": "Disabled", + "disable_state_subtext": "No friends/groups will be affected", + "whitelist_state_option": "No one except ...", + "whitelist_state_subtext": "Only {count} friends/groups will be affected by this rule", + "whitelist_state_button": "Select allowed friends/groups", + "blacklist_state_option": "Everyone except ...", + "blacklist_state_subtext": "Everyone except {count} friends/groups will be affected by this rule", + "blacklist_state_button": "Select excluded friends/groups", + "clear_list_button": "Clear friends/groups list", + "dialog_clear_confirmation_text": "Are you sure you want to clear the list?" + }, + "social": { + "friends_tab": "Friends", + "groups_tab": "Groups", + "empty_hint": "(empty)", + "streaks_expiration_short": "{hours}h" + }, + "manage_scope": { + "logged_stories_button": "Show Logged Stories", + "e2ee_title": "End-to-End Encryption", + "rules_title": "Rules", + "participants_text": "{count} participants", + "not_found": "Not found", + "streaks_title": "Streaks", + "streaks_length_text": "Length: {length}", + "streaks_expiration_text": "Expires in {eta}", + "streaks_expiration_text_expired": "Expired", + "reminder_button": "Set Reminder", + "delete_scope_confirm_dialog_title": "Are you sure you want to delete a {scope}?", + "notes_placeholder": "Click to add a note" + }, + "logged_stories": { + "story_failed_to_load": "Failed to load", + "no_stories": "No stories found", + "save_from_cache_button": "Save from Cache" + }, + "messaging_preview": { + "bridge_connection_failed": "Failed to connect to bridge. Make sure Snapchat is running in the background", + "bridge_init_failed": "Failed to initialize messaging bridge. Make sure Snapchat is running in the background", + "message_fetch_failed": "Failed to fetch messages", + "no_message_hint": "No message", + "save_selection_option": "Save Selection", + "save_all_option": "Save All", + "unsave_selection_option": "Unsave Selection", + "unsave_all_option": "Unsave All", + "mark_selection_as_seen_option": "Mark selected Snap as seen", + "mark_all_as_seen_option": "Mark all Snaps as seen", + "delete_selection_option": "Delete Selection", + "delete_all_option": "Delete All" + }, + "logger_history": { + "list_friend_format": "Friend {name}", + "list_group_format": "Group {name}", + "no_more_messages": "No more messages", + "reverse_order_checkbox": "Reverse Order", + "chat_attachment": "Attachment {index}", + "empty_message": "Empty Chat Message", + "message_parse_failed": "Failed to parse message", + "unknown_sender": "Unknown Sender", + "download_attachment_failed_toast": "Failed to download attachment" + }, + "file_imports": { + "import_file_button": "Import File", + "file_not_found": "File not found", + "file_import_failed": "Failed to import file: {error}", + "file_imported": "File imported successfully", + "file_delete_failed": "Failed to delete file", + "no_files_hint": "Here you can import files for use in Snapchat. Press the button below to import a file." + }, + "better_location": { + "spoofed_coordinates_title": "Lat {latitude}, Lng {longitude}", + "save_coordinates_dialog_title": "Save Coordinates", + "saved_name_dialog_hint": "Saved Name", + "latitude_dialog_hint": "Latitude", + "longitude_dialog_hint": "Longitude", + "save_dialog_button": "Save", + "choose_location_button": "Choose Location", + "teleport_to_friend_button": "Teleport to Friend", + "spoof_location_toggle": "Spoof Location", + "suspend_location_updates": "Suspend Location Updates", + "saved_coordinates_title": "Saved Coordinates", + "no_saved_coordinates_hint": "No saved coordinates", + "delete_dialog_title": "Delete Saved Coordinate", + "delete_dialog_message": "Are you sure you want to delete this saved coordinate?", + "teleport_to_friend_title": "Teleport to Friend", + "search_bar": "Search", + "no_friends_map": "No friends on the map", + "no_friends_found": "No friends found" + } + }, + "dialogs": { + "add_friend": { + "title": "Add Friend or Group", + "search_hint": "Search", + "fetch_error": "Failed to fetch data", + "category_groups": "Groups", + "category_friends": "Friends", + "participants_text": "{count} participants" + }, + "scripting_warning": { + "title": "Warning", + "content": "SnapEnhance includes a scripting tool, allowing the execution of user-defined code on your device. Use extreme caution and only install modules from known, reliable sources. Unauthorized or unverified modules may pose security risks to your system." + }, + "reset_config": { + "title": "Reset config", + "content": "Are you sure you want to reset the config?", + "success_toast": "Config reset successfully" + }, + "export_config": { + "title": "Export Sensitive Data?", + "content": "Do you want to export the config with sensitive data? (Such as location coordinates, etc.)" + }, + "messaging_action": { + "title": "Choose content types to process", + "select_all_button": "Select All" + }, + "file_imports": { + "no_files_settings_hint": "No files found. Make sure you have imported the required files in the File Imports section", + "settings_select_file_hint": "Select an imported file" + } + } + }, + + "rules": { + "toasts": { + "enabled": "{ruleName} enabled", + "disabled": "{ruleName} disabled" + }, + "modes": { + "blacklist": "Blacklist mode", + "whitelist": "Whitelist mode" + }, + "properties": { + "auto_download": { + "name": "Auto download", + "description": "Automatically download Snaps when viewing them", + "options": { + "blacklist": "Exclude from Auto Download", + "whitelist": "Auto Download" + } + }, + "stealth": { + "name": "Stealth Mode", + "description": "Prevents anyone from knowing you've opened their Snaps/Chats and conversations", + "options": { + "blacklist": "Exclude from Stealth Mode", + "whitelist": "Stealth mode" + } + }, + "auto_save": { + "name": "Auto Save", + "description": "Saves Chat Messages when viewing them", + "options": { + "blacklist": "Exclude from Auto save", + "whitelist": "Auto save" + } + }, + "unsaveable_messages": { + "name": "Unsaveable Messages", + "description": "Prevents messages from being saved in chat by other people", + "options": { + "blacklist": "Exclude from Unsaveable Messages", + "whitelist": "Unsaveable Messages" + } + }, + "auto_open_snaps": { + "name": "Auto Open Snaps", + "description": "Automatically opens Snaps when receiving them", + "options": { + "blacklist": "Exclude from Auto Open Snaps", + "whitelist": "Auto Open Snaps" + } + }, + "hide_friend_feed": { + "name": "Hide from Friend Feed" + }, + "e2e_encryption": { + "name": "Use E2E Encryption" + }, + "pin_conversation": { + "name": "Pin Conversation" + }, + "exclude_message_logger": { + "name": "Exclude From Message Logger" + } + } + }, + + "actions": { + "clean_snapchat_cache": { + "name": "Clean Snapchat Cache", + "description": "Cleans the Snapchat Cache" + }, + "manage_friend_list": { + "name": "Manage Friend List", + "description": "Import/export your friends list when backing up" + }, + "export_chat_messages": { + "name": "Export Chat Messages", + "description": "Exports conversation messages into a JSON/HTML/TXT file" + }, + "export_memories": { + "name": "Export Memories", + "description": "Exports memories into a ZIP file" + }, + "bulk_messaging_action": { + "name": "Bulk Messaging Action", + "description": "Performs operations such as deleting friends or mass deletion of conversations" + }, + "regen_mappings": { + "name": "Regenerate Mappings", + "description": "Manually regenerate mappings" + }, + "change_language": { + "name": "Change Language", + "description": "Change the language of SnapEnhance" + }, + "file_imports": { + "name": "File Imports", + "description": "Import files for use in Snapchat" + }, + "friend_tracker": { + "name": "Friend Tracker", + "description": "Track your friends on Snapchat" + }, + "logger_history": { + "name": "Logger History", + "description": "View the history of logged messages" + } + }, + + "features": { + "notices": { + "unstable": "\u26A0 Unstable", + "ban_risk": "\u26A0 This feature may cause bans", + "internal_behavior": "\u26A0 This may break Snapchat internal behavior" + }, + "properties": { + "downloader": { + "name": "Downloader", + "description": "Download Snapchat Media", + "properties": { + "save_folder": { + "name": "Save Folder", + "description": "Select the directory to which all media should be downloaded to" + }, + "auto_download_sources": { + "name": "Auto Download Sources", + "description": "Select the sources to automatically download from" + }, + "prevent_self_auto_download": { + "name": "Prevent Self Auto Download", + "description": "Prevents your own Snaps from being downloaded automatically" + }, + "path_format": { + "name": "Path Format", + "description": "Specify the File Path Format" + }, + "allow_duplicate": { + "name": "Allow Duplicate", + "description": "Allows the same media to be downloaded multiple times" + }, + "merge_overlays": { + "name": "Merge Overlays", + "description": "Combines the Text and the media of a Snap into a single file" + }, + "force_image_format": { + "name": "Force Image Format", + "description": "Forces images to be saved in a specified Format" + }, + "force_voice_note_format": { + "name": "Force Voice Note Format", + "description": "Forces Voice Notes to be saved in a specified Format" + }, + "auto_download_voice_notes": { + "name": "Auto Download Voice Notes", + "description": "Automatically downloads voice notes when playing them" + }, + "download_profile_pictures": { + "name": "Download Profile Pictures", + "description": "Allows you to download Profile Pictures from the profile page" + }, + "opera_download_button": { + "name": "Opera Download Button", + "description": "Adds a download button on the top right corner when viewing a Snap.\nLong press on buttons will force download" + }, + "download_context_menu": { + "name": "Download Context Menu", + "description": "Allows you to download/preview messages from a conversation or a story using the context menu.\nLong press on buttons will force download" + }, + "ffmpeg_options": { + "name": "FFmpeg Options", + "description": "Specify additional FFmpeg options", + "properties": { + "threads": { + "name": "Threads", + "description": "The amount of threads to use" + }, + "preset": { + "name": "Preset", + "description": "Set the speed of the conversion" + }, + "constant_rate_factor": { + "name": "Constant Rate Factor", + "description": "Set the constant rate factor for the video encoder\nFrom 0 to 51 for libx264" + }, + "video_bitrate": { + "name": "Video Bitrate", + "description": "Set the video bitrate (kbps)" + }, + "audio_bitrate": { + "name": "Audio Bitrate", + "description": "Set the audio bitrate (kbps)" + }, + "custom_video_codec": { + "name": "Custom Video Codec", + "description": "Set a custom Video Codec (e.g. libx264)" + }, + "custom_audio_codec": { + "name": "Custom Audio Codec", + "description": "Set a custom Audio Codec (e.g. AAC)" + } + } + }, + "logging": { + "name": "Logging", + "description": "Shows toasts when media is downloading" + }, + "custom_path_format": { + "name": "Custom Path Format", + "description": "Specify a custom path format for downloaded media\n\nAvailable variables:\n - %username%\n - %source%\n - %hash%\n - %date_time%" + } + } + }, + "user_interface": { + "name": "User Interface", + "description": "Change the look and feel of Snapchat", + "properties": { + "enable_app_appearance": { + "name": "Enable App Appearance Settings", + "description": "Enables the hidden App Appearance Setting\nMay not be required on newer Snapchat versions" + }, + "friend_feed_message_preview": { + "name": "Friend Feed Message Preview", + "description": "Shows a preview of the last messages in the Friend Feed", + "properties": { + "amount": { + "name": "Amount", + "description": "The amount of messages to get previewed" + } + } + }, + "snap_preview": { + "name": "Snap Preview", + "description": "Displays a small preview next to unseen Snaps in chat" + }, + "bootstrap_override": { + "name": "Bootstrap Override", + "description": "Overrides user interface bootstrap settings", + "properties": { + "app_appearance": { + "name": "App Appearance", + "description": "Sets a persistent App Appearance" + }, + "home_tab": { + "name": "Home Tab", + "description": "Overrides the startup tab when opening Snapchat" + } + } + }, + "map_friend_nametags": { + "name": "Enhanced Friend Map Nametags", + "description": "Improves the Nametags of friends on the Snapmap" + }, + "prevent_message_list_auto_scroll": { + "name": "Prevent Message List Auto Scroll", + "description": "Prevents the message list from scrolling to the bottom when sending/receiving a message" + }, + "streak_expiration_info": { + "name": "Show Streak Expiration Info", + "description": "Shows a Streak Expiration timer next to the Streaks counter" + }, + "hide_friend_feed_entry": { + "name": "Hide Friend Feed Entry", + "description": "Hides a specific friend from the Friend Feed\nUse the social tab to manage this feature" + }, + "hide_streak_restore": { + "name": "Hide Streak Restore", + "description": "Hides the Restore button in the friend feed" + }, + "hide_quick_add_suggestions": { + "name": "Hide Quick Add Suggestions", + "description": "Removes quick add friend suggestions" + }, + "hide_story_suggestions": { + "name": "Hide Story Suggestions", + "description": "Removes suggestions from the Stories page" + }, + "hide_ui_components": { + "name": "Hide UI Components", + "description": "Select which UI components to hide" + }, + "opera_media_quick_info": { + "name": "Opera Media Quick Info", + "description": "Shows useful information of media such as creation date in opera viewer context menu" + }, + "old_bitmoji_selfie": { + "name": "Old Bitmoji Selfie", + "description": "Brings back the Bitmoji selfies from older Snapchat versions" + }, + "disable_spotlight": { + "name": "Disable Spotlight", + "description": "Disables the Spotlight page" + }, + "friend_feed_menu_buttons": { + "name": "Friend Feed Menu Buttons", + "description": "Select which buttons to show in the Friend Feed Menu" + }, + "auto_close_friend_feed_menu": { + "name": "Auto Close Friend Feed Menu", + "description": "Automatically closes the Friend Feed Menu after pressing a setting button" + }, + "vertical_story_viewer": { + "name": "Vertical Story Viewer", + "description": "Enables the vertical story viewer for all stories" + }, + "enable_friend_feed_menu_bar": { + "name": "Friend Feed Menu Bar", + "description": "Enables the new Friend Feed Menu Bar" + }, + "message_indicators": { + "name": "Message Indicators", + "description": "Adds specific indicators icons to messages\nNote: Indicators might not be 100% accurate" + }, + "stealth_mode_indicator": { + "name": "Stealth Mode Indicator", + "description": "Adds a \uD83D\uDC7B emoji next to conversations in stealth mode" + }, + "edit_text_override": { + "name": "Edit Text Override", + "description": "Overrides text field behavior" + }, + "prevent_forced_keyboard": { + "name": "Prevent Forced Keyboard", + "description": "Prevents Snapchat from automatically popping up the keyboard when you open a conversation" + } + } + }, + "messaging": { + "name": "Messaging", + "description": "Change how you interact with friends", + "properties": { + "bypass_screenshot_detection": { + "name": "Bypass Screenshot Detection", + "description": "Prevents Snapchat from detecting when you take a screenshot" + }, + "anonymous_story_viewing": { + "name": "Anonymous Story Viewing", + "description": "Prevents anyone from knowing you've seen their story" + }, + "prevent_story_rewatch_indicator": { + "name": "Prevent Story Rewatch Indicator", + "description": "Prevents anyone from knowing you've rewatched their story" + }, + "hide_peek_a_peek": { + "name": "Hide Peek-a-Peek", + "description": "Prevents notification from being sent when you half swipe into a chat" + }, + "hide_bitmoji_presence": { + "name": "Hide Bitmoji Presence", + "description": "Prevents your Bitmoji from popping up while in Chat" + }, + "hide_typing_notifications": { + "name": "Hide Typing Notifications", + "description": "Prevents anyone from knowing you're typing a message" + }, + "unlimited_snap_view_time": { + "name": "Unlimited Snap View Time", + "description": "Removes the Time Limit for viewing Snaps" + }, + "auto_mark_as_read": { + "name": "Auto Mark as Read", + "description": "Automatically marks messages/snaps as read even when Stealth Mode is enabled" + }, + "mark_snap_as_seen_button": { + "name": "Mark Snap as Seen Button", + "description": "Adds a button to mark a Snap as seen when viewing it.\nThis will work even when Stealth Mode is enabled" + }, + "skip_when_marking_as_seen": { + "name": "Skip When Marking as Seen", + "description": "Automatically skips to the next Snap when marking a Snap as seen.\nUse in combination with Mark Snap as Seen Button" + }, + "loop_media_playback": { + "name": "Loop Media Playback", + "description": "Loops media playback when viewing Snaps / Stories" + }, + "disable_replay_in_ff": { + "name": "Disable Replay in FF", + "description": "Disables the ability to replay with a long press from the Friend Feed" + }, + "half_swipe_notifier": { + "name": "Half Swipe Notifier", + "description": "Notifies you when someone half swipes into a conversation", + "properties": { + "min_duration": { + "name": "Minimum Duration", + "description": "The minimum duration of the half swipe (in seconds)" + }, + "max_duration": { + "name": "Maximum Duration", + "description": "The maximum duration of the half swipe (in seconds)" + } + } + }, + "call_start_confirmation": { + "name": "Call Start Confirmation", + "description": "Shows a confirmation dialog when starting a call" + }, + "unlimited_conversation_pinning": { + "name": "Unlimited Conversation Pinning", + "description": "Allows you to pin an unlimited amount of conversations locally" + }, + "disable_snap_mode_restrictions": { + "name": "Disable Snap Mode Restrictions", + "description": "Allows you to view self-destructing Snaps without restrictions" + }, + "prevent_message_sending": { + "name": "Prevent Message Sending", + "description": "Prevents sending certain types of messages" + }, + "friend_mutation_notifier": { + "name": "Friend Mutation Notifier", + "description": "Notifies you when something changes in a friend's profile" + }, + "better_notifications": { + "name": "Better Notifications", + "description": "Adds more information in received notifications", + "properties": { + "group_notifications": { + "name": "Group Notifications", + "description": "Group notifications into a single one" + }, + "chat_preview": { + "name": "Chat Preview", + "description": "Shows a preview of received messages in the notification" + }, + "media_preview": { + "name": "Media Preview", + "description": "Shows a preview of the selected media types in the notification" + }, + "media_caption": { + "name": "Media Caption", + "description": "Shows the attached caption of media in the notification" + }, + "stacked_media_messages": { + "name": "Stacked Media Messages", + "description": "Combines multiple media messages into one text notification when they cannot be previewed. Use in combination with Chat Preview" + }, + "friend_add_source": { + "name": "Friend Add Source", + "description": "Shows the source of a friend request in the notification" + }, + "reply_button": { + "name": "Reply Button", + "description": "Adds a reply button to the notification" + }, + "smart_replies": { + "name": "Smart Replies", + "description": "Adds suggested replies to notifications (Android 10+). Use in combination with Reply Button" + }, + "download_button": { + "name": "Download Button", + "description": "Allows you to download media from the notification" + }, + "mark_as_read_button": { + "name": "Mark as Read Button", + "description": "Allows you to mark a message as read from the notification" + }, + "mark_as_read_and_save_in_chat": { + "name": "Mark as Read and Save in Chat", + "description": "Adds a mark as read and save in chat button to the notification" + } + } + }, + "notification_blacklist": { + "name": "Notification Blacklist", + "description": "Select notifications which should get blocked" + }, + "message_logger": { + "name": "Message Logger", + "description": "Prevents messages from being deleted", + "properties": { + "keep_my_own_messages": { + "name": "Keep My Own Messages", + "description": "Prevents your own messages from being deleted" + }, + "auto_purge": { + "name": "Auto Purge", + "description": "Automatically deletes cached messages that are older than the specified amount of time" + }, + "message_filter": { + "name": "Message Filter", + "description": "Select which messages should get logged (empty for all messages)" + }, + "deleted_message_color": { + "name": "Deleted Message Color", + "description": "Sets the color of deleted messages" + } + } + }, + "auto_save_messages_in_conversations": { + "name": "Auto Save Messages", + "description": "Automatically saves every message in conversations" + }, + "gallery_media_send_override": { + "name": "Gallery Media Send Override", + "description": "Spoofs the media source when sending from the Gallery" + }, + "strip_media_metadata": { + "name": "Strip Media Metadata", + "description": "Removes metadata of media before sending as a message" + }, + "bypass_message_retention_policy": { + "name": "Bypass Message Retention Policy", + "description": "Prevents messages from being deleted after viewing them" + }, + "bypass_message_action_restrictions": { + "name": "Bypass Message Action Restrictions", + "description": "Allows you to react to a snap without having opened it or to save an unsaveable message" + }, + "remove_groups_locked_status": { + "name": "Remove Groups Locked Status", + "description": "Allows you to view group information after being kicked" + }, + "double_tap_chat_action": { + "name": "Double Tap Chat Action", + "description": "Performs a custom action when double tapping a message in chat" + }, + "double_tap_chat_action_custom_emoji": { + "name": "Double Tap Chat Action Custom Emoji Reaction", + "description": "Sets a custom emoji reaction for the double tap chat action" + } + } + }, + "global": { + "name": "Global", + "description": "Tweak Global Snapchat Settings", + "properties": { + "better_location": { + "name": "Better Location", + "description": "Enhances the Snapchat Location", + "properties": { + "spoof_location": { + "name": "Spoof Location", + "description": "Spoofs your location to a specified one" + }, + "coordinates": { + "name": "Coordinates", + "description": "Set the coordinates of the spoofed location" + }, + "walk_radius": { + "name": "Walk Radius", + "description": "Randomly walk around within this radius (ft)" + }, + "always_update_location": { + "name": "Always Update Location", + "description": "Force Snapchat to update location even if no GPS data is received" + }, + "suspend_location_updates": { + "name": "Suspend Location Updates", + "description": "Prevents your location from being updated" + }, + "spoof_battery_level": { + "name": "Spoof Battery Level", + "description": "Spoofs the battery level of your device on map\nValue must be between 0 and 100" + }, + "spoof_headphones": { + "name": "Spoof Headphones", + "description": "Spoofs the status of listening to music on map" + }, + "show_battery_level": { + "name": "Show Battery Level", + "description": "Shows the battery level of your friends on the map" + } + } + }, + "snapchat_plus": { + "name": "Snapchat Plus", + "description": "Enables Snapchat Plus features\nSome Server-sided features may not work" + }, + "media_upload_quality": { + "name": "Media Upload Quality", + "description": "Overrides the media upload quality", + "properties": { + "force_video_upload_source_quality": { + "name": "Force Video Upload Source Quality", + "description": "Forces Snapchat to use the source quality when uploading videos\nPlease note that this may not remove metadata from media" + }, + "disable_image_compression": { + "name": "Disable Image Compression", + "description": "Disables image compression when uploading media" + }, + "custom_image_upload_format": { + "name": "Custom Image Upload Format", + "description": "Sets a custom image upload format\nSelect a lossless format (like PNG) for the best quality" + } + } + }, + "disable_confirmation_dialogs": { + "name": "Disable Confirmation Dialogs", + "description": "Automatically confirms selected actions" + }, + "auto_updater": { + "name": "Auto Updater", + "description": "Automatically checks for new updates" + }, + "disable_metrics": { + "name": "Disable Metrics", + "description": "Blocks sending specific analytic data to Snapchat" + }, + "disable_story_sections": { + "name": "Disable Story Sections", + "description": "Removes sections from the Stories page\nMay require a refresh to work properly" + }, + "block_ads": { + "name": "Block Ads", + "description": "Prevents Advertisements from being displayed" + }, + "disable_custom_tabs": { + "name": "Disable Custom Tabs", + "description": "Opens links in supported applications rather than in the Web Browser" + }, + "disable_permission_requests": { + "name": "Disable Permission Requests", + "description": "Prevents Snapchat from asking for specific permissions" + }, + "disable_memories_snap_feed": { + "name": "Disable Memories Snap Feed", + "description": "Prevents Snapchat from showing recent memories when you swipe up in camera" + }, + "spotlight_comments_username": { + "name": "Spotlight Comments Username", + "description": "Shows author username in Spotlight comments" + }, + "bypass_video_length_restriction": { + "name": "Bypass Video Length Restrictions", + "description": "Single: sends a single video\nSplit: split videos after editing" + }, + "default_video_playback_rate": { + "name": "Default Video Playback Rate", + "description": "Sets the default speed for the playback of videos\nValue must be between 0.1 and 4.0" + }, + "video_playback_rate_slider": { + "name": "Video Playback Rate Slider", + "description": "Adds a slider in opera context menu to change the video playback rate\nNote: Changes only apply to subsequent videos" + }, + "disable_google_play_dialogs": { + "name": "Disable Google Play Services Dialogs", + "description": "Prevent Google Play Services availability dialogs from being shown" + }, + "default_volume_controls": { + "name": "Default Volume Controls", + "description": "Forces Snapchat to use system volume controls" + }, + "disable_telecom_framework": { + "name": "Disable Telecom Framework", + "description": "Prevents Snapchat from using the Android Telecom framework\nThis allows you to listen to music while on a call" + }, + "hide_active_music": { + "name": "Hide Active Music", + "description": "Prevents Snapchat from knowing you're listening to music\nThis will allow you to take snaps using control volume buttons while listening to music" + }, + "disable_snap_splitting": { + "name": "Disable Snap Splitting", + "description": "Prevents Snaps from being split into multiple parts\nPictures you send will turn into videos" + } + } + }, + "rules": { + "name": "Rules", + "description": "Manage Automatic Features for individual people" + }, + "camera": { + "name": "Camera", + "description": "Adjust the right settings for the perfect snap", + "properties": { + "disable_cameras": { + "name": "Disable Cameras", + "description": "Prevents Snapchat from using the selected cameras" + }, + "black_photos": { + "name": "Black Photos", + "description": "Replaces captured photos with a black background\nVideos are not affected" + }, + "immersive_camera_preview": { + "name": "Immersive Preview", + "description": "Prevents Snapchat from Cropping the Camera preview\nThis might cause the camera to flicker on some devices" + }, + "override_front_resolution": { + "name": "Override Front Resolution", + "description": "Overrides the camera resolution for the front camera" + }, + "override_back_resolution": { + "name": "Override Back Resolution", + "description": "Overrides the camera resolution for the back camera" + }, + "custom_resolution": { + "name": "Custom Resolution", + "description": "Sets a custom camera resolution, width x height (e.g. 1920x1080).\nThe custom resolution must be supported by your device" + }, + "front_custom_frame_rate": { + "name": "Front Custom Frame Rate", + "description": "Overrides the front camera frame rate" + }, + "back_custom_frame_rate": { + "name": "Back Custom Frame Rate", + "description": "Overrides the back camera frame rate" + }, + "force_camera_source_encoding": { + "name": "Force Camera Source Encoding", + "description": "Forces the camera source encoding" + }, + "startup_default_camera": { + "name": "Startup Default Camera", + "description": "Sets the default camera when opening Snapchat" + }, + "hevc_recording": { + "name": "HEVC Recording", + "description": "Uses HEVC (H.265) codec for video recording" + } + } + }, + "streaks_reminder": { + "name": "Streaks Reminder", + "description": "Periodically notifies you about your Streaks", + "properties": { + "interval": { + "name": "Interval", + "description": "The interval between each reminder (hours)" + }, + "remaining_hours": { + "name": "Remaining Time", + "description": "The remaining amount of time before the notification is shown (hours)" + }, + "group_notifications": { + "name": "Group Notifications", + "description": "Group notifications into a single one" + } + } + }, + "experimental": { + "name": "Experimental", + "description": "Experimental features", + "properties": { + "native_hooks": { + "name": "Native Hooks", + "description": "Unsafe Features that hook into Snapchat's native code", + "properties": { + "composer_hooks": { + "name": "Composer Hooks", + "description": "Injects code into the Composer cross-platform UI framework", + "properties": { + "show_first_created_username": { + "name": "Show First Created Username", + "description": "Shows the first created username next to the current username in the profile page" + }, + "bypass_camera_roll_limit": { + "name": "Bypass Camera Roll Limit", + "description": "Increases the maximum amount of media you can send from the camera roll" + }, + "custom_self_destruct_snap_delay": { + "name": "Custom Self Destruct Snap Delay", + "description": "Gives more options for the self-destruct timer when sending a Snap" + }, + "composer_console": { + "name": "Composer Console", + "description": "Allows you to execute JavaScript code in Composer (arm64 only)" + }, + "composer_logs": { + "name": "Composer Logs", + "description": "Redirects console logs of Composer to SnapEnhance" + } + } + }, + "disable_bitmoji": { + "name": "Disable Bitmoji", + "description": "Disables Friends Profile Bitmoji" + }, + "custom_emoji_font": { + "name": "Custom Emoji Font", + "description": "Allows you to use a custom emoji font. Only works with .ttf fonts" + }, + "custom_shared_library": { + "name": "Custom Shared Library", + "description": "Loads a custom shared library into Snapchat. This feature is only for testing purposes" + } + } + }, + "spoof": { + "name": "Spoof", + "description": "Spoof various information about you", + "properties": { + "play_store_installer_package_name": { + "name": "Play Store Installer Package Name", + "description": "Overrides the installer package name to com.android.vending" + }, + "remove_vpn_transport_flag": { + "name": "Remove VPN Transport Flag", + "description": "Prevents Snapchat from detecting VPNs" + }, + "remove_mock_location_flag": { + "name": "Remove Mock Location Flag", + "description": "Prevents Snapchat from detecting Mock location" + } + } + }, + "convert_message_locally": { + "name": "Convert Message Locally", + "description": "Converts snaps to chat external media locally. This appears in chat download context menu" + }, + "media_file_picker": { + "name": "Media File Picker", + "description": "Allows you to pick any video/audio file from the gallery" + }, + "story_logger": { + "name": "Story Logger", + "description": "Provides a history of friends stories" + }, + "call_recorder": { + "name": "Call Recorder", + "description": "Automatically records audio calls" + }, + "account_switcher": { + "name": "Account Switcher", + "description": "Allows you to switch between accounts without logging out\nLong press on the search icon next to your Bitmoji profile to open the menu\nNote: This feature is experimental and will likely change in the future", + "properties": { + "auto_backup_current_account": { + "name": "Auto Backup Current Account", + "description": "Automatically backs up the current account when logging out or switching accounts" + } + } + }, + "better_transcript": { + "name": "Better Transcript", + "description": "Improves the voice note transcript", + "properties": { + "force_transcription": { + "name": "Force Voice Note Transcription", + "description": "Allows all voice notes to be transcribed" + }, + "preferred_transcription_lang": { + "name": "Preferred Transcription Language", + "description": "The preferred language for the voice note transcript (e.g. EN, ES, FR)" + }, + "notification_transcript": { + "name": "Notification Transcript", + "description": "Transcribes voice notes in notifications\nThis feature requires the Chat Preview feature to be enabled in Better Notifications" + } + } + }, + "voice_note_auto_play": { + "name": "Voice Note Auto Play", + "description": "Automatically plays the next voice note after the current one finishes" + }, + "friend_notes": { + "name": "Friend Notes", + "description": "Allows you to add notes to friends profiles" + }, + "cof_experiments": { + "name": "COF Experiments", + "description": "Enables unreleased/beta Snapchat features" + }, + "context_menu_fix": { + "name": "Context Menu Fix", + "description": "Attempt to repair the Friend Feed Menu as when the device is offline it cannot be displayed correctly" + }, + "app_lock": { + "name": "App Lock", + "description": "Prevents access to Snapchat without a passcode", + "properties": { + "lock_on_resume": { + "name": "Lock On Resume", + "description": "Locks the app when it's reopened" + } + } + }, + "infinite_story_boost": { + "name": "Infinite Story Boost", + "description": "Bypass the Story Boost Limit delay" + }, + "meo_passcode_bypass": { + "name": "My Eyes Only Passcode Bypass", + "description": "Bypass the My Eyes Only passcode\nThis will only work if the passcode has been entered correctly before" + }, + "no_friend_score_delay": { + "name": "No Friend Score Delay", + "description": "Removes the delay when viewing a Friends Score" + }, + "best_friend_pinning": { + "name": "Best Friend Pinning", + "description": "Allows you to pin a friend as your number one best friend. Note: Only you can see your pinned best friend" + }, + "e2ee": { + "name": "End-To-End Encryption", + "description": "Encrypts your messages with AES using a shared secret key\nMake sure to save your key somewhere safe!", + "properties": { + "encrypted_message_indicator": { + "name": "Encrypted Message Indicator", + "description": "Adds a \uD83D\uDD12 emoji next to encrypted messages" + }, + "force_message_encryption": { + "name": "Force Message Encryption", + "description": "Prevents sending encrypted messages to people who don't have E2E Encryption enabled only when multiple conversations are selected" + } + } + }, + "add_friend_source_spoof": { + "name": "Add Friend Source Spoof", + "description": "Spoofs the source of a Friend Request" + }, + "hidden_snapchat_plus_features": { + "name": "Hidden Snapchat Plus Features", + "description": "Enables unreleased/beta Snapchat Plus features\nMight not work on older Snapchat versions" + }, + "custom_streaks_expiration_format": { + "name": "Custom Streaks Expiration Format", + "description": "Customizes the Streaks Expiration format\n\nAvailable variables:\n - %c: Streaks Count\n - %e: Hourglass Emoji\n - %d: Days\n - %h: Hours\n - %m: Minutes\n - %s: Seconds\n - %w: Remaining Time" + }, + "prevent_forced_logout": { + "name": "Prevent Forced Logout", + "description": "Prevents Snapchat from logging you out when you login on another device" + }, + "snapscore_changes": { + "name": "Snapscore Changes", + "description": "Tracks changes in friends Snapscore\nUse this feature in newer versions of Snapchat only" + } + } + }, + "scripting": { + "name": "Scripting", + "description": "Run custom scripts to extend SnapEnhance", + "properties": { + "developer_mode": { + "name": "Developer Mode", + "description": "Shows debug info on Snapchat's UI" + }, + "module_folder": { + "name": "Module Folder", + "description": "The folder where the scripts are located" + }, + "auto_reload": { + "name": "Auto Reload", + "description": "Automatically reloads scripts when they change" + }, + "integrated_ui": { + "name": "Integrated UI", + "description": "Allows scripts to add custom UI components to Snapchat" + }, + "disable_log_anonymization": { + "name": "Disable Log Anonymization", + "description": "Disables the anonymization of logs" + }, + "disable_optimization": { + "name": "Disable Optimization", + "description": "Disables the optimization of scripts. This may cause performance issues." + } + } + }, + "friend_tracker": { + "name": "Friend Tracker", + "description": "Records friend's activity on Snapchat", + "properties": { + "record_messaging_events": { + "name": "Record Messaging Events", + "description": "Records messaging events such as opening a snap, reading a message, etc." + }, + "allow_running_in_background": { + "name": "Allow Running in Background", + "description": "Allows the tracker to run in the background. Note: This will significantly drain your battery" + }, + "auto_purge": { + "name": "Auto Purge", + "description": "Automatically deletes cached events that are older than the specified amount of time" + } + } + } + }, + "options": { + "app_appearance": { + "always_light": "Always Light", + "always_dark": "Always Dark" + }, + "friend_feed_menu_buttons": { + "auto_download": "\u2B07\uFE0F Auto Download", + "auto_save": "\uD83D\uDCAC Auto Save Messages", + "unsaveable_messages": "\u2B07\uFE0F Unsaveable Messages", + "auto_open_snaps": "\uD83D\uDCF7 Auto Open Snaps", + "stealth": "\uD83D\uDC7B Stealth Mode", + "mark_snaps_as_seen": "\uD83D\uDC40 Mark Snaps as seen", + "mark_stories_as_seen_locally": "\uD83D\uDC40 Mark Stories as seen locally", + "conversation_info": "\uD83D\uDC64 Conversation Info", + "e2e_encryption": "\uD83D\uDD12 Use E2E Encryption" + }, + "path_format": { + "create_author_folder": "Create folder for each author", + "create_source_folder": "Create folder for each media source type", + "append_hash": "Add a unique hash to the file name", + "append_source": "Add the media source to the file name", + "append_username": "Add the username to the file name", + "append_date_time": "Add the date and time to the file name" + }, + "auto_download_sources": { + "friend_snaps": "Friend Snaps", + "friend_stories": "Friend Stories", + "public_stories": "Public Stories", + "spotlight": "Spotlight" + }, + "logging": { + "started": "Started", + "success": "Success", + "progress": "Progress", + "failure": "Failure" + }, + "notifications": { + "chat_screenshot": "Screenshot", + "chat_screen_record": "Screen Record", + "snap_replay": "Snap Replay", + "camera_roll_save": "Camera Roll Save", + "chat": "Chat", + "chat_reply": "Chat Reply", + "snap": "Snap", + "typing": "Typing", + "stories": "Stories", + "speaking": "Speaking", + "chat_reaction": "DM Reaction", + "group_chat_reaction": "Group Reaction", + "initiate_audio": "Incoming Audio Call", + "abandon_audio": "Missed Audio Call", + "initiate_video": "Incoming Video Call", + "abandon_video": "Missed Video Call", + "map_live_location": "Map Live Location" + }, + "gallery_media_send_override": { + "always_ask": "Always Ask", + "ORIGINAL": "Original Media", + "NOTE": "Audio Note", + "SNAP": "Snap", + "SAVEABLE_SNAP": "Saveable Snap" + }, + "strip_media_metadata": { + "hide_caption_text": "Hide Caption Text", + "hide_snap_filters": "Hide Snap Filters", + "hide_extras": "Hide Extras (e.g. mentions)", + "remove_audio_note_duration": "Remove Audio Note Duration", + "remove_audio_note_transcript_capability": "Remove Audio Note Transcript Capability" + }, + "hide_ui_components": { + "hide_profile_call_buttons": "Remove Profile Call Buttons", + "hide_chat_call_buttons": "Remove Chat Call Buttons", + "hide_live_location_share_button": "Remove Live Location Share Button", + "hide_stickers_button": "Remove Stickers Button", + "hide_voice_record_button": "Remove Voice Record Button", + "hide_unread_chat_hint": "Remove Unread Chat Hint", + "hide_post_to_story_buttons": "Remove Post to Story buttons before sending a Snap", + "hide_billboard_prompt": "Remove Billboard Prompt In Friends Feed", + "hide_snapchat_plus_gift_reminders": "Remove Snapchat Plus gift reminders in conversations", + "hide_map_reactions": "Remove Map Reactions" + }, + "hide_story_suggestions": { + "hide_suggested_friend_stories": "Hide suggested friend stories", + "hide_my_stories": "Hide My Stories" + }, + "home_tab": { + "map": "Map", + "chat": "Chat", + "camera": "Camera", + "discover": "Discover", + "spotlight": "Spotlight" + }, + "add_friend_source_spoof": { + "added_by_username": "By Username", + "added_by_mention": "By Mention", + "added_by_group_chat": "By Group Chat", + "added_by_qr_code": "By QR Code", + "added_by_community": "By Community", + "added_by_quick_add": "By Quick Add (high risk of being banned)" + }, + "bypass_video_length_restriction": { + "single": "Single media", + "split": "Split media" + }, + "old_bitmoji_selfie": { + "2d": "2D Bitmoji", + "3d": "3D Bitmoji" + }, + "disable_confirmation_dialogs": { + "erase_message": "Erase Message", + "remove_friend": "Remove Friend", + "block_friend": "Block Friend", + "ignore_friend": "Ignore Friend", + "hide_friend": "Hide Friend", + "hide_conversation": "Hide Conversation", + "clear_conversation": "Clear Conversation from Friend Feed" + }, + "auto_reload": { + "snapchat_only": "Snapchat Only", + "all": "All (Snapchat + SnapEnhance)" + }, + "edit_text_override": { + "multi_line_chat_input": "Multi Line Chat Input", + "bypass_text_input_limit": "Bypass Text Input Limit" + }, + "auto_purge": { + "never": "Never", + "1_hour": "1 Hour", + "3_hours": "3 Hours", + "6_hours": "6 Hours", + "12_hours": "12 Hours", + "1_day": "1 Day", + "3_days": "3 Days", + "1_week": "1 Week", + "2_weeks": "2 Weeks", + "1_month": "1 Month", + "3_months": "3 Months", + "6_months": "6 Months" + }, + "disable_story_sections": { + "friends": "Friends", + "suggested_stories": "Suggested Stories", + "following": "Following", + "discover": "Discover" + }, + "disable_cameras":{ + "front": "Front Camera", + "back": "Back Camera" + }, + "disable_permission_requests": { + "notifications": "Notifications", + "read_media_images": "Read Media Images", + "read_media_video": "Read Media Video", + "camera": "Camera", + "microphone": "Microphone", + "location": "Location", + "read_contacts": "Read Contacts", + "nearby_devices": "Nearby Devices", + "phone_calls": "Phone Calls" + }, + "message_indicators": { + "encryption_indicator": "Adds a \uD83D\uDD12 icon next to messages that have been sent only to you", + "platform_indicator": "Adds the platform icon from which a media was sent (e.g. Android, iOS, Web)", + "location_indicator": "Adds a \uD83D\uDCCD icon to snaps when they have been sent with location enabled", + "ovf_editor_indicator": "Indicates if a snap has been sent using OVF Editor", + "director_mode_indicator": "Adds a \u270F\uFE0F icon to snaps when they have been sent using Director Mode, which can be used to send gallery images as snaps" + }, + "auto_mark_as_read": { + "conversation_read": "Mark conversation as read when sending a message", + "snap_reply": "Mark snaps as read when replying to them", + "save_snap_in_chat": "Mark snaps as read when saving them in chat while in Stealth Mode" + }, + "friend_mutation_notifier": { + "remove_friend": "Notify when someone removes you as a friend", + "birthday_changes": "Notify when someone changes their birthday", + "bitmoji_selfie_changes": "Notify when someone changes their Bitmoji selfie", + "bitmoji_avatar_changes": "Notify when someone changes their Bitmoji avatar", + "bitmoji_background_changes": "Notify when someone changes their Bitmoji background", + "bitmoji_scene_changes": "Notify when someone changes their Bitmoji scene" + }, + "snapchat_plus":{ + "not_subscribed": "Not Subscribed", + "basic": "Basic", + "ad_free": "Ad Free" + }, + "double_tap_chat_action": { + "like_message": "Like Message", + "copy_text": "Copy Text to Clipboard", + "delete_message": "Delete Message", + "mark_as_read": "Mark as Read", + "custom_emoji_reaction": "Custom Emoji Reaction" + }, + "startup_default_camera": { + "front": "Front Camera", + "back": "Back Camera" + } + } + }, + + "friend_menu_option": { + "mark_snaps_as_seen": "Mark Snaps as seen", + "mark_stories_as_seen_locally": "Mark Stories as seen locally", + "preview": "Preview", + "stealth_mode": "Stealth Mode", + "auto_download_blacklist": "Auto Download Blacklist", + "anti_auto_save": "Anti Auto Save" + }, + + "content_type": { + "CHAT": "Chat", + "SNAP": "Snap", + "EXTERNAL_MEDIA": "External Media", + "NOTE": "Audio Note", + "STICKER": "Sticker", + "SHARE": "Share", + "STATUS": "Status", + "LOCATION": "Location", + "STATUS_SAVE_TO_CAMERA_ROLL": "Saved to Camera Roll", + "STATUS_CONVERSATION_CAPTURE_SCREENSHOT": "Screenshot", + "STATUS_CONVERSATION_CAPTURE_RECORD": "Screen Record", + "STATUS_CALL_MISSED_VIDEO": "Missed Video Call", + "STATUS_CALL_MISSED_AUDIO": "Missed Audio Call", + "LIVE_LOCATION_SHARE": "Live Location Share", + "CREATIVE_TOOL_ITEM": "Creative Tool Item", + "FAMILY_CENTER_INVITE": "Family Center Invite", + "FAMILY_CENTER_ACCEPT": "Family Center Accept", + "FAMILY_CENTER_LEAVE": "Family Center Leave", + "STATUS_PLUS_GIFT": "Status Plus Gift", + "TINY_SNAP": "Tiny Snap", + "STATUS_COUNTDOWN": "Countdown", + "MAP_REACTION": "Map Reaction" + }, + + "media_download_source": { + "none": "None", + "pending": "Pending", + "chat_media": "Chat Media", + "story": "Story", + "public_story": "Public Story", + "spotlight": "Spotlight", + "profile_picture": "Profile Picture", + "story_logger": "Story Logger", + "message_logger": "Message Logger", + "merged": "Merged", + "voice_call": "Voice Call" + }, + + "chat_action_menu": { + "preview_button": "Preview", + "download_button": "Download", + "delete_logged_message_button": "Delete Logged Message", + "show_chat_edit_history": "Show Chat Edit History", + "convert_message": "Convert Message" + }, + + "opera_context_menu": { + "download": "Download Media", + "sent_at": "Sent at {date}", + "created_at": "Created at {date}", + "expires_at": "Expires at {date}", + "media_size": "Media size: {size}", + "media_duration": "Media duration: {duration} ms", + "show_debug_info": "Show Debug Info" + }, + + "modal_option": { + "profile_info": "Profile Info", + "close": "Close" + }, + + "gallery_media_send_override": { + "multiple_media_toast": "You can only send one media at a time" + }, + + "mark_as_seen": { + "no_unseen_snaps_toast": "No unseen Snaps found!", + "seen_toast": "Marked as seen!", + "unseen_toast": "Marked as unseen!", + "already_seen_toast": "Already marked as seen!", + "already_unseen_toast": "Already marked as unseen!" + }, + + "conversation_preview": { + "streak_expiration": "expires in {day} days {hour} hours {minute} minutes", + "total_messages": "Total sent/received messages: {count}", + "title": "Preview", + "unknown_user": "Unknown User", + "no_messages": "No messages found!" + }, + + "profile_info": { + "title": "Profile Info", + "first_created_username": "First Created Username", + "mutable_username": "Mutable Username", + "display_name": "Display Name", + "added_date": "Added Date", + "birthday": "Birthday : {month} {day}", + "hidden_birthday": "Birthday : Hidden", + "friendship": "Friendship", + "add_source": "Add Source", + "snapchat_plus": "Snapchat Plus", + "snapchat_plus_state": { + "subscribed": "Subscribed", + "not_subscribed": "Not Subscribed" + } + }, + + "friendship_link_type": { + "mutual": "Mutual", + "outgoing": "Outgoing", + "blocked": "Blocked", + "deleted": "Deleted", + "following": "Following", + "suggested": "Suggested", + "incoming": "Incoming", + "incoming_follower": "Incoming Follower" + }, + + "bulk_messaging_action": { + "choose_action_title": "Choose an action", + "progress_status": "Processing {index} of {total}", + "selection_dialog_continue_button": "Continue", + "confirmation_dialog": { + "title": "Are you sure?", + "message": "This will affect all selected friends. This action cannot be undone." + }, + "actions": { + "remove_friends": "Remove Friends", + "clear_conversations": "Clear Conversations" + } + }, + + "chat_export": { + "exporter_dialog": { + "select_conversations_title": "Select Conversations", + "text_field_selection": "{amount} selected", + "text_field_selection_all": "All", + "export_file_format_title": "Export File Format", + "message_type_filter_title": "Filter Messages by Type", + "amount_of_messages_title": "Amount of Messages (leave it blank for all)", + "download_medias_title": "Download Medias" + }, + "dialog_negative_button": "Cancel", + "dialog_positive_button": "Export", + "exported_to": "Exported to {path}", + "exporting_chats": "Exporting Chats...", + "processing_chats": "Processing {amount} conversations...", + "export_fail": "Failed to export conversation {conversation}", + "writing_output": "Writing output...", + "finished": "Done! You now can close this dialog.", + "no_messages_found": "No messages found!", + "exporting_message": "Exporting {conversation}..." + }, + + "button": { + "ok": "OK", + "positive": "Yes", + "negative": "No", + "cancel": "Cancel", + "open": "Open", + "download": "Download", + "send": "Send", + "restore_original": "Restore Original", + "convert_external_media": "Convert External Media" + }, + + "tracker_events": { + "conversation_enter": "Conversation Enter", + "conversation_exit": "Conversation Exit", + "started_typing": "Started Typing", + "stopped_typing": "Stopped Typing", + "started_speaking": "Started Speaking", + "stopped_speaking": "Stopped Speaking", + "started_peeking": "Started Peeking", + "stopped_peeking": "Stopped Peeking", + "message_read": "Message Read", + "message_deleted": "Message Deleted", + "message_saved": "Message Saved", + "message_unsaved": "Message Unsaved", + "message_edited": "Message Edited", + "message_reaction_add": "Message Reaction Add", + "message_reaction_remove": "Message Reaction Remove", + "snap_opened": "Snap Opened", + "snap_replayed": "Snap Replayed", + "snap_replayed_twice": "Snap Replayed Twice", + "snap_screenshot": "Snap Screenshot", + "snap_screen_record": "Snap Screen Record" + }, + + "tracker_actions": { + "log": "Log", + "in_app_notification": "In-App Notification", + "push_notification": "Push Notification", + "custom": "Custom" + }, + + "better_notifications": { + "button": { + "reply": "Reply", + "download": "Download", + "mark_as_read": "Mark as Read" + } + }, + + "profile_picture_downloader": { + "button": "Download Profile Picture", + "title": "Profile Picture Downloader", + "avatar_option": "Avatar", + "background_option": "Background" + }, + + "call_start_confirmation": { + "dialog_title": "Start Call", + "dialog_message": "Are you sure you want to start a call?" + }, + + "half_swipe_notifier": { + "notification_channel_name": "Half Swipe", + "notification_content_dm": "{friend} just half-swiped into your chat for {duration} seconds", + "notification_content_group": "{friend} just half-swiped into {group} for {duration} seconds" + }, + + "download_processor": { + "attachment_type": { + "snap": "Snap", + "sticker": "Sticker", + "gif": "GIF", + "external_media": "External Media", + "note": "Note", + "original_story": "Original Story" + }, + "select_attachments_title": "Select attachments", + "download_started_toast": "Download started", + "unsupported_content_type_toast": "Unsupported content type!", + "failed_no_longer_available_toast": "Media no longer available", + "no_attachments_toast": "No attachments found!", + "already_queued_toast": "Media already in queue!", + "already_downloaded_toast": "Media already downloaded!", + "content_saved_toast": "Saved!", + "download_toast": "Downloading {path}...", + "processing_toast": "Processing {path}...", + "failed_generic_toast": "Failed to download", + "failed_to_create_preview_toast": "Failed to create preview", + "failed_processing_toast": "Failed processing {error}", + "failed_gallery_toast": "Failed saving to gallery {error}", + "dash_no_chapter": "No chapter found", + "dash_dialog": { + "title": "Download dash media", + "download_all": "Download All", + "segment_text": "Segment {from} - {to}" + } + }, + + "streaks_reminder": { + "notification_title": "Streaks", + "notification_text": "You will lose your Streak with {friend} in {hoursLeft} hours" + }, + + "biometric_auth": { + "unlock_button": "Unlock", + "title": "Unlock Snapchat", + "subtitle": "Please authenticate to unlock Snapchat" + }, + + "end_to_end_encryption": { + "toolbox": { + "no_shared_key": "You don't have a shared secret with this friend yet. Click below to initiate a new one.", + "shared_key_fingerprint": "Your fingerprint is:\n\n{fingerprint}\n\nMake sure to check if it matches your friend's fingerprint!", + "initiate_exchange_button": "Initiate Key Exchange" + }, + "confirmation_dialogs": { + "title": "End-to-end encryption", + "confirmation_1": "WARNING: This will overwrite your existing key. You will loose access to all encrypted messages from this friend. Are you sure you want to continue?", + "confirmation_2": "Are you REALLY sure you want to continue? This is your last chance to back out." + }, + "unencrypted_conversation_send_failure_toast": "You can't send encrypted content to both encrypted and unencrypted conversations!", + "native_hooks_send_failure_toast": "Failed to send! Please enable Native Hooks in the settings.", + "no_participants_to_encrypt_toast": "You don't have any friends in this conversation to encrypt messages with!", + "encryption_failed_toast": "Failed to encrypt message! Check logcat for more details.", + "accept_public_key_success_toast": "Public key successfully accepted!", + "accept_secret_key_success_toast": "Done! You can now send and receive encrypted messages with this friend.", + "accept_public_key_failure_toast": "Failed to accept public key", + "accept_secret_key_failure_toast": "Failed to accept secret key", + "accept_secret_button": "Accept Secret", + "accept_public_key_button": "Accept Public Key", + "outgoing_pk_message": "Key exchange request", + "outgoing_secret_message": "Key exchange response", + "incoming_pk_message": "You just received a public key request. Click below to accept it.", + "incoming_secret_message": "Your friend just accepted your public key. Click below to accept the secret." + }, + + "auto_open_snaps": { + "title": "Auto Open Snaps", + "notification_content": "{count} Snaps opened" + }, + + "friend_mutation_observer": { + "notification_channel_name": "Friend Mutation Observer", + "friend_removed": "{username} has removed you as a friend", + "birthday_removed": "{username} has removed their birthday ({birthday})", + "birthday_added": "{username} has added their birthday ({birthday})", + "birthday_changed": "{username} has changed their birthday from {oldBirthday} to {newBirthday}", + "bitmoji_selfie_changed": "{username} has changed their Bitmoji selfie", + "bitmoji_avatar_changed": "{username} has changed their Bitmoji avatar", + "bitmoji_background_changed": "{username} has changed their Bitmoji background", + "bitmoji_scene_changed": "{username} has changed their Bitmoji scene" + }, + + "material3_strings": { + "date_range_picker_start_headline": "From", + "date_range_picker_end_headline": "To", + "date_range_picker_title": "Select date range", + "date_picker_switch_to_calendar_mode": "Calendar", + "date_picker_switch_to_input_mode": "Input", + "date_range_picker_scroll_to_previous_month": "Previous month", + "date_range_picker_scroll_to_next_month": "Next month", + "date_picker_today_description": "Today", + "date_range_picker_day_in_range": "Selected", + "date_input_invalid_for_pattern": "Invalid date", + "date_input_invalid_year_range": "Invalid year", + "date_input_invalid_not_allowed": "Invalid date", + "date_range_input_invalid_range_input": "Invalid date range" + }, + + "send_override_dialog": { + "title": "Send media as {type}", + "duration": "Duration: {duration}", + "saveable_snap_hint": "Make Snap saveable in the chat", + "unlimited_duration": "Unlimited" + } +} diff --git a/common/src/main/assets/lang/es_ES.json b/common/src/main/assets/lang/es_ES.json new file mode 100644 index 0000000000..b2203e67e2 --- /dev/null +++ b/common/src/main/assets/lang/es_ES.json @@ -0,0 +1,1656 @@ +{ + "setup": { + "mappings": { + "dialog": "Generando mapeos, esto puede tardar un rato ...", + "generate_failure_no_snapchat": "SnapEnhance no pudo detectar Snapchat, por favor intenta reinstalar Snapchat.", + "generate_failure": "Ocurrió un error al intentar generar mapeos, por favor intenta de nuevo." + }, + "dialogs": { + "select_save_folder_button": "Elige la carpeta", + "select_language": "Elige un idioma", + "save_folder": "Snapenhance necesita permisos de almacenamiento para descargar y guardar medios desde Snapchat.\nPor favor elige la ubicación dónde se deben descargar los medios." + }, + "permissions": { + "dialog": "Para continuar hay que cumplir los siguientes requisitos:", + "request_button": "Solicitar", + "notification_access": "Acceso a notificaciones", + "battery_optimization": "Optimización de la Batería", + "display_over_other_apps": "Mostrar sobre otras aplicaciones" + } + }, + "manager": { + "routes": { + "social": "Social", + "manage_scope": "Gestionar el alcance", + "messaging_preview": "Vista previa", + "scripts": "Secuencias", + "logger_history": "Historial del registro", + "logged_stories": "Historias registradas", + "tasks": "Tareas", + "features": "Características", + "home": "Página principal", + "home_logs": "Registros", + "home_settings": "Ajustes", + "friend_tracker": "Rastreador de amigos", + "edit_rule": "Editar regla", + "file_imports": "Importación de archivos", + "better_location": "Mejor ubicación", + "theming": "Temática", + "edit_theme": "Editar tema", + "manage_repos": "Administrar repositorios", + "manage_rule_feature": "Gestionar las reglas" + }, + "sections": { + "home": { + "update_title": "Actualización de SnapEnhance", + "update_content": "¡Versión {version} está disponible!", + "update_button": "Descargar", + "debug_build_summary_date": "Fecha de desarrollo: {date} (hace{days} día(s))", + "quick_actions_title": "Acciones rápidas", + "version_title": "v{versionName} · por rhunk", + "debug_build_summary_title": "Estás ejecutando una versión de depuración de SnapEnhance", + "debug_build_summary_content": "Versión {versionName} ({versionCode})" + }, + "home_logs": { + "no_logs_hint": "Registros no disponibles", + "clear_logs_button": "Borrar Registros", + "export_logs_button": "Exportar Registros", + "saving_logs_toast": "Guardando los registros, esto puede tardar un rato…", + "saved_logs_success_toast": "Registros guardados con éxito", + "saved_logs_failure_toast": "Error al guardar los registros" + }, + "home_settings": { + "actions_title": "Acciones", + "message_logger_title": "Registrador de mensajes", + "debug_title": "Depurar", + "success_toast": "¡Hecho!", + "message_logger_summary": "{messageCount} mensajes\n{storyCount} historias", + "export_button": "Exportar", + "clear_button": "Borrar", + "view_logger_history_button": "Ver historial del registrador" + }, + "tasks": { + "remove_selected_tasks_title": "¿Estás segura/o que quieres borrar las tareas seleccionadas?", + "remove_all_tasks_title": "¿Estás segura/o que quieres borrar todas las tareas?", + "delete_files_option": "También borrar archivos", + "remove_selected_tasks_confirm": "¿Borrar {count} tareas?", + "remove_all_tasks_confirm": "¿Borrar todas las tareas?", + "merge_files_toast": "Incorporando {count} archivos", + "no_tasks": "Sin tareas", + "failed_to_open_file": "Error al abrir el archivo" + }, + "features": { + "disabled": "Desactivado", + "export_option": "Exportar", + "import_option": "Importar", + "reset_option": "Restablecer", + "config_export_success_toast": "Configuración exportada con éxito", + "config_import_success_toast": "Configuración importada con éxito", + "config_import_failure_toast": "Error al importar la configuración {error}", + "saved_config_snackbar": "Configuración guardada", + "config_export_failure_toast": "No se pudo exportar la configuración {error}" + }, + "social": { + "friends_tab": "Amigxs", + "empty_hint": "(Vacío)", + "streaks_expiration_short": "{hours}h", + "groups_tab": "Grupos" + }, + "manage_scope": { + "rules_title": "Normas", + "participants_text": "{count} participantes", + "not_found": "No encontrado", + "streaks_length_text": "Longitud: {length}", + "streaks_expiration_text": "Caduca en {eta}", + "streaks_expiration_text_expired": "Caducado", + "reminder_button": "Establecer recordatorio", + "logged_stories_button": "Mostrar historias en el registro", + "delete_scope_confirm_dialog_title": "¿Segura/o de que quieres borrar un {scope}?", + "e2ee_title": "Cifrado de extremo a extremo", + "streaks_title": "Rachas" + }, + "logged_stories": { + "story_failed_to_load": "Error al cargar", + "no_stories": "Ningunas historias encontradas", + "save_from_cache_button": "Guardar desde el cache" + }, + "messaging_preview": { + "bridge_connection_failed": "No se pudo conectar al puente. Asegúrate de que Snapchat se esté ejecutando en segundo plano", + "bridge_init_failed": "No se pudo inicializar el puente de mensajería. Asegúrate de que Snapchat se esté ejecutando en segundo plano", + "message_fetch_failed": "Error al recuperar mensajes", + "no_message_hint": "Sin mensaje", + "save_selection_option": "Guardar selección", + "save_all_option": "Guardar Todo", + "delete_selection_option": "Borrar selección", + "delete_all_option": "Borrar todo", + "unsave_selection_option": "No guardar seleccion", + "unsave_all_option": "No guardar nada", + "mark_selection_as_seen_option": "Marca el Snap seleccionado como visto", + "mark_all_as_seen_option": "Marcar todos los Snaps como vistos" + }, + "logger_history": { + "list_friend_format": "Amigx {name}", + "no_more_messages": "Sin más mensajes", + "reverse_order_checkbox": "Orden al revés", + "chat_attachment": "Atajo {index}", + "empty_message": "Mensaje vacío", + "unknown_sender": "Remitente desconocido", + "download_attachment_failed_toast": "Error al descargar atajo", + "list_group_format": "Grupo {name}", + "message_parse_failed": "Error al analizar mensaje" + }, + "file_imports": { + "file_import_failed": "No se pudo importar el archivo: {error}", + "file_delete_failed": "No se pudo eliminar el archivo", + "import_file_button": "Importar archivo", + "file_not_found": "Archivo no encontrado", + "file_imported": "Archivo importado exitosamente", + "no_files_hint": "Aquí puedes importar los archivos para usarlos en Snapchat. Presiona el botón a continuación para importar un archivo." + }, + "better_location": { + "save_dialog_button": "Guardar", + "saved_name_dialog_hint": "Nombre guardado", + "no_friends_found": "No se encontraron amigos", + "saved_coordinates_title": "Coordenadas guardadas", + "delete_dialog_title": "Eliminar coordenadas guardadas", + "search_bar": "Buscar", + "spoofed_coordinates_title": "Lat {latitude}, Lng {longitude}", + "save_coordinates_dialog_title": "Guardar coordenadas", + "choose_location_button": "Elegir la ubicación", + "spoof_location_toggle": "Ubicación falsa", + "no_saved_coordinates_hint": "No hay coordenadas guardadas", + "delete_dialog_message": "¿Está seguro de que desea eliminar esta coordenada guardada?", + "no_friends_map": "No hay amigos en el mapa", + "teleport_to_friend_button": "Teletransportar a un amigo", + "teleport_to_friend_title": "Teletransportar a un amigo", + "latitude_dialog_hint": "Latitud", + "longitude_dialog_hint": "Longitud", + "suspend_location_updates": "Suspender las actualizaciones de ubicación" + }, + "theming": { + "no_themes_hint": "No se encontraron temas" + }, + "manage_rule_feature": { + "whitelist_state_button": "Seleccionar amig@s/grupos permitidos", + "blacklist_state_option": "Todo el mundo excepto ...", + "blacklist_state_subtext": "Todos excepto {count} amig@s/grupos se verán afectados por esta regla", + "blacklist_state_button": "Seleccionar amig@s/grupos excluidos", + "clear_list_button": "Borrar la lista de amig@s/grupos", + "disable_state_option": "Desactivado", + "disable_state_subtext": "Ningun@s amig@s/grupos se verán afectados", + "dialog_clear_confirmation_text": "¿Seguro que quieres borrar la lista?", + "whitelist_state_option": "Nadie excepto...", + "whitelist_state_subtext": "Solo {count} amig@s/grupos se verán afectados por esta regla" + } + }, + "dialogs": { + "add_friend": { + "title": "Añadir amigx o grupo", + "search_hint": "Buscar", + "fetch_error": "Error al cargar datos", + "category_groups": "Grupos", + "category_friends": "Amigxs" + }, + "scripting_warning": { + "title": "Aviso", + "content": "SnapEnhance incluye una herramienta de escribir comandos, que permite la ejecución de código definido por el usuario. Exige precaución extrema y asegúrese de sólo instalar módulos de fuentes conocidos y confiables. Módulos no verificados o autorizados pueden arriesgar la seguridad de tu sistema." + }, + "reset_config": { + "content": "¿Segura/o que quieres restablecer la configuración?", + "success_toast": "Configuración restablecida con éxito", + "title": "Restablecer configuración" + }, + "messaging_action": { + "select_all_button": "Seleccionar todo", + "title": "Elige el tipo de contenido para procesarlo" + }, + "file_imports": { + "no_files_settings_hint": "No se encontraron archivos. Asegúrate de haber importado los archivos requeridos en la sección Importaciones de archivos", + "settings_select_file_hint": "Selecciona un archivo importado" + }, + "export_config": { + "title": "¿Exportar datos confidenciales?", + "content": "¿Quieres exportar la configuración con tus datos privados? (Como coordenadas de la ubicación, etc.)" + } + } + }, + "rules": { + "toasts": { + "enabled": "{ruleName} activado", + "disabled": "{ruleName} desactivado" + }, + "modes": { + "blacklist": "Modo de lista negra", + "whitelist": "Modo de lista blanca" + }, + "properties": { + "auto_download": { + "name": "Descargar automáticamente", + "options": { + "blacklist": "Excluir de descargas automáticas", + "whitelist": "Descargar automáticamente" + }, + "description": "Descargar Snaps automáticamente mientras los ves" + }, + "stealth": { + "name": "Modo sigilo", + "description": "Impide que nadie sepa que hayas abierto sus Snaps/Chats y conversaciones", + "options": { + "blacklist": "Excluir de modo sigilo", + "whitelist": "Modo sigilo" + } + }, + "auto_save": { + "name": "Guardar automáticamente", + "description": "Guarda mensajes de chat mientras verlos", + "options": { + "blacklist": "Excluir de guardar automáticamente", + "whitelist": "Guardar automáticamente" + } + }, + "unsaveable_messages": { + "name": "Mensajes que no se puede guardar", + "options": { + "blacklist": "Excluir de mensajes que no se puede guardar", + "whitelist": "Mensajes que no se puede guardar" + }, + "description": "Impide poder guardar mensajes en el chat por otras personas" + }, + "auto_open_snaps": { + "name": "Automáticamente abrir los Snaps", + "description": "Automáticamente abre los Snaps al recibirlos", + "options": { + "blacklist": "Excluir de abrir Snaps automáticamente", + "whitelist": "Abrir Snaps automáticamente" + } + }, + "hide_friend_feed": { + "name": "Ocultar de la lista de amigxs" + }, + "e2e_encryption": { + "name": "Usa cifración de extremo a extremo" + }, + "pin_conversation": { + "name": "Fijar conversación" + } + } + }, + "actions": { + "clean_snapchat_cache": { + "description": "Borra cache de Snapchat", + "name": "Borrar cache de Snapchat" + }, + "change_language": { + "name": "Cambiar idioma", + "description": "Cambiar el idioma de SnapEnhance" + }, + "manage_friend_list": { + "name": "Administrar lista de amigxs", + "description": "Importar/exportar tu lista de amigxs al crear un respaldo" + }, + "export_chat_messages": { + "name": "Exportar mensajes de chat", + "description": "Exporta mensajes a un archivo de tipo JSON/HTML/TXT" + }, + "export_memories": { + "name": "Exportar memorias", + "description": "Exporta memorias a un archivo ZIP" + }, + "bulk_messaging_action": { + "name": "acción de mensajería a granel", + "description": "realiza operaciones como borrar amigxs o eliminar conversaciones en masa" + }, + "regen_mappings": { + "name": "regenerar mapeos", + "description": "regenerar mapeos a mano" + }, + "file_imports": { + "description": "Importar archivos para usar en Snapchat", + "name": "Importación de archivos" + }, + "friend_tracker": { + "name": "Rastreador de amigos", + "description": "Sigue a tus amigos en Snapchat" + }, + "logger_history": { + "description": "Ver el historial de mensajes guardados", + "name": "Historial del registro" + }, + "theming": { + "description": "Personaliza la apariencia de Snapchat", + "name": "Temática" + }, + "security_features": { + "name": "Características de seguridad", + "description": "Cambiar las preferencias de las funciones de seguridad" + } + }, + "scopes": { + "friend": "Amigx", + "group": "Grupo" + }, + "features": { + "properties": { + "user_interface": { + "properties": { + "bootstrap_override": { + "properties": { + "app_appearance": { + "name": "Apariencia de la aplicación", + "description": "Establece una apariencia persistente de la aplicación" + }, + "home_tab": { + "name": "Pestaña de inicio", + "description": "Anula la pestaña en la que se abre la app al abrir Snapchat" + } + }, + "name": "Anular Bootstrap", + "description": "Anula los ajustes de bootstrap de la interfaz de usuario" + }, + "enable_app_appearance": { + "name": "Habilitar ajustes de apariencia de la app", + "description": "Habilita los ajustes de apariencia de la app escondidos\nPuede que no sea necesario en nuevas versiones de Snapchat" + }, + "friend_feed_message_preview": { + "name": "Vista previa de mensajes en la lista de amigxs", + "description": "Muestra una vista previa de los últimos mensajes en la lista de amigxs", + "properties": { + "amount": { + "name": "Cantidad", + "description": "La cantidad de mensajes para hacer una vista previa" + } + } + }, + "snap_preview": { + "name": "Vista previa de Snap", + "description": "Muestra una pequeña vista previa al lado de Snaps no vistos en el chat" + }, + "hide_friend_feed_entry": { + "description": "Oculta un amigx específico de la lista de amigxs\nUsa la pestaña social para administrar esta característica", + "name": "Ocultar entrada de lista de amigxs" + }, + "hide_streak_restore": { + "description": "Oculta el botón de restablecer en la lista de amigxs", + "name": "Ocultar restablecer Snapracha" + }, + "opera_media_quick_info": { + "name": "Multimedias de Opera información rápida", + "description": "Muestra información útil sobre multimedias como la fecha de creación en el menú contextual de Opera Viewer" + }, + "enable_friend_feed_menu_bar": { + "name": "Barra de menú en la lista de amigxs", + "description": "Habilita la nueva barra de menú en la lista de amigxs" + }, + "stealth_mode_indicator": { + "name": "Indicador de modo sigilo", + "description": "Añade un 👻 emoji al lado de una conversación en modo sigilo" + }, + "map_friend_nametags": { + "name": "Etiquetas de nombre mejoradas en el mapa de amigxs", + "description": "Mejora las etiquetas de nombre de amigxs en el mapa de Snaps" + }, + "prevent_message_list_auto_scroll": { + "name": "Prevenir que la lista de mensajes se desplaza automáticamente", + "description": "Previene que la lista de mensajes se desplaza hasta el fondo al enviar/recibir un mensaje" + }, + "streak_expiration_info": { + "name": "Mostrar información de caducidad de la racha", + "description": "Muestra un temporizador de caducidad al lado del contador de racha" + }, + "hide_story_suggestions": { + "name": "Ocultar sugerencias de historias", + "description": "Elimina las sugerencias de la página de historias" + }, + "hide_ui_components": { + "name": "Ocultar componentes de la interfaz de usuario", + "description": "Elige que componentes de interfaz de usuario ocultar" + }, + "old_bitmoji_selfie": { + "name": "Retrato de Bitmoji viejo", + "description": "Trae de vuelta los retratos de Bitmoji de versiónes más antiguos de Snapchat" + }, + "disable_spotlight": { + "name": "Deshabilitar Spotlight", + "description": "Deshabilita la página de Spotlight" + }, + "friend_feed_menu_buttons": { + "name": "Botones de menú de la lista de amigxs", + "description": "Elige que botones mostrar en el menú de la lista de amigxs" + }, + "vertical_story_viewer": { + "name": "Ver las historias de forma vertical", + "description": "Permite ver todas las historias de forma vertical" + }, + "message_indicators": { + "name": "Indicadores de mensajes", + "description": "Añade iconos indicadores específicos a los mensajes\nTen en cuenta: Puede que los indicadores no sean 100% precisos" + }, + "edit_text_override": { + "name": "Editar anulación de texto", + "description": "Anula el comportamiento de entradas de texto" + }, + "auto_close_friend_feed_menu": { + "name": "Menú de feed de amigos cercanos automáticos", + "description": "Cierra automáticamente el menú de Feed de amigos después de presionar un botón de configuración" + }, + "custom_theme": { + "name": "Tema personalizado", + "description": "Personalizar los colores de Snapchat\nNota: si eliges un tema oscuro (como Amoled), es posible que tengas que activar el modo oscuro en los ajustes de Snapchat para obtener mejores resultados" + } + }, + "name": "Interfaz de usuario", + "description": "Cambia la apariencia y sensación Snapchat" + }, + "downloader": { + "properties": { + "force_voice_note_format": { + "description": "Obliga guardar las notas de voz en un formato específico", + "name": "Obligar formato de notas de voz" + }, + "download_profile_pictures": { + "name": "Descargar fotos de perfil", + "description": "Te permite guardar las fotos de perfil desde la página del perfil" + }, + "opera_download_button": { + "name": "Botón de descarga Opera", + "description": "Añade un botón de descarga en la esquina derecha superior al ver un Snap.\nUn pulso largo forzará la descarga" + }, + "ffmpeg_options": { + "name": "Opciones de FFmpeg", + "properties": { + "threads": { + "name": "Hilos", + "description": "La cantidad de hilos a utilizar" + }, + "preset": { + "name": "preestablecido", + "description": "Establecer la velocidad de la conversación" + }, + "constant_rate_factor": { + "name": "Factor de tasa constante", + "description": "Establecer el factor de tasa constante para el codificador de vídeo\nDesde 0 hasta 51 para libx264" + }, + "video_bitrate": { + "description": "Establecer el bitrate de vídeo (kbps)", + "name": "Bitrate de vídeo" + }, + "audio_bitrate": { + "name": "Bitrate de audio", + "description": "Establecer el bitrate de audio (kbps)" + }, + "custom_video_codec": { + "name": "Códec de vídeo personalizado", + "description": "Establecer un códec de vídeo personalizado (por ejemplo, libx264)" + }, + "custom_audio_codec": { + "name": "Códec de audio personalizado", + "description": "Establecer un códec de audio personalizado (por ejemplo, AAC)" + } + }, + "description": "especificar opciones de FFmpeg adicionales" + }, + "download_context_menu": { + "name": "Descargar menú contextual", + "description": "Te permite descargar/hacer una vista previa desde una conversación o historia usando el menú contextual.\nUn pulso largo en los botones forzará la descarga" + }, + "logging": { + "name": "Registrando", + "description": "muestra toasts mientras multimedia se está descargando" + }, + "custom_path_format": { + "name": "Ruta de archivo personalizada", + "description": "Especificar una ruta de archivo personalizada para multimedia descargada\n\nVariables disponibles:\n - %username%\n - %source%\n - %hash%\n - %date_time%" + }, + "merge_overlays": { + "description": "Une el texto y la multimedio de un Snap en un solo archivo", + "name": "Fusionar superposiciones" + }, + "save_folder": { + "name": "Guardar carpeta", + "description": "Seleccione el directorio al que se deben descargar todos los multimedios" + }, + "auto_download_sources": { + "name": "Descargar fuentes automáticamente", + "description": "Seleccione desde que fuentes descargar automáticamente" + }, + "prevent_self_auto_download": { + "name": "Evitar autodescarga automática", + "description": "Evita que tus propios Snaps se descarguen automáticamente" + }, + "path_format": { + "name": "Formato de ruta", + "description": "Especificar el formato de la ruta de archivos" + }, + "allow_duplicate": { + "name": "Permitir duplicado", + "description": "Permite que la misma multimedia se descarga múltiples veces" + }, + "force_image_format": { + "name": "Forzar formato de imagen", + "description": "Obliga que los imágenes se guardan en un formato especificado" + }, + "auto_download_voice_notes": { + "description": "Descarga automáticamente las notas de voz al reproducirlas", + "name": "Descarga automática de notas de voz" + } + }, + "description": "Descargar multimedios de Snapchat", + "name": "Descargador" + }, + "messaging": { + "properties": { + "prevent_story_rewatch_indicator": { + "name": "Deshabilitar el indicador de ver la historia de nuevo", + "description": "Evita que nadie sepa que has visto su historia de nuevo" + }, + "bypass_screenshot_detection": { + "name": "Anular detección de capturas de pantalla", + "description": "Evita que Snapchat pueda detectar cuando hagas una captura de pantalla" + }, + "anonymous_story_viewing": { + "name": "Viendo historias anónimamente", + "description": "Evita que nadie sepa que has visto su historia" + }, + "hide_peek_a_peek": { + "name": "Ocultar Peek-a-Peek", + "description": "Evita que se envía una notificación si no acabas de deslizar por completo en un chat" + }, + "hide_bitmoji_presence": { + "name": "Ocultar presencia de Bitmoji", + "description": "Evita que se muestra tu Bitmoji mientras estás en el chat" + }, + "hide_typing_notifications": { + "name": "Ocultar notificaciones de escribiendo", + "description": "Evita que nadie sepa que estás escribiendo un mensaje" + }, + "unlimited_snap_view_time": { + "name": "Ver Snaps sin límite de tiempo", + "description": "Quita el límite de tiempo para ver Snaps" + }, + "auto_mark_as_read": { + "name": "Marcar como leído automáticamente", + "description": "Automáticamente marca los mensajes/Snaps como leído incluso cuando el modo sigilo esté habilitado" + }, + "loop_media_playback": { + "name": "Reproducción de Multimedia en bucle", + "description": "Reproduce Multimedias en bucle al ver Snaps/Historias" + }, + "disable_replay_in_ff": { + "name": "Deshabilitar repetir en la lista de amigxs", + "description": "Deshabilita la posibilidad de repetir al mantener pulsado desde la lista de amigxs" + }, + "half_swipe_notifier": { + "name": "Notificación de no acabar de deslizar por completo en un chat", + "properties": { + "min_duration": { + "name": "Duración mínima", + "description": "La duración mínima de no acabar de deslizar por completo (en segundos)" + }, + "max_duration": { + "name": "duración máxima", + "description": "La duración máxima de no acabar de deslizar por completo (en segundos)" + } + }, + "description": "Te notifica cuando alguien no acaba de deslizar por completo a una conversación" + }, + "call_start_confirmation": { + "name": "Confirmación de empezar llamada", + "description": "Muestra un diálogo de confirmación al empezar una llamada" + }, + "notification_blacklist": { + "description": "Elige las notificaciones que deben ser bloqueadas", + "name": "Lista negra de notificaciones" + }, + "message_logger": { + "description": "Evita que se pueda borrar un mensaje", + "name": "Registro de mensajes", + "properties": { + "keep_my_own_messages": { + "name": "Guardar mis mensajes", + "description": "Evita que se pueda borrar tus mensajes" + }, + "auto_purge": { + "name": "purga automática", + "description": "Automáticamente borra los mensajes en el cache que son más antiguos que la duración del tiempo especificado" + }, + "message_filter": { + "name": "Filtro de mensajes", + "description": "Seleccione que mensajes deben ser registrados (vacío para todos los mensajes)" + } + } + }, + "auto_save_messages_in_conversations": { + "name": "Guardar mensajes automáticamente", + "description": "Automáticamente guarda cada mensaje en una conversación" + }, + "unlimited_conversation_pinning": { + "name": "Fijar conversaciones ilimitadas", + "description": "Te permite fijar una cantidad de conversaciones ilimitada localmente" + }, + "prevent_message_sending": { + "name": "Evitar el envío de mensajes", + "description": "Evita el envío de algunos tipos de mensajes" + }, + "friend_mutation_notifier": { + "name": "Notificación de mutación de amigx", + "description": "Te notifica si algo cambia en el perfil de un amigx" + }, + "better_notifications": { + "name": "Mejores notificaciones", + "description": "Agrega más información a notificaciones recibidas", + "properties": { + "chat_preview": { + "name": "Vista previa del chat", + "description": "Muestra una vista previa de los mensajes recibidos en la notificación" + }, + "media_preview": { + "name": "Vista previa del archivo multimedia", + "description": "Muestra una vista previa de los archivos multimedia seleccionados en la notificación" + }, + "media_caption": { + "name": "Título multimedia", + "description": "Muestra el título adjunto de los archivos multimedia en la notificación" + }, + "stacked_media_messages": { + "name": "Amontonar los mensajes multimedia", + "description": "Combina varios mensajes multimedia en una notificación de texto cuando no se pueden obtener una vista previa. Usar en combinación con la vista previa del chat" + }, + "download_button": { + "name": "Botón descargar", + "description": "Le permite descargar medios desde la notificación" + }, + "mark_as_read_button": { + "name": "Botón marcar como leído", + "description": "Te permite marcar un mensaje como leído desde la notificación" + }, + "group_notifications": { + "name": "Agrupar notificaciones", + "description": "Agrupa notificaciones en una sola notificación" + }, + "friend_add_source": { + "description": "Muestra la fuente de una solicitud de amistad en la notificación", + "name": "Fuente de solicitud de amistad" + }, + "reply_button": { + "name": "Botón responder", + "description": "Agrega un botón de responder a la notificación" + }, + "mark_as_read_and_save_in_chat": { + "name": "Marcar como leído y guardar en el chat", + "description": "Agrega un botón marcar como leído y guardar en el chat a la notificación" + }, + "smart_replies": { + "description": "Agregar respuestas sugeridas a las notificaciones (Android 10+). Usar en combinación con el botón Responder", + "name": "Respuestas inteligentes" + } + } + }, + "gallery_media_send_override": { + "name": "Anulación de envío de medios de la galería", + "description": "Falsifica el fuente de multimedia al enviar desde la galería" + }, + "strip_media_metadata": { + "name": "Borrar metadatos de medios", + "description": "Elimina los metadatos de la multimedia antes de enviar como un mensaje" + }, + "bypass_message_retention_policy": { + "name": "Pasar por alto la política de retención de mensajes", + "description": "Evita que se pueda borrar mensajes después de verlos" + }, + "bypass_message_action_restrictions": { + "name": "Pasar por alto restricciones de acciones de mensajería", + "description": "Te permite reaccionar a un Snap sin haberlo abierto o guardar un mensaje no guardable" + }, + "remove_groups_locked_status": { + "name": "Elimina el estatus de grupos bloqueados", + "description": "Te permite ver información del grupo después de ser expulsada/o" + }, + "mark_snap_as_seen_button": { + "description": "Agrega un botón para marcar un Snap como visto al visualizarlo.\nEsto funcionará incluso cuando el modo oculto esté habilitado", + "name": "Botón Marcar Snap como visto" + }, + "skip_when_marking_as_seen": { + "description": "Salta automáticamente al siguiente Snap al marcar un Snap como visto.\nUsar en combinación con el botón Marcar como visto", + "name": "Saltar al marcar como visto" + } + }, + "name": "mensajería", + "description": "Cambiar como interactúas con amigxs" + }, + "global": { + "properties": { + "disable_confirmation_dialogs": { + "name": "Deshabilita diálogos de confirmación", + "description": "Automáticamente verifica acciones seleccionadas" + }, + "disable_story_sections": { + "name": "Deshabilitar secciones de historias", + "description": "Descarta secciones de la página de historias\nPuede que se necesita recargar para funcionar correctamente" + }, + "block_ads": { + "name": "bloquear anuncios", + "description": "Evita que se muestran anuncios" + }, + "better_location": { + "properties": { + "walk_radius": { + "name": "Radio de caminata", + "description": "Andar dentro de este radio (en pies) al azar" + }, + "spoof_location": { + "name": "Falsificar ubicación", + "description": "Falsifica tu ubicación a una predeterminada" + }, + "coordinates": { + "name": "Coordenadas", + "description": "Establece las coordenadas de la ubicación falsificada" + }, + "always_update_location": { + "name": "Siempre actualizar ubicación", + "description": "Forzar a Snapchat a actualizar la ubicación incluso cuando no se ha recibido ninguna información del GPS" + }, + "suspend_location_updates": { + "name": "Suspender actualizaciones de la ubicación", + "description": "Evita que se actualice tu ubicación" + }, + "spoof_battery_level": { + "name": "Falsificar nivel de carga", + "description": "Falsifica nivel de carga de tu dispositivo en el mapa\nEl valor tiene que ser entre 0 y 100" + }, + "spoof_headphones": { + "name": "Falsificar auriculares", + "description": "Falsifica el estado de escuchar a música en el mapa" + }, + "show_battery_level": { + "name": "Mostrar nivel de bateria", + "description": "Muestta el nivel de bateria de tu amigos en el mapa" + } + }, + "name": "Mejor ubicación", + "description": "Mejora la ubicación de Snapchat" + }, + "snapchat_plus": { + "name": "Snapchat Plus", + "description": "Habilita funciones de Snapchat Plus\nAlgunas funciones del lado del servidor pueden no funcionar" + }, + "auto_updater": { + "name": "Actualizador automático", + "description": "Comprueba automáticamente si hay actualizaciones nuevas" + }, + "disable_metrics": { + "name": "Deshabilitar métricos", + "description": "Prohíbe enviar datos específicos de análisis a Snapchat" + }, + "media_upload_quality": { + "name": "Calidad de multimedia al subir", + "description": "Anula la calidad de subida de multimedias", + "properties": { + "force_video_upload_source_quality": { + "name": "Forzar calidad del fuente al subir vídeos", + "description": "Obliga a Snapchat usar la calidad del fuente al subir vídeos\nPor favor ten en cuenta que puede que esta opción no quitará los metadatos de los multimedios" + }, + "custom_image_upload_format": { + "description": "Establece un formato personalizado de subir imágenes\nElige un formato sin pérdida (como PNG) para la mayor calidad", + "name": "Formato personalizado de subir imágenes" + }, + "disable_image_compression": { + "name": "Deshabilitar compresión de imágenes", + "description": "Deshabilita compresión de imágenes al subir multimedios" + } + } + }, + "disable_custom_tabs": { + "name": "Deshabilitar pestañas personalizadas", + "description": "Abre enlaces en aplicaciones soportadas en cambio de en el navegador" + }, + "disable_memories_snap_feed": { + "description": "Impide que Snapchat te muestra memorias recientes al deslizar hacia arriba en la cámara", + "name": "Deshabilitar memorias en el Snap Feed" + }, + "disable_permission_requests": { + "name": "Deshabilitar solicitaciones de permisiones", + "description": "Impide que Snapchat pueda preguntar para permisiones especificas" + }, + "spotlight_comments_username": { + "description": "Muestra el usuario del autor en los comentarios de Spotlight", + "name": "Nombre de usuarios en comentarios de Spotlight" + }, + "bypass_video_length_restriction": { + "name": "Saltar restricciones de longitud de vídeo", + "description": "individuo: envía un solo vídeo\nDividido: divide los vídeos después de editar" + }, + "default_video_playback_rate": { + "name": "Tasa de reproducción de video por defecto", + "description": "Establece la velocidad por defecto para vídeos\nEl valor tiene que ser entre 0.1 y 4.0" + }, + "video_playback_rate_slider": { + "name": "Control deslizante de velocidad de reproducción de vídeo", + "description": "añade control deslizante en el menú de contexto de Opera para cambiar la velocidad de reproducción de vídeos\nTen en cuenta: Cambios solo se aplican a vídeos posteriores" + }, + "disable_google_play_dialogs": { + "name": "Deshabilitar diálogos de servicio de Google Play", + "description": "Prohíbe que Servicios de Google Play muestran diálogos de disponibilidad" + }, + "disable_snap_splitting": { + "description": "Evita que los Snaps se dividan en varias partes\nLas fotos que envíes se convertirán en vídeos", + "name": "Deshabilitar que los Snaps se dividen" + }, + "default_volume_controls": { + "name": "Controles de volumen por defecto", + "description": "Fuerza que Snapchat tenga que usar controles de volumen del sistema" + }, + "hide_active_music": { + "name": "Ocultar música activa", + "description": "Evita que Snapchat sepa que estás escuchando a música\nEsto te permitirá capturar Snaps usando los botones de volumen mientras escuchas a música" + }, + "disable_telecom_framework": { + "name": "Deshabilitar el marco de telecomunicaciones", + "description": "Impide que Snapchat utilice el marco de Android Telecom\nEsto te permite escuchar música durante una llamada" + } + }, + "name": "Global", + "description": "Cambiar ajustes globales de Snapchat" + }, + "camera": { + "properties": { + "immersive_camera_preview": { + "name": "Vista previa inmersiva", + "description": "Evita que Snapchat recorte la vista previa de la cámara\nEsto podría hacer que la cámara parpadee en algunos dispositivos" + }, + "override_front_resolution": { + "name": "Anular la resolución frontal", + "description": "Anula la resolución de la cámara frontal" + }, + "disable_cameras": { + "name": "Deshabilitar cámaras", + "description": "Evita que Snapchat use las cámaras seleccionadas" + }, + "black_photos": { + "name": "Fotos negras", + "description": "Sustituye las fotos capturadas por un fondo negro\nLos vídeos no se ven afectados" + }, + "override_back_resolution": { + "name": "Anular resolución anterior", + "description": "Anula la resolución de la cámara trasera" + }, + "custom_resolution": { + "name": "Resolución personalizada", + "description": "Establece una resolución de cámara personalizada, Ancho x altura (por ejemplo 1920x1080)\nLa resolución personalizada tiene que ser soportada por tu dispositivo" + }, + "front_custom_frame_rate": { + "name": "Velocidad de fotogramas personalizada frontal", + "description": "Anula la velocidad de fotogramas de la cámara frontal" + }, + "force_camera_source_encoding": { + "name": "Forzar codificación de fuente de la cámara", + "description": "Fuerza la codificación de la fuente de la cámara" + }, + "hevc_recording": { + "name": "Grabación HEVC", + "description": "Usa el códec HEVC (H.265) para grabaciones de vídeos" + }, + "back_custom_frame_rate": { + "description": "Anula la velocidad de fotogramas de la cámara trasera", + "name": "Velocidad de fotogramas trasera" + } + }, + "name": "Cámara", + "description": "Ajustar los ajustes correctos para el Snap prefecto" + }, + "rules": { + "name": "Normas", + "description": "Gestionar funciones automáticas para personas individuales" + }, + "streaks_reminder": { + "description": "Le notifica periódicamente de tus rachas", + "name": "Recordatorio de rachas", + "properties": { + "interval": { + "name": "Intervalo", + "description": "El intervalo entre cada recordatorio (horas)" + }, + "group_notifications": { + "description": "Agrupa notificaciones en una sola notificación", + "name": "Agrupar notificaciones" + }, + "remaining_hours": { + "name": "Tiempo restante", + "description": "El tiempo que queda antes de que la notificación se muestra (horas)" + } + } + }, + "experimental": { + "description": "Funciones experimentales", + "properties": { + "native_hooks": { + "name": "Ganchos nativos", + "properties": { + "composer_hooks": { + "properties": { + "show_first_created_username": { + "description": "Muestra el primer nombre de usuario creado al lado del usuario actual en la página de perfil", + "name": "Muestra el primer nombre de usuario creado" + }, + "bypass_camera_roll_limit": { + "name": "Saltar el límite del álbum de cámara", + "description": "Aumenta la cantidad máxima de archivos multimedia que puedes enviar desde el álbum de cámara" + }, + "composer_console": { + "name": "Consola del compositor", + "description": "Le permite ejecutar código de JavaScript en el compositor (sólo arm64)" + }, + "composer_logs": { + "name": "Registro del compositor", + "description": "Redirige los registros de la consola del compositor a SnapEnhance" + } + }, + "name": "Ganchas de compositor", + "description": "Inyecta código al marco multiplataforma de interfaz de usuario de Composer" + }, + "disable_bitmoji": { + "name": "Deshabilitar Bitmoji", + "description": "Deshabilita Bitmoji en perfiles de amigxs" + }, + "custom_emoji_font": { + "name": "Fuente de los emoji personalizada", + "description": "Te permite utilizar una fuente para los emoji personalizada. Sólo funciona con fuentes .ttf" + }, + "custom_shared_library": { + "name": "Libreria compartida customizada", + "description": "Carga una biblioteca compartida personalizada en Snapchat. Esta función es solo para fines de prueba." + } + }, + "description": "Funciones inseguras que se integran al código nativo de Snapchat" + }, + "media_file_picker": { + "description": "Le permite elegir cualquier archivo de vídeo/audio de la galería", + "name": "Selector de archivos multimedia" + }, + "spoof": { + "properties": { + "remove_vpn_transport_flag": { + "name": "Quitar bandera de transporte de VPN", + "description": "Impide que Snapchat pueda detectar VPNs" + }, + "play_store_installer_package_name": { + "name": "Nombre del paquete de instalación del Play Store", + "description": "Convierte el nombre del paquete de instalación a com.android.vending" + }, + "remove_mock_location_flag": { + "name": "Quitar bandera de ubicación falsificada", + "description": "Impide que Snapchat pueda detectar ubicación falsificada" + } + }, + "name": "Falsificar", + "description": "Falsifica diversa información sobre usted" + }, + "convert_message_locally": { + "name": "Convertir mensaje localmente", + "description": "Convierte Snaps a chat multimedia externas localmente. Esto aparece en el menú de contexto de las descargas" + }, + "story_logger": { + "name": "Registro de historias", + "description": "Proporciona un historial de historias de amigxs" + }, + "e2ee": { + "description": "Cifra tus mensajes con AES usando una clave secreta compartida\n¡Asegúrate de guardar tu clave en un lugar seguro!", + "name": "Cifrado de extremo a extremo", + "properties": { + "force_message_encryption": { + "description": "Impide enviar mensajes cifrados a personas que no tienen la cifración de extremo a extremo habilitada solo cuando haya varias conversaciones seleccionadas", + "name": "Forzar el cifrado de mensajes" + }, + "encrypted_message_indicator": { + "name": "Indicador de mensajes cifrados", + "description": "Añade el emoji 🔒 al lado de mensajes cifrados" + } + } + }, + "call_recorder": { + "name": "Grabador de llamadas", + "description": "Grabar llamadas de audio automáticamente" + }, + "account_switcher": { + "name": "Cambiar cuenta", + "description": "Le permite cambiar entre cuentas sin cerrar la sesión\nHaz un pulso largo en el icono de buscar al lado de tu perfil de Bitmoji para abrir el menú\nTen en cuenta: esta función es experimental y probablemente cambiará en el futuro", + "properties": { + "auto_backup_current_account": { + "name": "Automáticamente respaldar la centa actual", + "description": "Automáticamente crea respaldos al cerrar sesión o cambiar de cuenta" + } + } + }, + "edit_message": { + "name": "Editar mensajes", + "description": "Le permite editar mensajes en conversaciones" + }, + "app_lock": { + "name": "Bloqueo de aplicación", + "description": "Impide el acceso a Snapchat sin contraseña", + "properties": { + "lock_on_resume": { + "name": "Bloquear al reanudar", + "description": "Bloquea la app al reabrirla" + } + } + }, + "infinite_story_boost": { + "name": "Promoción de historias infinita", + "description": "Omitir el retraso del límite de promoción de historia" + }, + "meo_passcode_bypass": { + "name": "Omitir contraseña de solo para mí", + "description": "Omite la contraseña del área privada\nSolo funciona si ya se ha ingresado la contraseña correcta" + }, + "no_friend_score_delay": { + "name": "Puntos de amigxs sin demora", + "description": "Elimina la demora al ver una puntuación de los amigos" + }, + "best_friend_pinning": { + "name": "Fijar mejor amigo", + "description": "Te permite fijar a un mejor amigx como tu mejor amigx número uno. Ten en cuenta: solo tú puedes ver a tu mejor amigx fijado" + }, + "custom_streaks_expiration_format": { + "description": "Personaliza el formato de caducidad de rachas\n\nVariables disponibles:\n - %c: Número de rachas\n - %e: Reloj de arena emoji\n - %d: Dían\n - %h: Horas\n - %m: Minutos\n - %s: Segundos\n - %w: Tiempo restante", + "name": "Formato de caducidad de rachas personalizado" + }, + "add_friend_source_spoof": { + "name": "Falsificación de fuente de añadir amigx", + "description": "Falsifica la fuente de dónde viene la solicitud de amistad" + }, + "hidden_snapchat_plus_features": { + "name": "Funciones de Snapchat Plus ocultas", + "description": "Habilita funciones inéditas/beta de Snapchat Plus\nPuede no funcionar en versiones antiguas de Snapchat" + }, + "prevent_forced_logout": { + "name": "Impedir el cierre de sesión forzado", + "description": "Impide que Snapchat pueda cerrar la sesión cuando inicies sesión en otro dispositivo" + }, + "cof_experiments": { + "name": "Experimentos COF", + "description": "Habilita funciones inéditas/beta de Snapchat" + }, + "context_menu_fix": { + "description": "Intente reparar el menú Friend Feed ya que cuando el dispositivo está sin conexión no se mostraba correctamente", + "name": "Corrección del menú contextual" + }, + "better_transcript": { + "properties": { + "enhanced_transcript": { + "description": "Mejora la transcripción de notas de voz usando DeepL.\nAntes de utilizar esta función, asegúrese de haber leído su política de privacidad.", + "name": "Transcripción mejorada" + }, + "enhanced_transcript_in_notifications": { + "name": "Transcripción mejorada en las notificaciones", + "description": "Transcribe notas de voz en notificaciones utilizando la dirección DeepL. Esto requiere que la función Vista previa del chat esté habilitada en Mejores notificaciones" + }, + "force_transcription": { + "description": "Permitir transcribir todas las notas de voz", + "name": "Forzar transcripción de notas de voz" + }, + "preferred_transcription_lang": { + "name": "Idioma prefiero para la transcripción", + "description": "El idioma preferido para la transcripción de la nota de voz (por ejemplo EN, ES, FR)" + } + }, + "description": "Mejora la transcripción de notas de voz", + "name": "Mejor transcripción" + }, + "voice_note_auto_play": { + "description": "Reproducir automáticamente la siguiente nota de voz después de que finalice la actual", + "name": "Reproducir automáticamente las notas de voz" + } + }, + "name": "Experimental" + }, + "scripting": { + "description": "Ejecuta código personalizado para mejorar SnapEnhance", + "properties": { + "module_folder": { + "description": "La carpeta dónde está ubicado el código", + "name": "Carpeta de módulos" + }, + "auto_reload": { + "name": "Recargar automáticamente", + "description": "Recargar código automáticamente cuando cambie" + }, + "developer_mode": { + "name": "Modo de desarrollador", + "description": "Muestra información de depuración en la interfaz de usuario de Snapchat" + }, + "integrated_ui": { + "name": "Interfaz de Usuario integrado", + "description": "Permite al código de usuarios añadir componentes de la interfaz de usuario personalizados a Snapchat" + }, + "disable_log_anonymization": { + "description": "Deshabilita la anonimización de los registros", + "name": "Deshabilitar anonimización de reegistros" + } + }, + "name": "Codificando" + }, + "friend_tracker": { + "name": "Rastreador de amigos", + "properties": { + "record_messaging_events": { + "description": "Registra eventos de mensajería como la apertura de un snap, la lectura de un mensaje, etc.", + "name": "Registra eventos de mensajería" + }, + "allow_running_in_background": { + "name": "Permitir la ejecución en segundo plano", + "description": "Permite que el rastreador se ejecute en segundo plano. Nota: Esto agotará significativamente la batería" + }, + "auto_purge": { + "name": "Purga automática", + "description": "Elimina automáticamente los eventos almacenados en caché que son más antiguos que la cantidad de tiempo especificada" + } + }, + "description": "Registra la actividad de un amigo en Snapchat" + } + }, + "notices": { + "ban_risk": "⚠ Esta característica puede causar baneos", + "unstable": "⚠ inestable", + "internal_behavior": "⚠ Esto puede romper el funcionamiento interno de Snapchat" + }, + "options": { + "app_appearance": { + "always_light": "Siempre claro", + "always_dark": "Siempre oscuro" + }, + "friend_feed_menu_buttons": { + "conversation_info": "👤 Información de conversación", + "auto_save": "💬 Guardar mensajes automáticamente", + "unsaveable_messages": "⬇️ Mensajes no guardables", + "auto_download": "⬇️ Descarga automática", + "auto_open_snaps": "📷 Automáticamente abrir Snaps", + "stealth": "👻 Modo sigilo", + "mark_snaps_as_seen": "👀 Marca Snaps como vistos", + "mark_stories_as_seen_locally": "👀 Marcar historias como vistas localmente", + "e2e_encryption": "🔒 Usar cifración de extremo a extremo" + }, + "hide_ui_components": { + "hide_profile_call_buttons": "Quitar botones de llamada de perfil", + "hide_live_location_share_button": "Quitar botón de compartir ubicación en vivo", + "hide_stickers_button": "Quitar botón de pegatinas", + "hide_voice_record_button": "Quitar botón de Grabar voz", + "hide_unread_chat_hint": "Quitar indicación de chat no leído", + "hide_chat_call_buttons": "Quitar botones de llamada de chat", + "hide_post_to_story_buttons": "Eliminar los botones Publicar en la historia antes de enviar un Snap", + "hide_billboard_prompt": "Eliminar el aviso de la página principal en el feed de amigos", + "hide_snapchat_plus_gift_reminders": "Eliminar los recordatorios de regalo de Snapchat Plus en las conversaciones", + "hide_map_reactions": "Eliminar reacciones del mapa" + }, + "hide_story_suggestions": { + "hide_my_stories": "Ocultar mis historias", + "hide_suggested_friend_stories": "Ocultar historias de amigxs sugeridas" + }, + "home_tab": { + "camera": "Cámara", + "spotlight": "Spotlight", + "map": "Mapa", + "chat": "Chat", + "discover": "Descubrir" + }, + "add_friend_source_spoof": { + "added_by_username": "Por nombre de usuario", + "added_by_mention": "Por mención", + "added_by_community": "Por comunidad", + "added_by_group_chat": "Por Grupo", + "added_by_qr_code": "Por Código QR", + "added_by_quick_add": "Por Quick Add (alto riesgo de ser baneado)" + }, + "edit_text_override": { + "multi_line_chat_input": "Entrada de chat multilínea", + "bypass_text_input_limit": "Omitir límite de entrada de texto" + }, + "disable_permission_requests": { + "notifications": "Notificaciones", + "read_media_images": "Leer imágenes de medios", + "location": "Ubicación", + "read_media_video": "Leer vídeos de medios", + "camera": "Cámara", + "microphone": "Micrófono", + "read_contacts": "Leer contactos", + "nearby_devices": "Dispositivos cercanos", + "phone_calls": "Llamadas" + }, + "message_indicators": { + "ovf_editor_indicator": "Indica si un Snap se envió usando un editor OVF", + "encryption_indicator": "Añade un icono de 🔒 al lado de mensajes que han sido enviados solo a ti", + "platform_indicator": "Añade el icono de la plataforma desde cual se envió el mensaje (por ejemplo Android, iOS, Web)", + "location_indicator": "Añade un icono de 📍 a Snaps si han sido enviados con la ubicación habilitada", + "director_mode_indicator": "Añade un icono de ✏️ a Snaps si han sido enviados usando el modo director, que se puede usar para enviar imágenes de la galería como Snaps" + }, + "bypass_video_length_restriction": { + "single": "Archivo de Media único", + "split": "Archivos de media divididos" + }, + "path_format": { + "append_username": "Añadir el usuario al nombre del archivo", + "create_author_folder": "Crear carpeta para cada autor", + "create_source_folder": "Crear carpeta para cada tipo de fuente de los archivos media", + "append_hash": "Añadir un hash único al nombre del archivo", + "append_source": "Añadir la fuente de archivo de media al nombre del archivo", + "append_date_time": "Añadir la fecha y hora al nombre del archivo" + }, + "auto_download_sources": { + "friend_snaps": "Snaps de amigxs", + "friend_stories": "Historias de amigxs", + "public_stories": "Historias públicas", + "spotlight": "Spotlight" + }, + "logging": { + "started": "Iniciado", + "success": "Finalizado con éxito", + "progress": "Progreso", + "failure": "Fallo" + }, + "notifications": { + "initiate_audio": "Llamada de audio entrante", + "chat_screenshot": "Captura de pantalla", + "chat_screen_record": "Grabación de pantalla", + "snap_replay": "Snap visto de nuevo", + "camera_roll_save": "Guardado en álbum de cámara", + "chat": "Chat", + "chat_reply": "Chat visto de nuevo", + "snap": "Snap", + "typing": "Escribiendo", + "stories": "Historias", + "speaking": "Hablando", + "chat_reaction": "Reacción a mensaje directo", + "group_chat_reaction": "Reacción de grupo", + "abandon_audio": "Llamada de audio perdida", + "initiate_video": "Llamada de vídeo entrante", + "abandon_video": "Llamada de vídeo perdida" + }, + "gallery_media_send_override": { + "always_ask": "Preguntar siempre", + "ORIGINAL": "Archivo multimedia original", + "NOTE": "Nota de voz", + "SNAP": "Snap", + "SAVEABLE_SNAP": "Snap guardable" + }, + "strip_media_metadata": { + "hide_caption_text": "Ocultar texto de subtítulos", + "hide_snap_filters": "Ocultar filtros de Snap", + "hide_extras": "Ocultar extras (por ejemplo menciones)", + "remove_audio_note_duration": "Quitar duración de nota de voz", + "remove_audio_note_transcript_capability": "Quitar capacidad de transcribir nota de voz" + }, + "old_bitmoji_selfie": { + "2d": "Bitmoji 2D", + "3d": "Bitmoji 3D" + }, + "disable_confirmation_dialogs": { + "erase_message": "Borrar mensaje", + "remove_friend": "Eliminar amigx", + "block_friend": "Bloquear amigx", + "ignore_friend": "Ignorar amigx", + "hide_friend": "Ocultar amigx", + "hide_conversation": "Ocultar conversación", + "clear_conversation": "Borrar conversación desde la lista de amigxs" + }, + "auto_reload": { + "snapchat_only": "Solo Snapchat", + "all": "Todo (Snapchat + SnapEnhance)" + }, + "auto_purge": { + "never": "Nunca", + "1_hour": "1 Hora", + "3_hours": "3 Horas", + "6_hours": "6 Horas", + "12_hours": "12 Horas", + "1_day": "1 día", + "3_days": "3 Días", + "1_week": "1 Semana", + "2_weeks": "2 Semanas", + "1_month": "1 Mes", + "3_months": "3 Meses", + "6_months": "6 Meses" + }, + "disable_story_sections": { + "discover": "Descubrir", + "friends": "Amigxs", + "following": "Siguiendo", + "suggested_stories": "Historias recomendadas" + }, + "disable_cameras": { + "front": "Cámara frontal", + "back": "Cámara trasera" + }, + "auto_mark_as_read": { + "conversation_read": "Marcar conversaciones como leídos al enviar un mensaje", + "snap_reply": "Marcar Snaps como leídos al responder" + }, + "friend_mutation_notifier": { + "remove_friend": "Notificar si alguien te elimina como amigx", + "birthday_changes": "Notificar si alguien cambia sus cumpleaños", + "bitmoji_selfie_changes": "Notificar si alguien cambia su retrato de Bitmoji", + "bitmoji_avatar_changes": "Notificar si alguien cambia su avatar de Bitmoji", + "bitmoji_background_changes": "Notificar si alguien cambia el fondo de su Bitmoji", + "bitmoji_scene_changes": "Notificar si alguien cambia su escena de Bitmoji" + }, + "custom_theme": { + "custom": "Temas personalizados (utilice las acciones rápidas para gestionar los temas)", + "material_you_dark": "Material You oscuro (Android 12+)", + "amoled_dark_mode": "Modo oscuro Amoled", + "material_you_light": "Material You claro (Android 12+)" + } + } + }, + "content_type": { + "STICKER": "Pegatina", + "STATUS_SAVE_TO_CAMERA_ROLL": "Guardado al álbum de cámara", + "STATUS_CONVERSATION_CAPTURE_SCREENSHOT": "Captura de pantalla", + "STATUS": "Estado", + "STATUS_CONVERSATION_CAPTURE_RECORD": "Grabación de pantalla", + "STATUS_CALL_MISSED_VIDEO": "Llamada de vídeo perdida", + "STATUS_CALL_MISSED_AUDIO": "Llamada de Audio perdida", + "LOCATION": "Ubicación", + "LIVE_LOCATION_SHARE": "Compartir ubicación en vivo", + "CHAT": "Chat", + "SNAP": "Snap", + "EXTERNAL_MEDIA": "Archivos de media externos", + "NOTE": "Nota de voz", + "CREATIVE_TOOL_ITEM": "Elemento de herramienta creativa", + "FAMILY_CENTER_INVITE": "Invitación de centro de familia", + "FAMILY_CENTER_ACCEPT": "Aceptar centro de familia", + "FAMILY_CENTER_LEAVE": "Salir del centro de familia", + "STATUS_PLUS_GIFT": "Regalo de estado plus", + "TINY_SNAP": "Snap diminuto", + "STATUS_COUNTDOWN": "Cuenta atrás", + "MAP_REACTION": "Reacción de mapa", + "SHARE": "Compartir" + }, + "mark_as_seen": { + "already_unseen_toast": "¡Ya marcado como no visto!", + "already_seen_toast": "¡Ya marcado como visto!", + "no_unseen_snaps_toast": "¡No se han encontrado Snaps no vistos!", + "seen_toast": "¡Marcado como visto!", + "unseen_toast": "¡Marcado como no visto!" + }, + "bulk_messaging_action": { + "choose_action_title": "Elegir una acción", + "progress_status": "Procesando {index} de {total}", + "selection_dialog_continue_button": "Continuar", + "confirmation_dialog": { + "title": "¿Estás segura/o?", + "message": "Esto afectará todos los amigxs seleccionados. Esta acción no se puede deshacer." + }, + "actions": { + "remove_friends": "Quitar amigxs", + "clear_conversations": "Borrar conversaciones" + } + }, + "chat_export": { + "exporter_dialog": { + "select_conversations_title": "Seleccionar conversaciones", + "text_field_selection": "{amount} seleccionado", + "text_field_selection_all": "Todo", + "export_file_format_title": "Exportar formato de archivo", + "message_type_filter_title": "Filtrar mensajes por tipo", + "amount_of_messages_title": "Cantidad de mensajes (dejar vacío para todos)", + "download_medias_title": "Descargar archivos de media" + }, + "dialog_negative_button": "Cancelar", + "dialog_positive_button": "Exportar", + "exported_to": "Exportado a {path}", + "exporting_chats": "Exportando Chats...", + "processing_chats": "Procesando {amount} conversaciones...", + "export_fail": "Fallo al exportar conversación {conversation}", + "writing_output": "Escribiendo salida...", + "finished": "¡Hecho! Puedes cerrar este diálogo.", + "no_messages_found": "¡No se han encontrado ningunos mensajes!", + "exporting_message": "Exportando{conversation}..." + }, + "button": { + "download": "Descargar", + "cancel": "Cancelar", + "open": "Abrir", + "positive": "Sí", + "negative": "No", + "ok": "Vale", + "send": "Enviar" + }, + "better_notifications": { + "button": { + "reply": "Responder", + "download": "Descargar", + "mark_as_read": "Marcar como leído" + } + }, + "download_processor": { + "select_attachments_title": "Seleccionar atajos", + "already_downloaded_toast": "¡Archivo de media ya descargado!", + "attachment_type": { + "snap": "Snap", + "sticker": "Pegatina", + "gif": "GIF", + "external_media": "Archivos de media externos", + "note": "Nota", + "original_story": "Historia original" + }, + "download_started_toast": "Descarga iniciada", + "unsupported_content_type_toast": "¡Tipo de contenido no compatible!", + "failed_no_longer_available_toast": "Archivos de media ya no disponibles", + "no_attachments_toast": "¡No se han encontrado atajos!", + "already_queued_toast": "¡Archivo de media ya en cola!", + "download_toast": "Descargando {path}...", + "processing_toast": "Procesando {path}...", + "failed_generic_toast": "Error al descargar", + "failed_to_create_preview_toast": "Fallo al crear vista previa", + "failed_processing_toast": "Fallo de procesar {error}", + "failed_gallery_toast": "No se pudo guardar a la galería {error}", + "dash_no_chapter": "No se encontró ningún capítulo", + "dash_dialog": { + "title": "Descargar archivos de media del tablero", + "download_all": "Descargar todo", + "segment_text": "Segmento {from} - {to}" + } + }, + "biometric_auth": { + "subtitle": "Por favor, autentícate para desbloquear Snapchat", + "unlock_button": "Desbloquear", + "title": "Desbloquear Snapchat" + }, + "end_to_end_encryption": { + "toolbox": { + "initiate_exchange_button": "Iniciar intercambio de clave", + "no_shared_key": "Aún no tienes un secreto compartido con este amigx. Pulsa abajo para iniciar un secreto nuevo.", + "shared_key_fingerprint": "Tu huella digital es:\n\n{fingerprint}\n\n¡Asegúrese de comprobar si coincide con la huella de tu amigx!" + }, + "confirmation_dialogs": { + "confirmation_2": "¿De VERDAD estás segura/o que quieres continuar? Esta es tu última oportunidad para retroceder.", + "title": "Cifrado de extremo a extremo", + "confirmation_1": "ADVERTENCIA: Esto sobrescribirá su clave existente. Perderás el acceso a todos los mensajes cifrados de este amigx. ¿Estás segura/o de que quieres continuar?" + }, + "no_participants_to_encrypt_toast": "¡No tienes ningunos amigxs en esta conversación con quien cifrar mensajes!", + "accept_public_key_failure_toast": "Fallo al aceptar clave pública", + "accept_secret_button": "Aceptar secreto", + "encryption_failed_toast": "¡Error al cifrar el mensaje! Comprueba el logcat para más detalles.", + "incoming_pk_message": "Acabas de recibir una solicitud de clave pública. Haga clic a continuación para aceptarla.", + "incoming_secret_message": "Tu amig@ acaba de aceptar tu clave pública. Haz clic abajo para aceptar el secreto.", + "accept_secret_key_success_toast": "¡Hecho! Ahora puedes enviar y recibir mensajes cifrados con este amigx.", + "accept_secret_key_failure_toast": "Fallo al aceptar clave secreta", + "accept_public_key_button": "Aceptar clave pública", + "outgoing_pk_message": "Solicitud de cambio de clave", + "outgoing_secret_message": "Respuesta de cambio de clave", + "unencrypted_conversation_send_failure_toast": "¡No puedes enviar contenido cifrado a conversaciones cifradas y no cifradas!", + "native_hooks_send_failure_toast": "¡Fallo al enviar! Por favor habilita Ganchos Nativos en los ajustes.", + "accept_public_key_success_toast": "¡Clave pública aceptada exitosamente!" + }, + "chat_action_menu": { + "delete_logged_message_button": "Borrar mensaje registrado", + "edit_message": "Editar mensaje", + "convert_message": "Convertir mensaje", + "preview_button": "Vista previa", + "download_button": "Descargar", + "show_chat_edit_history": "Mostrar el historial de edición del chat" + }, + "opera_context_menu": { + "download": "Descargar archivos de media", + "created_at": "Creado {date}", + "expires_at": "Caduca {date}", + "media_size": "Tamaño de media: {size}", + "media_duration": "Duración de media: {duration} ms", + "sent_at": "Enviado {date}", + "show_debug_info": "Mostrar información de depuración" + }, + "profile_picture_downloader": { + "title": "Descargador de imagenes de perfil", + "button": "Descargar imagen de perfil", + "avatar_option": "Avatar", + "background_option": "Fondo" + }, + "call_start_confirmation": { + "dialog_title": "Iniciar llamada", + "dialog_message": "¿Segura/o que quieres empezar una llamada?" + }, + "auto_open_snaps": { + "title": "Abrir Snaps automáticamente", + "notification_content": "{count} Snaps abiertos" + }, + "material3_strings": { + "date_input_invalid_not_allowed": "Fecha invalida", + "date_range_picker_start_headline": "Desde", + "date_range_picker_end_headline": "A", + "date_range_picker_title": "Elegir rango de fechas", + "date_picker_switch_to_calendar_mode": "Calendario", + "date_picker_switch_to_input_mode": "Entrada", + "date_range_picker_scroll_to_previous_month": "Último mes", + "date_range_picker_scroll_to_next_month": "Próximo mes", + "date_picker_today_description": "Hoy", + "date_range_picker_day_in_range": "Seleccionado", + "date_input_invalid_for_pattern": "Fecha invalida", + "date_input_invalid_year_range": "Año invalido", + "date_range_input_invalid_range_input": "Rango de fechas invalido" + }, + "friend_menu_option": { + "anti_auto_save": "Evitar el guardado automático", + "mark_snaps_as_seen": "Marcar Snap como leído", + "mark_stories_as_seen_locally": "Marcar historias como vistos localmente", + "preview": "Prevista", + "stealth_mode": "Modo sigilo", + "auto_download_blacklist": "Lista negra de descargas automáticas" + }, + "friend_mutation_observer": { + "notification_channel_name": "Observador de mutación de amigx", + "friend_removed": "{username} te ha eliminado como amigx", + "birthday_removed": "{username} ha eliminado su cumpleaños ({birthday})", + "birthday_added": "{username} ha añadido su cumpleaños ({birthday})", + "birthday_changed": "{username} ha cambiado su cumpleaños de {oldBirthday} a {newBirthday}", + "bitmoji_selfie_changed": "{username} ha cambiado su retrato de Bitmoji", + "bitmoji_avatar_changed": "{username} ha cambiado su avatar de Bitmoji", + "bitmoji_background_changed": "{username} ha cambiado el fondo de su Bitmoji", + "bitmoji_scene_changed": "{username} ha cambiado la escena de su Bitmoji" + }, + "media_download_source": { + "none": "Ninguno", + "pending": "Pendiente", + "chat_media": "Archivos de media de chat", + "story": "Historia", + "public_story": "Historia pública", + "spotlight": "Spotlight", + "profile_picture": "Imagen de perfil", + "story_logger": "Registrador de Historias", + "message_logger": "Registrador de mensajes", + "merged": "Mezclado", + "voice_call": "Llamada de voz" + }, + "modal_option": { + "profile_info": "Información de perfil", + "close": "Cerrar" + }, + "gallery_media_send_override": { + "multiple_media_toast": "Solo puedes enviar un medio a la vez" + }, + "conversation_preview": { + "streak_expiration": "Caduca en {day} días {hour} horas {minute} minutos", + "total_messages": "Suma de mensajes enviados/recibidos: {count}", + "title": "Vista previa", + "unknown_user": "Usuario desconocido", + "no_messages": "¡No se han encontrado mensajes!" + }, + "profile_info": { + "title": "Información de perfil", + "first_created_username": "Primer nombre de usuario creado", + "mutable_username": "Nombre de usuario mutable", + "display_name": "Nombre para mostrar", + "added_date": "Fecha añadida", + "birthday": "Cumpleaños: {month} {day}", + "hidden_birthday": "Cumpleaños: Ocultado", + "friendship": "Amistad", + "add_source": "Fuente de añadir", + "snapchat_plus": "Snapchat Plus", + "snapchat_plus_state": { + "subscribed": "Suscrito", + "not_subscribed": "No suscrito" + } + }, + "friendship_link_type": { + "mutual": "Mutuo", + "outgoing": "Saliente", + "blocked": "Bloqueado", + "deleted": "Borrado", + "following": "Siguiendo", + "suggested": "Sugerido", + "incoming": "Entrante", + "incoming_follower": "Seguidor entrante" + }, + "half_swipe_notifier": { + "notification_channel_name": "No acabar de deslizar por completo en un chat", + "notification_content_dm": "{friend} acaba de no acabar de deslizar por completo en tu chat durante {duration} segundos", + "notification_content_group": "{friend} Acaba de no acabar de deslizar por completo en {group} durante {duration} segundos" + }, + "streaks_reminder": { + "notification_title": "Rachas", + "notification_text": "Perderás tu racha con {friend} en {hoursLeft} horas" + }, + "theming_attributes": { + "sigColorTextPrimary": "Color principal del texto", + "sigColorBackgroundSurface": "Color del fondo", + "sigColorChatSnapWithSound": "Color del contenido de los Snaps de voz", + "sigColorChatSnapWithoutSound": "Color del contenido de los Snaps de texto", + "sigColorBackgroundMain": "Color de fondo", + "actionSheetBackgroundDrawable": "Color de fondo del Menú de acciones", + "actionSheetRoundedBackgroundDrawable": "Color de fondo redondeado del menú de acciones", + "sigExceptionColorCameraGridLines": "Color de las cuadrículas de la cámara", + "listDivider": "Color del separador de las listas", + "actionSheetDescriptionTextColor": "Color del texto de la descripción", + "listBackgroundDrawable": "Fondo de lista de la conversación", + "sigColorIconSecondary": "Color secundario del icono", + "sigColorChatConversationsLine": "Color de la línea de la conversación", + "sigColorIconPrimary": "Color del icono del menú de acciones", + "itemShapeFillColor": "Color de relleno de la forma del elemento", + "boxBackgroundColor": "Color de fondo del cuadro", + "recipientInputStyle": "Forma de entrada del destinatario", + "rangeFillColor": "Color de relleno para el rango", + "statusBarForeground": "Color en primer plano de la barra de estado", + "statusBarBackground": "Color en segundo plano de la barra de estado", + "sigColorButtonPrimary": "Color del botón principal", + "editTextColor": "Editar color del texto", + "pstsTabBackground": "Fondo de la pestaña PSTS", + "pstsDividerColor": "Color del divisor PSTS", + "tabTextColor": "Color del texto de la pestaña", + "pstsIndicatorColor": "Color del indicador PSTS", + "strokeColor": "Color del trazo", + "scButtonColor": "Color del botón de Snapchat", + "sigColorBaseAppYellow": "Aplicación principal de color amarillo", + "sigColorBackgroundSurfaceTranslucent": "Color del fondo translúcido", + "sigColorChatChat": "Color del texto del feed del amigo principal", + "sigColorChatPendingSending": "Color del texto del feed para otros amigos", + "ringColor": "Color del anillo", + "sigColorLayoutPlaceholder": "Color del marcador de posición", + "chipBackgroundColor": "Color de fondo del chip", + "ringStartColor": "Color inicial del anillo", + "storyReplayViewRingColor": "Repetición del historial Ver el color del círculo", + "sigColorStoryRingFriendsFeedStoryRing": "Color del círculo del feed de los amigos y las historias" + }, + "send_override_dialog": { + "title": "Enviar medios como {type}", + "unlimited_duration": "Ilimitado", + "duration": "Duración: {duration}", + "saveable_snap_hint": "Hacer que Snap se pueda guardar en el chat" + } +} diff --git a/common/src/main/assets/lang/fi.json b/common/src/main/assets/lang/fi.json new file mode 100644 index 0000000000..4a79737c00 --- /dev/null +++ b/common/src/main/assets/lang/fi.json @@ -0,0 +1,852 @@ +{ + "setup": { + "dialogs": { + "select_language": "Valitse Kieli", + "save_folder": "SnapEnhance vaatii tallennus oikeudet ladatakseen mediaa Snapchatista.\nValitse sijainti mihin media tallennetaan.", + "select_save_folder_button": "Valitse kansio" + }, + "mappings": { + "dialog": "Luodaan kartoituksia, tämä voi kestää jonkin aikaa...", + "generate_failure_no_snapchat": "SnapEnhance ei havainnut Snapchattia, yritä asentaa Snapchat uudelleen.", + "generate_failure": "Kartoituksien luomisessa tapahtui virhe. Yritä uudelleen." + }, + "permissions": { + "dialog": "Jatkaaksesi sinun on täytettävä seuraavat vaatimukset:", + "notification_access": "Ilmoitusten Käyttöoikeus", + "battery_optimization": "Akun Optimointi", + "display_over_other_apps": "Näytä Muiden Sovellusten Päällä", + "request_button": "Pyydä" + } + }, + "manager": { + "routes": { + "features": "Ominaisuudet", + "home": "Etusivu", + "home_settings": "Asetukset", + "home_logs": "Lokit", + "social": "Sosiaalinen", + "scripts": "Komentosarjat", + "tasks": "Tehtävät", + "logger_history": "Lokejen historia", + "logged_stories": "kirjattuja tarinoita", + "manage_scope": "Muokkaa laajuutta", + "messaging_preview": "Esikatsoa", + "friend_tracker": "Kavereiden Seuranta", + "edit_rule": "Muokkaa Sääntöä", + "better_location": "Parempi sijainti", + "manage_rule_feature": "Hallitse sääntöominaisuutta", + "file_imports": "Tiedostojen tuonnit", + "theming": "Teema", + "edit_theme": "Muokkaa teemaa", + "manage_repos": "Hallinnoi tietovarastoja" + }, + "sections": { + "features": { + "disabled": "Pois päältä", + "export_option": "Exporttaa", + "import_option": "Importtaa", + "reset_option": "Resetoida", + "saved_config_snackbar": "Config tallennettu", + "config_import_failure_toast": "Configin tuonti epäonnistui {error}", + "config_import_success_toast": "Config tuotu onnistuneesti", + "config_export_success_toast": "Config viety onnistuneesti", + "config_export_failure_toast": "Määrityksen vienti epäonnistui {error}" + }, + "social": { + "streaks_expiration_short": "{Tunnit}h", + "friends_tab": "Kaverit", + "groups_tab": "Ryhmät", + "empty_hint": "Tyhjä" + }, + "home_logs": { + "saved_logs_success_toast": "Lokit tallennettu onnistuneesti", + "saved_logs_failure_toast": "Epäonnistui tallentamaan lokit", + "no_logs_hint": "Ei lokeja saatavilla", + "clear_logs_button": "Tyhjennä lokit", + "export_logs_button": "Exporttaa lokit", + "saving_logs_toast": "Tallennetaan lokeja, tämä voi kestää hetken ..." + }, + "home_settings": { + "actions_title": "Tapahtumat", + "message_logger_title": "viestiloggeri", + "debug_title": "debug", + "success_toast": "Valmis!", + "message_logger_summary": "Tekstit\ntarinat", + "export_button": "Exporttaa", + "clear_button": "Tyhjentää", + "view_logger_history_button": "Katso logger historia" + }, + "home": { + "update_title": "SnapEnchance päivitys", + "update_content": "Versio on saatavilla", + "update_button": "Lataa", + "debug_build_summary_title": "Käytössäsi on SnapEnhance-virheenkorjaus", + "debug_build_summary_date": "Koontipäivämäärä: {date} ({days} päivää sitten)", + "quick_actions_title": "Pikatoimet" + }, + "tasks": { + "no_tasks": "Ei tehtäviä", + "merge_files_toast": "Yhdistetään tiedostoja", + "remove_selected_tasks_title": "Oletko varma että haluat poistaa valitut tehtävät?", + "remove_all_tasks_title": "Oletko varma että haluat poistaa kaikki tehtävät?", + "delete_files_option": "Myös poista tiedostot", + "remove_selected_tasks_confirm": "Poista tehtävät?", + "remove_all_tasks_confirm": "Poista kaikki tehtävät?", + "failed_to_open_file": "Tiedoston avaaminen epäonnistui" + }, + "manage_scope": { + "logged_stories_button": "Näytä logatut tarinat", + "rules_title": "Säännöt", + "not_found": "Ei löydetty", + "streaks_expiration_text_expired": "Vanhentunut", + "reminder_button": "Aseta muistutus", + "delete_scope_confirm_dialog_title": "Oletko varma että haluat poistaa {scope}?", + "streaks_title": "Striikit", + "streaks_length_text": "Pituus: {length}", + "streaks_expiration_text": "Vanhentuu {eta}", + "e2ee_title": "Päästä päähän salaus", + "participants_text": "{count} osallistujat" + }, + "logged_stories": { + "story_failed_to_load": "Epäonnistui lataamaan", + "no_stories": "Ei tarinoita löydetty", + "save_from_cache_button": "Tallenna Välimuistista" + }, + "logger_history": { + "list_friend_format": "Kaveri {name}", + "list_group_format": "Ryhmä {group}", + "unknown_sender": "Tuntematon Lähettäjä", + "message_parse_failed": "Viestin jäsentäminen epäonnistui", + "no_more_messages": "Ei lisää viestejä", + "chat_attachment": "Liite {index}", + "empty_message": "Tyhjä Keskustelu Viesti", + "download_attachment_failed_toast": "Liitteen lataaminen epäonnistui", + "reverse_order_checkbox": "Järjestyksen flippaaminen" + }, + "messaging_preview": { + "save_selection_option": "Tallenna Valinta", + "save_all_option": "Tallenna Kaikki", + "mark_all_as_seen_option": "Merkitse kaikki Snäpit nähdyksi", + "delete_selection_option": "Poista Valinta", + "delete_all_option": "Poista Kaikki", + "bridge_connection_failed": "Yhteyden muodostaminen siltaan epäonnistui. Varmista, että Snapchat on käynnissä taustalla", + "unsave_selection_option": "Poista valittu Tallennus", + "unsave_all_option": "Poista tallennus Kaikista", + "mark_selection_as_seen_option": "Merkitse valittu Snäppi nähdyksi", + "bridge_init_failed": "Viestintäsillan alustaminen epäonnistui. Varmista, että Snapchat on käynnissä taustalla", + "message_fetch_failed": "Viestien haku epäonnistui", + "no_message_hint": "Ei viestiä" + }, + "better_location": { + "search_bar": "Etsi", + "save_coordinates_dialog_title": "Tallenna koordinaatit", + "latitude_dialog_hint": "Leveysaste", + "saved_coordinates_title": "Tallennetut koordinaatit", + "no_friends_map": "Ei ystäviä kartalla", + "teleport_to_friend_title": "Teleporttaa ystävälle", + "save_dialog_button": "Tallenna", + "delete_dialog_message": "Oletko varma, että haluat poistaa tämän tallennetun koordinaatin?", + "no_friends_found": "Ystäviä ei löytynyt", + "choose_location_button": "Valitse sijainti", + "teleport_to_friend_button": "Teleporttaa ystävälle", + "suspend_location_updates": "Keskeytä sijaintipäivitykset", + "delete_dialog_title": "Poista tallennettu koordinaatti", + "saved_name_dialog_hint": "Tallennettu nimi", + "longitude_dialog_hint": "Pituusaste", + "spoof_location_toggle": "Huijaa sijaintia", + "no_saved_coordinates_hint": "Ei tallennettuja koordinaatteja" + }, + "file_imports": { + "no_files_hint": "Täältä voit tuoda tiedostoja käytettäväksi Snapchatissa. Tuo tiedosto painamalla alla olevaa painiketta.", + "import_file_button": "Tuo tiedosto", + "file_delete_failed": "Tiedoston poistaminen epäonnistui", + "file_import_failed": "Tiedoston tuonti epäonnistui: {error}", + "file_not_found": "Tiedostoa ei löytynyt", + "file_imported": "Tiedoston tuonti onnistui" + }, + "manage_rule_feature": { + "disable_state_option": "Poistettu käytöstä", + "disable_state_subtext": "Ei vaikuta ystäviin/ryhmiin", + "whitelist_state_subtext": "Tämä sääntö vaikuttaa vain {count} kaveriin/ryhmään", + "blacklist_state_option": "Kaikki paitsi...", + "blacklist_state_button": "Valitse poissuljetut ystävät/ryhmät", + "whitelist_state_option": "Kukaan paitsi...", + "whitelist_state_button": "Valitse sallitut ystävät/ryhmät", + "blacklist_state_subtext": "Tämä sääntö vaikuttaa kaikkiin paitsi {count} kaveriin/ryhmään", + "clear_list_button": "Tyhjennä ystävä-/ryhmäluettelo", + "dialog_clear_confirmation_text": "Oletko varma, että haluat tyhjentää luettelon?" + }, + "theming": { + "no_themes_hint": "Teemoja ei löytynyt" + } + }, + "dialogs": { + "add_friend": { + "title": "Lisää Kaveri tai Ryhmä", + "search_hint": "Etsi", + "fetch_error": "Tietojen noutaminen epäonnistui", + "category_groups": "Ryhmät", + "category_friends": "Kaverit" + }, + "messaging_action": { + "select_all_button": "Valitse Kaikki", + "title": "Valitse sisältö tyypit toimintoon" + }, + "reset_config": { + "title": "Alusta config", + "content": "Oletko varma että haluat alustaa configin?", + "success_toast": "Config alustettu onnistuneesti" + }, + "scripting_warning": { + "title": "Varoitus", + "content": "SnapEnhance sisältää skriptityökalun, joka mahdollistaa käyttäjän määrittämän koodin suorittamisen. Ole erittäin varovainen ja asenna moduuleja vain tunnetuista ja luotettavista lähteistä. Luottamattomat moduulit voivat aiheuttaa tietoturvariskejä järjestelmällesi." + }, + "file_imports": { + "no_files_settings_hint": "Tiedostoja ei löytynyt. Varmista, että olet tuonut tarvittavat tiedostot Tiedostojen tuonti -osiossa", + "settings_select_file_hint": "Valitse tuotu tiedosto" + }, + "export_config": { + "title": "Viedäänkö arkaluonteisia tietoja?", + "content": "Haluatko viedä kokoonpanon arkaluontoisilla tiedoilla? (kuten sijaintikoordinaatit jne.)" + } + } + }, + "rules": { + "modes": { + "blacklist": "Blacklist tila", + "whitelist": "Whitelist tila" + }, + "properties": { + "auto_download": { + "name": "Automaattinen Lataus", + "description": "Lataa automaattisesti vastaanotetun snäpin avauksen yhteydessä", + "options": { + "blacklist": "Sulje pois automaattisesta latauksesta", + "whitelist": "Automaattinen Lataus" + } + }, + "stealth": { + "name": "Haamutila", + "description": "Estää ketään tietämästä, että olet avannut heidän snäpin tai chatin", + "options": { + "blacklist": "Sulje pois haamutilasta", + "whitelist": "Haamutila" + } + }, + "auto_save": { + "name": "Automaattinen Tallennus", + "description": "Tallentaa chat-viestit katselun yhteydessä", + "options": { + "blacklist": "Sulje pois automaattinen tallentaminen", + "whitelist": "Automaattinen tallennus" + } + }, + "hide_friend_feed": { + "name": "Piilota Ystäväsyötteestä" + }, + "e2e_encryption": { + "name": "Käytä E2E-salausta" + }, + "pin_conversation": { + "name": "Kiinnitä Keskustelu" + }, + "unsaveable_messages": { + "options": { + "blacklist": "Jätä pois tallentamattomista viesteistä", + "whitelist": "Tallentamattomat viestit" + }, + "name": "Tallentamattomat viestit", + "description": "Estää muita ihmisiä tallentamasta viestejä pikakeskusteluissa" + }, + "auto_open_snaps": { + "name": "Avaa Snäpit Automaattisesti", + "options": { + "whitelist": "Avaa Snäpit Automaattisesti", + "blacklist": "Poislue Automaattisesta Snäppien Avaamisesta" + }, + "description": "Avaa snäpit automaattisesti vastaanottaessa" + } + }, + "toasts": { + "disabled": "{ruleName} Poissa päältä", + "enabled": "{ruleName} Päällä" + } + }, + "features": { + "notices": { + "unstable": "⚠ Epävakaa", + "ban_risk": "⚠ Voi aiheuttaa eston tilillesi", + "internal_behavior": "⚠ Voi rikkoa Snapchatin sisäisiä toiminnallisuuksia" + }, + "properties": { + "downloader": { + "name": "Lataaja", + "description": "Lataa Snapchat Mediaa", + "properties": { + "save_folder": { + "name": "Tallennus Kansio", + "description": "Valitse kansio, johon kaikki media ladataan" + }, + "auto_download_sources": { + "name": "Automaattiset Latauslähteet", + "description": "Valitse lähteet, joista ladataan automaattisesti" + }, + "prevent_self_auto_download": { + "name": "Estä Automaattiset Lataukset Itseltä", + "description": "Estää omia Snappejasi latautumasta automaattisesti" + }, + "path_format": { + "name": "Polun Muoto", + "description": "Määritä tiedostopolun muoto" + }, + "allow_duplicate": { + "name": "Salli Kaksoiskappaleet", + "description": "Salli saman median lataamisen useita kertoja" + }, + "merge_overlays": { + "name": "Yhdistä Päälitasot", + "description": "Yhdistää snäpin tekstin ja median yhdeksi tiedostoksi" + }, + "force_image_format": { + "name": "Pakota Kuvan Formaatti", + "description": "Pakottaa tallentamaan kuvat valitussa formaatissa" + }, + "force_voice_note_format": { + "name": "Pakota äänimuistiinpanomuoto", + "description": "Pakottaa äänimuistiinpanot tallentamaan tietyssä muodossa" + }, + "download_profile_pictures": { + "name": "Lataa profiilikuvat", + "description": "Antaa sinun ladata profiilikuvia profiilin sivulta" + }, + "ffmpeg_options": { + "name": "FFmpeg Asetukset", + "description": "Määritä FFmpeg-lisäasetukset", + "properties": { + "threads": { + "name": "Säikeet", + "description": "Määritä rinnakkaisten säikeiden määrä" + }, + "preset": { + "name": "Esiasetus", + "description": "Aseta muunnoksen nopeus" + }, + "constant_rate_factor": { + "name": "Vakionopeustekijä", + "description": "Aseta videoenkooderin vakionopeuskerroin\n 0-51 libx264: lle" + }, + "video_bitrate": { + "name": "Videon bittinopeus", + "description": "Aseta videon bittinopeus (kbps)" + }, + "audio_bitrate": { + "name": "Äänen bittinopeus", + "description": "Aseta äänen bittinopeus (kbps)" + }, + "custom_video_codec": { + "name": "Mukautettu Videokoodekki", + "description": "Aseta mukautettu videokoodekki (esim. libx264)" + }, + "custom_audio_codec": { + "name": "Mukautettu Äänikoodekki", + "description": "Aseta mukautettu äänikoodekki (esim. AAC)" + } + } + }, + "logging": { + "name": "Lokiin kirjaus", + "description": "Näytä pieni ilmoitus kun lataus on käynnissä" + }, + "download_context_menu": { + "name": "Latauksen lisävalikko", + "description": "Mahdollistaa keskustelun tai tarinan viestien lataamisen/esikatselun kontekstivalikon avulla.\nPainikkeiden pitkä painallus pakottaa latauksen" + }, + "custom_path_format": { + "description": "Määritä mukautettu polkumuoto ladatulle medialle\n\nSaatavilla olevat muuttujat:\n - %username%\n - %source%\n - %hash%\n - %date_time%", + "name": "Mukautettu polkurakenne" + }, + "opera_download_button": { + "description": "Lisää latauspainikkeen oikeaan yläkulmaan, kun katselet Snapia.\nPainikkeiden pitkä painallus pakottaa latauksen", + "name": "Opera Lataus Painike" + } + } + }, + "user_interface": { + "name": "Ulkoasu", + "description": "Muuta Snapchatin ulkoasua ja tuntumaa", + "properties": { + "enable_app_appearance": { + "name": "Ota käyttöön sovelluksen ulkoasuasetukset", + "description": "Ottaa käyttöön piilotetun sovelluksen ulkoasuasetuksen\nEi välttämättä vaadita uudemmissa Snapchat-versioissa" + }, + "friend_feed_message_preview": { + "name": "Ystäväsyötteen Viestien Esikatselu", + "description": "Näyttää esikatselun ystäväsyötteen viimeisimmistä viesteistä", + "properties": { + "amount": { + "name": "Määrä", + "description": "Esikatseltavien viestien määrä" + } + } + }, + "bootstrap_override": { + "name": "Bootstrap Ohitus", + "description": "Ohittaa käyttöliittymän bootstrap-asetukset", + "properties": { + "app_appearance": { + "name": "Sovelluksen Ulkoasu", + "description": "Asettaa pysyvän sovelluksen ulkoasun" + }, + "home_tab": { + "name": "Koti-välilehti", + "description": "Valitse välilehti mikä aukeaa Snapchatin käynnistyessä" + } + } + }, + "map_friend_nametags": { + "name": "Parannetut ystäväkartan nimilaput", + "description": "Parantaa ystävien nimilappuja Snapkartassa" + }, + "streak_expiration_info": { + "name": "Näytä Striikkien Vanhenemistiedot", + "description": "Näyttää striikkien loppumisajastin streakkilaskurin vieressä" + }, + "hide_friend_feed_entry": { + "name": "Piilota ystäväsyöte", + "description": "Piilottaa tietyn ystävän ystäväsyötteestä\n Käytä sosiaalisen median välilehteä hallitaksesi tätä ominaisuutta" + }, + "hide_streak_restore": { + "name": "Piilota streakkien palautus", + "description": "Piilottaa Palauta-painikkeen ystäväsyötteessä" + }, + "hide_ui_components": { + "name": "Piilota käyttöliittymäkomponentit", + "description": "Valitse piilotettavat käyttöliittymäkomponentit" + }, + "disable_spotlight": { + "name": "Poista Valokeila Käytöstä", + "description": "Poistaa Valokeila-sivun käytöstä" + }, + "friend_feed_menu_buttons": { + "name": "Ystävän syötteen valikkopainikkeet", + "description": "Valitse, mitkä painikkeet näytetään ystäväsyötteen valikkopalkissa" + }, + "enable_friend_feed_menu_bar": { + "name": "Ystäväsyötteen valikkopalkki", + "description": "Ottaa käyttöön uuden ystäväsyötteen valikkopalkin" + }, + "snap_preview": { + "name": "Snapin esikatselu" + } + } + }, + "messaging": { + "name": "Viestintä", + "description": "Muuta tapaa, jolla olet vuorovaikutuksessa ystävien kanssa", + "properties": { + "anonymous_story_viewing": { + "name": "Nimetön tarinan katselu", + "description": "Estää ketään tietämästä, että olet nähnyt heidän tarinansa" + }, + "hide_bitmoji_presence": { + "name": "Piilota Bitmojin läsnäolo", + "description": "Estää Bitmojia ponnahtamasta esiin chatissa" + }, + "hide_typing_notifications": { + "name": "Piilota Kirjoitusilmoitukset", + "description": "Estää ketään huomaamasta, että kirjoitat viestiä" + }, + "unlimited_snap_view_time": { + "name": "Rajatot Snapin Katseluaika", + "description": "Poistaa snäppien katselun aikarajan käytöstä" + }, + "disable_replay_in_ff": { + "name": "Poista uusinta käytöstä FF: ssä", + "description": "Poistaa käytöstä mahdollisuuden toistaa kaverisyötteestä pitkällä painalluksella" + }, + "prevent_message_sending": { + "name": "Estä Viestien Lähettäminen", + "description": "Estää tietyntyyppisten viestien lähettämisen" + }, + "better_notifications": { + "name": "Paremmat ilmoitukset", + "description": "Antaa enemmän tietoja vastaanotetuissa ilmoituksissa" + }, + "notification_blacklist": { + "name": "Ilmoitusten Esto Lista", + "description": "Valitse ilmoitukset joita et halua nähdä" + }, + "message_logger": { + "name": "Viestiloki", + "description": "Estää viestien poistamisen" + }, + "auto_save_messages_in_conversations": { + "name": "Automaattinen Viestien Tallennus", + "description": "Tallentaa automaattisesti jokaisen keskustelun viestin" + }, + "gallery_media_send_override": { + "name": "Gallerian Medialähetyksen Ohitus", + "description": "Väärennä median lähde lähetettäessä galleriasta" + } + } + }, + "global": { + "name": "Globaali", + "description": "Säädä Globaaleja Snapchatin Asetuksia", + "properties": { + "snapchat_plus": { + "name": "Snapchat Plus", + "description": "Ota käyttöön Snapchat Plus ominaisuudet\nJotkin palvelinpohjaiset ominaisuudet eivät välttämättä toimi" + }, + "auto_updater": { + "name": "Automaattiset Päivitykset", + "description": "Tarkistaa automaattisesti uudet päivitykset" + }, + "disable_metrics": { + "name": "Poista Analytiikka Käytöstä", + "description": "Estä analyyttisten tietojen lähettäminen Snapchatille" + }, + "block_ads": { + "name": "Estä Mainokset", + "description": "Estää mainoksia näkymästä" + }, + "bypass_video_length_restriction": { + "name": "Ohita videon pituus rajoitukset", + "description": "Yksittäinen: lähettää yhden videon\nKatkottu: katkaise videot muokkauksen jälkeen" + }, + "disable_google_play_dialogs": { + "name": "Poista Google Play -palveluiden valintaikkunat käytöstä", + "description": "Estä Google Play -palveluiden saatavuusvalintaikkunoiden näyttäminen" + }, + "disable_snap_splitting": { + "name": "Poista Snäppien katkominen käytöstä", + "description": "Estää Snäppejä jakautumasta useisiin osiin\nLähettämäsi kuvat muuttuvat videoiksi" + } + } + }, + "rules": { + "name": "Säännöt", + "description": "Hallitse yksittäisten henkilöiden automaattisia ominaisuuksia" + }, + "camera": { + "name": "Kamera", + "description": "Säädä oikeat asetukset täydellisen kuvan saamiseksi", + "properties": { + "immersive_camera_preview": { + "name": "Mukaansatempaava esikatselu", + "description": "Estää Snapchatia rajaamasta kameran esikatselua.\nTämä saattaa aiheuttaa kameran välkkymistä joissakin laitteissa" + }, + "force_camera_source_encoding": { + "name": "Pakota kameran lähdekoodaus", + "description": "Pakottaa kameran lähdekoodauksen" + } + } + }, + "streaks_reminder": { + "name": "Striikkien muistutukset", + "description": "Muistuttaa sinua loppuvista striikeistä", + "properties": { + "interval": { + "name": "Aikaväli", + "description": "Kuinka usein sinua muistutetaan (tunneissa)" + }, + "remaining_hours": { + "name": "Jäljellä Oleva Aika", + "description": "Jäljellä oleva aika ennen ilmoituksen näyttämistä (tunteja)" + }, + "group_notifications": { + "name": "Ryhmä ilmoitukset", + "description": "Näytä ilmoitukset yhdessä ryhmässä" + } + } + }, + "experimental": { + "name": "Kokeelliset", + "description": "Kokeelliset ominaisuudet", + "properties": { + "native_hooks": { + "name": "Native Hooks", + "description": "Vaarallinen ominaisuus joka kytkeytyy Snapchatin alkuperäiseen koodiin", + "properties": { + "disable_bitmoji": { + "name": "Poista Bitmoji Käytöstä", + "description": "Poista kavereiden profiili Bitmojit käytöstä" + } + } + }, + "spoof": { + "name": "Väärennä", + "description": "Väärennä erinäisiä tietoja sinusta" + }, + "infinite_story_boost": { + "name": "Loputon Tarinan Tehostus", + "description": "Ohita Story Boost Limit -viive" + }, + "meo_passcode_bypass": { + "name": "Vain Omille Silmille- Tunnuskoodin Ohitus", + "description": "Ohittaa salasanan \"Vain omille silmille\" osioon\nTämä toimii vain, jos salasana on syötetty oikein aiemmin" + }, + "no_friend_score_delay": { + "name": "Ei Snäppi-Score Viivettä", + "description": "Poistaa viiveen katsottaessa Snäppi-scorea" + }, + "e2ee": { + "name": "End-To-End Salaus", + "description": "Salaa viestisi AES: llä jaetun salaisen avaimen avulla\n Muista tallentaa avaimesi turvalliseen paikkaan!", + "properties": { + "encrypted_message_indicator": { + "name": "Salatun viestin ilmaisin", + "description": "Lisää 🔒 emoji salattujen viestien viereen" + }, + "force_message_encryption": { + "name": "Pakota Viestien Salaus", + "description": "Estää salattujen viestien lähettämisen ihmisille, joilla ei ole E2E-salausta käytössä vain kun useita keskusteluja on valittu" + } + } + }, + "add_friend_source_spoof": { + "name": "Lisää ystävälähteen huijaus", + "description": "Väärentää mistä kaveripyyntö on peräisin" + }, + "hidden_snapchat_plus_features": { + "name": "Piilotetut Snapchat Plus -ominaisuudet", + "description": "Ottaa käyttöön julkaisemattomat/beta Snapchat Plus -ominaisuudet\nEi ehkä toimi vanhemmissa Snapchat-versioissa" + } + } + }, + "scripting": { + "name": "Komentojono", + "description": "Suorita mukautettuja komentosarjoja laajentaaksesi SnapEnhancea", + "properties": { + "developer_mode": { + "name": "Kehittäjätila", + "description": "Näyttää virheenkorjaustiedot Snapchatin käyttöliittymässä" + }, + "module_folder": { + "name": "Moduulin kansio", + "description": "Kansio, jossa komentosarjat sijaitsevat" + } + } + } + }, + "options": { + "app_appearance": { + "always_light": "Aina Vaalea", + "always_dark": "Aina Tumma" + }, + "friend_feed_menu_buttons": { + "auto_download": "⬇️ Automaattinen Lataus", + "auto_save": "💬 Automaattinen Viestien Tallennus", + "stealth": "👻 Haamutila", + "conversation_info": "👤 Keskustelun Tiedot", + "e2e_encryption": "🔒 Käytä E2E-salausta" + }, + "path_format": { + "create_author_folder": "Luo kansio jokaiselle kirjoittajalle", + "create_source_folder": "Luo oma kansio jokaiselle median lähdetyypille", + "append_hash": "Lisää tiedoston nimeen yksilöllinen tiiviste", + "append_source": "Lisää median lähde tiedoston nimeen", + "append_username": "Lisää käyttäjänimi tiedoston nimeen", + "append_date_time": "Lisää päivämäärä ja aika tiedoston nimeen" + }, + "auto_download_sources": { + "friend_snaps": "Kaverien Snäpit", + "friend_stories": "Kaverien Tarinat", + "public_stories": "Julkiset Tarinat", + "spotlight": "Valokeila" + }, + "logging": { + "started": "Aloitettu", + "success": "Suoritettu", + "progress": "Edistyminen", + "failure": "Epäonnistui" + }, + "notifications": { + "chat_screenshot": "Kuvakaappaus", + "chat_screen_record": "Näytön Tallennus", + "snap_replay": "Snapin Uudelleenkatselu", + "camera_roll_save": "Tallennettu kameranrullaan", + "chat": "Chatti", + "chat_reply": "Chatin Vastaus", + "snap": "Snap", + "typing": "Kirjoittaa", + "stories": "Tarinat", + "chat_reaction": "DM Reaktio", + "group_chat_reaction": "Ryhmän Reaktio", + "initiate_audio": "Saapuva Äänipuhelu", + "abandon_audio": "Vastaamaton Äänipuhelu", + "initiate_video": "Saapuva Videopuhelu", + "abandon_video": "Vastaamaton Videopuhelu" + }, + "gallery_media_send_override": { + "ORIGINAL": "Alkuperäinen media", + "NOTE": "Ääniviesti", + "SNAP": "Snap" + }, + "hide_ui_components": { + "hide_profile_call_buttons": "Poista Profiilin Soittopainikkeet", + "hide_chat_call_buttons": "Poista Chatin Puhelupainikkeet", + "hide_live_location_share_button": "Poista Sijainnin Jakopainike", + "hide_stickers_button": "Poista Tarrat -painike", + "hide_voice_record_button": "Poista Äänityspainike" + }, + "home_tab": { + "map": "Kartta", + "chat": "Chatti", + "camera": "Kamera", + "discover": "Tutustu", + "spotlight": "Valokeila" + }, + "add_friend_source_spoof": { + "added_by_username": "Käyttäjänimellä", + "added_by_mention": "Maininnasta", + "added_by_group_chat": "Ryhmäkeskustelusta", + "added_by_qr_code": "QR-koodilla", + "added_by_community": "Yhteisöstä" + }, + "bypass_video_length_restriction": { + "single": "Yksittäinen media", + "split": "Jaettu media" + } + } + }, + "friend_menu_option": { + "preview": "Esikatselu", + "stealth_mode": "Haamutila", + "auto_download_blacklist": "Automaattisen latauksen esto lista", + "anti_auto_save": "Automaattisen tallennuksen esto" + }, + "chat_action_menu": { + "preview_button": "Esikatselu", + "download_button": "Lataa", + "delete_logged_message_button": "Poista kirjattu viesti" + }, + "opera_context_menu": { + "download": "Lataa Media" + }, + "modal_option": { + "profile_info": "Profiilin Tiedot", + "close": "Sulje" + }, + "gallery_media_send_override": { + "multiple_media_toast": "Voit lähettää vain yhden median kerrallaan" + }, + "conversation_preview": { + "streak_expiration": "loppuu {day} päivän {hour} tunnin {minute} minuutin kuluttua", + "total_messages": "Lähetetyt/vastaanotetut viestit yhteensä: {count}", + "title": "Esikatselu", + "unknown_user": "Tuntematon käyttäjä" + }, + "profile_info": { + "title": "Profiilin tiedot", + "first_created_username": "Ensimmäinen Käyttäjätunnus", + "mutable_username": "Muutettava Käyttäjätunnus", + "display_name": "Näyttönimi", + "added_date": "Lisätty Päivämäärä", + "birthday": "Syntymäpäivä: {month} {day}", + "friendship": "Ystävyys", + "add_source": "Lisää lähde", + "snapchat_plus": "Snapchat Plus", + "snapchat_plus_state": { + "subscribed": "Tilattu", + "not_subscribed": "Ei Tilattu" + } + }, + "chat_export": { + "dialog_negative_button": "Peruuta", + "dialog_positive_button": "Vie", + "exported_to": "Viety {path}", + "exporting_chats": "Viedään Keskusteluja...", + "processing_chats": "Käsitellään {amount} keskustelu(a)...", + "export_fail": "Ei voitu viedä keskustelua {conversation}", + "writing_output": "Tulostetta kirjoitetaan...", + "finished": "Valmis! Voit nyt sulkea tämän ikkunan.", + "no_messages_found": "Viestejä ei löytynyt!", + "exporting_message": "Viedään {conversation}..." + }, + "button": { + "ok": "OK", + "positive": "Kyllä", + "negative": "Ei", + "cancel": "Peruuta", + "open": "Avaa", + "download": "Lataa" + }, + "profile_picture_downloader": { + "button": "Lataa Profiilikuva", + "title": "Profiilikuvan Lataaja", + "avatar_option": "Hahmo", + "background_option": "Tausta" + }, + "download_processor": { + "attachment_type": { + "snap": "Snap", + "sticker": "Tarra", + "external_media": "Ulkoinen Media", + "note": "Merkintä", + "original_story": "Alkuperäinen Tarina" + }, + "select_attachments_title": "Valitse liitteet", + "download_started_toast": "Lataus aloitettu", + "unsupported_content_type_toast": "Sisältötyyppiä ei tueta!", + "failed_no_longer_available_toast": "Media ei ole enää saatavilla", + "no_attachments_toast": "Liitteitä ei löytynyt!", + "already_queued_toast": "Media on jo jonossa!", + "already_downloaded_toast": "Media on jo ladattu!", + "download_toast": "Ladataan {path}...", + "processing_toast": "Käsitellään {path}...", + "failed_generic_toast": "Lataus epäonnistui", + "failed_to_create_preview_toast": "Esikatselun luominen epäonnistui", + "failed_processing_toast": "Käsittely epäonnistui {error}", + "failed_gallery_toast": "Tallennus galleriaan epäonnistui {error}" + }, + "streaks_reminder": { + "notification_title": "Striikit", + "notification_text": "Menetät Striikit {Kaveri}: n kanssa {hoursLeft} tunnin kuluttua" + }, + "scopes": { + "friend": "Kaveri", + "group": "Ryhmä" + }, + "actions": { + "bulk_messaging_action": { + "description": "Tee operaatiota kuten kavereiden poistaminen tai useiden keskuteluiden poisto" + }, + "regen_mappings": { + "name": "Tee uudelleen Kartoitus", + "description": "Tee kartoitus uudestaan manuaalisesti" + }, + "export_memories": { + "description": "Vie Muistot ZIP tiedostoon", + "name": "Vie Muistot" + }, + "change_language": { + "description": "Vaihda SnapEnchancen kieli", + "name": "Vaihda Kieli" + }, + "export_chat_messages": { + "name": "Vie Chatti Viestit", + "description": "Vie keskustelun viestit JSON/HTML/TXT formaattiseen tiedostoon" + }, + "manage_friend_list": { + "name": "Hallitse Kaveri Listaa", + "description": "Tuo/Vie kaveri lista varmuuskopiota tehdessä" + }, + "clean_snapchat_cache": { + "name": "Puhdista Snapchatin Välimuisti", + "description": "Puhdistaa Snapchatin Välimuistin" + }, + "friend_tracker": { + "name": "Ystävien seuranta", + "description": "Seuraa ystäviäsi Snapchatissa" + }, + "security_features": { + "name": "Turvaominaisuudet", + "description": "Muuta turvaominaisuuksien asetuksia" + }, + "file_imports": { + "description": "Tuo tiedostoja Snapchatissa käytettäväksi", + "name": "Tiedostojen tuonnit" + } + } +} diff --git a/common/src/main/assets/lang/fr_FR.json b/common/src/main/assets/lang/fr_FR.json new file mode 100644 index 0000000000..ee2b2177fd --- /dev/null +++ b/common/src/main/assets/lang/fr_FR.json @@ -0,0 +1,1720 @@ +{ + "setup": { + "dialogs": { + "select_language": "Choisir la langue", + "select_save_folder_button": "Sélectionner un dossier", + "save_folder": "SnapEnhance requiert des autorisations de stockage pour télécharger et enregistrer des médias depuis Snapchat.\nVeuillez choisir l'emplacement où les médias doivent être téléchargés." + }, + "mappings": { + "dialog": "Génération des mappings, cela peut prendre un certain temps...", + "generate_failure_no_snapchat": "SnapEnhance n'a pas pu détecter Snapchat, essayez de réinstaller Snapchat.", + "generate_failure": "Une erreur s'est produite lors de la génération des mappings, veuillez réessayer." + }, + "permissions": { + "dialog": "Pour continuer, vous devez remplir les conditions suivantes :", + "notification_access": "Accès aux notifications", + "battery_optimization": "Optimisation de la batterie", + "display_over_other_apps": "Superposer aux autres applis", + "request_button": "Autoriser" + } + }, + "manager": { + "routes": { + "features": "Fonctionnalités", + "home": "Accueil", + "home_settings": "Paramètres", + "home_logs": "Journaux", + "social": "Social", + "scripts": "Scripts", + "tasks": "Tâches", + "logger_history": "Historique du journal", + "logged_stories": "Stories enregistrées", + "manage_scope": "Gérer le champ d'application", + "messaging_preview": "Aperçu", + "edit_rule": "Modifier la règle", + "friend_tracker": "Traqueur d'amis", + "file_imports": "Importations de fichiers", + "better_location": "Localisation Améliorée", + "theming": "Thèmes", + "edit_theme": "Modifier le thème", + "manage_repos": "Gérer les dépots", + "manage_rule_feature": "Gérer la règle de la fonctionnalité" + }, + "sections": { + "features": { + "disabled": "Désactivé", + "export_option": "Exporter", + "import_option": "Importer", + "reset_option": "Réinitialiser", + "config_export_success_toast": "Configuration exportée avec succès", + "config_import_success_toast": "Configuration importée avec succès", + "config_import_failure_toast": "Échec de l'importation de la configuration {error}", + "saved_config_snackbar": "Configuration enregistrée", + "config_export_failure_toast": "Échec de l'exportation de la configuration {error}", + "older_required": "Cette fonctionnalité nécessite Snapchat v{version} ou une version antérieure pour fonctionner correctement", + "newer_required": "Cette fonctionnalité nécessite Snapchat v{version} ou une version plus récente pour fonctionner correctement", + "search_button": "Recherche" + }, + "social": { + "streaks_expiration_short": "{hours}h", + "groups_tab": "Groupes", + "empty_hint": "(vide)", + "friends_tab": "Amis" + }, + "tasks": { + "no_tasks": "Aucune tâche", + "merge_files_toast": "Fusion de {count} fichiers", + "remove_selected_tasks_title": "Êtes-vous sûr de vouloir supprimer les tâches sélectionnées ?", + "remove_all_tasks_title": "Êtes-vous sûr de vouloir supprimer toutes les tâches ?", + "delete_files_option": "Supprimer également les fichiers", + "remove_selected_tasks_confirm": "Supprimer {count} tâches ?", + "remove_all_tasks_confirm": "Supprimer toutes les tâches ?", + "failed_to_open_file": "Échec de l'ouverture du fichier", + "merge_button": "Fusionner" + }, + "home": { + "update_content": "La version {version} est disponible !", + "update_button": "Télécharger", + "update_title": "Mise à jour SnapEnhance", + "version_title": "v{versionName} · par rhunk", + "debug_build_summary_title": "Vous utilisez une version de debug de SnapEnhance", + "debug_build_summary_content": "Version {versionName} ({versionCode})", + "debug_build_summary_date": "Date de création: {date} (Il y a {days} jours)", + "quick_actions_title": "Actions rapides" + }, + "manage_scope": { + "logged_stories_button": "Afficher les stories enregistrées", + "e2ee_title": "Chiffrement de bout en bout", + "rules_title": "Règles", + "participants_text": "{count} participant(s)", + "not_found": "Pas trouvé", + "streaks_title": "Flammes", + "streaks_length_text": "Longueur: {length}", + "streaks_expiration_text": "Expire dans {eta}", + "streaks_expiration_text_expired": "Expiré", + "reminder_button": "Définir un rappel", + "delete_scope_confirm_dialog_title": "Êtes-vous sûr de vouloir supprimer ce {scope} ?", + "notes_placeholder": "Cliquez pour ajouter une note" + }, + "home_logs": { + "no_logs_hint": "Aucun journal disponible", + "clear_logs_button": "Effacer les journaux", + "export_logs_button": "Exporter les journaux", + "saving_logs_toast": "Sauvegarde des journaux, cela peut prendre un certain temps ...", + "saved_logs_success_toast": "Journaux enregistrés avec succès", + "saved_logs_failure_toast": "Échec de l'enregistrement des journaux" + }, + "home_settings": { + "actions_title": "Actions", + "message_logger_title": "Loggeur de messages", + "debug_title": "Déboguer", + "success_toast": "Succès !", + "message_logger_summary": "{messageCount} message(s)\n{storyCount} stories", + "export_button": "Exporter", + "clear_button": "Vider", + "view_logger_history_button": "Afficher l'historique de l'enregistreur" + }, + "logged_stories": { + "story_failed_to_load": "Échec du chargement", + "no_stories": "Aucune story trouvée", + "save_from_cache_button": "Enregistrer depuis le cache" + }, + "messaging_preview": { + "bridge_connection_failed": "Échec de la connexion au pont. Assurez-vous que Snapchat s'exécute en arrière-plan", + "bridge_init_failed": "Échec de l'initialisation du pont de messagerie. Assurez-vous que Snapchat s'exécute en arrière-plan", + "message_fetch_failed": "Échec de la récupération des messages", + "no_message_hint": "Pas de message", + "save_selection_option": "Enregistrer la sélection", + "save_all_option": "Enregistrer tout", + "unsave_selection_option": "Désenregistrer la sélection", + "unsave_all_option": "Tout désenregistrer", + "mark_selection_as_seen_option": "Marquer le Snap sélectionné comme vu", + "mark_all_as_seen_option": "Marquer tous les Snaps comme vus", + "delete_selection_option": "Supprimer la sélection", + "delete_all_option": "Supprimer tout" + }, + "logger_history": { + "list_friend_format": "Ami(e) {name}", + "list_group_format": "Groupe {name}", + "no_more_messages": "Plus de messages", + "reverse_order_checkbox": "Ordre inverse", + "chat_attachment": "Pièce jointe n°{index}", + "empty_message": "Message vide", + "message_parse_failed": "Échec de l'analyse du message", + "unknown_sender": "Expéditeur inconnu", + "download_attachment_failed_toast": "Échec du téléchargement de la pièce jointe" + }, + "file_imports": { + "no_files_hint": "Ici, vous pouvez importer des fichiers à utiliser dans Snapchat. Appuyez sur le bouton ci-dessous pour importer un fichier.", + "import_file_button": "Importer un fichier", + "file_not_found": "Fichier introuvable", + "file_import_failed": "Échec de l'importation du fichier : {error}", + "file_imported": "Fichier importé avec succès", + "file_delete_failed": "Échec de la suppression du fichier" + }, + "better_location": { + "spoofed_coordinates_title": "Lat {latitude}, Lng {longitude}", + "save_coordinates_dialog_title": "Enregistrer les coordonnées", + "saved_name_dialog_hint": "Nom enregistré", + "delete_dialog_title": "Supprimer une coordonnée enregistrée", + "spoof_location_toggle": "Usurpation de la localisation", + "saved_coordinates_title": "Coordonnées enregistrées", + "teleport_to_friend_title": "Se téléporter à un ami", + "delete_dialog_message": "Êtes-vous sûr de vouloir supprimer cette coordonnée enregistrée ?", + "search_bar": "Rechercher", + "no_friends_found": "Aucun ami trouvé", + "save_dialog_button": "Sauvegarder", + "choose_location_button": "Choisir un emplacement", + "teleport_to_friend_button": "Se téléporter à un ami", + "no_saved_coordinates_hint": "Aucune coordonnée enregistrée", + "no_friends_map": "Pas d'amis sur la carte", + "latitude_dialog_hint": "Latitude", + "longitude_dialog_hint": "Longitude", + "suspend_location_updates": "Suspendre les mises à jour de localisation" + }, + "theming": { + "no_themes_hint": "Aucun thème trouvé" + }, + "manage_rule_feature": { + "disable_state_option": "Désactivé", + "disable_state_subtext": "Aucun ami/groupe ne sera concerné", + "whitelist_state_option": "Personne sauf ...", + "blacklist_state_option": "Tout le monde sauf ...", + "blacklist_state_button": "Sélectionner les amis/groupes exclus", + "clear_list_button": "Effacer la liste des amis/groupes", + "dialog_clear_confirmation_text": "Êtes-vous sûr de vouloir effacer la liste ?", + "whitelist_state_button": "Sélectionner les amis/groupes autorisés", + "whitelist_state_subtext": "Seuls {count} amis/groupes seront affectés par cette règle", + "blacklist_state_subtext": "Tout le monde, sauf {count} amis/groupes, seront concernés par cette règle" + } + }, + "dialogs": { + "add_friend": { + "title": "Ajouter des amis ou des groupes", + "search_hint": "Rechercher", + "fetch_error": "Échec de la récupération des données", + "category_groups": "Groupes", + "category_friends": "Amis", + "participants_text": "{count} participants" + }, + "scripting_warning": { + "content": "SnapEnhance inclut un outil de développement de scripts, permettant l'exécution d'un code défini par l'utilisateur sur votre appareil. Soyez extrêmement prudent et n'installez que des modules provenant de sources connues et fiables. Les modules non autorisés ou non vérifiés peuvent présenter des risques pour la sécurité de votre système.", + "title": "Attention" + }, + "messaging_action": { + "title": "Choisissez les types de contenu à traiter", + "select_all_button": "Tout sélectionner" + }, + "reset_config": { + "title": "Réinitialiser la configuration", + "content": "Êtes-vous sûr de vouloir réinitialiser la configuration ?", + "success_toast": "La configuration a été réinitialisée avec succès" + }, + "file_imports": { + "no_files_settings_hint": "Aucun fichier trouvé. Assurez-vous d'avoir importé les fichiers requis dans la section Importations de fichiers", + "settings_select_file_hint": "Sélectionnez un fichier importé" + }, + "export_config": { + "title": "Exporter des données sensibles ?", + "content": "Voulez-vous exporter la configuration avec des données sensibles ? (Comme les coordonnées de localisation, etc.)" + } + } + }, + "rules": { + "modes": { + "blacklist": "Mode liste noire", + "whitelist": "Mode liste blanche" + }, + "properties": { + "auto_download": { + "name": "Téléchargement automatique", + "description": "Télécharger automatiquement les Snaps lors de leur visionnage", + "options": { + "blacklist": "Exclure du téléchargement automatique", + "whitelist": "Téléchargement automatique" + } + }, + "stealth": { + "name": "Mode incognito", + "description": "Empêche quiconque de savoir que vous avez ouvert ses Snaps/Chats", + "options": { + "blacklist": "Exclure du mode incognito", + "whitelist": "Mode incognito" + } + }, + "auto_save": { + "name": "Sauvegarde automatique", + "description": "Enregistre les messages lors de leur visionnage", + "options": { + "blacklist": "Exclure de la sauvegarde automatique", + "whitelist": "Sauvegarde automatique" + } + }, + "hide_friend_feed": { + "name": "Masquer du flux d'amis" + }, + "e2e_encryption": { + "name": "Utiliser le chiffrement de bout en bout" + }, + "pin_conversation": { + "name": "Épingler la conversation" + }, + "unsaveable_messages": { + "name": "Messages non enregistrables", + "options": { + "blacklist": "Exclure des Messages non enregistrables", + "whitelist": "Messages non enregistrables" + }, + "description": "Empêche les messages d'être enregistrés dans le chat par d'autres personnes" + }, + "auto_open_snaps": { + "name": "Auto-ouverture des Snaps", + "description": "Ouvre automatiquement les Snaps dès leur réception", + "options": { + "blacklist": "Exclure de l'ouverture automatique des Snaps", + "whitelist": "Ouverture automatique des Snaps" + } + }, + "exclude_message_logger": { + "name": "Exclure du journal des messages" + } + }, + "toasts": { + "enabled": "{ruleName} activé", + "disabled": "{ruleName} désactivé" + } + }, + "features": { + "notices": { + "unstable": "⚠️ Instable", + "ban_risk": "⚠️ Cette fonctionnalité pourrait causer des bannissements", + "internal_behavior": "⚠️ Cela peut casser le comportement interne de Snapchat" + }, + "properties": { + "downloader": { + "name": "Téléchargeur", + "description": "Télécharger médias de Snapchat", + "properties": { + "save_folder": { + "name": "Dossier d'enregistrement", + "description": "Sélectionnez le répertoire dans lequel tous les médias doivent être téléchargés" + }, + "auto_download_sources": { + "name": "Sources de téléchargements automatiques", + "description": "Sélectionner les sources pour lesquelles les téléchargements seront automatiques" + }, + "prevent_self_auto_download": { + "name": "Empêcher l'automatisation du téléchargement", + "description": "Empêcher vos propres Snaps d'êtres automatiquement téléchargement" + }, + "path_format": { + "name": "Format du chemin d'accès", + "description": "Spécifier le format de l'emplacement de fichier" + }, + "allow_duplicate": { + "name": "Autoriser les doublons", + "description": "Permet au même média d'être téléchargé plusieurs fois" + }, + "merge_overlays": { + "name": "Fusionner les superpositions", + "description": "Combine le texte et le média d'un Snap en un seul fichier" + }, + "force_image_format": { + "name": "Forcer le format d'image", + "description": "Force l'enregistrement des images dans un format spécifié" + }, + "force_voice_note_format": { + "name": "Forcer le format de la note vocale", + "description": "Forcer l'enregistrement des notes vocales dans un format spécifié" + }, + "download_profile_pictures": { + "name": "Télécharger les photos de profil", + "description": "Vous permet de télécharger les photos du profil depuis la page de profil" + }, + "ffmpeg_options": { + "name": "Options FFmpeg", + "description": "Spécifier des options supplémentaires pour FFmpeg", + "properties": { + "threads": { + "name": "Fil de discussions", + "description": "Le nombre de fils de discussions à utiliser" + }, + "preset": { + "name": "Préréglages", + "description": "Définir la vitesse de conversion" + }, + "constant_rate_factor": { + "name": "Facteur de taux constant", + "description": "Définir le facteur de débit constant pour l'encodeur vidéo\nde 0 à 51 pour libx264" + }, + "video_bitrate": { + "name": "Débit vidéo", + "description": "Définir le débit vidéo (kbps)" + }, + "audio_bitrate": { + "name": "Débit audio", + "description": "Définir le débit audio (kbps)" + }, + "custom_video_codec": { + "name": "Codec vidéo personnalisé", + "description": "Définir un Codec Vidéo personnalisé tel que (libx264)" + }, + "custom_audio_codec": { + "name": "Codec audio personnalisé", + "description": "Définir un Codec Audio personnalisé tel que (AAC)" + } + } + }, + "logging": { + "name": "Journalisation", + "description": "Afficher une bulle de notification lorsque un média est en cours de téléchargement" + }, + "custom_path_format": { + "description": "Spécifier un format de chemin personnalisé pour les médias téléchargés\n\nVariables disponibles :\n - %username%\n - %source%\n - %hash%\n - %date_time%", + "name": "Format de chemin personnalisé" + }, + "opera_download_button": { + "description": "Ajoute un bouton de téléchargement dans le coin en haut à droite lors de la visualisation d'un Snap.\nUne longue pression sur ce bouton forcera le téléchargement", + "name": "Bouton de téléchargement Opera" + }, + "download_context_menu": { + "name": "Menu de téléchargement contextuel", + "description": "Vous permet de télécharger/voir les messages a partir d'une conversation ou d'une story a partir du menu contextuel.\nUne pression longue sur ce bouton forcera le téléchargement" + }, + "auto_download_voice_notes": { + "name": "Téléchargement automatique des messages vocaux", + "description": "Télécharge automatiquement les messages vocaux lors de leur lecture" + } + } + }, + "user_interface": { + "name": "Interface utilisateur", + "description": "Changer l'apparence de Snapchat", + "properties": { + "enable_app_appearance": { + "name": "Activer les paramètres d'apparence de l'appli", + "description": "Active le paramètre d’apparence caché de l’application\nPeut ne pas être nécessaire sur les nouvelles versions de Snapchat" + }, + "friend_feed_message_preview": { + "name": "Aperçu du message du flux d'amis", + "description": "Affiche un aperçu des derniers messages du flux d'ami", + "properties": { + "amount": { + "name": "Quantité", + "description": "Le nombre de messages à prévisualiser" + } + } + }, + "bootstrap_override": { + "name": "Remplacement de l'interface d'utilisateur", + "description": "Contourne les paramètres de démarrage de l'interface utilisateur", + "properties": { + "app_appearance": { + "name": "Apparance de l'application", + "description": "Définit une apparence persistante de l'application" + }, + "home_tab": { + "name": "Onglet d'accueil", + "description": "Remplacement de l'onglet à l'ouverture" + }, + "simple_snapchat": { + "description": "Permet une version simplifiée de Snapchat", + "name": "Snapchat simple" + } + } + }, + "map_friend_nametags": { + "name": "Améliorations des nametags d'amis sur la Carte Snap", + "description": "Améliore les nametags des amis sur la Carte Snap" + }, + "streak_expiration_info": { + "name": "Infos sur l'expiration des flammes", + "description": "Affiche un compteur d'expiration de flamme à côté du compteur de flamme" + }, + "hide_streak_restore": { + "name": "Masque la restauration de Snapflamme", + "description": "Masque le bouton de restauration" + }, + "hide_ui_components": { + "name": "Masque les composants de l'interface utilisateur", + "description": "Sélectionner quels éléments de l'interface est à masqué" + }, + "disable_spotlight": { + "name": "Désactive la section Spotlight", + "description": "Désactive la page Spotlight" + }, + "friend_feed_menu_buttons": { + "name": "Boutons dans le chat d'amis", + "description": "Sélectionner les boutons à afficher dans la barre de menu du fil d'amis" + }, + "enable_friend_feed_menu_bar": { + "description": "Active la nouvelle barre de menu de flux d'amis", + "name": "Barre du menu du fil d'amis" + }, + "opera_media_quick_info": { + "description": "Affiche des informations utiles sur les médias, telles que la date de création, dans le menu contextuel de la visionneuse d'Opera", + "name": "Informations rapides des médias Opera" + }, + "vertical_story_viewer": { + "name": "Visionneuse verticale de story", + "description": "Active la visualisation verticale pour toutes les Stories" + }, + "old_bitmoji_selfie": { + "name": "Ancien Selfie Bitmoji", + "description": "Restaurer les selfies Bitmoji des anciennes versions de Snapchat" + }, + "prevent_message_list_auto_scroll": { + "name": "Empêcher le défilement automatique de la liste des messages", + "description": "Empêche le défilement de la liste des messages vers le bas lors de l'envoi ou de la réception d'un message" + }, + "edit_text_override": { + "name": "Modification de l'éditeur de texte", + "description": "Remplace le comportement des champs de texte" + }, + "snap_preview": { + "name": "Aperçu des Snaps", + "description": "Affiche un petit aperçu à côté des Snaps non vus dans le chat" + }, + "hide_friend_feed_entry": { + "description": "Masque un ami spécifique du fil des amis\nUtilisez l'onglet social pour gérer cette fonctionnalité", + "name": "Masquer l'entrée dans le fil d'amis" + }, + "hide_story_suggestions": { + "name": "Cacher les suggestions de story", + "description": "Supprimer les suggestions de la section des stories" + }, + "stealth_mode_indicator": { + "name": "Indicateur de Mode Furtif", + "description": "Ajoute un emoji 👻 à côté des conversations en mode furtif" + }, + "message_indicators": { + "name": "Indicateur de messages", + "description": "Ajoute des icônes d'indicateurs spécifiques aux messages\nRemarque : les indicateurs peuvent ne pas être à 100% précis" + }, + "auto_close_friend_feed_menu": { + "name": "Menu de fermeture automatique du fil d'amis", + "description": "Ferme automatiquement le menu liste d'amis après avoir appuyé sur un bouton de réglage" + }, + "custom_theme": { + "description": "Personnalisez les couleurs de Snapchat\nRemarque : si vous choisissez un thème sombre (comme Amoled), vous devrez peut-être activer le mode sombre dans les paramètres de Snapchat pour de meilleurs résultats", + "name": "Thème personnalisé" + }, + "hide_quick_add_suggestions": { + "name": "Masquer les suggestions d'ajout rapide", + "description": "Supprime les suggestions d'ajout rapide d'amis" + } + } + }, + "messaging": { + "name": "Messagerie", + "description": "Changez la façon dont vous interagissez avec vos amis", + "properties": { + "anonymous_story_viewing": { + "name": "Anonymiser le visionnage des stories", + "description": "Empêcher n'importe qui de savoir que vous avez vu leur story" + }, + "hide_bitmoji_presence": { + "name": "Cacher le Bitmoji dans la conversation", + "description": "Empêche votre Bitmoji d'apparaître dans le chat" + }, + "hide_typing_notifications": { + "name": "Masquer la notification \"En train d'écrire\"", + "description": "Empêcher n'importe qui de savoir que vous avez lu leurs messages" + }, + "unlimited_snap_view_time": { + "name": "Temps de visionnage des Snaps illimités", + "description": "Supprime la limite de temps pour la visualisation des Snaps" + }, + "disable_replay_in_ff": { + "name": "Désactiver la relecture dans le FF", + "description": "Désactive la possibilité de rejouer avec un appui long du fil des amis" + }, + "prevent_message_sending": { + "name": "Empêcher l'envoi de message", + "description": "Empêche l'envoi de certains types de message" + }, + "better_notifications": { + "name": "Notifications améliorées", + "description": "Ajouter plus d'informations dans les notifications reçues", + "properties": { + "reply_button": { + "description": "Ajoute un bouton de réponse à la notification", + "name": "Bouton Répondre" + }, + "download_button": { + "name": "Bouton de téléchargement", + "description": "Vous permet de télécharger des médias à partir de la notification" + }, + "mark_as_read_and_save_in_chat": { + "name": "Marquer comme lu et enregistrer dans le chat", + "description": "Ajoute une marque comme lu et enregistre le bouton de discussion à la notification" + }, + "group_notifications": { + "name": "Notifications de groupe", + "description": "Regrouper les notifications en une seule" + }, + "chat_preview": { + "name": "Aperçu du chat", + "description": "Affiche un aperçu des messages reçus dans la notification" + }, + "media_preview": { + "name": "Aperçu des médias", + "description": "Affiche un aperçu des types de médias sélectionnés dans la notification" + }, + "media_caption": { + "name": "Légende du média", + "description": "Affiche la légende jointe du média dans la notification" + }, + "stacked_media_messages": { + "name": "Messages multimédias empilés", + "description": "Combine plusieurs messages multimédias en une seule notification texte lorsqu'ils ne peuvent pas être prévisualisés. Utiliser en combinaison avec Aperçu du Chat" + }, + "friend_add_source": { + "name": "Source d'ajout d'ami", + "description": "Affiche la source d'une demande d'ami dans la notification" + }, + "mark_as_read_button": { + "name": "Bouton Marquer comme lu", + "description": "Vous permet de marquer un message comme lu à partir de la notification" + }, + "smart_replies": { + "description": "Ajoute des réponses suggérées aux notifications (Android 10+). À utiliser en combinaison avec le bouton Répondre", + "name": "Réponses intelligentes" + } + } + }, + "notification_blacklist": { + "name": "Liste noire des notifications", + "description": "Sélectionnez les notifications qui devraient être bloquées" + }, + "message_logger": { + "name": "Journalisation des messages", + "description": "Empêcher l'effacement des messages", + "properties": { + "message_filter": { + "name": "Filtre de message", + "description": "Sélectionner les messages qui doivent être enregistrés (vide pour l'ensemble des messages)" + }, + "auto_purge": { + "description": "Supprime automatiquement les messages mis en cache qui sont plus anciens que la durée spécifiée", + "name": "Purge automatique" + }, + "keep_my_own_messages": { + "name": "Conserver mes propres messages", + "description": "Empêche la suppression de vos propres messages" + }, + "deleted_message_color": { + "description": "Définit la couleur des messages supprimés", + "name": "Couleur du message supprimé" + } + } + }, + "auto_save_messages_in_conversations": { + "name": "Enregistrement automatique des messages", + "description": "Enregistre automatiquement tous les messages des conversations" + }, + "gallery_media_send_override": { + "name": "Remplacement de l'envoi des médias de la galerie", + "description": "Falsifie la source du média lors de l'envoi depuis la Galerie" + }, + "loop_media_playback": { + "name": "Lecture en boucle des médias", + "description": "Lecture des médias en boucle lors de la visualisation des Snaps / Stories" + }, + "call_start_confirmation": { + "name": "Confirmation de début d'appel", + "description": "Affiche une fenêtre de confirmation lors du lancement d'un appel" + }, + "half_swipe_notifier": { + "properties": { + "min_duration": { + "name": "Durée minimale", + "description": "Durée minimale du balayage (en secondes)" + }, + "max_duration": { + "description": "Durée maximale du balayage (en secondes)", + "name": "Durée maximale" + } + }, + "name": "Notifications des entrouverts", + "description": "Vous avertit lorsque quelqu'un entrouvre une conversation" + }, + "bypass_screenshot_detection": { + "description": "Empêche Snapchat de détecter les captures d'écran", + "name": "Contourner la détection de capture d'écran" + }, + "prevent_story_rewatch_indicator": { + "name": "Empêcher l'indicateur de relecture des stories", + "description": "Empêche quiconque de savoir que vous avez revu leur Story" + }, + "hide_peek_a_peek": { + "description": "Empêche l'envoi d'une notification lorsque vous entre ouvrez une conversation", + "name": "Cacher les ouvertures partielles des conversations" + }, + "strip_media_metadata": { + "description": "Supprime les métadonnées des médias avant de les envoyer sous forme de message", + "name": "Enlever les métadonnées des médias" + }, + "bypass_message_retention_policy": { + "name": "Contourner la politique de conservation des messages", + "description": "Empêche la disparition des messages après les avoir consultés" + }, + "bypass_message_action_restrictions": { + "name": "Contourner les restrictions d'action des messages", + "description": "Vous permet de réagir à un snap sans l'avoir ouvert ou de sauvegarder un message non sauvegardable" + }, + "remove_groups_locked_status": { + "name": "Supprimer le statut verrouillé des groupes", + "description": "Vous permet de consulter les informations du groupe après avoir été expulsé" + }, + "auto_mark_as_read": { + "name": "Marquer automatiquement comme lu", + "description": "Marque automatiquement les messages/Snaps comme lus même lorsque le mode Stealth est activé" + }, + "friend_mutation_notifier": { + "name": "Notificateur de mutation d'ami", + "description": "Vous avertit lorsque quelque chose change dans le profil d'un ami" + }, + "unlimited_conversation_pinning": { + "description": "Permet d'épingler localement un nombre illimité de conversations", + "name": "Épinglage illimité des conversations" + }, + "mark_snap_as_seen_button": { + "name": "Bouton Marquer Snap comme vu", + "description": "Ajoute un bouton pour marquer un Snap comme vu lors de sa visualisation.\nCela fonctionnera même lorsque le mode furtif est activé" + }, + "skip_when_marking_as_seen": { + "name": "Ignorer lors du marquage comme vu", + "description": "Passe automatiquement au Snap suivant lors du marquage d'un Snap comme vu.\nÀ utiliser en combinaison avec le Bouton Marquer Snap comme vu" + }, + "double_tap_chat_action_custom_emoji": { + "name": "Action de chat en appuyant deux fois Réaction Emoji personnalisée", + "description": "Définit une réaction emoji personnalisée pour l'action de chat en double-cliquant" + }, + "double_tap_chat_action": { + "name": "Action de chat en appuyant deux fois", + "description": "Effectue une action personnalisée en appuyant deux fois sur un message dans le chat" + } + } + }, + "global": { + "name": "Global", + "description": "Modifier les paramètres globaux de Snapchat", + "properties": { + "snapchat_plus": { + "name": "Snapchat Plus", + "description": "Activer les fonctionnalités de Snapchat Plus\nCertaines fonctionnalités côté serveur peuvent ne pas fonctionner" + }, + "auto_updater": { + "name": "Mise à jour automatique", + "description": "Vérifier automatiquement les mises à jour" + }, + "disable_metrics": { + "name": "Désactiver les métriques", + "description": "Bloque l'envoi de données analytiques spécifiques vers Snapchat" + }, + "block_ads": { + "name": "Bloquer les publicités", + "description": "Empêche l'affichage des publicités" + }, + "bypass_video_length_restriction": { + "name": "Contourner la restriction de longueur vidéo", + "description": "Unique : envoie une seule vidéo\n Split : diviser les vidéos après le montage" + }, + "disable_google_play_dialogs": { + "name": "Désactiver les avertissements des services Google Play", + "description": "Empêcher les services Google Play d'afficher des boites de dialogues" + }, + "disable_snap_splitting": { + "name": "Désactiver le fractionnement des Snaps", + "description": "Empêche les Snaps d'être divisés en plusieurs parties\nLes images que vous envoyez seront transformées en vidéos" + }, + "disable_confirmation_dialogs": { + "name": "Désactiver les fenêtres de confirmation", + "description": "Confirme automatiquement les actions sélectionnées" + }, + "spotlight_comments_username": { + "name": "Nom d'utilisateur des commentaires Spotlight", + "description": "Affiche le nom d'utilisateur de l'auteur dans les commentaires Spotlight" + }, + "disable_story_sections": { + "name": "Désactiver les sections de story", + "description": "Supprime les sections de la page des stories\nUne actualisation peut être nécessaire pour fonctionner correctement" + }, + "disable_memories_snap_feed": { + "name": "Désactive le Fil Souvenirs Snap", + "description": "Empêche Snapchat d'afficher les souvenirs récents lorsque vous faites glisser vers le haut dans l'appareil photo" + }, + "default_video_playback_rate": { + "name": "Taux de lecture vidéo par défaut", + "description": "Définit la vitesse par défaut pour la lecture des vidéos\nLa valeur doit être comprise entre 0,1 et 4,0" + }, + "disable_permission_requests": { + "name": "Désactiver la demande d'autorisation", + "description": "Empêche Snapchat de demander des autorisations spécifiques" + }, + "better_location": { + "name": "Meilleure localisation", + "properties": { + "coordinates": { + "name": "Coordonnées", + "description": "Définissez les coordonnées de l'emplacement simulé" + }, + "always_update_location": { + "name": "Toujours actualiser la localisation", + "description": "Forcer Snapchat à mettre à jour la position même si aucune donnée GPS n'est reçue" + }, + "spoof_location": { + "description": "Simule votre emplacement à un endroit spécifié", + "name": "Fausser la localisation" + }, + "suspend_location_updates": { + "name": "Suspendre les mises à jour de localisation", + "description": "Empêche la mise à jour de votre position" + }, + "spoof_battery_level": { + "description": "Usure le niveau de la batterie de votre appareil sur la carte\n La valeur doit être comprise entre 0 et 100", + "name": "Usurper le niveau de batterie" + }, + "spoof_headphones": { + "description": "Usurpe le statut d'écoute de musique sur la carte", + "name": "Usurper les écouteurs" + }, + "walk_radius": { + "name": "Rayon de marche", + "description": "Déplacer de manière aléatoire à l'intérieur de ce rayon (en pieds)" + }, + "show_battery_level": { + "description": "Affiche le niveau de batterie de vos amis sur la carte", + "name": "Afficher le niveau de la batterie" + } + }, + "description": "Améliore la localisation sur Snapchat" + }, + "default_volume_controls": { + "description": "Force Snapchat à utiliser les commandes de volume du système", + "name": "Contrôles de volume par défaut" + }, + "video_playback_rate_slider": { + "name": "Curseur de taux de lecture vidéo", + "description": "Ajoute un curseur dans le menu contextuel de l'opéra pour modifier la vitesse de lecture vidéo\nRemarque: Les modifications ne s'appliquent qu'aux vidéos suivantes" + }, + "hide_active_music": { + "description": "Empêche Snapchat de savoir que vous écoutez de la musique\nCela vous permettra de prendre des snaps en utilisant les boutons de volume de contrôle tout en écoutant de la musique", + "name": "Cache la musique active" + }, + "disable_custom_tabs": { + "name": "Désactiver les onglets personnalisés", + "description": "Ouvre les liens dans les applications prises en charge plutôt que dans le navigateur Web" + }, + "media_upload_quality": { + "properties": { + "force_video_upload_source_quality": { + "name": "Forcer la qualité source de l'importation vidéo", + "description": "Force Snapchat à utiliser la qualité source lors de l'importation de vidéos\nVeuillez noter que cela ne supprimera peut-être pas les métadonnées du média." + }, + "disable_image_compression": { + "name": "Désactiver la compression d'image", + "description": "Désactive la compression d'image lors de l'importation de médias" + }, + "custom_image_upload_format": { + "name": "Format d'importation d'image personnalisé", + "description": "Définit un format d'importation d'image personnalisé\nSélectionnez un format sans perte (comme PNG) pour la meilleure qualité" + } + }, + "name": "Qualité d'importation des médias", + "description": "Remplace la qualité d'importation multimédia" + }, + "disable_telecom_framework": { + "name": "Désactiver le cadre de télécommunications", + "description": "Empêche Snapchat d'utiliser le framework Android Telecom\nCela vous permet d'écouter de la musique pendant un appel" + } + } + }, + "rules": { + "name": "Règles", + "description": "Gérer les fonctionnalités automatiques pour les personnes individuelles" + }, + "camera": { + "name": "Caméra", + "description": "Ajuster les bons réglages pour le Snap parfait", + "properties": { + "immersive_camera_preview": { + "name": "Aperçu de la caméra immersif", + "description": "Empêche Snapchat de recadrer l'aperçu de la caméra\nCela peut faire clignoter la caméra sur certains appareils" + }, + "force_camera_source_encoding": { + "name": "Forcer l'encodage source de la caméra", + "description": "Forcer l'encodage source de la caméra" + }, + "black_photos": { + "description": "Remplace les photos capturées par un arrière-plan noir\nLes vidéos ne sont pas affectées", + "name": "Photos noires" + }, + "hevc_recording": { + "name": "Enregistrement HEVC", + "description": "Utilise le codec HEVC (H.265) pour l'enregistrement vidéo" + }, + "back_custom_frame_rate": { + "name": "Fréquence d'images personnalisée à l'arrière", + "description": "Remplace la fréquence d'images de la caméra arrière" + }, + "override_front_resolution": { + "description": "Remplace la résolution de la caméra frontale", + "name": "Changer résolution frontale" + }, + "override_back_resolution": { + "description": "Remplace la résolution de la caméra arrière", + "name": "Changer résolution arrière" + }, + "custom_resolution": { + "name": "Résolution personnalisée", + "description": "Définit une résolution de caméra personnalisée, largeur x hauteur (par exemple 1920x1080).\nLa résolution personnalisée doit être prise en charge par votre appareil" + }, + "disable_cameras": { + "name": "Désactiver les caméras", + "description": "Empêche Snapchat d'utiliser les caméras sélectionnées" + }, + "front_custom_frame_rate": { + "name": "Fréquence d'images personnalisée à l'avant", + "description": "Remplace la fréquence d'images de la caméra frontale" + }, + "startup_default_camera": { + "name": "Caméra par défaut de démarrage", + "description": "Définit l'appareil photo par défaut lors de l'ouverture de Snapchat" + } + } + }, + "streaks_reminder": { + "name": "Rappels des Flammes", + "description": "Vous informe périodiquement concernant vos flammes", + "properties": { + "interval": { + "name": "Fréquence", + "description": "L'intervalle entre chaque rappel (heures)" + }, + "remaining_hours": { + "name": "Temps restant", + "description": "Le temps restant avant que la notification soit affichée (heures)" + }, + "group_notifications": { + "name": "Notifications de groupe", + "description": "Grouper les notifications en une seule" + } + } + }, + "experimental": { + "name": "Expérimental", + "description": "Fonctionnalités expérimentales", + "properties": { + "native_hooks": { + "name": "Hooks natifs", + "properties": { + "disable_bitmoji": { + "name": "Désactiver le Bitmoji", + "description": "Désactive le Bitmoji sur le profil d'amis" + }, + "composer_hooks": { + "name": "Hooks Composer", + "description": "Injecte du code dans le framework d'interface utilisateur multiplateforme de Composer (uniquement arm64)", + "properties": { + "bypass_camera_roll_limit": { + "name": "Dépassement de la limite de la pellicule de l'appareil photo", + "description": "Augmente le nombre maximal de médias que vous pouvez envoyer depuis la pellicule de l'appareil photo" + }, + "composer_console": { + "name": "Console de Composer", + "description": "Permet d'exécuter du code JavaScript dans Composer" + }, + "composer_logs": { + "name": "Journaux de Composer", + "description": "Redirige les journaux de console de Composer vers SnapEnhance" + }, + "show_first_created_username": { + "description": "Affiche le premier nom d'utilisateur créé à côté du nom d'utilisateur actuel dans la page de profil", + "name": "Afficher le premier nom d'utilisateur créé" + } + } + }, + "custom_emoji_font": { + "name": "Police Emoji personnalisée", + "description": "Vous permet d'utiliser une police emoji personnalisée. Fonctionne uniquement avec les polices .ttf" + }, + "custom_shared_library": { + "name": "Bibliothèque partagée personnalisée", + "description": "Charge une bibliothèque partagée personnalisée dans Snapchat. Cette fonctionnalité est uniquement à des fins de test" + } + }, + "description": "Fonctionnalités non fiables qui se greffent au code natif de Snapchat" + }, + "spoof": { + "name": "Falsification", + "description": "Falsifier diverses informations vous concernant", + "properties": { + "remove_mock_location_flag": { + "name": "Supprimer le flag de Mock Location", + "description": "Empêche Snapchat de détecter les localisations Mock" + }, + "remove_vpn_transport_flag": { + "description": "Empêche Snapchat de détecter les VPN", + "name": "Supprimer le flag VPN Transport" + }, + "play_store_installer_package_name": { + "description": "Remplace le nom du package d'installation par com.android.vending", + "name": "Nom du package de l'installateur Play Store" + } + } + }, + "infinite_story_boost": { + "name": "Boost de story infini", + "description": "Contournez le délai de Boost de Story" + }, + "no_friend_score_delay": { + "name": "Pas de délai du score d'amis", + "description": "Supprime le délai lors de la visualisation d'un score d'amis" + }, + "e2ee": { + "name": "Chiffrement de bout-en-bout", + "description": "Chiffre vos messages avec AES à l'aide d'une clé secrète\nAssurez-vous de sauvegarder votre clé quelque part en sécurité !", + "properties": { + "encrypted_message_indicator": { + "name": "Indicateur de message chiffré", + "description": "Ajoute un émoji 🔒 à côté des messages chiffrés" + }, + "force_message_encryption": { + "name": "Forcer le chiffrement des messages", + "description": "Empêche l'envoi de messages chiffrés aux personnes qui n'ont pas de chiffrement de bout en bout activé uniquement lorsque plusieurs conversations sont sélectionnées" + } + } + }, + "add_friend_source_spoof": { + "description": "Falsifie la source d'une demande d'ami", + "name": "Changer la source des demandes d'amis" + }, + "hidden_snapchat_plus_features": { + "name": "Fonctionnalités Snapchat Plus cachées", + "description": "Active les fonctionnalités non publiées/beta de Snapchat Plus\nPeut ne pas fonctionner sur les anciennes versions de Snapchat" + }, + "meo_passcode_bypass": { + "description": "Contourner le code d'accès My Eyes Only\nCela ne fonctionnera que si le code d'accès a été saisi correctement auparavant", + "name": "Contournement du code d'accès de My Eyes Only" + }, + "prevent_forced_logout": { + "name": "Empêcher la déconnexion forcée", + "description": "Empêche Snapchat de vous déconnecter lorsque vous vous connectez sur un autre appareil" + }, + "convert_message_locally": { + "description": "Convertit localement les Snaps en médias externes dans le chat. Ceci apparaît dans le menu contextuel de téléchargement du chat", + "name": "Convertir le message localement" + }, + "story_logger": { + "description": "Fournit un historique des Stories d'amis", + "name": "Journal des Stories" + }, + "media_file_picker": { + "name": "Sélecteur de fichiers multimédias", + "description": "Vous permet de choisir n'importe quel fichier vidéo/audio dans la galerie" + }, + "call_recorder": { + "name": "Enregistreur d'appel", + "description": "Enregistre automatiquement les appels audio" + }, + "account_switcher": { + "name": "Changeur de compte", + "description": "Vous permet de passer d'un compte à un autre sans vous déconnecter\nAppuyez longuement sur l'icône de recherche à côté de votre profil Bitmoji pour ouvrir le menu\nRemarque : cette fonctionnalité est expérimentale et risque de changer à l'avenir", + "properties": { + "auto_backup_current_account": { + "name": "Compte courant de sauvegarde automatique", + "description": "Sauvegarde automatiquement le compte actuel lors de la déconnexion ou du changement de compte" + } + } + }, + "edit_message": { + "name": "Modifier les messages", + "description": "Vous permet de modifier les messages dans les conversations" + }, + "app_lock": { + "properties": { + "lock_on_resume": { + "description": "Verrouille l'application lors de sa réouverture", + "name": "Verrouiller à la reprise" + } + }, + "name": "Verrou d'application", + "description": "Empêche l'accès à Snapchat sans mot de passe" + }, + "custom_streaks_expiration_format": { + "name": "Format personnalisé d'expiration des flammes", + "description": "Personnalise le format d'expiration des flammes\n\nVariables disponibles :\n- %c : Nombre de flammes\n- %e : Emoji sablier\n- %d : Jours\n- %h : Heures\n- %m : Minutes\n- %s : Secondes\n- %w : Temps restant" + }, + "best_friend_pinning": { + "name": "Épinglage du meilleur ami", + "description": "Permet d'épingler un ami comme votre meilleur ami numéro un. Remarque : vous êtes le seul à pouvoir voir votre meilleur ami épinglé" + }, + "cof_experiments": { + "name": "Expériences COF", + "description": "Active les fonctionnalités Snapchat inédites/bêta" + }, + "context_menu_fix": { + "name": "Correction du menu contextuel", + "description": "Essayez de réparer le menu Liste d'amie, car lorsque l'appareil est hors ligne, il ne peut pas être affiché correctement" + }, + "better_transcript": { + "properties": { + "force_transcription": { + "description": "Permet de transcrire toutes les notes vocales", + "name": "Forcer la transcription des notes vocales" + }, + "enhanced_transcript": { + "name": "Transcription améliorée", + "description": "Améliore la transcription des notes vocales à l'aide de DeepL.\nAvant d'utiliser cette fonctionnalité, assurez-vous d'avoir lu leur politique de confidentialité." + }, + "preferred_transcription_lang": { + "name": "Langue de transcription préférée", + "description": "La langue préférée pour la transcription des notes vocales (par exemple EN, ES, FR)" + }, + "enhanced_transcript_in_notifications": { + "name": "Transcription améliorée dans les notifications", + "description": "Transcrit les notes vocales dans les notifications à l'aide de DeepL. Cela nécessite que la fonctionnalité Aperçu du chat soit activée dans Meilleur Notifications" + } + }, + "name": "Meilleure transcription", + "description": "Améliore la transcription des notes vocales" + }, + "voice_note_auto_play": { + "name": "Lecture automatique des notes vocales", + "description": "Joue automatiquement la note vocale suivante une fois la note actuelle terminée" + }, + "friend_notes": { + "description": "Vous permet d'ajouter des notes aux profils d'amis", + "name": "Notes d'ami" + }, + "snapscore_changes": { + "name": "Modifications de Snapscore", + "description": "Suivi des changements dans le Snapscore des amis\nUtilisez cette fonctionnalité uniquement dans les versions plus récentes de Snapchat" + } + } + }, + "scripting": { + "name": "Scripting", + "description": "Exécuter des scripts personnalisés pour étendre SnapEnhance", + "properties": { + "developer_mode": { + "name": "Mode Développeur", + "description": "Affiche les informations de débogage sur l'interface utilisateur de Snapchat" + }, + "module_folder": { + "name": "Dossier des modules", + "description": "Le dossier où se trouvent les scripts" + }, + "integrated_ui": { + "name": "Interface utilisateur intégrée", + "description": "Permet aux scripts d'ajouter des composants d'interface utilisateur personnalisés à Snapchat" + }, + "disable_log_anonymization": { + "description": "Désactive l'anonymisation des journaux", + "name": "Désactiver l'anonymisation des journaux" + }, + "auto_reload": { + "description": "Recharge automatiquement les scripts lorsqu'ils sont modifiés", + "name": "Rechargement automatique" + } + } + }, + "friend_tracker": { + "properties": { + "record_messaging_events": { + "name": "Enregistrer les événements de messagerie", + "description": "Enregistre les événements de messagerie tels que l'ouverture d'un snap, la lecture d'un message, etc." + }, + "allow_running_in_background": { + "name": "Autoriser l'exécution en arrière-plan", + "description": "Permet au traqueur de fonctionner en arrière-plan. Remarque : cette fonction entraînera une consommation importante de la batterie" + }, + "auto_purge": { + "name": "Purge automatique", + "description": "Supprime automatiquement les événements mis en cache qui sont plus anciens que la durée spécifiée" + } + }, + "name": "Traqueur d'amis", + "description": "Enregistre l'activité d'un ami sur Snapchat" + } + }, + "options": { + "app_appearance": { + "always_light": "Toujours Clair", + "always_dark": "Toujours Sombre" + }, + "friend_feed_menu_buttons": { + "auto_download": "⬇️ Téléchargement automatique", + "auto_save": "💬 Enregistrement automatique des messages", + "stealth": "👻 Mode incognito", + "conversation_info": "👤 Infos de la Conversation", + "e2e_encryption": "🔒 Utiliser le chiffrement de bout en bout", + "mark_stories_as_seen_locally": "👀 Marquer localement les Stories comme vues", + "mark_snaps_as_seen": "👀 Marquer les snaps comme vu", + "unsaveable_messages": "⬇️ Messages non enregistrables", + "auto_open_snaps": "📷 Ouverture automatique des Snaps" + }, + "path_format": { + "create_author_folder": "Créer un dossier pour chaque utilisateur", + "create_source_folder": "Créer un dossier pour chaque type de média", + "append_hash": "Ajouter une empreinte unique au nom du fichier", + "append_source": "Ajouter la source du média au nom du fichier", + "append_username": "Ajouter le nom d'utilisateur au nom du fichier", + "append_date_time": "Ajouter la date ainsi que l'heure au nom du fichier" + }, + "auto_download_sources": { + "friend_snaps": "Snaps des amis", + "friend_stories": "Stories des amis", + "public_stories": "Stories publiques", + "spotlight": "Spotlight" + }, + "logging": { + "started": "Démarré", + "success": "Succès", + "progress": "Progression", + "failure": "Échec" + }, + "notifications": { + "chat_screenshot": "Capture d'écran", + "chat_screen_record": "Enregistrement vidéo de l'écran", + "snap_replay": "Revisionnage du Snap", + "camera_roll_save": "Sauvegarde de la pellicule", + "chat": "Discussion", + "chat_reply": "Répondre", + "snap": "Snap", + "typing": "Saisie en cours", + "stories": "Stories", + "chat_reaction": "Réaction du MP", + "group_chat_reaction": "Réaction du groupe", + "initiate_audio": "Appel audio entrant", + "abandon_audio": "Appel audio manqué", + "initiate_video": "Appel vidéo entrant", + "abandon_video": "Appel vidéo manqué", + "speaking": "En train de parler" + }, + "gallery_media_send_override": { + "ORIGINAL": "Media Originale", + "NOTE": "Vocal", + "SNAP": "Snap", + "always_ask": "Demandez toujours", + "SAVEABLE_SNAP": "Snap enregistrable" + }, + "auto_reload": { + "snapchat_only": "Snapchat uniquement", + "all": "Tous (Snapchat + SnapEnhance)" + }, + "strip_media_metadata": { + "remove_audio_note_duration": "Supprimer la durée des vocaux", + "remove_audio_note_transcript_capability": "Supprimer la capacité de transcription des vocaux", + "hide_extras": "Cacher les autres informations (ex : les mentions)", + "hide_caption_text": "Cacher le texte des légendes", + "hide_snap_filters": "Cacher les filtres des Snaps" + }, + "bypass_video_length_restriction": { + "single": "Un seul média", + "split": "Division des médias" + }, + "auto_purge": { + "1_day": "1 jour", + "1_week": "1 semaine", + "1_month": "1 mois", + "2_weeks": "2 semaines", + "never": "Jamais", + "1_hour": "1 heure", + "3_hours": "3 heures", + "6_months": "6 mois", + "3_days": "3 jours", + "6_hours": "6 heures", + "3_months": "3 mois", + "12_hours": "12 heures" + }, + "home_tab": { + "map": "Carte", + "discover": "Découverte", + "spotlight": "Spotlight", + "camera": "Caméra", + "chat": "Chat" + }, + "add_friend_source_spoof": { + "added_by_community": "Par communauté", + "added_by_mention": "Par mention", + "added_by_group_chat": "Par groupe de discussion", + "added_by_username": "Par nom d'utilisateur", + "added_by_qr_code": "Par QR Code", + "added_by_quick_add": "Par ajout rapide (risque élevé d'être banni)" + }, + "disable_confirmation_dialogs": { + "hide_conversation": "Cacher la conversation", + "clear_conversation": "Effacer la conversation du fil d'amis", + "remove_friend": "Supprimer l'ami", + "hide_friend": "Cacher l'ami", + "ignore_friend": "Ignorer l'ami", + "block_friend": "Bloquer l'ami", + "erase_message": "Effacer le message" + }, + "hide_ui_components": { + "hide_voice_record_button": "Supprimer le bouton d'enregistrement vocal", + "hide_unread_chat_hint": "Supprime l'indice de Chat non lu", + "hide_stickers_button": "Supprime le bouton des Stickers", + "hide_chat_call_buttons": "Supprimer les boutons d'appel dans le chat", + "hide_live_location_share_button": "Supprimer le bouton de partage de la localisation en direct", + "hide_profile_call_buttons": "Supprimer les boutons d'appel sur le profil", + "hide_post_to_story_buttons": "Supprimez les boutons Publier dans votre Story avant d'envoyer un Snap", + "hide_billboard_prompt": "Supprimer la fenêtre publicitaire dans le fil des amis", + "hide_snapchat_plus_gift_reminders": "Supprimer les rappels de cadeaux de Snapchat Plus dans les conversations", + "hide_map_reactions": "Supprimer les réactions de la carte" + }, + "edit_text_override": { + "bypass_text_input_limit": "Contourner la limite de saisie de texte", + "multi_line_chat_input": "Saisie multi-lignes" + }, + "old_bitmoji_selfie": { + "2d": "Bitmoji 2D", + "3d": "Bitmoji 3D" + }, + "disable_story_sections": { + "discover": "Découverte", + "friends": "Amis", + "following": "Suivis", + "suggested_stories": "Storys suggérées" + }, + "hide_story_suggestions": { + "hide_suggested_friend_stories": "Cacher les stories d'amis suggérées", + "hide_my_stories": "Cacher Ma Story" + }, + "disable_cameras": { + "front": "Caméra Frontale", + "back": "Caméra arrière" + }, + "disable_permission_requests": { + "notifications": "Notifications", + "microphone": "microphone", + "location": "Emplacement", + "phone_calls": "Appels téléphoniques", + "read_media_images": "Lire des images médiatiques", + "read_media_video": "Lire la vidéo multimédia", + "camera": "Caméra", + "read_contacts": "Lire les contacts", + "nearby_devices": "Appareils à proximité" + }, + "message_indicators": { + "encryption_indicator": "Ajoute une icône 🔒 à côté des messages qui vous ont été envoyés uniquement", + "location_indicator": "Ajoute une icône 📍 aux clichés lorsqu'ils ont été envoyés avec la localisation activée", + "ovf_editor_indicator": "Indique si un snap a été envoyé à l'aide de l'éditeur OVF", + "director_mode_indicator": "Ajoute une icône ✏️ aux clichés lorsqu'ils ont été envoyés en mode Directeur, qui peut être utilisée pour envoyer des images de la galerie sous forme de clichés", + "platform_indicator": "Ajoute l'icône de la plateforme à partir de laquelle un média a été envoyé (par exemple Android, iOS, Web)" + }, + "auto_mark_as_read": { + "conversation_read": "Marquer la conversation comme lue lors de l'envoi d'un message", + "snap_reply": "Marque les Snaps comme lus lorsqu'on y répond" + }, + "friend_mutation_notifier": { + "remove_friend": "Vous averti lorsque quelqu'un vous supprime en tant qu'ami", + "birthday_changes": "Vous averti lorsque quelqu'un change son anniversaire", + "bitmoji_selfie_changes": "Vous averti lorsque quelqu'un modifie son selfie Bitmoji", + "bitmoji_avatar_changes": "Vous averti lorsque quelqu'un change son avatar Bitmoji", + "bitmoji_background_changes": "Vous averti lorsque quelqu'un modifie son arrière-plan Bitmoji", + "bitmoji_scene_changes": "Vous averti lorsque quelqu'un change sa scène Bitmoji" + }, + "custom_theme": { + "amoled_dark_mode": "Mode sombre Amoled", + "custom": "Thèmes personnalisés (utilisez les actions rapides pour gérer les thèmes)", + "material_you_light": "Material You Light (Android 12+)", + "material_you_dark": "Materiel You Light (Android 12+)" + }, + "snapchat_plus": { + "not_subscribed": "Non abonné", + "basic": "Basique", + "ad_free": "Sans publicité" + }, + "simple_snapchat": { + "always_enabled": "Toujours activé", + "always_disabled": "Toujours désactivé" + }, + "double_tap_chat_action": { + "like_message": "Aime le message", + "mark_as_read": "Marquer comme lu", + "copy_text": "Copier le texte dans le presse-papiers", + "delete_message": "Supprimer le message", + "custom_emoji_reaction": "Réaction Emoji personnalisée" + }, + "startup_default_camera": { + "front": "Caméra frontale", + "back": "Caméra arrière" + } + } + }, + "friend_menu_option": { + "preview": "Aperçu", + "stealth_mode": "Mode furtif", + "auto_download_blacklist": "Liste noire des téléchargements automatiques", + "anti_auto_save": "Empêcher l'enregistrement automatique", + "mark_snaps_as_seen": "Marquer les Snaps comme vu", + "mark_stories_as_seen_locally": "Marquer localement les Stories comme vues" + }, + "chat_action_menu": { + "preview_button": "Aperçu", + "download_button": "Télécharger", + "delete_logged_message_button": "Supprimer le message enregistré", + "convert_message": "Convertir le message", + "edit_message": "Modifier le message", + "show_chat_edit_history": "Afficher l'historique des modifications du chat" + }, + "opera_context_menu": { + "download": "Télécharger les médias", + "media_duration": "Durée du média : {duration} ms", + "show_debug_info": "Afficher les informations de débogage", + "expires_at": "Expire le {date}", + "created_at": "Créé le {date}", + "sent_at": "Envoyé le {date}", + "media_size": "Taille du média : {size}" + }, + "modal_option": { + "profile_info": "Informations du profil", + "close": "Fermer" + }, + "gallery_media_send_override": { + "multiple_media_toast": "Vous ne pouvez envoyer qu'un seul média à la fois" + }, + "conversation_preview": { + "streak_expiration": "expire dans {day} jour(s) {hour} heure(s) {minute} minute(s)", + "total_messages": "Total des messages envoyés/reçus : {count}", + "title": "Aperçu", + "unknown_user": "Utilisateur inconnu", + "no_messages": "Aucun message trouvé !" + }, + "profile_info": { + "title": "Informations du profil", + "display_name": "Nom d'affichage", + "added_date": "Date d'ajout", + "birthday": "Anniversaire : {day} {month}", + "snapchat_plus_state": { + "subscribed": "Abonné", + "not_subscribed": "Non abonné" + }, + "friendship": "Amitié", + "hidden_birthday": "Anniversaire : Caché", + "snapchat_plus": "Snapchat Plus", + "add_source": "Source d'ajout", + "first_created_username": "Premier nom d'utilisateur créé", + "mutable_username": "Nom d'utilisateur modifiable" + }, + "chat_export": { + "dialog_negative_button": "Annuler", + "dialog_positive_button": "Exporter", + "exported_to": "Exporté vers {path}", + "exporting_chats": "Exportation des chats...", + "processing_chats": "Traitement de {amount} conversations...", + "export_fail": "Échec de l'export de la conversation {conversation}", + "writing_output": "Écriture...", + "finished": "Terminé ! Vous pouvez maintenant fermer cette fenêtre.", + "no_messages_found": "Aucun message trouvé !", + "exporting_message": "Exportation de {conversation}...", + "exporter_dialog": { + "text_field_selection_all": "Tout", + "export_file_format_title": "Format du fichier exporté", + "download_medias_title": "Télécharger les médias", + "amount_of_messages_title": "Nombre de messages (laissez vide pour tous les messages)", + "message_type_filter_title": "Filtrer les messages par type", + "text_field_selection": "{amount} sélectionné(s)", + "select_conversations_title": "Sélectionner les conversations" + } + }, + "button": { + "ok": "Ok", + "positive": "Oui", + "negative": "Non", + "cancel": "Annuler", + "open": "Ouvrir", + "download": "Télécharger", + "send": "Envoyer" + }, + "profile_picture_downloader": { + "button": "Télécharger les photos de profil", + "title": "Téléchargeur de photos de profil", + "avatar_option": "Avatar", + "background_option": "Arrière-plan" + }, + "download_processor": { + "attachment_type": { + "snap": "Snap", + "sticker": "Autocollant", + "external_media": "Média externe", + "note": "Note", + "original_story": "Storie originale", + "gif": "GIF" + }, + "select_attachments_title": "Sélectionnez les pièces jointes", + "download_started_toast": "Téléchargement démarré", + "unsupported_content_type_toast": "Type de contenu non supporté !", + "failed_no_longer_available_toast": "Média plus disponible", + "no_attachments_toast": "Aucune pièce jointe trouvée !", + "already_queued_toast": "Média déjà en file d'attente !", + "already_downloaded_toast": "Média déjà téléchargé !", + "download_toast": "Téléchargement {path}...", + "processing_toast": "Traitement de {path}...", + "failed_generic_toast": "Échec du téléchargement", + "failed_to_create_preview_toast": "Échec de création de l'aperçu", + "failed_processing_toast": "Échec du traitement {error}", + "failed_gallery_toast": "Échec de l'enregistrement dans la galerie {error}", + "dash_dialog": { + "download_all": "Tout télécharger", + "title": "Télécharger le média Dash", + "segment_text": "Segment {from} - {to}" + }, + "dash_no_chapter": "Aucun chapitre trouvé", + "content_saved_toast": "Sauvegarder!" + }, + "streaks_reminder": { + "notification_title": "Rappels des flammes", + "notification_text": "Vous perdrez vos flammes avec {friend} dans {hoursLeft} heure(s)" + }, + "content_type": { + "FAMILY_CENTER_INVITE": "Invitation Family Center", + "STATUS_CONVERSATION_CAPTURE_RECORD": "Enregistrement d'écran", + "STATUS_CALL_MISSED_VIDEO": "Appel vidéo manqué", + "CREATIVE_TOOL_ITEM": "Item de Creative Tool", + "STICKER": "Autocollant", + "TINY_SNAP": "Mini Snap", + "STATUS_SAVE_TO_CAMERA_ROLL": "Sauvegardé dans la galerie", + "EXTERNAL_MEDIA": "Média externe", + "SNAP": "Snap", + "LOCATION": "Localisation", + "CHAT": "Message Textuel", + "STATUS_PLUS_GIFT": "Cadeau Snapchat Plus", + "STATUS_COUNTDOWN": "Compte à rebours", + "LIVE_LOCATION_SHARE": "Partage d'emplacement en direct", + "STATUS": "Statut", + "STATUS_CONVERSATION_CAPTURE_SCREENSHOT": "Capture d'écran", + "FAMILY_CENTER_ACCEPT": "Acceptation Family Center", + "FAMILY_CENTER_LEAVE": "A quitté Family Center", + "STATUS_CALL_MISSED_AUDIO": "Appel audio manqué", + "NOTE": "Vocal", + "MAP_REACTION": "Réaction de la carte", + "SHARE": "Partager" + }, + "media_download_source": { + "public_story": "Story Publique", + "spotlight": "Spotlight", + "pending": "En cours", + "merged": "Fusionné", + "story_logger": "Journal des stories", + "none": "Aucun", + "profile_picture": "Photo de profil", + "story": "Story", + "chat_media": "Média de Chat", + "voice_call": "Appel vocal", + "message_logger": "Enregistreur de messages" + }, + "material3_strings": { + "date_input_invalid_not_allowed": "Date invalide", + "date_range_input_invalid_range_input": "Plage de dates non valide", + "date_range_picker_scroll_to_previous_month": "Mois précédent", + "date_picker_switch_to_input_mode": "Entrée", + "date_range_picker_day_in_range": "Séléctionné", + "date_input_invalid_for_pattern": "Date invalide", + "date_picker_today_description": "Aujourd'hui", + "date_picker_switch_to_calendar_mode": "Calendrier", + "date_range_picker_start_headline": "Depuis", + "date_range_picker_end_headline": "Jusqu'à", + "date_range_picker_scroll_to_next_month": "Mois suivant", + "date_range_picker_title": "Sélectionner une plage de dates", + "date_input_invalid_year_range": "Année invalide" + }, + "better_notifications": { + "button": { + "download": "Télécharger", + "reply": "Répondre", + "mark_as_read": "Marquer comme lu" + } + }, + "half_swipe_notifier": { + "notification_content_group": "{friend} a entrouvert dans {group} pendant {duration} seconde(s)", + "notification_channel_name": "Entrouverts", + "notification_content_dm": "{friend} a entrouvert votre discussion pendant {duration} seconde(s)" + }, + "friendship_link_type": { + "mutual": "Mutuel", + "deleted": "Supprimé", + "following": "Suivi", + "incoming_follower": "Follower entrant", + "incoming": "Entrant", + "blocked": "Bloqué", + "suggested": "Suggéré", + "outgoing": "Sortant" + }, + "call_start_confirmation": { + "dialog_message": "Êtes-vous sûr de vouloir lancer un appel ?", + "dialog_title": "Démarrer un appel" + }, + "bulk_messaging_action": { + "choose_action_title": "Choisir une action", + "progress_status": "Traitement de {index} sur {total}", + "actions": { + "clear_conversations": "Nettoyer des conversations", + "remove_friends": "Supprimer des amis" + }, + "selection_dialog_continue_button": "Continuer", + "confirmation_dialog": { + "message": "Cette action affectera tous les amis sélectionnés. Cette action ne peut pas être annulée.", + "title": "Êtes-vous sûr ?" + } + }, + "actions": { + "clean_snapchat_cache": { + "name": "Nettoyer le cache Snapchat", + "description": "Nettoie le cache de Snapchat" + }, + "manage_friend_list": { + "name": "Gérer la liste d'amis", + "description": "Importez/exportez votre liste d'amis lors de la sauvegarde" + }, + "export_chat_messages": { + "name": "Exporter les messages de discussion", + "description": "Exporte les messages de conversation dans un fichier JSON/HTML/TXT" + }, + "export_memories": { + "name": "Exporter des souvenirs", + "description": "Exporter les souvenirs dans un fichier ZIP" + }, + "bulk_messaging_action": { + "description": "Effectue des opérations telles que la suppression d'amis ou la suppression en masse de conversations", + "name": "Action de messagerie en masse" + }, + "change_language": { + "name": "Changer la langue", + "description": "Changer la langue de SnapEnhance" + }, + "regen_mappings": { + "name": "Régénérer les mappages", + "description": "Régénérer manuellement les mappages" + }, + "file_imports": { + "name": "Imports de fichiers", + "description": "Importer des fichiers pour les utiliser dans Snapchat" + }, + "logger_history": { + "description": "Consulter l'historique des messages enregistrés", + "name": "Historique du logger" + }, + "friend_tracker": { + "name": "Traqueur d'amis", + "description": "Traquer vos amis sur Snapchat" + }, + "security_features": { + "name": "Fonctions de sécurité", + "description": "Modifier les préférences des fonctionnalités de sécurité" + }, + "theming": { + "name": "Thèmes", + "description": "Personnalisez l'apparence de Snapchat" + } + }, + "mark_as_seen": { + "no_unseen_snaps_toast": "Aucun Snap invisible trouvé !", + "seen_toast": "Marqué comme vu !", + "already_seen_toast": "Déjà marqué comme vu !", + "unseen_toast": "Marqué comme vu !", + "already_unseen_toast": "Déjà marqué comme invisible !" + }, + "end_to_end_encryption": { + "toolbox": { + "no_shared_key": "Vous n'avez pas encore de secret partagé avec cet ami. Cliquez ci-dessous pour en lancer un nouveau.", + "shared_key_fingerprint": "Votre fingerprint est :\n\n{fingerprint}\n\nAssurez-vous de vérifier si cela correspond à la fingerprint de votre ami !", + "initiate_exchange_button": "Lancer l'échange de clés" + }, + "confirmation_dialogs": { + "confirmation_1": "AVERTISSEMENT : Cela écrasera votre clé existante. Vous perdrez l'accès à tous les messages chiffrés de cet ami. Êtes-vous sûr de vouloir continuer ?", + "title": "Chiffrement de bout en bout", + "confirmation_2": "Êtes-vous VRAIMENT sûr de vouloir continuer ? C'est votre dernière chance de faire marche arrière." + }, + "unencrypted_conversation_send_failure_toast": "Vous ne pouvez pas envoyer de contenu chiffré à des conversations chiffrées et non chiffrées !", + "accept_public_key_success_toast": "Clé publique acceptée avec succès !", + "native_hooks_send_failure_toast": "Échec de l'envoi ! Veuillez activer les Hooks Natifs dans les paramètres.", + "accept_secret_key_success_toast": "Terminé ! Vous pouvez désormais envoyer et recevoir des messages chiffrés avec cet ami.", + "accept_public_key_failure_toast": "Échec de l'acceptation de la clé publique", + "accept_secret_button": "Accepter la clé publique", + "no_participants_to_encrypt_toast": "Vous n'avez aucun ami dans cette conversation avec qui chiffrer les messages !", + "accept_secret_key_failure_toast": "Échec de l'acceptation de la clé secrète", + "encryption_failed_toast": "Échec de chiffrement du message ! Veuillez vérifier le logcat pour plus de détails.", + "accept_public_key_button": "Accepter la clé publique", + "outgoing_pk_message": "Demande d'échange de clés", + "outgoing_secret_message": "Réponse d'échange de clés", + "incoming_pk_message": "Vous venez de recevoir une demande de clé publique. Cliquez ci-dessous pour l'accepter.", + "incoming_secret_message": "Votre ami vient d'accepter votre clé publique. Cliquez ci-dessous pour accepter le secret." + }, + "scopes": { + "friend": "Ami", + "group": "Groupe" + }, + "biometric_auth": { + "title": "Débloquer Snapchat", + "subtitle": "Veuillez vous authentifier pour déverrouiller Snapchat", + "unlock_button": "Déverrouiller" + }, + "auto_open_snaps": { + "notification_content": "{count} Snaps ouverts", + "title": "Auto-ouverture des Snaps" + }, + "friend_mutation_observer": { + "notification_channel_name": "Observateur de mutation des amis", + "birthday_changed": "{username} a changé son anniversaire de {oldBirthday} à {newBirthday}", + "bitmoji_selfie_changed": "{username} a modifié son selfie Bitmoji", + "bitmoji_avatar_changed": "{username} a changé son avatar Bitmoji", + "bitmoji_background_changed": "{username} a changé son arrière-plan Bitmoji", + "friend_removed": "{username} vous a supprimé de sa liste d'amis", + "birthday_removed": "{username} a supprimé son anniversaire ({birthday})", + "birthday_added": "{username} a ajouté son anniversaire ({birthday})", + "bitmoji_scene_changed": "{username} a changé sa scène Bitmoji" + }, + "theming_attributes": { + "listBackgroundDrawable": "Liste de conversations", + "sigColorChatConversationsLine": "Couleur de la ligne de conversation", + "actionSheetBackgroundDrawable": "Couleur d’arrière-plan du menu d’action", + "actionSheetRoundedBackgroundDrawable": "Couleur d'arrière-plan ronde du menu d'action", + "sigColorIconPrimary": "Couleur d'arrière-plan ronde du menu d'action", + "sigExceptionColorCameraGridLines": "Couleur du quadrillage de la caméra", + "rangeFillColor": "Couleur de remplissage de plage", + "pstsIndicatorColor": "Couleur de l’indicateur PSTS", + "pstsTabBackground": "Arrière-plan de l'onglet PSTS", + "pstsDividerColor": "Couleur du séparateur PSTS", + "statusBarBackground": "Couleur d'arrière-plan de la barre d'état", + "strokeColor": "Couleur du trait", + "storyReplayViewRingColor": "Voir la rediffusion de l'histoire Couleur de la bague", + "sigColorButtonPrimary": "Couleur du bouton principal", + "sigColorBaseAppYellow": "Couleur jaune de l'application de base", + "sigColorBackgroundSurfaceTranslucent": "Couleur de la surface de fond translucide", + "sigColorStoryRingFriendsFeedStoryRing": "Bague d'histoire Flux d'amis Couleur de la bague d'histoire", + "sigColorStoryRingDiscoverTabThumbnailStoryRing": "Story Ring Onglet Découverte Vignette Story Ring Couleur", + "sigColorTextPrimary": "Couleur du texte principal", + "sigColorChatChat": "Couleur du texte du flux d'amis principal", + "sigColorBackgroundSurface": "Couleur de la surface d'arrière-plan", + "sigColorChatPendingSending": "Couleur du texte du fil d’ami secondaire", + "sigColorChatSnapWithSound": "Snaps avec la couleur du texte sonore", + "sigColorChatSnapWithoutSound": "Snaps sans couleur de texte sonore", + "actionSheetDescriptionTextColor": "Action Menu Description Couleur du texte", + "sigColorBackgroundMain": "Couleur de l'arrière plan", + "recipientPillBackgroundDrawable": "Fond de pilule du destinataire", + "boxBackgroundColor": "Couleur d'arrière-plan de la boîte", + "editTextColor": "Modifier la couleur du texte", + "chipBackgroundColor": "Couleur d'arrière-plan de la puce", + "listDivider": "Couleur du séparateur de liste", + "sigColorIconSecondary": "Couleur de l’icône secondaire", + "recipientInputStyle": "Style de saisie du destinataire", + "itemShapeFillColor": "Couleur de remplissage de la forme de l'élément", + "ringColor": "Couleur de la bague", + "ringStartColor": "Couleur de début de sonnerie", + "sigColorLayoutPlaceholder": "Couleur de l'espace réservé à la mise en page", + "scButtonColor": "Couleur du bouton Snapchat", + "tabTextColor": "Couleur du texte de l'onglet", + "statusBarForeground": "Couleur de premier plan de la barre d'état" + }, + "send_override_dialog": { + "title": "Envoyer des médias en tant que {type}", + "saveable_snap_hint": "Rendre Snap enregistrable dans le chat", + "duration": "Durée : {duration}", + "unlimited_duration": "Illimité" + } +} diff --git a/common/src/main/assets/lang/gsw.json b/common/src/main/assets/lang/gsw.json new file mode 100644 index 0000000000..6dbafff35b --- /dev/null +++ b/common/src/main/assets/lang/gsw.json @@ -0,0 +1,441 @@ +{ + "features": { + "properties": { + "user_interface": { + "properties": { + "opera_media_quick_info": { + "description": "Zeigt nützlechi Informatione zu Medie wi Erschtelligsdatum im Opernbetrachter-Kontextmenü" + }, + "friend_feed_message_preview": { + "description": "Zeigt e Vorschou vo de letschte Nachrichte im Friend Feed" + } + } + }, + "messaging": { + "properties": { + "bypass_message_action_restrictions": { + "description": "Ermöglichts nechs ufne Snap z reagiere ohni ne göffnet zha, oder e nid speicherbari Nachricht z speichere" + }, + "auto_mark_as_read": { + "description": "Markiert automatisch Nachrichte/Snaps aus gläse, o we dr Steauth-Modus aktiviert isch" + } + } + }, + "global": { + "properties": { + "media_upload_quality": { + "properties": { + "force_video_upload_source_quality": { + "description": "Zwingt Snapchat, d Quellqualität bim Hochlade vo Videos z verwände\nBitte beachte, dass debi kei Metadate us Medie dörfe entfernt wärde." + } + } + }, + "disable_custom_tabs": { + "description": "Öffnet Links i unterstützte Awändige statt im Webrowser" + }, + "hide_active_music": { + "description": "Verhinderet, dass Snapchat merkt, dassd Musig losisch\nDas ermöglichts dir, Schnappschüss mit Lutstärcheregler-Taschte z'näh, während du Musig lose" + }, + "video_playback_rate_slider": { + "description": "Fügt e Schieberegler im Opera-Kontextmenü hinzue zum d Video-Wiedergabegschwindigkeit z ändere\nHiwiis: Änderige gälte nume für nachfolgende Videos" + } + } + }, + "experimental": { + "properties": { + "best_friend_pinning": { + "description": "Ermöglicht's Nech, e Fründ aus dine beschte Fründ Nummere eins anzheften. Hiwiis: Nume chasch din agheftete besti Fründ gseh." + }, + "e2ee": { + "properties": { + "force_message_encryption": { + "description": "Verhinderet ds Sände verschlüsselter Nachrichte a Persone wo d E2E Verschlüsselig nid aktiviert isch, nume we mehreri Ungerhautige usgwählt si" + } + }, + "description": "Verschlüsslet dini Nachrichte mit AES unger Verwändig vomene freigegebene gheime Schlüssel\nAchte druf, dass din Schlüssel amene sichere Ort ufbewahre!" + }, + "native_hooks": { + "properties": { + "composer_hooks": { + "properties": { + "show_first_created_username": { + "description": "Zeigt dr erschtellti Benutzername näbem aktuelle Benutzername ir Profilsiite a" + } + } + } + } + }, + "convert_message_locally": { + "description": "Konvertiert Snaps um externi Medie lokal z'chatte. Das wird im Kontextmenü zum Abelade vom Chat azeigt." + }, + "account_switcher": { + "description": "Ermöglichts ne zwüsche kontene z wechsle ohni sech abzmelde\nLang drücke uf ds Suchsymbol näb dim Bitmoji Profil zum ds Menü z öffne\nHiwiis: Die Funktion isch experimentell und wird sech wahrschinlech ir Zuekunft ändere." + }, + "custom_streaks_expiration_format": { + "description": "Passet ds Streaks Ablouf-Format a\n\nVerfüegbari Variable:\n – %c: Azahl der Streife\n – %e: Sanduhr-Emoji\n – %d: Tage\n – %h: Stunde\n – %m: Protokoll\n – %s: Sekunde\n – %w: Verbleibendi Zyt" + }, + "meo_passcode_bypass": { + "description": "Umghig vom My Eyes Only Passcode\nDas funktioniert nume we dr Passcode vor" + } + } + }, + "downloader": { + "properties": { + "opera_download_button": { + "description": "Fügt bim Betrachte vomene Snap e Download-Button obe rächts hinzue.\nLanges Drück uf Taschte wird ds Abelade erzwinge" + }, + "save_folder": { + "description": "Wähle s Verzeichnis wo alli Medie selled abeglade werde", + "name": "Speicherverzeichnis" + }, + "download_context_menu": { + "description": "Ermöglicht ds abeelade/Vorluege vo Nachrichte usere Ungerhautig oder ere Gschicht über ds Kontextmenü.\nLanges Drück uf Taschte wird ds Abelade erzwinge" + }, + "custom_path_format": { + "description": "Setzt e benutzzerdefinierti Kamerauflösig, Breiti x Höchi (z.B. 1920x1080).\nDi benutzzerdefinierti Uflösig mues unterstützt werde Geef e aapascht padformat op voor downloade media\n\nBeschikbari variable:\n – %username%\n – %source%\n – %hash%\n - %date_time% vo dim Gerät" + }, + "auto_download_sources": { + "description": "Wähl d'Quellä, vo dene automatisch abäzladä isch", + "name": "Quellä automatisch abäladä" + }, + "prevent_self_auto_download": { + "name": "Selbscht-Auto-Download verhinderä", + "description": "Verhinderät, dass dini eigenä Snaps automatisch abägladä wirdet" + }, + "allow_duplicate": { + "description": "Ermöglichts, dass diäselben Medien mehrmals abägladä wirdet", + "name": "Druplikat erlaubä" + }, + "merge_overlays": { + "name": "Overlays zsämmäfüährä", + "description": "Kombiniert dä Text und d'Mediä vo einem Snap i ei Datei" + }, + "path_format": { + "name": "Pfadformat", + "description": "Gibs Dateiformat a" + } + }, + "name": "Downloader", + "description": "Snapchat Mediä abäladä" + }, + "camera": { + "properties": { + "black_photos": { + "description": "Ersetzt ufgnommi Fötteli mit schwarzem Hintergrund\nVideos sy nid betroffe" + }, + "custom_resolution": { + "description": "Setzt e benutzzerdefinierti Kamerauflösig, Breiti x Höchi (z.B. 1920x1080).\nDi benutzzerdefinierti Uflösig mues vo dim Grät unterstützt werde" + } + } + }, + "streaks_reminder": { + "properties": { + "remaining_hours": { + "description": "Di verblybendi Zyt bis zur Azeig vor Benachrichtigung (Stund)" + } + } + } + }, + "options": { + "message_indicators": { + "director_mode_indicator": "Fügt es ✏️-Symbol zu Snaps hinzue, we si im Diräctor-Modus gsändet worde si, mit em Galerie-Bilder aus Snaps chöi gsändet wärde", + "encryption_indicator": "Fügt es 🔒-Symbol näb Nachrichte derzue, wo nume ah du gsesch worde si", + "platform_indicator": "Fügt ds Plattform-Symbol hinzue wo es Medium gsendet worde isch (z.B. Android, iOS, Web)", + "location_indicator": "Fügt es 📍-Symbol zu Snaps hinzue, we si mit aktiviertem Speicherort gsendet worde si" + } + }, + "notices": { + "unstable": "⚠ Instabil", + "ban_risk": "⚠ Dieses Feature chan zu Bans führä", + "internal_behavior": "⚠ Das chan s internä Verhaltä vo Snapchat störä" + } + }, + "biometric_auth": { + "title": "Snapchat entsperre", + "subtitle": "Bitte authentifiziere zum d Snapchat entsperre", + "unlock_button": "Entsperre" + }, + "end_to_end_encryption": { + "confirmation_dialogs": { + "title": "Ende-zu-End-Verschlüsselig", + "confirmation_1": "WARNIG: Durch wird dis vorhandene Schlüssel überschribe. Si verliere dr Zuegriff uf alli verschlüsslete Nachrichte vo däm Fründ. Bisch sicher, dass du witermache wotsch?", + "confirmation_2": "Bisch WÜRKLI sicher dass du witermache wotsch? Das isch dini letschti Chance es Rückzieher z mache." + }, + "native_hooks_send_failure_toast": "Versände fehlgschlage! Bitte aktiviered Sie nativi Hooks i de Ihstellige.", + "no_participants_to_encrypt_toast": "Du hesch kei Fründe i dere Unterhaltig wod Nachrichte chasch verschlüssle!", + "accept_public_key_success_toast": "Public Key erfolgrich akzeptiert!", + "accept_public_key_failure_toast": "Öffentliche Schlüssel het nid chönne akzeptiert wärde", + "accept_secret_button": "Akzeptiere gheim", + "toolbox": { + "initiate_exchange_button": "Schlüsseluusstuusch initiiere", + "no_shared_key": "Du hesch no kes Gheimnis mit däm Fründ teilt. Klick dunne um es neus z initiiere.", + "shared_key_fingerprint": "Dir Fingerabdruck isch:\n\n{fingerprint}\n\nÜberprüef, öb är mit em Fingerabdruck vo dim Fründ überiistimmt!" + }, + "incoming_pk_message": "Du hesch grad e Public-Key-Afrag übercho. Klick unde zum sie akzeptiere.", + "incoming_secret_message": "Din Fründ het grad dine öffentliche Schlüssel akzeptiert. Klick unde zums Gheimnis akzeptiere.", + "unencrypted_conversation_send_failure_toast": "Chasch ke verschlüsslete Inhaut sowou a verschlüssleti Inhaut, aus o a unverschlüssleti Ungerhautige schicke!", + "encryption_failed_toast": "D Verschlüsselig vo de Nachricht isch fählgschlage! Überprüefe Si logcat für wyteri Details.", + "accept_secret_key_success_toast": "Fertig! Dir chöi jetz verschlüssleti Nachrichte mit däm Fründ schicke und empfange.", + "accept_secret_key_failure_toast": "Dä gheimi Schlüssel isch nid akzeptiert worde" + }, + "friend_mutation_observer": { + "bitmoji_background_changed": "{username} het de Bitmoji hintergrund gänderet", + "bitmoji_scene_changed": "{username} het ihri Bitmoji szene gänderet" + }, + "material3_strings": { + "date_range_picker_end_headline": "Zu", + "date_picker_switch_to_input_mode": "Iihgab", + "date_range_picker_start_headline": "Vo", + "date_range_picker_title": "Datumsbereich uswähle", + "date_picker_switch_to_calendar_mode": "Kalender" + }, + "setup": { + "dialogs": { + "select_language": "Sprach uswähle", + "save_folder": "SnapEnhance benötigt Speicherberächtigunge zum Abelade und Speichere vo Medie vo Snapchat.\nBitte wähl de Ort us wo d Medie sölled abeglade werde.", + "select_save_folder_button": "Ordner uswähle" + }, + "mappings": { + "dialog": "S generiere vo mappings chan es wiili duure ...", + "generate_failure_no_snapchat": "SnapEnhance hät Snapchat nöd chöne erkenne, bitte versueched Sie Snapchat neu z installiere.", + "generate_failure": "Bim Versuech mappings generiere isch en fehler ufträtte, bitte versueched Sies erneut." + }, + "permissions": { + "dialog": "Zum wiitermache müend Sie folgendi Aforderige erfülle:", + "notification_access": "Benachrichtigung Zugang", + "battery_optimization": "Batterieoptimierig", + "display_over_other_apps": "Ahzeig über anderi Apps", + "request_button": "Anfrage" + } + }, + "manager": { + "sections": { + "home_logs": { + "no_logs_hint": "Kei Protokoll verfüegbar", + "saving_logs_toast": "Logs speichere, das chan es wiili duure ...", + "clear_logs_button": "Protokoll lösche", + "export_logs_button": "Logs exportiere", + "saved_logs_success_toast": "Protokoll erfolgriich gspicheret", + "saved_logs_failure_toast": "Speichere vo Protokoll fählgschlage" + }, + "tasks": { + "remove_selected_tasks_confirm": "{count}-Ufgabe entferne?", + "no_tasks": "Kei Ufgabe", + "merge_files_toast": "Zämeführe vo {count}-Dateien", + "remove_selected_tasks_title": "Sind Sie sicher, dass Sie usegwählti Ufgabe lösche wönd?", + "remove_all_tasks_title": "Sind Sie sicher, dass Sie alli Ufgabe möchted lösche?", + "delete_files_option": "Au Dateiä löschä", + "remove_all_tasks_confirm": "Alli Ufgabe lösche?" + }, + "features": { + "disabled": "Deaktiviert", + "export_option": "Exportierä", + "import_option": "Iifuhr", + "reset_option": "Zrücksetze", + "config_export_success_toast": "Istelligä erfolgrich exportiert", + "config_import_success_toast": "Istelligä erfolgrich importiert", + "config_import_failure_toast": "Istelligä chönd nid importiert werdä {error}", + "saved_config_snackbar": "Konfiguration gspeicheret" + }, + "manage_scope": { + "streaks_expiration_text_expired": "Abglaufe", + "reminder_button": "Erinnerig istelle", + "delete_scope_confirm_dialog_title": "Sind Sie sicher, dass Sie {scope} lösche wönd?", + "logged_stories_button": "g'loggdi Stories azeige", + "e2ee_title": "Änd-zu-Änd-Verschlüsselig", + "rules_title": "Reglä", + "participants_text": "{count}-Teilnehmer", + "not_found": "Nöd gfunde", + "streaks_title": "Flämmli", + "streaks_length_text": "Längi: {length}", + "streaks_expiration_text": "Lauft ab i {eta}" + }, + "logged_stories": { + "story_failed_to_load": "Fehler bim Lade", + "no_stories": "Kei Stories gfunde", + "save_from_cache_button": "Vom Zwischäspiecher speicherä" + }, + "messaging_preview": { + "save_selection_option": "Uswahl speichere", + "bridge_connection_failed": "Verbindig zu Snapchat über de Bruggä-Dienst fehlgschlage", + "bridge_init_failed": "Initialisierig vo de Messaging-Brugg fehlgschlage", + "message_fetch_failed": "Fehler bim Abruefe vo Nachrichte", + "no_message_hint": "Kei Nachricht", + "mark_all_as_seen_option": "Alli Snaps als gseh merkierä", + "save_all_option": "Alli speichere", + "unsave_selection_option": "Uswahl rückgängig mache", + "unsave_all_option": "Uswahl rückgängig mache", + "mark_selection_as_seen_option": "Usgwähltä Snap als g'seh markierä", + "delete_selection_option": "Uswahl lösche", + "delete_all_option": "Alli lösche" + }, + "home": { + "update_title": "SnapEnhance Update", + "update_content": "Version {version} isch verfüegbar!", + "update_button": "Abelade" + }, + "home_settings": { + "actions_title": "Massnahme", + "message_logger_title": "Nachrichtelogger", + "debug_title": "Debugge", + "success_toast": "Fertig!", + "message_logger_summary": "{messageCount} Nachrichtä\n{storyCount} Stories", + "export_button": "Exportierä", + "clear_button": "Löschä", + "view_logger_history_button": "Logger-Verlauf azeige" + }, + "social": { + "friends_tab": "Fründe", + "groups_tab": "Gruppe", + "empty_hint": "(leer)", + "streaks_expiration_short": "{hours} Stündli" + }, + "logger_history": { + "unknown_sender": "Unbekannte Absender", + "download_attachment_failed_toast": "De Ahang het nöd chöne abeglade werde", + "list_friend_format": "Fründ {Name}", + "list_group_format": "Gruppe {Name}", + "no_more_messages": "Kei Meldige meh", + "reverse_order_checkbox": "Umgekehrti Reihefolg", + "empty_message": "Lääri Chat-Nachricht", + "chat_attachment": "Ahang {index}", + "message_parse_failed": "Fehler bim Parse vo de Nachricht" + } + }, + "routes": { + "tasks": "Aufgaben", + "features": "Merkmal", + "home": "Heimat", + "home_settings": "Settigs", + "home_logs": "Logs", + "logger_history": "Logger-Verlauf", + "logged_stories": "Protokollierti Stories", + "friend_tracker": "Friend erkenni", + "edit_rule": "Edit Rulewe", + "social": "Soziales", + "manage_scope": "Rahme verwalte", + "messaging_preview": "Vorschau", + "scripts": "Skripte" + }, + "dialogs": { + "add_friend": { + "title": "Fründ oder Gruppe hinzuefüege", + "search_hint": "Düresueche", + "fetch_error": "Fehler bim Abruefe vo Date", + "category_groups": "Gruppe", + "category_friends": "Fründe" + }, + "scripting_warning": { + "title": "Warnig", + "content": "SnapEnhance enthaltet es Skripting-Tool, wo d Usfüerig vo benutzzerdefiniertem Code uf dim Grät ermöglicht. Si si vorsichtig u installiere Si nume Modul us bekannt, zuverlässige Quelle. Nid autorisierti oder nid verifizierti Modul chöi Sicherheitsrisike für dis System darstelle." + }, + "reset_config": { + "content": "Bisch sicher, dass du d Konfiguration wotsch zruggsetze?", + "success_toast": "Config erfolgrich zrüggsetze", + "title": "Konfiguration zrüggsetze" + }, + "messaging_action": { + "title": "Zu verarbeitende Inhaltstype uswähle", + "select_all_button": "Aui uswähle" + } + } + }, + "scopes": { + "friend": "Freund", + "group": "Gruppe" + }, + "rules": { + "properties": { + "stealth": { + "description": "Verhinderet, dass öpper erfahre das sini Snaps/Chats und Unterhaltige göffnet hesch", + "name": "Stealth-Modus", + "options": { + "blacklist": "Us em Schtealth-Modus usschlüsse", + "whitelist": "Stealth-Modus" + } + }, + "auto_save": { + "description": "Speicheret Chat-Nachrichte bim Betrachte", + "options": { + "blacklist": "Vom Auto speichere usschlüsse", + "whitelist": "Outo spichere" + }, + "name": "Outo spichere" + }, + "auto_download": { + "name": "Automatisches Abewähli", + "description": "Snaps outomatisch abelade we si azeigt wärde", + "options": { + "blacklist": "Vom Auto-Download usschlüsse", + "whitelist": "Automatisches Abewähli" + } + }, + "unsaveable_messages": { + "options": { + "whitelist": "Nid speicherbari Nachrichte", + "blacklist": "Vo nid speicherbare Nachrichte usschlüsse" + }, + "name": "Nid speicherbari Nachrichte", + "description": "Verhinderet, dass Nachrichte vo anderne Persone im Chat gspicheret wärde" + }, + "auto_open_snaps": { + "description": "Öffnet automatisch snaps we si empfange wärde", + "options": { + "blacklist": "Vum Auto Open Snaps usschlüsse", + "whitelist": "Automatischs Öffne vo Schnappschüss" + }, + "name": "Automatischs Öffne vo Schnappschüss" + }, + "hide_friend_feed": { + "name": "Usblende vo Fründ-Feed" + }, + "e2e_encryption": { + "name": "E2E-Verschlüsselig verwände" + }, + "pin_conversation": { + "name": "Konversation anpinnen" + } + }, + "toasts": { + "enabled": "{ruleName} aktiviert", + "disabled": "{ruleName} deaktiviert" + }, + "modes": { + "blacklist": "Blacklist-Modus", + "whitelist": "Wisslischte-Modus" + } + }, + "actions": { + "bulk_messaging_action": { + "description": "Füehrt Vorgäng wi ds Lösche vo Fründe oder ds Masselösche vo Ungerhautige us", + "name": "Massä Nachrichtä Aktion" + }, + "clean_snapchat_cache": { + "name": "Snapchat-Cache bereinige", + "description": "Reinigt der Snapchat-Cache" + }, + "export_memories": { + "name": "Speicher exportiere", + "description": "Exportiert d'Memories in än ZIP-Datei" + }, + "manage_friend_list": { + "name": "Fründesliste verwalte", + "description": "Importiere/Exportiere dini Fründeslischte bim Backup" + }, + "export_chat_messages": { + "name": "Chat-Nachrichte exportiere", + "description": "Exportiert Konversationsnachrichte i ne JSON/HTML/TXT-Datei" + }, + "regen_mappings": { + "name": "Ernüer d'Mappings", + "description": "Ernüer d'Mappings manuell" + }, + "change_language": { + "name": "Sproch änderä", + "description": "Ändert d'Sproch vo SnapEnhance" + } + }, + "streaks_reminder": { + "notification_text": "Du verliersch dy streak mit {friend} in {hoursLeft} Stunde" + } +} diff --git a/common/src/main/assets/lang/gu_IN.json b/common/src/main/assets/lang/gu_IN.json new file mode 100644 index 0000000000..4373c10ca3 --- /dev/null +++ b/common/src/main/assets/lang/gu_IN.json @@ -0,0 +1,16 @@ +{ + "setup": { + "dialogs": { + "save_folder": "SnapEnhance ને Snapchat માંથી મીડિયા ડાઉનલોડ કરવા અને સાચવવા માટે સ્ટોરેજ પરવાનગીની જરૂર છે. કૃપા કરીને તે સ્થાન પસંદ કરો જ્યાં મીડિયા ડાઉનલોડ કરવું જોઈએ.", + "select_save_folder_button": "ફોલ્ડર પસંદ કરો" + }, + "mappings": { + "generate_failure": "મેપિંગ જનરેટ કરવાનો પ્રયાસ કરતી વખતે એક ભૂલ આવી, કૃપા કરીને ફરી પ્રયાસ કરો.", + "generate_failure_no_snapchat": "SnapEnhance Snapchat શોધવામાં અસમર્થ હતું, કૃપા કરીને Snapchat પુનઃસ્થાપિત કરવાનો પ્રયાસ કરો.", + "dialog": "Snapchat સંસ્કરણોની વિશાળ શ્રેણીને ગતિશીલ રીતે સમર્થન આપવા માટે, SnapEnhance યોગ્ય રીતે કાર્ય કરવા માટે મેપિંગ જરૂરી છે, આમાં 5 સેકન્ડથી વધુ સમય લાગવો જોઈએ નહીં." + }, + "permissions": { + "dialog": "ચાલુ રાખવા માટે તમારે નીચેની આવશ્યકતાઓને ફિટ કરવાની જરૂર છે:" + } + } +} diff --git a/common/src/main/assets/lang/gwi.json b/common/src/main/assets/lang/gwi.json new file mode 100644 index 0000000000..9af7d9b831 --- /dev/null +++ b/common/src/main/assets/lang/gwi.json @@ -0,0 +1,1389 @@ +{ + "media_download_source": { + "none": "Keine", + "pending": "Ausstehend", + "chat_media": "Chat-Medium", + "story": "Story", + "public_story": "Öffentliche Story", + "spotlight": "Spotlight", + "profile_picture": "Profilbild", + "story_logger": "Story Logger", + "message_logger": "Nachrichtenprotokollierung", + "merged": "Zusammengeführt", + "voice_call": "Sprachanruf" + }, + "mark_as_seen": { + "already_unseen_toast": "Schon als ungelesen markiert!", + "no_unseen_snaps_toast": "Es wurden keine ungesehenen Snaps gefunden!", + "seen_toast": "Markiert als gesehen!", + "unseen_toast": "Markiert als ungesehen!", + "already_seen_toast": "Bereits markiert als gesehen!" + }, + "conversation_preview": { + "streak_expiration": "läuft in {day} Tagen, {hour} Stunden, {minute} Minuten ab", + "total_messages": "Insgesamt gesendete/empfangene Nachrichten: {count}", + "title": "Vorschau", + "unknown_user": "Unbekannter Benutzer" + }, + "chat_export": { + "processing_chats": "{amount} Konversationen werden verarbeitet...", + "exporter_dialog": { + "select_conversations_title": "Konversationen auswählen", + "text_field_selection": "{amount} ausgewählt", + "text_field_selection_all": "Alle", + "export_file_format_title": "Dateiformat für den Export", + "message_type_filter_title": "Nachrichten nach Typ filtern", + "amount_of_messages_title": "Anzahl der Nachrichten (für alle leer lassen)", + "download_medias_title": "Medien herunterladen" + }, + "dialog_negative_button": "Abbrechen", + "dialog_positive_button": "Exportieren", + "exported_to": "Exportiert zu {path}", + "exporting_chats": "Chats exportieren...", + "export_fail": "Konversation {conversation} konnte nicht exportiert werden", + "writing_output": "Ausgabe schreiben...", + "finished": "Fertig! Du kannst diesen Dialog jetzt schließen.", + "no_messages_found": "Keine Nachrichten gefunden!", + "exporting_message": "{conversation} wird exportiert..." + }, + "button": { + "ok": "OK", + "open": "Öffnen", + "download": "Download", + "positive": "Ja", + "negative": "Nein", + "cancel": "Abbrechen" + }, + "download_processor": { + "dash_dialog": { + "segment_text": "Segment {from} - {to}", + "title": "Dash-Medium herunterladen", + "download_all": "Alle herunterladen" + }, + "attachment_type": { + "snap": "Snap", + "sticker": "Sticker", + "gif": "GIF", + "external_media": "Externe Medien", + "note": "Notiz", + "original_story": "Originale Story" + }, + "select_attachments_title": "Anhänge auswählen", + "download_started_toast": "Download gestartet", + "unsupported_content_type_toast": "Nicht unterstützter Content-Typ!", + "failed_no_longer_available_toast": "Datei ist nicht mehr verfügbar", + "no_attachments_toast": "Keine Anhänge gefunden!", + "already_queued_toast": "Datei wird bereits bearbeitet!", + "already_downloaded_toast": "Datei wurde bereits heruntergeladen!", + "download_toast": "{path} wird heruntergeladen...", + "processing_toast": "Verarbeite {path}...", + "failed_generic_toast": "Download fehlgeschlagen", + "failed_to_create_preview_toast": "Fehler beim Erstellen der Vorschau", + "failed_processing_toast": "Fehler beim Verarbeiten {error}", + "failed_gallery_toast": "Speichern in der Galerie fehlgeschlagen {error}", + "dash_no_chapter": "Kein Abschnitt gefunden" + }, + "auto_open_snaps": { + "title": "Auto-Öffnen von Snaps", + "notification_content": "{count} Snaps geöffnet" + }, + "friend_mutation_observer": { + "birthday_changed": "{username} hat sein/ ihr Geburtsdatum von {oldBirthday} auf {newBirthday} geändert", + "notification_channel_name": "Freund-Mutationsbeobachter", + "friend_removed": "{username} hat dich als Freund:in entfernt", + "birthday_removed": "{username} hat sein/ ihr Geburtsdatum ({birthday}) entfernt", + "birthday_added": "{username} hat sein/ ihr Geburtsdatum ({birthday}) hinzugefügt", + "bitmoji_selfie_changed": "{username} hat sein/ ihr Bitmoji-Selfie geändert", + "bitmoji_avatar_changed": "{username} hat sein/ ihr Bitmoji-Avatar geändert", + "bitmoji_background_changed": "{username} hat den Hintergrund seines/ ihres Bitmojis geändert", + "bitmoji_scene_changed": "{username} hat seine/ ihre Bitmoji-Szene geändert" + }, + "material3_strings": { + "date_input_invalid_year_range": "Invalides Jahr", + "date_input_invalid_not_allowed": "Invalides Datum", + "date_range_input_invalid_range_input": "Ungültiger Zeitraum", + "date_picker_switch_to_calendar_mode": "Kalender", + "date_picker_switch_to_input_mode": "Eingabe", + "date_range_picker_start_headline": "Von", + "date_range_picker_end_headline": "Bis", + "date_range_picker_title": "Wähle einen Zeitraum", + "date_range_picker_scroll_to_previous_month": "Vorheriger Monat", + "date_range_picker_scroll_to_next_month": "Nächster Monat", + "date_picker_today_description": "Heute", + "date_range_picker_day_in_range": "Gewählt", + "date_input_invalid_for_pattern": "Invalides Datum" + }, + "setup": { + "dialogs": { + "select_language": "Sprache auswählen", + "save_folder": "SnapEnhance benötigt Zugriff auf den Gerätespeicher, um Medien von Snapchat herunterzuladen und zu sichern.\nBitte wähle einen Ziel-Ordner für die Downloads aus.", + "select_save_folder_button": "Ordner wählen" + }, + "mappings": { + "dialog": "Mappings werden generiert, dies könnte ein bisschen dauern ...", + "generate_failure_no_snapchat": "SnapEnhance konnte Snapchat nicht finden, bitte versuchen Sie Snapchat neu zu installieren.", + "generate_failure": "Beim Generieren der Zuordnungen ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut." + }, + "permissions": { + "dialog": "Um fortfahren zu können, müssen Sie diese Anforderungen erfüllen:", + "notification_access": "Zugriff auf Benachrichtigungen", + "battery_optimization": "Batterieoptimierung", + "display_over_other_apps": "Über anderen Apps einblenden", + "request_button": "Anfordern" + } + }, + "scopes": { + "group": "Gruppe", + "friend": "Freund:in" + }, + "manager": { + "routes": { + "tasks": "Aufgaben", + "home_settings": "Einstellungen", + "messaging_preview": "Vorschau", + "scripts": "Skripte", + "features": "Funktionen", + "home": "Startseite", + "home_logs": "Logs", + "logger_history": "Logs Verlauf", + "logged_stories": "Geloggte Storys", + "friend_tracker": "Freundtracker:in", + "edit_rule": "Regel bearbeiten", + "social": "Sozial", + "manage_scope": "Verwalte Scope" + }, + "sections": { + "home": { + "update_title": "SnapEnhance Update", + "update_button": "Herunterladen", + "update_content": "Version {version} ist verfügbar!" + }, + "social": { + "empty_hint": "(leer)", + "friends_tab": "Freunde", + "groups_tab": "Gruppen", + "streaks_expiration_short": "{hours}h" + }, + "home_settings": { + "success_toast": "Erledigt!", + "actions_title": "Aktionen", + "message_logger_title": "Nachrichtenaufzeichner", + "debug_title": "Debuggen", + "message_logger_summary": "{messageCount} Nachrichten\n{storyCount} Stories", + "export_button": "Exportieren", + "clear_button": "Löschen", + "view_logger_history_button": "Historie des Nachrichtenaufzeichners ansehen" + }, + "home_logs": { + "no_logs_hint": "Keine Protokolle verfügbar", + "clear_logs_button": "Protokolle löschen", + "export_logs_button": "Protokolle exportieren", + "saving_logs_toast": "Speichern von Protokollen, das kann eine Weile dauern ...", + "saved_logs_success_toast": "Protokolle erfolgreich gespeichert", + "saved_logs_failure_toast": "Speichern von Protokollen fehlgeschlagen" + }, + "tasks": { + "no_tasks": "Keine Aufgaben", + "merge_files_toast": "Zusammenführen von {count} Dateien", + "remove_selected_tasks_title": "Sind Sie sicher, dass Sie die ausgewählten Aufgaben entfernen möchten?", + "remove_all_tasks_title": "Sind Sie sicher, dass Sie alle Aufgaben entfernen möchten?", + "delete_files_option": "Auch Dateien löschen", + "remove_selected_tasks_confirm": "{count} Aufgaben entfernen?", + "remove_all_tasks_confirm": "Alle Aufgaben entfernen?" + }, + "features": { + "disabled": "Deaktiviert", + "export_option": "Exportieren", + "import_option": "Importieren", + "reset_option": "Zurücksetzen", + "config_export_success_toast": "Einstellungen erfolgreich exportiert", + "config_import_success_toast": "Einstellungen erfolgreich importiert", + "config_import_failure_toast": "Einstellungen konnten nicht importiert werden. {error}", + "saved_config_snackbar": "Einstellungen gespeichert" + }, + "manage_scope": { + "logged_stories_button": "Aufgezeichnete Stories anschauen", + "e2ee_title": "Ende-zu-Ende Verschlüsselung", + "rules_title": "Regeln", + "participants_text": "{count} Teilnehmer", + "not_found": "Nicht gefunden", + "streaks_title": "Streaks", + "streaks_length_text": "Länge: {length}", + "streaks_expiration_text": "Läuft ab in {eta}", + "streaks_expiration_text_expired": "Abgelaufen", + "reminder_button": "Erinnerung erstellen", + "delete_scope_confirm_dialog_title": "Sind Sie sicher, dass Sie ein {scope} löschen wollen?" + }, + "logged_stories": { + "story_failed_to_load": "Laden fehlgeschlagen", + "no_stories": "Keine Stories gefunden", + "save_from_cache_button": "Aus Zwischenspeicher speichern" + }, + "messaging_preview": { + "bridge_connection_failed": "Verbindung zu Snapchat über den Brückendienst fehlgeschlagen", + "bridge_init_failed": "Initialisierung der Nachrichtenbrücke fehlgeschlagen", + "message_fetch_failed": "Fehler beim Abrufen der Nachrichten", + "no_message_hint": "Keine Nachricht", + "save_selection_option": "Auswahl speichern", + "save_all_option": "Alles speichern", + "unsave_selection_option": "Auswahl nicht mehr speichern", + "unsave_all_option": "Alles nicht mehr speichern", + "mark_selection_as_seen_option": "Ausgewählten Snap als gesehen markieren", + "mark_all_as_seen_option": "Alle Snaps als gesehen markieren", + "delete_selection_option": "Auswahl löschen", + "delete_all_option": "Alles löschen" + }, + "logger_history": { + "list_friend_format": "Freund {name}", + "list_group_format": "Gruppe {name}", + "no_more_messages": "Keine weiteren Nachrichten", + "reverse_order_checkbox": "Umgekehrte Reihenfolge", + "chat_attachment": "Anhang {index}", + "empty_message": "Leere Chatnachricht", + "message_parse_failed": "Nachricht konnte nicht verarbeitet werden", + "unknown_sender": "Unbekannter Absender", + "download_attachment_failed_toast": "Fehler beim Herunterladen des Anhangs" + } + }, + "dialogs": { + "scripting_warning": { + "title": "Warnung", + "content": "SnapEnhance enthält ein Skripting-Tool, das die Ausführung von benutzerdefinierten Code auf Ihrem Gerät ermöglicht. Seien Sie äußerst vorsichtig und installieren Sie nur Module aus bekannten, zuverlässigen Quellen. Unautorisierte oder ungeprüfte Module können Sicherheitsrisiken für Ihr System darstellen." + }, + "add_friend": { + "category_groups": "Gruppen", + "title": "Freund oder Gruppe hinzufügen", + "search_hint": "Suchen", + "fetch_error": "Fehler beim Abrufen der Daten", + "category_friends": "Freunde" + }, + "reset_config": { + "title": "Einstellungen zurücksetzen", + "content": "Bist Du sicher, dass Du die Einstellungen zurücksetzen möchtest?", + "success_toast": "Einstellungen erfolgreich zurückgesetzt" + }, + "messaging_action": { + "title": "Wählen Sie die zu verarbeitenden Inhaltstypen um fortzufahren", + "select_all_button": "Alle auswählen" + } + } + }, + "rules": { + "modes": { + "blacklist": "Blacklist Modus", + "whitelist": "Whitlist Modus" + }, + "properties": { + "stealth": { + "options": { + "blacklist": "Vom Heimlichen Modus ausschließen", + "whitelist": "Heimlicher Modus" + }, + "name": "Heimlicher Modus", + "description": "Verhindert, dass jemand weiß, dass du seine Snaps/Chats oder Konversationen geöffnet hast" + }, + "auto_save": { + "name": "Automatisches speichern", + "description": "Speichert Chat-Nachrichten beim Ansehen", + "options": { + "blacklist": "Vom automatischen Speichern ausschließen", + "whitelist": "Automatisch speichern" + } + }, + "auto_download": { + "name": "Auto-Download", + "description": "Snaps beim Ansehen automatisch herunterladen", + "options": { + "blacklist": "Vom Auto-Download ausschließen", + "whitelist": "Auto-Download" + } + }, + "unsaveable_messages": { + "name": "Nicht speicherbare Nachrichten", + "description": "Verhindert, dass Nachrichten im Chat von anderen Personen gespeichert werden können", + "options": { + "blacklist": "Von nicht speicherbaren Nachrichten ausschließen", + "whitelist": "Nicht speicherbare Nachrichten" + } + }, + "auto_open_snaps": { + "name": "Automatisches Öffnen von Snaps", + "description": "Öffnet Snaps automatisch beim Empfang", + "options": { + "blacklist": "Von der automatischen Snap-Öffnung ausschließen", + "whitelist": "Auto-Öffnen von Snaps" + } + }, + "hide_friend_feed": { + "name": "Vom Freundes-Feed ausblenden" + }, + "e2e_encryption": { + "name": "E2E-Verschlüsselung verwenden" + }, + "pin_conversation": { + "name": "Unterhaltung anheften" + } + }, + "toasts": { + "enabled": "{ruleName} aktiviert", + "disabled": "{ruleName} deaktiviert" + } + }, + "actions": { + "bulk_messaging_action": { + "name": "Massen Nachrichten Aktion", + "description": "Führt Operationen wie das Löschen von Freunden oder amassenlöschung von Konversationen durch" + }, + "clean_snapchat_cache": { + "name": "Leere den Snapchat Cache", + "description": "Leert den Snapchat Cache" + }, + "manage_friend_list": { + "name": "Freundesliste verwalten", + "description": "Im-/ex- portiere deine Freundesliste beim Backup" + }, + "export_chat_messages": { + "name": "Exportiere die Chatnachrichten", + "description": "Exportiert Chat-Nachrichten in eine JSON/HTML/TXT Datei" + }, + "export_memories": { + "name": "Exportiere die Memories", + "description": "Exportiere die Memories in eine ZIP-Datei" + }, + "regen_mappings": { + "name": "Erneuere die Mappings", + "description": "Erneuere die Mappings manuell" + }, + "change_language": { + "name": "Sprache ändern", + "description": "Ändere die Sprache von SnapEnhance" + } + }, + "features": { + "properties": { + "downloader": { + "properties": { + "auto_download_sources": { + "name": "Quellen automatisch herunterladen", + "description": "Wähle die Quellen, von denen automatisch herunterzuladen ist" + }, + "force_voice_note_format": { + "name": "Sprachnotiz Format erzwingen", + "description": "Erzwingt das Speichern von Sprachnotizen in einem bestimmten Format" + }, + "logging": { + "description": "Zeigt Toasts, wenn Medien heruntergeladen werden", + "name": "Logging" + }, + "save_folder": { + "name": "Speicherverzeichnis", + "description": "Wähle das Verzeichnis, in das alle Medien heruntergeladen werden sollen" + }, + "prevent_self_auto_download": { + "name": "Selbst-Auto-Download verhindern", + "description": "Verhindert, dass eigene Snaps automatisch heruntergeladen werden" + }, + "path_format": { + "name": "Pfadformat", + "description": "Gib das Dateiformat an" + }, + "allow_duplicate": { + "name": "Duplikate erlauben", + "description": "Ermöglicht es, dass dieselben Medien mehrmals heruntergeladen werden" + }, + "merge_overlays": { + "name": "Overlays zusammenführen", + "description": "Kombiniert den Text und die Medien eines Snaps in eine Datei" + }, + "force_image_format": { + "name": "Bildformat erzwingen", + "description": "Erzwingt das Speichern von Bildern in einem bestimmten Format" + }, + "download_profile_pictures": { + "name": "Profilbilder herunterladen", + "description": "Ermöglicht das Herunterladen von Profilbildern von einer Profilseite" + }, + "opera_download_button": { + "name": "Schwebender Download Button", + "description": "Fügt einen Download-Button in der oberen rechten Ecke hinzu, wenn ein Snap angezeigt wird.\nGedrückt halten um einen Download zu starten" + }, + "download_context_menu": { + "name": "Download Kontext Menü", + "description": "Ermöglicht es, Nachrichten oder eine Story herunterzuladen/vorher anzuschauen mithilfe des Kontext Menüs.\nLanges Drücken des Knopfes erzwingt den Download" + }, + "ffmpeg_options": { + "name": "FFmpeg-Optionen", + "description": "Zusätzliche FFmpeg-Optionen angeben", + "properties": { + "threads": { + "name": "Threads", + "description": "Die Anzahl Threads, welche zu gebrauchen ist" + }, + "preset": { + "name": "Voreinstellungen", + "description": "Geschwindigkeit der Konvertierung festlegen" + }, + "constant_rate_factor": { + "name": "Konstanter Rate-Faktor", + "description": "Setze den Constant Rate Factor für den Video-Encoder\nvon 0 bis 51 für libx264" + }, + "video_bitrate": { + "name": "Videobitrate", + "description": "Video-Bitrate (kbps) festlegen" + }, + "audio_bitrate": { + "name": "Audiobitrate", + "description": "Audio-Bitrate (kbps) festlegen" + }, + "custom_video_codec": { + "name": "Benutzerdefinierter Video-Codec", + "description": "Wähle einen benutzerdefinierten Video-Codec (z.B. libx264)" + }, + "custom_audio_codec": { + "name": "Benutzerdefinierter Audio-Codec", + "description": "Wähle einen benutzerdefinierten Audio-Codec (z.B. AAC)" + } + } + }, + "custom_path_format": { + "name": "Benutzerdefiniertes Pfadformat", + "description": "Legen Sie ein benutzerdefiniertes Pfadformat für heruntergeladene Medien fest\n\nVerfügbare Variablen:\n - %username%\n - %source%\n - %hash%\n - %date_time%" + } + }, + "name": "Downloader", + "description": "Snapchat Medien herunterladen" + }, + "user_interface": { + "name": "Benutzeroberfläche", + "properties": { + "friend_feed_message_preview": { + "name": "Freund Feed Nachrichten Vorschau", + "description": "Zeigt eine Vorschau der letzten Nachrichten im Freundes-Feed", + "properties": { + "amount": { + "name": "Anzahl", + "description": "Die Anzahl der Nachrichten, die in der Vorschau angezeigt werden" + } + } + }, + "enable_app_appearance": { + "name": "Aktiviert die App Darstellungseinstellungen", + "description": "Aktiviert die versteckte App-Erscheinungsbild Einstellung,\nbei neueren Snapchat-Versionen möglicherweise nicht erforderlich" + }, + "snap_preview": { + "name": "Snap-Vorschau", + "description": "Zeigt eine kleine Vorschau neben ungesehenen Snaps im Chat an" + }, + "bootstrap_override": { + "name": "Bootstrap Überschreibung", + "description": "Bootstrap-Einstellungen der Benutzeroberfläche überschreiben", + "properties": { + "app_appearance": { + "name": "App-Erscheinungsbild", + "description": "Legt eine dauerhafte App-Darstellung fest" + }, + "home_tab": { + "name": "Home Registerkarte", + "description": "Überschreibt den Start-Tab beim Öffnen von Snapchat" + } + } + }, + "map_friend_nametags": { + "name": "Verbesserte Karten-Namensschilder von Freunden", + "description": "Verbessert die Namensschilder von Freunden auf der Snapmap" + }, + "prevent_message_list_auto_scroll": { + "name": "Automatisches Scrollen der Nachrichtenliste verhindern", + "description": "Verhindert, dass die Nachrichtenliste beim Senden/Empfangen einer Nachricht nach unten scrollt" + }, + "streak_expiration_info": { + "name": "Informationen zum Flammen-Ablauf anzeigen", + "description": "Zeigt einen Flammen-Ablauf-Timer neben dem Flammen-Zähler" + }, + "hide_friend_feed_entry": { + "name": "Freund Feed Eintrag ausblenden", + "description": "Versteckt einen bestimmten Freund aus dem Freundes-Feed,\nBenutze den sozialen Tab um diese Funktion zu verwalten" + }, + "hide_streak_restore": { + "name": "Flammen-Wiederherstellung verstecken", + "description": "Versteckt den Wiederherstellen-Button im Freundesfeed" + }, + "hide_story_suggestions": { + "name": "Story Vorschläge ausblenden", + "description": "Entfernt Empfehlungen von der Story‐Seite" + }, + "hide_ui_components": { + "name": "UI-Komponenten ausblenden", + "description": "Wähle aus welche UI-Elemente ausgeblendet werden sollen" + }, + "opera_media_quick_info": { + "name": "Medien Schnellinfo", + "description": "Zeigt nützliche Informationen zu Medien wie das Erstellungsdatum im Kontextmenü der Snap-Ansicht an" + }, + "old_bitmoji_selfie": { + "name": "Altes Bitmoji-Selfie", + "description": "Bringt die Bitmoji-Selfies aus früheren Snapchat-Versionen zurück" + }, + "disable_spotlight": { + "name": "Spotlight deaktivieren", + "description": "Deaktiviert die Spotlight Seite" + }, + "friend_feed_menu_buttons": { + "name": "Schaltflächen für das Freunde-Feed Menü", + "description": "Wähle aus welche Schaltflächen in der Freunde Feed Menüleiste angezeigt werden sollen" + }, + "vertical_story_viewer": { + "name": "Vertikale Story Ansicht", + "description": "Aktiviert die vertikale Story Ansicht für alle Storys" + }, + "enable_friend_feed_menu_bar": { + "name": "Freunde Feed Menüleiste", + "description": "Aktiviert die neue Freunde Feed Menüleiste" + }, + "message_indicators": { + "name": "Nachrichtenindikatoren", + "description": "Fügt Nachrichten spezifische Anzeigesymbole hinzu\nHinweis: Die Symbole sind möglicherweise nicht 100 % genau" + }, + "stealth_mode_indicator": { + "name": "Diebstahl Modus Indikator", + "description": "Fügt den Konversationen im Stealth-Modus ein 👻-Emoji hinzu" + }, + "edit_text_override": { + "name": "Textfeld-Verhalten überschreiben", + "description": "Überschreibt das Verhalten von Textfeldern" + } + }, + "description": "Ändere das Aussehen von Snapchat" + }, + "messaging": { + "properties": { + "auto_mark_as_read": { + "name": "Automatisch als gelesen markieren", + "description": "Markiert Nachrichten bzw. Snaps automatisch als gelesen wenn der Stealth Mode aktiviert ist" + }, + "message_logger": { + "name": "Nachrichten Logger", + "description": "Verhindert, dass Nachrichten gelöscht werden", + "properties": { + "keep_my_own_messages": { + "name": "Eigene Nachrichten behalten", + "description": "Verhindert, dass Ihre eigenen Nachrichten gelöscht werden" + }, + "auto_purge": { + "name": "Automatische Bereinigung", + "description": "Löscht automatisch zwischengespeicherte Nachrichten, die älter als die angegebene Zeit sind" + }, + "message_filter": { + "name": "Nachrichtenfilter", + "description": "Wählen Sie aus, welche Nachrichten behalten werden sollen (leer für alle Nachrichten)" + } + } + }, + "bypass_screenshot_detection": { + "name": "Umgehen der Screenshot-Erkennung", + "description": "Verhindert, dass Snapchat erkennt, wenn du einen Screenshot machst" + }, + "anonymous_story_viewing": { + "name": "Anonyme Story Ansicht", + "description": "Verhindert, dass jemand erfährt, dass du seine Story gesehen hast" + }, + "prevent_story_rewatch_indicator": { + "name": "Wiederholungs-Indikator bei Stories verhindern", + "description": "Verhindert, dass andere wissen, dass Sie ihre Story noch einmal angeschaut haben" + }, + "hide_peek_a_peek": { + "name": "Vorschau Benachrichtigung verhindern", + "description": "Verhindert, dass eine Benachrichtigung gesendet wird, wenn Sie halb in einen Chat swipen" + }, + "hide_bitmoji_presence": { + "name": "Bitmoji Präsenz verstecken", + "description": "Verhindert, dass dein Bitmoji im Chat auftaucht" + }, + "hide_typing_notifications": { + "name": "Tippen-Benachrichtigungen verbergen", + "description": "Verhindert, dass jemand erfährt, dass du eine Nachricht tippst" + }, + "unlimited_snap_view_time": { + "name": "Unbegrenzte Zeit zum Ansehen von Snaps", + "description": "Entfernt das Zeitlimit für die Anzeige von Snaps" + }, + "loop_media_playback": { + "name": "Medien Wiedergabe Wiederholen", + "description": "Wiederholt Snaps & Stories beim ansehen in einer Schleife" + }, + "disable_replay_in_ff": { + "name": "Replay in FF deaktivieren", + "description": "Deaktiviert die Möglichkeit, mit einem langen Drücken vom Freundes-Feed zu wiederholen" + }, + "half_swipe_notifier": { + "name": "Über Half-Swipes informieren", + "description": "Benachrichtigt Sie, wenn jemand halb in ihren Chat swiped", + "properties": { + "min_duration": { + "name": "Mindestdauer", + "description": "Die Mindestdauer der halben Swipes (in Sekunden)" + }, + "max_duration": { + "name": "Maximale Dauer", + "description": "Die maximale Dauer des halben Swipes (in Sekunden)" + } + } + }, + "call_start_confirmation": { + "name": "Bestätigung des Starts eines Anrufs", + "description": "Zeigt einen Bestätigungsdialog beim Starten eines Anrufs an" + }, + "unlimited_conversation_pinning": { + "name": "Unlimitiertes Anpinnen von Konversationen", + "description": "Erlaubt dir eine unlimitierte Anzahl von Konversationen lokal anzupinnen" + }, + "prevent_message_sending": { + "name": "Nachrichtenversand verhindern", + "description": "Verhindert das Versenden bestimmter Nachrichten" + }, + "friend_mutation_notifier": { + "name": "Freund-Mutationsbenachrichtigung", + "description": "Benachrichtigt Sie, wenn sich etwas im Profil eines Freundes ändert" + }, + "better_notifications": { + "name": "Bessere Benachrichtigungen", + "description": "Zeige weitere Informationen in Benachrichtigungen an" + }, + "notification_blacklist": { + "name": "Benachrichtigungs Blacklist", + "description": "Wählen Sie Benachrichtigungen aus, die blockiert werden sollen" + }, + "auto_save_messages_in_conversations": { + "name": "Automatisches Speichern von Nachrichten", + "description": "Speichert automatisch jede Nachricht in Unterhaltungen" + }, + "gallery_media_send_override": { + "name": "Galerie-Medien senden Überschreiben", + "description": "Fälscht die Medienquelle, wenn etwas von der Galerie gesendet wird" + }, + "strip_media_metadata": { + "name": "Medien-Metadaten entfernen", + "description": "Entfernt Metadaten von Medien vor dem Versand als Nachricht" + }, + "bypass_message_retention_policy": { + "name": "Umgehung der Richtlinie zur Aufbewahrung von Nachrichten", + "description": "Verhindert, dass Nachrichten nach dem Anzeigen gelöscht werden" + }, + "bypass_message_action_restrictions": { + "name": "Umgehung von Nachrichtenaktionsbeschränkungen", + "description": "Ermöglicht es Ihnen, auf einen Snap zu reagieren, ohne ihn geöffnet zu haben, oder eine nicht speicherbare Nachricht zu speichern" + }, + "remove_groups_locked_status": { + "name": "Entfernen des Gruppen-Sperrstatus", + "description": "Ermöglicht es Ihnen, nach dem Rauswurf Gruppeninformationen anzuzeigen" + } + }, + "name": "Mitteilungen", + "description": "Ändern wie mit Freunden interagiert wird" + }, + "global": { + "properties": { + "media_upload_quality": { + "properties": { + "force_video_upload_source_quality": { + "name": "Erzwingen Sie die Qualität der Video-Upload-Quelle", + "description": "Erzwing Snapchat die Quellenqualität zu nutzen, wenn Videos hochgelden werden\nBitte merkte, dass dies eventuell die Metadaten von Medien nicht entfernt" + }, + "disable_image_compression": { + "name": "Deaktiviert Bildkompression", + "description": "Deaktiviert Bildkompression, wenn Medien hochgeladen werden" + }, + "custom_image_upload_format": { + "name": "Benutzerdefiniertes Bild-Upload-Format", + "description": "Setzt ein benutzerdefiniertes Bildhochladformat\nWähle ein verlustfreies Format (wie PNG) für die beste Qualität" + } + }, + "name": "Qualität", + "description": "Überschreibt die Medienuploadqualität" + }, + "disable_snap_splitting": { + "name": "Snap-Aufteilung deaktivieren", + "description": "Verhindert, dass Snaps in mehrere Teile aufgeteilt werden\nBilder werden in Videos umgewandelt" + }, + "better_location": { + "name": "Besserer Ort", + "description": "Erweitert den Snapchat-Ort", + "properties": { + "spoof_location": { + "name": "Täusche den Ort", + "description": "Täuscht Ihren Standort auf einen bestimmten Ort vor" + }, + "coordinates": { + "name": "Koordinaten", + "description": "Wähle die Koordinaten des getäuschten Standorts" + }, + "walk_radius": { + "name": "Radius", + "description": "Laufe zufälligerweise in diesem Radium (ft) herum" + }, + "always_update_location": { + "name": "Aktualisiere den Ort immer", + "description": "Zwingt Snapchat dazu, die den Standort zu aktualisieren, auch wenn es kein GPS Signal erhält" + }, + "suspend_location_updates": { + "name": "Stoppe die Aktualisierung des Standortes", + "description": "Fügt in den Karteneinstellungen eine Schaltfläche hinzu, um Standortaktualisierungen auszusetzen" + }, + "spoof_battery_level": { + "name": "­Täusche den Akkustand vor", + "description": "Verfälscht den Akkustand Ihres Geräts auf der Karte\nDer Wert muss zwischen 0 und 100 liegen" + }, + "spoof_headphones": { + "name": "Simuliere Kopfhörer", + "description": "Verfälscht den Status des Musikhörens auf der Karte" + } + } + }, + "snapchat_plus": { + "name": "Snapchat Plus", + "description": "Aktiviert Snapchat Plus Funktionen\nEinige serverseitige Funktionen funktionieren möglicherweise nicht" + }, + "disable_confirmation_dialogs": { + "name": "Bestätigungsdialoge deaktivieren", + "description": "Bestätigt automatisch ausgewählte Aktionen" + }, + "auto_updater": { + "name": "Auto Updater", + "description": "Automatisch auf Updates prüfen" + }, + "disable_metrics": { + "name": "Metriken deaktivieren", + "description": "Verhindert das Senden von analytischen Daten an Snapchat" + }, + "disable_story_sections": { + "name": "Story Sektion deaktivieren", + "description": "Entfernt Sektionen von der Story-Seite\nErfordert möglicherweise eine Aktualisierung, um richtig zu funktionieren" + }, + "block_ads": { + "name": "Werbung blockieren", + "description": "Verhindert die Anzeige von Werbung" + }, + "disable_custom_tabs": { + "name": "Deaktivieren Sie benutzerdefinierte Registerkarten", + "description": "Öffnet Links in unterstützen Applikationen, anstatt dem Webbrowser" + }, + "disable_permission_requests": { + "name": "Berechtigungsanfragen deaktivieren", + "description": "Verhindert, dass Snapchat nach bestimmten Berechtigungen fragt" + }, + "disable_memories_snap_feed": { + "name": "Memories Snap Feed deaktivieren", + "description": "Verhindert, dass Snapchat aktuelle Erinnerungen anzeigt, wenn Sie in der Kamera nach oben wischen" + }, + "spotlight_comments_username": { + "name": "Spotlight Kommentare Benutzername", + "description": "Zeigt den Benutzernamen des Autors in Spotlight-Kommentaren an" + }, + "bypass_video_length_restriction": { + "name": "Umgeht Videolängenbeschränkungen", + "description": "Einzel: sendet ein einzelnes Video\nSplitt: Videos nach Bearbeitung aufteilen" + }, + "default_video_playback_rate": { + "name": "Standardmäßige Videowiedergaberate", + "description": "Legt die Standardgeschwindigkeit für die Wiedergabe von Videos fest\n\tDer Wert muss zwischen 0,1 und 4,0 liegen" + }, + "video_playback_rate_slider": { + "name": "Schieberegler für die Videowiedergaberate", + "description": "Fügt einen Schieberegler im Opera-Kontextmenü hinzu, um die Videowiedergabegeschwindigkeit zu ändern\nHinweis: Änderungen gelten nur für nachfolgende Videos" + }, + "disable_google_play_dialogs": { + "name": "Google Play-Service Dialog deaktivieren", + "description": "Verfügbarkeitsdialog für Google Play Services nicht anzeigen" + }, + "default_volume_controls": { + "name": "Standard Lautstärkekontrolle", + "description": "Zwinge Snapchat die Systemlautstärke zu nutzen" + }, + "hide_active_music": { + "name": "Aktive Musik ausblenden", + "description": "Verhindert, dass Snapchat erkennt, dass Sie Musik hören.\nSo können Sie mithilfe der Lautstärketasten Snaps aufnehmen, während Sie Musik hören" + } + }, + "name": "Global", + "description": "Globale Snapchat-Einstellungen anpassen" + }, + "rules": { + "name": "Regeln", + "description": "Automatische Funktionen für einzelne Personen verwalten" + }, + "camera": { + "properties": { + "immersive_camera_preview": { + "name": "Immersive Vorschau", + "description": "Verhindert das Beschneiden der Kameravorschau\nDas kann dazu führen, dass die Kamera auf einigen Geräten flickert" + }, + "front_custom_frame_rate": { + "name": "Benutzerdefinierte Frame-Rate Selfie-Kamera", + "description": "Überschreibt die Frame-Rate der Selfie-Kamera" + }, + "disable_cameras": { + "name": "Kameras deaktivieren", + "description": "Verhindert, dass Snapchat die gewählten Kameras nutzt" + }, + "black_photos": { + "name": "Schwarze Fotos", + "description": "Ersetzt die aufgenommenen Fotos durch einen schwarzen Hintergrund\nVideos sind davon nicht betroffen" + }, + "override_front_resolution": { + "name": "Überschreiben der Frontauflösung", + "description": "Überschreibt die Kameraauflösung für die Selfie-Kamera" + }, + "override_back_resolution": { + "name": "Hauptkamera Auflösung überschreiben", + "description": "Überschreibt die Kameraauflösung der Hauptkamera" + }, + "custom_resolution": { + "name": "Benutzerdefinierte Auflösung", + "description": "Legt eine benutzerdefinierte Kameraauflösung (Breite x Höhe) fest (z. B. 1920x1080).\nDie benutzerdefinierte Auflösung muss von Ihrem Gerät unterstützt werden" + }, + "back_custom_frame_rate": { + "name": "Benutzerdefinierte Frame-Rate Haupt-Kamera", + "description": "Überschreibt die Frame-Rate der Haupt-Kamera" + }, + "force_camera_source_encoding": { + "name": "Kodierung der Kameraquelle erzwingen", + "description": "Erzwingt die Kodierung der Kameraquelle" + }, + "hevc_recording": { + "name": "HEVC Aufnahme", + "description": "Verwendet HEVC (H.265) Codec für die Videoaufzeichnung" + } + }, + "name": "Kamera", + "description": "Pass die richtigen Einstellungen für den perfekten Snap an" + }, + "streaks_reminder": { + "properties": { + "group_notifications": { + "name": "Gruppierte Benachrichtigungen", + "description": "Benachrichtigungen in eine einzelne gruppieren" + }, + "interval": { + "name": "Intervall", + "description": "Das Intervall zwischen jeder Erinnerung (Stunden)" + }, + "remaining_hours": { + "name": "Verbleibende Zeit", + "description": "Die verbleibende Zeit, bevor die Benachrichtigung angezeigt wird (in Stunden)" + } + }, + "name": "Flammen-Erinnerung", + "description": "Benachrichtigt dich regelmäßig über deine Flammen" + }, + "experimental": { + "properties": { + "account_switcher": { + "description": "Ermöglicht es Ihnen, zwischen Konten zu wechseln, ohne sich auszuloggen\nHalten Sie lange auf das Suchsymbol neben Ihrem Bitmoji-Profil, um das Menü zu öffnen\nHinweis: Diese Funktion ist experimentell und wird wahrscheinlich in Zukunft geändert", + "name": "­Accountwechsler", + "properties": { + "auto_backup_current_account": { + "name": "Automatisches Backup des aktuellen Accounts", + "description": "Automatisch wird das aktuelle Konto gesichert, wenn Sie sich ausloggen oder zwischen Konten wechseln" + } + } + }, + "no_friend_score_delay": { + "name": "Keine Friend Score Verzögerung", + "description": "Entfernt die Verzögerung beim Betrachten einer Friends Score" + }, + "best_friend_pinning": { + "name": "Bester Freund Pinning", + "description": "Erlaubt einen Freund als besten Freund Nummer 1 anzupinnen. Notiz: Nur du kannst deinen gepinnten besten Freund sehen" + }, + "hidden_snapchat_plus_features": { + "description": "Aktiviert unveröffentlichte/beta Snapchat Plus Funktionen\nKönnte auf älteren Snapchat-Versionen nicht funktionieren", + "name": "Verborgene Snapchat Plus-Funktionen" + }, + "native_hooks": { + "name": "Native Hooks", + "description": "Unsichere Funktionen, welche sich in Snapchats nativen Code einhängen", + "properties": { + "composer_hooks": { + "name": "Composer Haken", + "description": "Injiziert Code in das Composer UI-Framework (nur arm64)", + "properties": { + "show_first_created_username": { + "name": "Zeige erstgewählten Benutzernamen", + "description": "Zeige den erstgewählten Benutzernamen neben dem jetzigen Benutzernamen in der Profileseite" + }, + "bypass_camera_roll_limit": { + "name": "Umgeht das Kamerarollenlimit", + "description": "Erhöht die maximale Anzahl von Medien, die Sie aus der Kamerarolle senden können" + }, + "composer_console": { + "name": "Composer-Konsole", + "description": "Ermöglicht das Ausführen von JavaScript-Code in Composer (nur arm64)" + }, + "composer_logs": { + "name": "Composer-Protokolle", + "description": "Leitet Konsolenprotokolle von Composer zu SnapEnhance um" + } + } + }, + "disable_bitmoji": { + "name": "Bitmojis deaktivieren", + "description": "Deaktiviert das Bitmoji des Freund:innenprofil" + } + } + }, + "spoof": { + "name": "Simulieren", + "description": "Verschiedene Informationen über dich vortäuschen", + "properties": { + "play_store_installer_package_name": { + "name": "Goolge Play Installationsname des Paketes", + "description": "Überschreibt den Namen des Installationspakets auf com.android.vending_machine" + }, + "remove_vpn_transport_flag": { + "name": "VPN-Transport-Flagge entfernen", + "description": "Hindert Snapchat daran, VPNs zu erkennen" + }, + "remove_mock_location_flag": { + "name": "Kennzeichnung für den gefälschten Standort entfernen", + "description": "Verhindert, dass Snapchat gefälschte Standorte erkennt" + } + } + }, + "convert_message_locally": { + "name": "Nachricht lokal umwandeln", + "description": "Konvertiert Snaps lokal in externe Chat-Medien. Dies erscheint im Kontextmenü für den Chat-Download" + }, + "media_file_picker": { + "name": "Mediendatei-Auswahl", + "description": "Ermöglicht es beliebige Video und Audio Dateien von der Gallerie auszuwählen" + }, + "story_logger": { + "name": "Geschichtenprotokollierer", + "description": "Liefert eine Historie der Stories von Freund:innen" + }, + "call_recorder": { + "name": "Anrufaufzeichner", + "description": "Zeichnet automatisch Audioanrufe auf" + }, + "edit_message": { + "name": "Nachrichten bearbeiten", + "description": "Ermöglicht es Nachrichten in Konversationen zu bearbeiten" + }, + "app_lock": { + "name": "App-Sperre", + "description": "Verhindert den Zugriff auf Snapchat ohne einen passcode", + "properties": { + "lock_on_resume": { + "name": "Sperren beim fortsetzen", + "description": "Sperrt die App wenn sie wieder geöffnet wird" + } + } + }, + "infinite_story_boost": { + "name": "Unendlicher Story Boost", + "description": "Story Boost Limit Verzögerung umgehen" + }, + "meo_passcode_bypass": { + "name": "Passwortumgehung für privaten Bereich", + "description": "Umgeht das Passwort für den privaten Bereich\nFunktioniert nur, wenn das korrekte Passwort schon einmal eingegeben wurde" + }, + "e2ee": { + "name": "Ende-zu-Ende-Verschlüsselung", + "description": "Verschlüsselt deine Nachrichten mit AES unter Verwendung eines freigegebenen geheimen Schlüssels\nAchte darauf, dass du deinen Schlüssel an einem sicheren Ort aufbewahrst!", + "properties": { + "encrypted_message_indicator": { + "name": "Anzeige für verschlüsselte Nachrichten", + "description": "Fügt einen 🔒 Emoji neben verschlüsselten Nachrichten hinzu" + }, + "force_message_encryption": { + "name": "Nachrichtenverschlüsselung erzwingen", + "description": "Verhindert das Senden von verschlüsselten Nachrichten an Personen, die keine E2E-Verschlüsselung aktiviert haben, wenn mehrere Unterhaltungen ausgewählt sind" + } + } + }, + "add_friend_source_spoof": { + "name": "Freundesquellen-Änderung hinzufügen", + "description": "Verfälscht die Quelle einer Freundschaftsanfrage" + }, + "custom_streaks_expiration_format": { + "name": "Benutzerdefiniertes Ablaufdatum für Serien", + "description": "Passt das Ablaufdatumformat für Serien an\n\nVerfügbare Variablen:\n- %c: Anzahl der Serien\n- %e: Sanduhr-Emoji\n- %d: Tage\n- %h: Stunden\n- %m: Minuten\n- %s: Sekunden\n- %w: Verbleibende Zeit" + }, + "prevent_forced_logout": { + "name": "Erzwungenen Logout verhindern", + "description": "Verhindert, dass Snapchat dich abmeldet, wenn du dich auf einem anderen Gerät anmeldest" + } + }, + "name": "Experimentell", + "description": "Experimentelle Funktionen" + }, + "scripting": { + "name": "Scripting", + "description": "Benutzerdefinierte Skripte ausführen, um SnapEnhance zu erweitern", + "properties": { + "developer_mode": { + "name": "Entwickler:innenmodus", + "description": "Zeigt Debug-Informationen auf Snapchat's UI" + }, + "module_folder": { + "name": "Modulordner", + "description": "Der Ordner, in dem sich die Skripte befinden" + }, + "auto_reload": { + "name": "Automatisches Neuladen", + "description": "Automatisches Neuladen von Skripten, wenn diese sich ändern" + }, + "integrated_ui": { + "name": "Integrierte Benutzeroberfläche", + "description": "Erlaubt Skripten, benutzerdefinierte UI-Komponenten zu Snapchat hinzuzufügen" + }, + "disable_log_anonymization": { + "name": "Log-Anonymisierung deaktivieren", + "description": "Deaktiviert die Anonymisierung von Logs" + } + } + }, + "friend_tracker": { + "name": "Freundestracker", + "description": "Die Aktivität der Freunde aufzeichnen", + "properties": { + "record_messaging_events": { + "name": "Nachrichtenevents aufzeichnen", + "description": "Zeichnet Nachrichtenereignisse wie das Öffnen eines Snaps, das Lesen einer Nachricht usw. auf." + }, + "allow_running_in_background": { + "name": "Hintergrundnutzung erlauben", + "description": "Ermöglicht die Ausführung des Trackers im Hintergrund. Hinweis: Dadurch wird Ihr Akku erheblich entladen" + } + } + } + }, + "options": { + "friend_feed_menu_buttons": { + "auto_download": "⬇️ Auto-Download", + "auto_save": "💬 Auto-Nachricht-Speichern", + "unsaveable_messages": "⬇️ Nicht speicherbare Nachrichten", + "auto_open_snaps": "📷 Automatisches Öffnen von Snaps", + "stealth": "👻 Heimlicher Modus", + "mark_snaps_as_seen": "👀 Snaps als gesehen markieren", + "mark_stories_as_seen_locally": "👀 Stories als lokal gesehen markieren", + "conversation_info": "👤 Gesprächsinformationen", + "e2e_encryption": "🔒 E2E-Verschlüsselung verwenden" + }, + "path_format": { + "append_source": "Füge die Medienquelle zum Dateinamen hinzu", + "append_username": "Füge den Benutzernamen zum Dateinamen hinzu", + "append_date_time": "Füge Datum und Uhrzeit zum Dateinamen hinzu", + "create_author_folder": "Erzeuge ein Verzeichnis für jeden Benutzer", + "create_source_folder": "Ordner für jeden Medienquellentyp erstellen", + "append_hash": "Fügt jedem Dateinamen einen einzigartigen Hash hinzu" + }, + "auto_download_sources": { + "friend_snaps": "Freund-Snaps", + "friend_stories": "Freund-Stories", + "public_stories": "Öffentliche Stories", + "spotlight": "Spotlight" + }, + "auto_purge": { + "1_hour": "1 Stunde", + "3_hours": "3 Stunden", + "6_hours": "6 Stunden", + "12_hours": "12 Stunden", + "1_day": "1 Tag", + "3_days": "3 Tage", + "never": "Nie", + "1_week": "1 Woche", + "2_weeks": "2 Wochen", + "1_month": "1 Monat", + "3_months": "3 Monate", + "6_months": "6 Monate" + }, + "app_appearance": { + "always_light": "Immer hell", + "always_dark": "Immer dunkel" + }, + "logging": { + "started": "Gestartet", + "success": "Erfolgreich", + "progress": "Fortschritt", + "failure": "Fehler" + }, + "notifications": { + "chat_screenshot": "Screenshot", + "chat_screen_record": "Bildschirmaufnahme", + "typing": "Schreiben", + "snap_replay": "Snap Wiederholung", + "camera_roll_save": "In Aufnahmen gespeichert", + "chat": "Chat", + "chat_reply": "Chat Antwort", + "snap": "Snap", + "stories": "Stories", + "speaking": "Sprache", + "chat_reaction": "DM-Reaktion", + "group_chat_reaction": "Gruppenreaktion", + "initiate_audio": "Eingehender Audioanruf", + "abandon_audio": "Verpasster Audioanruf", + "initiate_video": "Eingehender Videoanruf", + "abandon_video": "Verpasster Videoanruf" + }, + "gallery_media_send_override": { + "always_ask": "Immer Fragen", + "ORIGINAL": "Original", + "NOTE": "Audio-Notiz", + "SNAP": "Snap" + }, + "strip_media_metadata": { + "hide_caption_text": "Bildunterschriftstext ausblenden", + "hide_snap_filters": "Snap Filter ausblenden", + "hide_extras": "Extras ausblenden (z. B. Erwähnungen)", + "remove_audio_note_duration": "Dauer der Sprachnachricht entfernen", + "remove_audio_note_transcript_capability": "Sprachnachricht-Transkriptionsfunktion entfernen" + }, + "hide_ui_components": { + "hide_profile_call_buttons": "Entferne Anruf Tasten", + "hide_chat_call_buttons": "Entferne Anruf Tasten im Chat", + "hide_live_location_share_button": "Schaltfläche Live-Standortfreigabe entfernen", + "hide_stickers_button": "Entferne Stickers Taste", + "hide_voice_record_button": "Knopf für Sprachaufzeichnung entfernen", + "hide_unread_chat_hint": "Hinweis auf ungelesene Chats entfernen" + }, + "hide_story_suggestions": { + "hide_suggested_friend_stories": "Empfohlene Stories von Freunden ausblenden", + "hide_my_stories": "Meine Stories verbergen" + }, + "home_tab": { + "map": "Karte", + "chat": "Chat", + "camera": "Kamera", + "discover": "Entdecken", + "spotlight": "Spotlight" + }, + "add_friend_source_spoof": { + "added_by_username": "Nach Benutzername", + "added_by_mention": "Durch Erwähnung", + "added_by_group_chat": "Per Gruppenchat", + "added_by_qr_code": "Per QR-Code", + "added_by_community": "Per Community" + }, + "bypass_video_length_restriction": { + "single": "Einzelnes Medium", + "split": "Medien aufteilen" + }, + "old_bitmoji_selfie": { + "2d": "2D Bitmoji", + "3d": "3D Bitmoji" + }, + "disable_confirmation_dialogs": { + "erase_message": "Nachricht löschen", + "remove_friend": "Freund entfernen", + "block_friend": "Freund blockieren", + "ignore_friend": "Freund ignorieren", + "hide_friend": "Freund ausblenden", + "hide_conversation": "Konversation ausblenden", + "clear_conversation": "Konversation aus dem Freundes-Feed löschen" + }, + "auto_reload": { + "snapchat_only": "Nur Snapchat", + "all": "Alles (Snapchat + SnapEnhance)" + }, + "edit_text_override": { + "multi_line_chat_input": "Mehrzeiliges Chat-Eingabefeld", + "bypass_text_input_limit": "Umgehen des Limits für die Texteingabe" + }, + "disable_story_sections": { + "friends": "Freunde", + "following": "Folge Ich", + "discover": "Entdecken" + }, + "disable_cameras": { + "front": "Selfie Kamera", + "back": "Hauptkamera" + }, + "disable_permission_requests": { + "notifications": "Benachrichtigungen", + "read_media_images": "Medienbilder lesen", + "read_media_video": "Medienvideos lesen", + "camera": "Kamera", + "microphone": "Mikrofon", + "location": "Standort", + "read_contacts": "Kontakte lesen", + "nearby_devices": "In der Nähe befindliche Geräte", + "phone_calls": "Telefonanrufe" + }, + "message_indicators": { + "encryption_indicator": "Fügt neben Nachrichten, die nur an Sie gesendet wurden, ein 🔒-Symbol hinzu", + "platform_indicator": "Fügt das Plattformsymbol hinzu, von der aus ein Medium gesendet wurde (z. B. Android, iOS, Web)", + "location_indicator": "Fügt Snaps ein 📍-Symbol hinzu, wenn sie mit aktivierter Standortfunktion gesendet wurden", + "ovf_editor_indicator": "Kennzeichnet, ob ein Snap mit dem OVF-Editor gesendet wurde", + "director_mode_indicator": "Fügt Snaps ein ✏️-Symbol hinzu, wenn sie mit dem Director-Modus gesendet wurden, der verwendet werden kann, um Galeriebilder als Snaps zu senden" + }, + "auto_mark_as_read": { + "conversation_read": "Markiert Konversation als gelesen sobald eine Narchicht gesendet wird", + "snap_reply": "Markiert Snaps als gelesen sobald auf sie geantwortet wird" + }, + "friend_mutation_notifier": { + "remove_friend": "Benachrichtige, wenn dich jemand als Freund entfernt", + "birthday_changes": "Benachrichtige, wenn jemand sein Geburtsdatum ändert", + "bitmoji_selfie_changes": "Benachrichtigen, wenn jemand sein Bitmoji-Selfie ändert", + "bitmoji_avatar_changes": "Benachrichtige, wenn jemand sein Bitmoji-Avatar ändert", + "bitmoji_background_changes": "Benachrichtige, wenn jemand den Hintergrund seines Bitmoji ändert", + "bitmoji_scene_changes": "Benachrichtige, wenn jemand seine Bitmoji-Szene ändert" + } + }, + "notices": { + "unstable": "⚠ Instabil", + "ban_risk": "⚠ Dieses Feature kann zu Bans führen", + "internal_behavior": "⚠ Dies kann das interne Verhalten von Snapchat stören" + } + }, + "friend_menu_option": { + "mark_snaps_as_seen": "Snaps als gesehen markieren", + "mark_stories_as_seen_locally": "Stories als lokal gesehen markieren", + "preview": "Vorschau", + "stealth_mode": "Inkognitomodus", + "auto_download_blacklist": "Blacklist für automatische Downloads", + "anti_auto_save": "Anti-Auto-Speichern" + }, + "content_type": { + "CHAT": "Chat", + "SNAP": "Snap", + "EXTERNAL_MEDIA": "Externe Medien", + "NOTE": "Sprachnachricht", + "STICKER": "Sticker", + "STATUS": "Status", + "LOCATION": "Standort", + "STATUS_SAVE_TO_CAMERA_ROLL": "In Camera Roll gespeichert", + "STATUS_CONVERSATION_CAPTURE_SCREENSHOT": "Screenshot", + "STATUS_CONVERSATION_CAPTURE_RECORD": "Bildschirmaufnahme", + "STATUS_CALL_MISSED_VIDEO": "Verpasster Videoanruf", + "STATUS_CALL_MISSED_AUDIO": "Verpasster Sprachanruf", + "LIVE_LOCATION_SHARE": "Live-Standort teilen", + "CREATIVE_TOOL_ITEM": "Kreativ-Werkzeug Element", + "FAMILY_CENTER_INVITE": "Family Center einladen", + "FAMILY_CENTER_ACCEPT": "Family Center akzeptieren", + "FAMILY_CENTER_LEAVE": "Family Center verlassen", + "STATUS_PLUS_GIFT": "Status Plus Geschenk", + "TINY_SNAP": "Winziger Snap", + "STATUS_COUNTDOWN": "Countdown", + "MAP_REACTION": "Kartenreaktion" + }, + "chat_action_menu": { + "preview_button": "Vorschau", + "download_button": "Download", + "delete_logged_message_button": "Gespeicherte Nachrichten löschen", + "convert_message": "Nachricht konvertieren", + "edit_message": "Nachricht bearbeiten" + }, + "opera_context_menu": { + "download": "Medien herunterladen", + "sent_at": "Gesendet am {date}", + "created_at": "Erstellt am {date}", + "expires_at": "Läuft am {date} ab", + "media_size": "Mediengröße: {size}", + "media_duration": "Mediendauer: {duration} ms", + "show_debug_info": "Debug-Informationen anzeigen" + }, + "modal_option": { + "profile_info": "Profil Info", + "close": "Schließen" + }, + "gallery_media_send_override": { + "multiple_media_toast": "Du kannst nur eine Datei auf einmal senden" + }, + "profile_info": { + "title": "Profil Info", + "first_created_username": "Erster Benutzername", + "mutable_username": "Änderbarer Benutzername", + "display_name": "Anzeigename", + "added_date": "Datum hinzugefügt", + "birthday": "Geburtstag: {month} {day}", + "hidden_birthday": "Geburtstag : Versteckt", + "friendship": "Freundschaft", + "add_source": "Quelle hinzufügen", + "snapchat_plus": "Snapchat Plus", + "snapchat_plus_state": { + "subscribed": "Abonniert", + "not_subscribed": "Nicht abonniert" + } + }, + "friendship_link_type": { + "mutual": "Gegenseitig", + "outgoing": "Ausgehend", + "blocked": "Blockiert", + "deleted": "Gelöscht", + "following": "Folge Ich", + "suggested": "Empfohlen", + "incoming": "Eingehend", + "incoming_follower": "Eingehender Follower" + }, + "bulk_messaging_action": { + "choose_action_title": "Wähle eine Aktion", + "progress_status": "Verarbeite {index} von {total}", + "selection_dialog_continue_button": "Weiter", + "confirmation_dialog": { + "title": "Sind Sie sicher?", + "message": "Das betrifft alle ausgewählten Freunde. Diese Aktion kann nicht rückgängig gemacht werden." + }, + "actions": { + "remove_friends": "Freunde entfernen", + "clear_conversations": "Lösche Konversationen" + } + }, + "better_notifications": { + "button": { + "reply": "Antwort", + "download": "Herunterladen", + "mark_as_read": "Als gelesen markieren" + } + }, + "profile_picture_downloader": { + "button": "Profilbilder herunterladen", + "title": "Profilbild-Downloader", + "avatar_option": "Avatar", + "background_option": "Hintergrund" + }, + "call_start_confirmation": { + "dialog_title": "Anruf starten", + "dialog_message": "Sind Sie sicher, dass Sie einen Anruf starten wollen?" + }, + "half_swipe_notifier": { + "notification_content_group": "{friend} hat gerade halb in {group} für {duration} Sekunden geswiped", + "notification_channel_name": "Halb-Swipe", + "notification_content_dm": "{friend} hat gerade für {duration} Sekunden halb in deinen Chat geswiped" + }, + "streaks_reminder": { + "notification_title": "Flammen", + "notification_text": "Du wirst deine Flammen mit {friend} in {hoursLeft} Stunden verlieren" + }, + "biometric_auth": { + "unlock_button": "Entsperren", + "title": "Entsperre Snapchat", + "subtitle": "Bitte authentifizieren Sie sich um Snapchat zu entsperren" + }, + "end_to_end_encryption": { + "toolbox": { + "no_shared_key": "Sie haben noch kein gemeinsames Geheimnis mit diesem Freund. Klicken Sie unten, um ein neues zu erstellen.", + "shared_key_fingerprint": "Dein Fingerabdruck ist:\n\n{fingerprint}\n\nSieh nach, ob er mit dem Fingerabdruck deines Freundes übereinstimmt!", + "initiate_exchange_button": "Schlüsselaustausch einleiten" + }, + "confirmation_dialogs": { + "title": "Ende-zu-Ende Verschlüsselung", + "confirmation_1": "WARNUNG: Dadurch wird Ihr vorhandener Schlüssel überschrieben. Sie werden den Zugang zu allen verschlüsselten Nachrichten dieses Freundes verlieren. Sind Sie sicher, dass Sie fortfahren möchten?", + "confirmation_2": "Sind Sie WIRKLICH sicher, dass Sie weitermachen wollen? Dies ist Ihre letzte Chance, einen Rückzieher zu machen." + }, + "unencrypted_conversation_send_failure_toast": "Sie können keine verschlüsselten Inhalte zu verschlüsselten und unverschlüsselten Gesprächen gleichzeitig senden!", + "native_hooks_send_failure_toast": "Senden fehlgeschlagen! Bitte aktivieren Sie die nativen Hooks in den Einstellungen.", + "no_participants_to_encrypt_toast": "Sie haben in diesem Gespräch keine Freunde, mit denen Sie Nachrichten verschlüsseln können!", + "encryption_failed_toast": "Nachricht konnte nicht verschlüsselt werden! Prüfen Sie logcat für weitere Details.", + "accept_public_key_success_toast": "Öffentlicher Schlüssel erfolgreich akzeptiert!", + "accept_secret_key_success_toast": "Geschafft! Sie können nun verschlüsselte Nachrichten an diesen Freund senden und empfangen.", + "accept_public_key_failure_toast": "Akzeptieren des öffentlichen Schlüssels fehlgeschlagen", + "accept_secret_key_failure_toast": "Akzeptieren des geheimen Schlüssels fehlgeschlagen", + "accept_secret_button": "Geheimnis akzeptieren", + "accept_public_key_button": "Öffentlichen Schlüssel akzeptieren", + "outgoing_pk_message": "Schlüsselaustausch-Anfrage", + "outgoing_secret_message": "Schlüsselaustausch-Antwort", + "incoming_pk_message": "Sie haben gerade eine Anfrage für einen öffentlichen Schlüssel erhalten. Klicken Sie unten, um sie anzunehmen.", + "incoming_secret_message": "Ihr Freund hat gerade Ihren öffentlichen Schlüssel akzeptiert. Klicken Sie unten, um das Passwort zu akzeptieren." + } +} diff --git a/common/src/main/assets/lang/hi_IN.json b/common/src/main/assets/lang/hi_IN.json new file mode 100644 index 0000000000..5136e4efc3 --- /dev/null +++ b/common/src/main/assets/lang/hi_IN.json @@ -0,0 +1,414 @@ +{ + "setup": { + "dialogs": { + "select_language": "भाषा चुनें", + "select_save_folder_button": "फोल्डर चुनें", + "save_folder": "SnapEnhance को स्नैपचैट से मीडिया को डाउनलोड करने और सेव करने के लिए स्टोरेज अनुमति की आवश्यकता होती है।\n कृपया वह स्थान चुनें जहां से मीडिया डाउनलोड किया जाना चाहिए।" + }, + "mappings": { + "generate_failure_no_snapchat": "स्नैपएन्हांस के द्वारा स्नैपचैट को डिटेक्ट नहीं किया गया, कृपया स्नैपचैट दुबारा इंस्टॉल करें।", + "generate_failure": "मैपिंग बनाते समय एक त्रुटि पाई गई है, कृपया दुबारा कोसिस करें।", + "dialog": "स्नैपचैट संस्करणों की एक विस्तृत श्रृंखला को गतिशील रूप से समर्थन देने के लिए, SnapEnhance के ठीक से काम करने के लिए मैपिंग आवश्यक है, इसमें 5 सेकंड से अधिक समय नहीं लगना चाहिए।" + }, + "permissions": { + "dialog": "आगे बढ़ने के लिए आपको निम्नलिखित मांगो को पूरा करना पड़ेगा।:", + "notification_access": "नोटिफिकेशन अनुमति", + "battery_optimization": "बैटरी ऑप्टिमाइजेशन", + "display_over_other_apps": "दूसरे ऐप्लिकेशन के ऊपर दिखाए जाने का ऐक्सेस", + "request_button": "अनुरोध" + } + }, + "manager": { + "routes": { + "features": "फीचर्स", + "home": "होम", + "home_settings": "सेटिंग्स", + "social": "सामाजिक", + "scripts": "स्क्रिप्ट", + "home_logs": "लॉग्स", + "tasks": "कार्य", + "manage_scope": "दायरा प्रबंधित करें", + "messaging_preview": "पूर्व दर्शन", + "logger_history": "प्रचालेखन इतिहास", + "logged_stories": "प्रचालेखित कि गई स्टोरी" + }, + "sections": { + "features": { + "disabled": "निष्क्रिय", + "export_option": "निर्यात", + "import_option": "आयात", + "reset_option": "फिर से सब पहले जैसा करे", + "config_import_success_toast": "फाइल सफलतापूर्वक लग चुकी है", + "config_import_failure_toast": "फाइल असफल हुई आने मे (Import failed)", + "saved_config_snackbar": "फाइल सेव हो चुकी है", + "config_export_success_toast": "विन्यास सफलतापूर्वक निर्यात किया गया" + }, + "social": { + "streaks_expiration_short": "{hours}h", + "friends_tab": "दोस्त", + "groups_tab": "समूह" + }, + "tasks": { + "no_tasks": "कोई कार्य नहीं", + "merge_files_toast": "{संख्या} फाईल जुड़ रही है", + "delete_files_option": "files भी हटाएं", + "remove_selected_tasks_confirm": "{संख्या} कार्य हटाएं?", + "remove_all_tasks_confirm": "सभी कार्य हटाएं?", + "remove_all_tasks_title": "क्या आप सभी कार्यों को हटाने के लिए सहमत हैं?", + "remove_selected_tasks_title": "क्या आप चिह्नित किए हुए कार्यों को हटाने के लिए सहमत है?" + }, + "home": { + "update_content": "वर्जन {वर्जन} उपलब्ध है!", + "update_title": "SnapEnhance अपडेट", + "update_button": "डाऊनलोड" + }, + "home_logs": { + "no_logs_hint": "logs उपलब्ध नहीं", + "clear_logs_button": "logs साफ करें", + "export_logs_button": "logs निर्यात करें", + "saving_logs_toast": "logs सेव हो रहे हैं, इसमें थोड़ा सा ही समय लगेगा..।", + "saved_logs_success_toast": "विवरण सफलतापूर्वक सेव हो चुके हैं", + "saved_logs_failure_toast": "logs सेव होने में असफल" + }, + "home_settings": { + "actions_title": "प्रत्क्रिया", + "message_logger_summary": "{मैसेज संख्या} मैसेज\n{स्टोरी संख्या} स्टोरीज", + "view_logger_history_button": "logger हिस्ट्री देखे", + "export_button": "निर्यात करें", + "clear_button": "साफ करें", + "message_logger_title": "मेसेज को डिलीट होने से बचाएं", + "debug_title": "दोष मुक्त", + "success_toast": "हो चुका!" + }, + "messaging_preview": { + "mark_selection_as_seen_option": "चुने हुए स्नेप को पढा हुआ करे", + "mark_all_as_seen_option": "सभी स्नेप को पढा हुआ करे", + "delete_all_option": "सभी डिलीट करे", + "bridge_connection_failed": "ब्रिज सर्विस के माध्यम से स्नैपचैट से कनेक्ट करने में विफल", + "bridge_init_failed": "मैसेजिंग ब्रिज आरंभ करने में विफल", + "no_message_hint": "कोई मैसेज नही", + "save_all_option": "सभी सेव करें", + "unsave_all_option": "सभी सेव हटाए", + "delete_selection_option": "चुने हुए डिलिट करें", + "message_fetch_failed": "संदेश लाने में विफल", + "save_selection_option": "चयन सहेजें", + "unsave_selection_option": "चयन सहेजें नहीं" + }, + "manage_scope": { + "rules_title": "नियम", + "logged_stories_button": "लॉग की गई कहानियाँ दिखाएँ", + "e2ee_title": "एंड-टू-एंड एन्क्रिप्शन", + "participants_text": "{गिनती} प्रतिभागी", + "not_found": "नहीं मिला", + "streaks_title": "धारियाँ", + "streaks_length_text": "लंबाई: {लंबाई}", + "streaks_expiration_text": "{eta} में समाप्त होगा", + "streaks_expiration_text_expired": "खत्म हो चुका", + "reminder_button": "अनुस्मारक सेट करें", + "delete_scope_confirm_dialog_title": "क्या आप वाकई एक {स्कोप} हटाना चाहते हैं?" + }, + "logged_stories": { + "story_failed_to_load": "लोड करने में असफल", + "no_stories": "कोर्ई स्टोरी नही", + "save_from_cache_button": "कैश से बचाएं" + }, + "logger_history": { + "list_friend_format": "दोस्त {नाम}", + "list_group_format": "समुह {नाम}", + "no_more_messages": "कोई मैसेज नही", + "message_parse_failed": "संदेश को पार्स करने में विफल", + "unknown_sender": "अज्ञात व्यक्ति", + "reverse_order_checkbox": "उल्टे क्रम", + "chat_attachment": "अनुलग्नक {सूचकांक}", + "empty_message": "खाली चैट संदेश", + "download_attachment_failed_toast": "अनुलग्नक डाउनलोड करने में विफल" + } + }, + "dialogs": { + "add_friend": { + "title": "मित्र या समूह जोड़ें", + "category_groups": "समूह", + "fetch_error": "डेटा लाने में विफल", + "category_friends": "दोस्त", + "search_hint": "खोज" + }, + "scripting_warning": { + "title": "चेतावनी", + "content": "SnapEnhance में एक स्क्रिप्टिंग टूल शामिल है, जो आपके डिवाइस पर उपयोगकर्ता द्वारा परिभाषित कोड के निष्पादन की अनुमति देता है। अत्यधिक सावधानी बरतें और केवल ज्ञात, विश्वसनीय स्रोतों से ही मॉड्यूल स्थापित करें। अनधिकृत या असत्यापित मॉड्यूल आपके सिस्टम के लिए सुरक्षा जोखिम पैदा कर सकते हैं।" + }, + "messaging_action": { + "select_all_button": "सभी सिलेक्ट करे", + "title": "संसाधित करने के लिए सामग्री प्रकार चुनें" + }, + "reset_config": { + "title": "कॉन्फ़िगरेशन रीसेट करें", + "content": "क्या आप वाकई कॉन्फिग को रीसेट करना चाहते हैं?", + "success_toast": "कॉन्फ़िगरेशन सफलतापूर्वक रीसेट हो गया" + } + } + }, + "friend_menu_option": { + "preview": "पूर्वावलोकन", + "stealth_mode": "छिपे हुए मोड", + "auto_download_blacklist": "ऑटो डाउनलोड ब्लैकलिस्ट", + "anti_auto_save": "ऑटो सेव के खिलाफ" + }, + "chat_action_menu": { + "preview_button": "पूर्वावलोकन", + "download_button": "डाउनलोड करें", + "delete_logged_message_button": "लॉग किए गए संदेश को हटाएं" + }, + "opera_context_menu": { + "download": "मीडिया डाउनलोड करें" + }, + "modal_option": { + "profile_info": "प्रोफ़ाइल जानकारी", + "close": "बंद करें" + }, + "gallery_media_send_override": { + "multiple_media_toast": "एक बार में बस 1 मीडिया भेज सकते है" + }, + "conversation_preview": { + "streak_expiration": "{day} दिन {hour} घंटे {minute} मिनट में समाप्त होता है", + "total_messages": "कुल मिल के भेजे/मिले मेसेजेस: {count}", + "title": "पूर्वावलोकन", + "unknown_user": "अज्ञात उपयोगकर्ता" + }, + "profile_info": { + "title": "प्रोफ़ाइल जानकारी", + "display_name": "प्रदर्शित नाम", + "added_date": "जोड़ने की तारीख", + "birthday": "जन्मदिन: {month} {day}" + }, + "chat_export": { + "dialog_negative_button": "रद्द करें", + "dialog_positive_button": "निर्यात करें", + "exported_to": "{path} में निर्यात किया गया", + "exporting_chats": "बातचीत निर्यात कर रहा है...", + "processing_chats": "{amount} बातचीतों का प्रसंस्करण कर रहा है...", + "export_fail": "बातचीत {conversation} को निर्यात करने में विफल रहा", + "writing_output": "आउटपुट लिख रहा है...", + "finished": "तैयार हो गया! अब आप इस संवाद बॉक्स को बंद कर सकते हैं।", + "no_messages_found": "कोई संदेश नहीं मिले!", + "exporting_message": "{conversation} को निर्यात कर रहा है..." + }, + "button": { + "ok": "ठीक है", + "positive": "हाँ", + "negative": "नहीं", + "cancel": "रद्द करें", + "open": "खोलें" + }, + "download_processor": { + "download_started_toast": "डाउनलोड शुरू", + "unsupported_content_type_toast": "असमर्थित फ़ाइल प्रकार!", + "failed_no_longer_available_toast": "यह मीडिया अब उपलब्ध नहीं है", + "already_queued_toast": "मीडिया पहले से कतार में है!", + "already_downloaded_toast": "मीडिया पहले से डाउनलोड हो चुका है!", + "download_toast": "डोनलोडिंग टू {path}...", + "processing_toast": "प्रोसेसिंग {path}...", + "failed_generic_toast": "डाउनलोड करने में असफल", + "failed_to_create_preview_toast": "प्रीव्यू करने में असफल" + }, + "scopes": { + "friend": "दोस्त", + "group": "समुह" + }, + "rules": { + "modes": { + "blacklist": "इन्हे छोड़ कर", + "whitelist": "सिर्फ इन्हे" + }, + "properties": { + "auto_download": { + "description": "देखते समय अपने आप डाउनलोड करे", + "name": "अपने आप डाउनलोड करे", + "options": { + "blacklist": "अपने आप डाउनलोड होने से इन्हे हटाएँ", + "whitelist": "स्वतः डाउनलोड" + } + }, + "stealth": { + "name": "चुपके मोड", + "description": "किसी को भी यह जानने से रोकता है कि आपने उनके स्नैप/चैट और वार्तालाप खोले हैं", + "options": { + "blacklist": "चुपके मोड से बाहर रखें", + "whitelist": "चुपके मोड" + } + }, + "unsaveable_messages": { + "options": { + "whitelist": "सहेजे न जा सकने वाले संदेश", + "blacklist": "सहेजे न जा सकने वाले संदेशों को बाहर निकालें" + }, + "name": "सहेजे न जा सकने वाले संदेश", + "description": "संदेशों को अन्य लोगों द्वारा चैट में सहेजे जाने से रोकता है" + }, + "auto_save": { + "name": "स्वतः सहेजें", + "options": { + "blacklist": "ऑटो सेव से बाहर निकालें", + "whitelist": "स्वतः सहेजें" + }, + "description": "चैट संदेशों को देखने पर उन्हें सहेजता है" + }, + "pin_conversation": { + "name": "वार्तालाप पिन करें" + }, + "e2e_encryption": { + "name": "E2E एन्क्रिप्शन का उपयोग करें" + }, + "hide_friend_feed": { + "name": "मित्र फ़ीड से छिपाएँ" + } + }, + "toasts": { + "enabled": "{नियम नाम} सक्षम किया गया", + "disabled": "{नियम का नाम} अक्षम किया गया" + } + }, + "actions": { + "clean_snapchat_cache": { + "name": "स्नैपचैट कैश साफ़ करें", + "description": "स्नैपचैट कैश को साफ़ करता है" + }, + "manage_friend_list": { + "description": "बैकअप लेते समय अपनी मित्र सूची आयात/निर्यात करें", + "name": "मित्र सूची प्रबंधित करें" + }, + "export_chat_messages": { + "name": "चैट संदेश निर्यात करें", + "description": "वार्तालाप संदेशों को JSON/HTML/TXT फ़ाइल में निर्यात करता है" + }, + "export_memories": { + "name": "यादें निर्यात करें", + "description": "स्मृतियों को एक ज़िप फ़ाइल में निर्यात करता है" + }, + "bulk_messaging_action": { + "name": "थोक संदेश क्रिया", + "description": "मित्रों को हटाने या वार्तालापों को बड़े पैमाने पर हटाने जैसे ऑपरेशन करता है" + }, + "regen_mappings": { + "name": "मैपिंग पुन: उत्पन्न करें", + "description": "मैपिंग को मैन्युअल रूप से पुन: उत्पन्न करें" + }, + "change_language": { + "name": "भाषा बदलें", + "description": "SnapEnhance की भाषा बदलें" + } + }, + "features": { + "properties": { + "downloader": { + "properties": { + "opera_download_button": { + "name": "ओपेरा डाउनलोड बटन", + "description": "स्नैप देखते समय शीर्ष दाएं कोने पर एक डाउनलोड बटन जोड़ता है।\nबटनों को देर तक दबाने से डाउनलोड बाध्य हो जाएगा" + }, + "ffmpeg_options": { + "description": "अतिरिक्त FFmpeg विकल्प निर्दिष्ट करें", + "properties": { + "custom_video_codec": { + "description": "एक कस्टम वीडियो कोडेक सेट करें (उदा. libx264)", + "name": "कस्टम वीडियो कोडेक" + }, + "threads": { + "name": "धागे", + "description": "उपयोग किये जाने वाले धागों की मात्रा" + }, + "constant_rate_factor": { + "description": "वीडियो एन्कोडर के लिए स्थिर दर कारक सेट करें\nlibx264 के लिए 0 से 51 तक", + "name": "स्थिर दर कारक" + }, + "video_bitrate": { + "name": "वीडियो बिटरेट", + "description": "वीडियो बिटरेट (kbps) सेट करें" + }, + "preset": { + "name": "प्रीसेट", + "description": "रूपांतरण की गति निर्धारित करें" + }, + "custom_audio_codec": { + "name": "कस्टम ऑडियो कोडेक", + "description": "एक कस्टम ऑडियो कोडेक सेट करें (उदा. AAC)" + }, + "audio_bitrate": { + "name": "ऑडियो बिटरेट", + "description": "ऑडियो बिटरेट (kbps) सेट करें" + } + }, + "name": "एफएफएमपीईजी विकल्प" + }, + "auto_download_sources": { + "description": "स्वचालित रूप से डाउनलोड करने के लिए स्रोतों का चयन करें", + "name": "ऑटो डाउनलोड स्रोत" + }, + "save_folder": { + "name": "फ़ोल्डर सहेजें", + "description": "उस निर्देशिका का चयन करें जिसमें सभी मीडिया को डाउनलोड किया जाना चाहिए" + }, + "path_format": { + "name": "पथ प्रारूप", + "description": "फ़ाइल पथ प्रारूप निर्दिष्ट करें" + }, + "allow_duplicate": { + "description": "एक ही मीडिया को कई बार डाउनलोड करने की अनुमति देता है", + "name": "डुप्लिकेट की अनुमति दें" + }, + "force_image_format": { + "name": "बलपूर्वक छवि प्रारूप", + "description": "छवियों को एक निर्दिष्ट प्रारूप में सहेजने के लिए बाध्य करता है" + }, + "force_voice_note_format": { + "name": "वॉयस नोट प्रारूप को बाध्य करें", + "description": "वॉयस नोट्स को एक निर्दिष्ट प्रारूप में सहेजने के लिए बाध्य करता है" + }, + "download_profile_pictures": { + "name": "प्रोफ़ाइल चित्र डाउनलोड करें", + "description": "आपको प्रोफ़ाइल पृष्ठ से प्रोफ़ाइल चित्र डाउनलोड करने की अनुमति देता है" + }, + "logging": { + "description": "जब मीडिया डाउनलोड हो रहा हो तो टोस्ट दिखाता है", + "name": "लॉगिंग" + }, + "custom_path_format": { + "description": "डाउनलोड किए गए मीडिया के लिए एक कस्टम पथ प्रारूप निर्दिष्ट करें\n\nउपलब्ध चर:\n - %उपयोगकर्ता नाम%\n - %स्रोत%\n - %हैश%\n - %दिनांक समय%", + "name": "कस्टम पथ प्रारूप" + }, + "download_context_menu": { + "description": "आपको संदर्भ मेनू का उपयोग करके किसी वार्तालाप या कहानी से संदेशों को डाउनलोड/पूर्वावलोकन करने की अनुमति देता है।\nबटनों को देर तक दबाने से डाउनलोड बाध्य हो जाएगा", + "name": "संदर्भ मेनू डाउनलोड करें" + }, + "prevent_self_auto_download": { + "name": "सेल्फ ऑटो डाउनलोड को रोकें", + "description": "आपके स्वयं के स्नैप्स को स्वचालित रूप से डाउनलोड होने से रोकता है" + }, + "merge_overlays": { + "description": "स्नैप के टेक्स्ट और मीडिया को एक फ़ाइल में संयोजित करता है", + "name": "ओवरले मर्ज करें" + } + }, + "description": "स्नैपचैट मीडिया डाउनलोड करें", + "name": "डाउनलोडर" + }, + "user_interface": { + "name": "प्रयोक्ता इंटरफ़ेस", + "properties": { + "bootstrap_override": { + "properties": { + "home_tab": { + "name": "होम टैब" + } + } + } + }, + "description": "स्नैपचैट का स्वरूप और अनुभव बदलें" + } + }, + "notices": { + "internal_behavior": "⚠ इससे स्नैपचैट का आंतरिक व्यवहार ख़राब हो सकता है", + "unstable": "⚠अस्थिर", + "ban_risk": "⚠इस सुविधा पर प्रतिबंध लग सकता है" + } + } +} diff --git a/common/src/main/assets/lang/hr_HR.json b/common/src/main/assets/lang/hr_HR.json new file mode 100644 index 0000000000..1b43a6a52c --- /dev/null +++ b/common/src/main/assets/lang/hr_HR.json @@ -0,0 +1,71 @@ +{ + "setup": { + "dialogs": { + "select_save_folder_button": "Odaberite mapu", + "select_language": "Odaberite jezik", + "save_folder": "SnapEnhance-u je potrebno omogućiti pristup datotekama kako bi mogao skidati i spremati medije sa Snapchat-a.\nMolimo odaberite lokaciju gdje želite da se mediji spremaju." + }, + "mappings": { + "dialog": "Generiranje mapiranja, ovo može potrajati..", + "generate_failure_no_snapchat": "SnapEnhance nije uspio detektirati Snapchat, probajte ga ponovo instalirati.", + "generate_failure": "Greška prilikom generiranja \"mappings\"-a, molimo pokušajte ponovo." + }, + "permissions": { + "notification_access": "Pristup notifikacijama", + "battery_optimization": "Optimizacija baterije", + "display_over_other_apps": "Prikaz preko drugih aplikacija", + "request_button": "Zahtjev", + "dialog": "Kako bih nastavili morate ispunjavati sljedeće uvjete:" + } + }, + "scopes": { + "friend": "Prijatelj", + "group": "Grupa" + }, + "manager": { + "routes": { + "tasks": "Zadaci", + "features": "Značajke", + "home": "Početna", + "home_logs": "Logovi", + "logger_history": "Povijest loggera", + "social": "Social", + "messaging_preview": "Pretpregled", + "scripts": "Skripte", + "home_settings": "Postavke", + "logged_stories": "Zabilježene priče", + "manage_scope": "Upravljanje opsegom" + }, + "sections": { + "home": { + "update_title": "SnapEnhance nadogradnja", + "update_content": "Verzija {version} je dostupna!", + "update_button": "Preuzimanje" + }, + "home_logs": { + "export_logs_button": "Izvezi logove", + "saved_logs_failure_toast": "Logovi neuspješno spremljeni", + "no_logs_hint": "Nema dostupnih logova", + "clear_logs_button": "Očisti logove", + "saving_logs_toast": "Spremanje logova, ovo može potrajati...", + "saved_logs_success_toast": "Logovi uspješno spremljeni" + }, + "home_settings": { + "actions_title": "Akcije", + "message_logger_title": "Logger poruka", + "debug_title": "Debug", + "clear_button": "Očisti", + "view_logger_history_button": "Pregledaj povijest loggera", + "success_toast": "Gotovo!", + "message_logger_summary": "{messageCount} poruka\n{storyCount} stories", + "export_button": "Izvoz" + }, + "tasks": { + "no_tasks": "Nema zadataka", + "merge_files_toast": "Spajanje {count} datoteka", + "remove_selected_tasks_title": "Jeste li sigurni da želite ukloniti odabrane zadatke?", + "remove_all_tasks_title": "Jeste li sigurni da ste uklonili sve zadatke?" + } + } + } +} diff --git a/common/src/main/assets/lang/hu_HU.json b/common/src/main/assets/lang/hu_HU.json new file mode 100644 index 0000000000..dcdb33903d --- /dev/null +++ b/common/src/main/assets/lang/hu_HU.json @@ -0,0 +1,1517 @@ +{ + "setup": { + "dialogs": { + "select_language": "Nyelv kiválasztása", + "save_folder": "A SnapEnhance-nek tárhelyhozzáférésre van szüksége média mentéséhez és letöltéséhez. \nKérem válassza ki a média mentési helyét.", + "select_save_folder_button": "Mappa kiválasztása" + }, + "mappings": { + "dialog": "Hozzárendelések generálása, ez eltarthat egy darabig ...", + "generate_failure_no_snapchat": "A SnapEnhance nem találja a Snapchatet, próbálja meg újra telepíteni a Snapchatet.", + "generate_failure": "Hiba történt a beállítások mentése során, kérjük próbálja meg újra." + }, + "permissions": { + "dialog": "A folytatáshoz a következőkre van szükség:", + "notification_access": "Értesítési hozzáférés", + "battery_optimization": "Akkumulátor optimalizálás", + "display_over_other_apps": "Megjelenítés a többi alkalmazás fölött", + "request_button": "Kérelem" + } + }, + "manager": { + "routes": { + "features": "Funkciók", + "home": "Kezdőlap", + "home_settings": "Beállítások", + "home_logs": "Naplófájlok", + "social": "Közösség", + "scripts": "Szkriptek", + "tasks": "Feladatok", + "logger_history": "Naplózó előzménye", + "logged_stories": "Naplózott sztorik", + "manage_scope": "Terület kezelése", + "messaging_preview": "Előnézet", + "edit_rule": "Szabály szerkesztése", + "friend_tracker": "Barát figyelő", + "better_location": "Jobb helyzet", + "file_imports": "Fájl importálások" + }, + "sections": { + "features": { + "disabled": "Kikapcsolva", + "import_option": "Importálás", + "reset_option": "Visszaállítás", + "config_export_success_toast": "Beállításfájl sikeresen exportálva", + "config_import_success_toast": "Beállításfájl sikeresen importálva", + "config_import_failure_toast": "Beállításfájl importálása sikertelen {error}", + "saved_config_snackbar": "Beállításfájl elmentve", + "export_option": "Exportálás", + "config_export_failure_toast": "Nem sikerült a configot exportálni {error}" + }, + "social": { + "streaks_expiration_short": "{hours} óra", + "groups_tab": "Csoportok", + "empty_hint": "(üres)", + "friends_tab": "Barátok" + }, + "tasks": { + "no_tasks": "Nincsenek feladatok", + "merge_files_toast": "{count} fájl egybevonása", + "remove_selected_tasks_title": "Biztosan törölni szeretnéd a kiválasztott feladatokat?", + "remove_all_tasks_title": "Biztosan törölni szeretnéd az összes feladatot?", + "delete_files_option": "Törölje a fájlokat is", + "remove_selected_tasks_confirm": "{count} feladat eltávolítása?", + "remove_all_tasks_confirm": "Minden feladat eltávolítása?", + "failed_to_open_file": "Fájl megnyitása sikertelen" + }, + "home": { + "update_title": "SnapEnhance frissítés", + "update_content": "A(z) {version} már elérhető!", + "update_button": "Letöltés", + "version_title": "v{versionName} · rhunk által", + "debug_build_summary_content": "Verzió {versionName} ({versionCode})", + "debug_build_summary_date": "Buildelés dátuma: {date} ({days} napja)", + "quick_actions_title": "Gyors műveletek", + "debug_build_summary_title": "Egy debug verziót használsz a SnapEnhance-ből" + }, + "home_settings": { + "view_logger_history_button": "Naplóelőzmények megtekintése", + "actions_title": "Műveletek", + "message_logger_title": "Üzenet naplózó", + "debug_title": "Hibakeresés", + "success_toast": "Kész!", + "message_logger_summary": "{messageCount} üzenet(ek)\n{storyCount} történet(ek)", + "export_button": "Exportálás", + "clear_button": "Töröl" + }, + "home_logs": { + "no_logs_hint": "Nincsenek elérhető naplófájlok", + "clear_logs_button": "Naplófájlok törlése", + "export_logs_button": "Naplófájlok exportálása", + "saving_logs_toast": "Naplófájlok mentése, eltarthat egy ideig...", + "saved_logs_success_toast": "Naplófájlok sikeresen elmentve", + "saved_logs_failure_toast": "Naplófájlok mentése sikertelen" + }, + "manage_scope": { + "logged_stories_button": "Mutassa a naplózott történeteket", + "e2ee_title": "Végpontok közötti titkosítás", + "rules_title": "Szabályok", + "participants_text": "{count} résztvevő(k)", + "not_found": "Nincs találat", + "streaks_title": "Sorozatok", + "streaks_length_text": "Hossz: {length}", + "streaks_expiration_text": "Lejár {eta} időn belül", + "streaks_expiration_text_expired": "Lejárt", + "reminder_button": "Emlékeztető beállítása", + "delete_scope_confirm_dialog_title": "Biztosan törölni szeretnéd a {scope}-ot?" + }, + "logged_stories": { + "story_failed_to_load": "Sikertelen betöltés", + "no_stories": "Nincsenek történetek", + "save_from_cache_button": "Mnetés gyorsítótárból" + }, + "messaging_preview": { + "bridge_connection_failed": "Nem sikerült a hídhoz csatlakozni. Ellenőrizd, hogy a Snapchat fut-e a háttérben", + "bridge_init_failed": "Nem sikerült a híd szolgáltatás létrehozása. Ellenőrizd, higy a Snapchat fut-e a háttérben", + "message_fetch_failed": "Nem sikerült az üzenetek lekérése", + "no_message_hint": "Nincs(enek) üzenet(ek)", + "save_selection_option": "Kiválasztás mentése", + "save_all_option": "Mind mentése", + "unsave_selection_option": "Mégse mentse a kiválasztottakat", + "unsave_all_option": "Egyiket se mentse", + "mark_selection_as_seen_option": "Kijelölt snap megjelölése \"láttam\"-ként", + "mark_all_as_seen_option": "Minden snap megjelölése \"láttam\"-ként", + "delete_selection_option": "Kiválasztottak törlése", + "delete_all_option": "Mind törlése" + }, + "logger_history": { + "list_friend_format": "Barát: {name}", + "list_group_format": "Csoport: {name}", + "no_more_messages": "Nincs több üzenet", + "reverse_order_checkbox": "Sorrend megfordítása", + "chat_attachment": "Csatolmány {index}", + "empty_message": "Chat üzeneteinek törlése", + "message_parse_failed": "Nem sikerült az üzenetet értelmezni", + "unknown_sender": "Ismeretlen küldő", + "download_attachment_failed_toast": "Nem sikerült a csatolmányt letölteni" + }, + "file_imports": { + "file_imported": "A fájl sikeresen importálva", + "file_delete_failed": "Nem sikerült törölni a fájlt", + "file_import_failed": "Nem sikerült a fájlt importálni: {error}", + "no_files_hint": "Itt tudsz fájlokat importálni, hogy a Snapchatben használd. Nyomd meg a gombot egy fájl importálásához.", + "import_file_button": "Fájl importálása", + "file_not_found": "Fájl nem található" + }, + "better_location": { + "spoofed_coordinates_title": "Szé {latitude}, Hsz {longitude}", + "save_coordinates_dialog_title": "Kordináták mentése", + "saved_name_dialog_hint": "Elmentett név", + "longitude_dialog_hint": "Hosszúság", + "save_dialog_button": "Mentés", + "teleport_to_friend_button": "Teleportálás egy baráthoz", + "saved_coordinates_title": "Elmentett kordináták", + "no_saved_coordinates_hint": "Nincs mentett kordináta", + "delete_dialog_message": "Biztos, hogy törölni szeretnéd ezt az elmentett kordinátát?", + "teleport_to_friend_title": "Teleportálás egy baráthoz", + "no_friends_map": "Nincsen a térképen barátod", + "no_friends_found": "Nincsenek barátaid", + "latitude_dialog_hint": "Szélesség", + "choose_location_button": "Válassz egy helyet", + "spoof_location_toggle": "Helyzet hamisítása", + "delete_dialog_title": "Mentett kordináta törlése", + "search_bar": "Keresés", + "suspend_location_updates": "Helyadatok küldésének letiltása" + } + }, + "dialogs": { + "add_friend": { + "title": "Barát vagy csoport hozzáadása", + "search_hint": "Keresés", + "fetch_error": "Sikertelen adatlekérdezés", + "category_groups": "Csoportok", + "category_friends": "Barátok" + }, + "scripting_warning": { + "content": "A SnapEnhance egy script-író eszközt, amely lehetővé teszi a felhasználó által meghatározott kódok végrehajtását az eszközön. Használja fokozott óvatossággal és csak megbízható forrásból származó modulokat telepítsen. A nem engedélyezett vagy nem ellenőrzött modulok biztonsági kockázatokat jelenthetnek a rendszerének.", + "title": "Figyelmeztetés" + }, + "reset_config": { + "content": "Biztosan vissza akarod állítani a beállításfájlt?", + "title": "Beállításfájl visszaállítása", + "success_toast": "Beállításfájl sikeresen visszaállítva" + }, + "messaging_action": { + "title": "Válassza ki a feldolgozni kívánt tartalomtípusokat", + "select_all_button": "Összes keresése" + }, + "file_imports": { + "no_files_settings_hint": "Nincsenek fájlok. Ellenőrizd, hogy minden szükséges fájl benne van-e a Fájl importálás részben", + "settings_select_file_hint": "Válassz egy importált fájlt" + }, + "export_config": { + "title": "Exportálod az érzékeny adatokat?", + "content": "Szeretnéd exportálni a beállításaidat az érzékeny adataiddal együtt? (Mint a helyzeted kordinátái, stb.)" + } + } + }, + "rules": { + "modes": { + "blacklist": "Feketelista mód", + "whitelist": "Whitelist mód" + }, + "properties": { + "auto_download": { + "name": "Automatikus letöltés", + "description": "Automatikusan letölti a Snap-eket a megtekintésükkor", + "options": { + "blacklist": "Automatikus letöltésből való kizárás", + "whitelist": "Automatikus Letöltés" + } + }, + "stealth": { + "name": "Rejtőzködő üzemmód", + "description": "Megakadályozza, hogy bárki megtudja, hogy megnyitotta a Snap-eket/Chat-eket és beszélgetéseket", + "options": { + "blacklist": "Kizárás a rejtőzködő üzemmódból", + "whitelist": "Rejtőzködő üzemmód" + } + }, + "auto_save": { + "name": "Automatikus mentés", + "description": "Elmenti a Chat Üzeneteket megtekintéskor", + "options": { + "blacklist": "Automatikus mentésből kizárás", + "whitelist": "Automatikus mentés" + } + }, + "hide_friend_feed": { + "name": "Elrejtés a barát hírfolyamáról" + }, + "e2e_encryption": { + "name": "Végpontok közötti titkosítás használata" + }, + "pin_conversation": { + "name": "Beszélgetés kitűzése" + }, + "unsaveable_messages": { + "name": "Nem menthető üzenetek", + "options": { + "whitelist": "Nem menthető üzenetek", + "blacklist": "Kizárás a nem menthető üzenetekből" + }, + "description": "Megakadályozza, hogy az üzeneteket elmenthessék mások a chatben" + }, + "auto_open_snaps": { + "name": "Automatikus Snap-ek", + "description": "Automatikusan megnyitja a Snap-eket, amikor megkapja azokat", + "options": { + "blacklist": "A kihagyás az automatikus snap-ekből", + "whitelist": "Automatikus snap-ek megnyitása" + } + } + }, + "toasts": { + "enabled": "{ruleName} engedélyezve", + "disabled": "{ruleName} letiltva" + } + }, + "features": { + "notices": { + "unstable": "⚠ Instabil", + "ban_risk": "⚠ Ez a funkció tiltást eredményezhet", + "internal_behavior": "⚠ Ez a funkció hibát okozhat a Snapchat működésében" + }, + "properties": { + "downloader": { + "name": "Letöltő", + "description": "Snapchat média letöltése", + "properties": { + "save_folder": { + "name": "Letöltések mappa", + "description": "Válaszd ki a könyvtárat ahová a letöltött médiát szeretnéd menteni" + }, + "auto_download_sources": { + "name": "Automatikus letöltési források", + "description": "Válaszd ki a forrásokat ahonnan automatikusan leszeretnél tölteni" + }, + "prevent_self_auto_download": { + "name": "Sajátmagam automatikus letöltésének megakadályozása", + "description": "Megakadályozza hogy a saját Snap-jeid automatikusan le legyenek töltve" + }, + "path_format": { + "name": "Elérési útvonal formátuma", + "description": "A fájl elérési útvonal formátumának megadása" + }, + "allow_duplicate": { + "name": "Ismétlődések engedélyezése", + "description": "Engedélyezi a médiák többszöri letöltését" + }, + "force_image_format": { + "name": "Képformátum kényszerítése", + "description": "Kényszeríti a képeket hogy egy megadott formátumba legyenek mentve" + }, + "force_voice_note_format": { + "name": "Hangüzenet formátumának kényszerítése", + "description": "Kényszeríti a hangüzeneteket hogy egy megadott formátumba legyenek mentve" + }, + "download_profile_pictures": { + "name": "Profilképek letöltése", + "description": "Lehetővé teszi hogy letölts profilképeket a profil oldaláról" + }, + "ffmpeg_options": { + "name": "FFmpeg beállítások", + "description": "További FFmpeg beállítások meghatározása", + "properties": { + "preset": { + "name": "Sablon", + "description": "Beszélgetés gyorsaságának állítása" + }, + "video_bitrate": { + "name": "Videó bitrátája", + "description": "A videó bitrátájának állítása (kbps)" + }, + "audio_bitrate": { + "name": "Hang bitrátája", + "description": "A hang bitrátájának állítása (kbps)" + }, + "custom_video_codec": { + "name": "Egyéni videó codec", + "description": "Egyéni videó codec beállítása (pl.: libx264)" + }, + "custom_audio_codec": { + "name": "Egyéni audió codec", + "description": "Egyéni audió codec beállítása (pl.: AAC)" + }, + "threads": { + "name": "Szálak", + "description": "A felhasznált szálak mennyisége" + }, + "constant_rate_factor": { + "name": "Állandó ráta tényező", + "description": "Állandó sebességtényező beállítása a videokódolóhoz\n0 és 51 között libx264 esetén" + } + } + }, + "logging": { + "name": "Naplózás", + "description": "Üzenet megjelenítése média letöltésekor" + }, + "opera_download_button": { + "description": "Hozzáad egy letöltés gombot a jobb felső sarokban, amikor egy Snapet nézel.\nA gombok hosszú megnyomása kikényszeríti a letöltést", + "name": "Letöltés Operával Gomb" + }, + "merge_overlays": { + "description": "Egyesíti egy Snap szövegét és médiáját egy fájlba", + "name": "Overlay-ek összefűzése" + }, + "custom_path_format": { + "name": "Egyéni elérési útvonal formátum", + "description": "Egyéni elérési útvonal-formátum megadása a letöltött média számára\n\nElérhető változók:\n - %username%\n - %source%\n - %hash%\n - %date_time%" + }, + "download_context_menu": { + "name": "Letöltés kontextusmenü", + "description": "Lehetővé teszi az üzenetek letöltését/előnézetét egy beszélgetésből vagy egy történetből a kontextusmenü segítségével.\nA gombok hosszú megnyomása kikényszeríti a letöltést" + } + } + }, + "user_interface": { + "name": "Felhasználói felület", + "properties": { + "enable_app_appearance": { + "name": "Az alkalmazás megjelenési beállításainak engedélyezése", + "description": "Engedélyezi a rejtett Megjelenés beállítást az alkalmazásban\nLehet, hogy az újabb Snapchat verziókban nincs szükség rá" + }, + "friend_feed_message_preview": { + "name": "Barát hírfolyam üzenet előnézet", + "description": "Megmutatja az előnézetét az utolsó üzeneteknek a barát hírfolyamban", + "properties": { + "amount": { + "name": "Mennyiség", + "description": "Az előnézetben megjelenítendő üzenetek mennyisége" + } + } + }, + "bootstrap_override": { + "properties": { + "app_appearance": { + "name": "App Megjelenése", + "description": "Állandó alkalmazásmegjelenítés beállítása" + }, + "home_tab": { + "name": "Kezdőlap fül", + "description": "Felülírja a megjelenő ablakot a Snapchat indulásakor" + } + }, + "description": "Felülírja a kezelőfelület Bootstrap beállításait", + "name": "Bootstrap felülírása" + }, + "streak_expiration_info": { + "name": "Mutassa a streak lejárati adatait", + "description": "A Streak számláló mellett megjelenik a Streak lejárati időzítője" + }, + "hide_friend_feed_entry": { + "name": "Barát hírfolyam elrejtése", + "description": "Elrejt egy kiválasztott barátot a barát hírfolyamról\nHasználd a közösségi lapot ahhoz hogy kezeld ezt a funkciót" + }, + "hide_streak_restore": { + "name": "Sorozat pontszám elrejtése", + "description": "Elrejti a visszaállítás gombot a barát hírfolyamról" + }, + "hide_ui_components": { + "name": "Felhasználói felület elemeinek elrejtése", + "description": "Válaszd ki hogy a felhasználói felület mely részeit szeretnéd elrejteni" + }, + "disable_spotlight": { + "name": "Spotlight letiltása", + "description": "Letiltja a Spotlight oldalt" + }, + "friend_feed_menu_buttons": { + "name": "Barát hírfolyam menü gombok", + "description": "Annak kiválasztása hogy mely gombok jelennek meg a barát hírfolyam menüsorban" + }, + "enable_friend_feed_menu_bar": { + "name": "Barát hírfolyam menüsor", + "description": "Engedélyezi az új barát hírfolyam menüsort" + }, + "prevent_message_list_auto_scroll": { + "name": "Megakadályozza az üzenet lista automatikus görgetését", + "description": "Megakadályozza, hogy az üzenetlista üzenetek küldésekor/fogadásakor a lista aljára gördüljön" + }, + "snap_preview": { + "name": "Snap előnézet", + "description": "Megjelenít egy kis előnézetet a nem látott pillanatok mellett a csevegésben" + }, + "map_friend_nametags": { + "name": "Továbbfejlesztett baráti térkép névtáblák", + "description": "Javítja a barátok névtábláit a Snapmap-en" + }, + "stealth_mode_indicator": { + "description": "Hozzáad egy 👻 emojit a rejtett módban lévő beszélgetések mellé", + "name": "Rejtőzködő üzemmód jelzője" + }, + "edit_text_override": { + "name": "Szöveg szerkesztése felülírása", + "description": "Felülírja a szövegmező viselkedését" + }, + "opera_media_quick_info": { + "name": "Opera Media gyors információ", + "description": "Megjeleníti a média hasznos információit, például a létrehozás dátumát az Opera Viewer kontextusmenüjében" + }, + "vertical_story_viewer": { + "description": "Engedélyezi a függőleges történetnézegetőt az összes történethez", + "name": "Függőleges történetnéző" + }, + "old_bitmoji_selfie": { + "description": "Visszahozza a Bitmoji szelfiket a régebbi Snapchat verziókból", + "name": "Régi Bitmoji szelfi" + }, + "hide_story_suggestions": { + "name": "Történet javaslatok elrejtése", + "description": "Eltávolítja a javaslatokat a Történetek oldalról" + }, + "message_indicators": { + "name": "Üzenetjelzők", + "description": "Speciális jelzőikonok hozzáadása az üzenetekhez\nMegjegyzés: a mutatók nem biztos, hogy 100%-ban pontosak" + } + }, + "description": "Módosítsd a Snapchat kinézetét" + }, + "messaging": { + "name": "Üzenetküldés", + "properties": { + "anonymous_story_viewing": { + "name": "Anonim Story nézés", + "description": "Megakadályozza, hogy bárki megtudja, hogy láttad a történetüket" + }, + "hide_bitmoji_presence": { + "name": "Bitmojik elrejtése", + "description": "Megakadályozza a Bitmoji-d megjelenítését, miközben a Chat meg van nyitva" + }, + "hide_typing_notifications": { + "name": "Gépelési értesítések elrejtése", + "description": "Megakadályozza, hogy bárki megtudja, hogy éppen üzenetet írsz" + }, + "unlimited_snap_view_time": { + "name": "Korlátlan Snap Megtekintési Idő", + "description": "Eltávolítja a Snap-ek megtekintési időkorlátját" + }, + "disable_replay_in_ff": { + "description": "Letiltja a lehetőséget hogy újrajátsz a barát hírfolyamból hosszú nyomással", + "name": "A visszajátszás kikapcsolása FF-ben" + }, + "better_notifications": { + "name": "Jobb Értesítések", + "description": "Több információt mutat az értesítésekben", + "properties": { + "group_notifications": { + "description": "Egy értesítésbe csoportosítja az értesítéseket", + "name": "Csoportosított értesítés" + }, + "chat_preview": { + "name": "Beszélgetés előnézet", + "description": "Megjeleníti a kapott üzenetek előnézetét az értesítésben" + }, + "media_preview": { + "description": "Megjeleníti a kapott média típusok előnézetét az értesítésben", + "name": "Média előnézet" + }, + "stacked_media_messages": { + "name": "Csoportosított média üzenetek", + "description": "Több médiaüzenet egyetlen szöveges értesítésben történő egyesítése, ha azok nem tekinthetők meg előnézetben. A Beszélgetés előnézet funkcióval együtt használható" + }, + "friend_add_source": { + "name": "Barát hozzáadásának forrása", + "description": "Megjeleníti a barát kérelem forrását az értesítésben" + }, + "media_caption": { + "name": "Média felirat", + "description": "Megjeleníti a csatolt feliratokat a média értesítésben" + }, + "reply_button": { + "name": "Válasz gomb", + "description": "Hozzáad egy válasz gombot az értesítéshez" + }, + "download_button": { + "name": "Letöltés gomb", + "description": "Engedi, hogy letöltse a médiát az értesítésből" + }, + "mark_as_read_button": { + "name": "Megjelölés olvasottként gomb", + "description": "Engedi, hogy megjelöld az üzenetet olvasottként az értesítésből" + }, + "mark_as_read_and_save_in_chat": { + "name": "Megjelölés olvasottként és mentés", + "description": "Megjelöli olvasottként és elmenti a beszélgetésben az értesítésből" + }, + "smart_replies": { + "name": "Okos válaszok", + "description": "Hozzáad ajánlott válaszokat az értesítéshez (Android 10+). Használd a válasz gombbal együtt" + } + } + }, + "notification_blacklist": { + "name": "Értesítési Tiltólista", + "description": "Jelöld ki azokat az értesítéseket, amelyeket tiltani szeretnél" + }, + "message_logger": { + "name": "Üzenetnapló", + "description": "Megakadályozza az üzenetek törlését", + "properties": { + "message_filter": { + "name": "Üzenet szűrő", + "description": "Jelöld ki, hogy mely üzenetek kerüljenek naplózásra (üres az összes üzenet esetén)" + }, + "keep_my_own_messages": { + "description": "Megakadályozza a saját üzenetek törlését", + "name": "Saját üzeneteim megtartása" + }, + "auto_purge": { + "name": "Automatikus tisztítás", + "description": "Automatikusan törli a megadott időnél régebbi gyorsítótárazott üzeneteket" + } + } + }, + "auto_save_messages_in_conversations": { + "name": "Üzenetek automatikus mentése", + "description": "Automatikusan elment minden üzenetet a beszélgetésekben" + }, + "gallery_media_send_override": { + "name": "Galéria Média küldés felülbírálása", + "description": "Meghamisítja a média forrását amikor a gallériából küldöd" + }, + "prevent_story_rewatch_indicator": { + "name": "Megakadályozza a történet újranézésének jelzését", + "description": "Megakadályozza, hogy bárki megtudja, hogy újra megnézted a történetüket" + }, + "bypass_screenshot_detection": { + "name": "Képernyőkép-érzékelés megkerülése", + "description": "Megakadályozza, hogy a Snapchat érzékelje, ha képernyőképet készítesz" + }, + "hide_peek_a_peek": { + "name": "Peek-a-Peek elrejtése", + "description": "Megakadályozza az értesítés elküldését, amikor félig belelapozol egy csevegésbe" + }, + "loop_media_playback": { + "name": "Média lejátszás hurokban", + "description": "Média lejátszási hurok a Snaps / Stories megtekintésekor" + }, + "strip_media_metadata": { + "name": "Média metaadatok eltávolítása", + "description": "A média metaadatainak eltávolítása üzenetként való elküldés előtt" + }, + "call_start_confirmation": { + "name": "Hívásindítás megerősítése", + "description": "Megerősítő párbeszédpanel megjelenítése hívás indításakor" + }, + "prevent_message_sending": { + "name": "Üzenetküldés megakadályozása", + "description": "Megakadályozza bizonyos típusú üzenetek küldését" + }, + "half_swipe_notifier": { + "name": "Fél húzás értesítő", + "description": "Értesít, ha valaki félig belecsapódik egy beszélgetésbe", + "properties": { + "min_duration": { + "name": "Minimális időtartam", + "description": "A fél áthúzás minimális időtartama (másodpercben)" + }, + "max_duration": { + "name": "Maximális időtartam", + "description": "A félbehúzás maximális időtartama (másodpercben)" + } + } + }, + "bypass_message_retention_policy": { + "description": "Megakadályozza az üzenetek törlését azok megtekintése után", + "name": "Üzenetmegőrzési irányelvek megkerülése" + }, + "bypass_message_action_restrictions": { + "name": "Üzenetműveleti korlátozások megkerülése", + "description": "Lehetővé teszi, hogy reagálj egy snapre anélkül, hogy megnyitottad volna, vagy hogy elmentsd a menthetetlen üzenetet" + }, + "remove_groups_locked_status": { + "name": "Csoportok zárolt státuszának eltávolítása", + "description": "Lehetővé teszi a csoportinformációk megtekintését, miután kirúgták" + }, + "auto_mark_as_read": { + "name": "Automatikus megjelölés olvasottnak", + "description": "Automatikusan megjelöli az üzeneteket/snap-eket olvasottként, még akkor is, ha a Láthatatlanság mód be van kapcsolva" + }, + "friend_mutation_notifier": { + "name": "Barát Módosítás Értesítő", + "description": "Értesít, amikor valami változik egy barát profiljában" + }, + "unlimited_conversation_pinning": { + "description": "Engedélyezi, hogy végtelen mennyiségű beszélgetést kitűzz helyileg", + "name": "Korlátlan beszélgetés kitűzés" + } + }, + "description": "Változtasd meg a barátaiddal való kapcsolattartásodat" + }, + "global": { + "name": "Globális", + "properties": { + "snapchat_plus": { + "name": "Snapchat Plusz", + "description": "Engedélyezi a Snapchat Plusz funkciókat\nNéhány szerver oldali funkció lehetséges hogy nem működik" + }, + "auto_updater": { + "name": "Automatikus frissítő", + "description": "Automatikusan keres új frissítéseket" + }, + "disable_metrics": { + "name": "Mérések letiltása", + "description": "Blokkolja az elemző adatküldést a Snapchat számára" + }, + "block_ads": { + "name": "Hirdetések Blokkolása", + "description": "Megakadályozza a reklámok megjelenítését" + }, + "bypass_video_length_restriction": { + "name": "Videóhossz-korlátozás megkerülése", + "description": "Egyetlen: egyetlen videó küldése\nOsztás: a videók szétválasztása szerkesztés után" + }, + "disable_google_play_dialogs": { + "name": "A Google Play Szolgáltatások Párbeszédablakainak Letiltása", + "description": "Megakadályozza a Google Play Szolgáltatások elérhetőségére vonatkozó párbeszédablakok megjelenítését" + }, + "disable_snap_splitting": { + "name": "Snap szétválasztás kikapcsolása", + "description": "Megakadályozza a Snap-ek több részre történő szétválasztását\nA képek amiket küldesz videók lesznek" + }, + "disable_confirmation_dialogs": { + "name": "Megerősítő ablakok letiltása", + "description": "Automatikusan megerősíti a kiválasztott műveleteket" + }, + "spotlight_comments_username": { + "description": "Megjeleníti a szerző felhasználónevét a Spotlight hozzászólásokban", + "name": "Spotlight hozzászólások felhasználónév" + }, + "better_location": { + "description": "Javítja a Snapchaten a helyzeted", + "name": "Jobb helyzet", + "properties": { + "spoof_location": { + "name": "Helyzet hamisítása", + "description": "Átírja a tartózkodási helyedet egy megadott helyhez" + }, + "coordinates": { + "name": "Koordináták", + "description": "Az átírt hely koordinátáinak beállítása" + }, + "always_update_location": { + "name": "Mindig frissítse a helyet", + "description": "Kényszerítsd a Snapchatet a helyfrissítésre akkor is, ha nem érkezik GPS-adat" + }, + "suspend_location_updates": { + "name": "Helyszínfrissítések felfüggesztése", + "description": "Megakadályozza, hogy a térkép frissítéseket kapjon tőled" + }, + "spoof_battery_level": { + "name": "Akkumulátor szint átírása", + "description": "Átírja a készülék akkumulátorának szintjét a térképen\nAz értéknek 0 és 100 között kell lennie" + }, + "spoof_headphones": { + "name": "Hamisított fejhallgató", + "description": "Hazudik a zenehallgatás státuszáról a térképen" + }, + "walk_radius": { + "description": "Random ezen a területen belül fogsz sétálni (láb/ft)", + "name": "Séta körzet" + } + } + }, + "disable_story_sections": { + "description": "Eltávolítja a szakaszokat a Sztorik oldalról\nA megfelelő működéshez frissítésre lehet szükség", + "name": "Sztori részek letiltása" + }, + "disable_permission_requests": { + "name": "Engedélykérelmek letiltása", + "description": "Megakadályozza, hogy a Snapchat bizonyos engedélyeket kérjen" + }, + "disable_memories_snap_feed": { + "name": "Emlékek Snap Feed letiltása", + "description": "Megakadályozza, hogy a Snapchat megjelenítse a legutóbbi emlékeket, amikor a kamerában felfelé húzza a lapot" + }, + "default_volume_controls": { + "name": "Alapértelmezett hangerőszabályozók", + "description": "Kényszeríti a Snapchatet a rendszer hangerőszabályzóinak használatára" + }, + "default_video_playback_rate": { + "name": "Alapértelmezett videó lejátszási sebesség", + "description": "A videók lejátszásának alapértelmezett sebességének beállítása\nAz értéknek 0,1 és 4,0 között kell lennie" + }, + "video_playback_rate_slider": { + "name": "Videó lejátszási sebesség csúszka", + "description": "Hozzáad egy csúszkát az opera kontextus menüjébe a videó lejátszási sebesség megváltoztatásához\nMegjegyzés: A módosítások csak a következő videókra vonatkoznak" + }, + "hide_active_music": { + "name": "Aktív zene elrejtése", + "description": "Megakadályozza, hogy a Snapchat megtudja, hogy éppen zenét hallgatsz\nEz lehetővé teszi, hogy zenehallgatás közben is készíthess pillanatképeket a vezérlő hangerőgombok segítségével" + }, + "media_upload_quality": { + "properties": { + "disable_image_compression": { + "description": "Letiltja a kép tömörítést, amikor feltöltesz egy fájlt", + "name": "Kép tömörítés letiltása" + }, + "force_video_upload_source_quality": { + "name": "Eredeti videóminőség feltöltésének kényszerítése", + "description": "A Snapchatet kényszeríti, hogy a forrással megegyező minőségben töltse fel\nTartsd észben, hogy ezzel az opcióval nem törlődnek a metaadatok a fájból" + }, + "custom_image_upload_format": { + "name": "Egyedi kép feltöltési formátum", + "description": "Egyedi kép kiterjesztést használ feltöltéshez\nVálassz egy veszteségmentes formátumot (pl PNG) a legjobb minőségért" + } + }, + "description": "Felülírja a média feltöltés minőségét", + "name": "Feltöltési minőség" + }, + "disable_custom_tabs": { + "name": "Egyedi lapok letiltása", + "description": "A linkeket inkább az appokban nyitja meg, mint a böngészőben" + }, + "disable_telecom_framework": { + "name": "Telecom Framework letiltása", + "description": "Megakadályozza, hogy a Snapchat használja az Android Telecom framework-öt\nEz lehetővé teszi, hogy hívás közben zenét hallgass" + } + }, + "description": "Globális Snapchat beállítások módosítása" + }, + "rules": { + "name": "Szabályok", + "description": "Automatikus funkciók kezelése egyes személyek számára" + }, + "camera": { + "name": "Kamera", + "properties": { + "black_photos": { + "name": "Fekete fotók", + "description": "A rögzített fényképek fekete háttérrel való helyettesítése\nA videókat nem érinti" + }, + "hevc_recording": { + "description": "HEVC (H.265) kodeket használ videofelvételhez", + "name": "HEVC felvétel" + }, + "force_camera_source_encoding": { + "description": "Kényszeríti a kamera forráskódolását", + "name": "Kamera forráskódolás kikényszerítése" + }, + "disable_cameras": { + "name": "Kamera letiltása", + "description": "Megakadályozza, hogy a Snapchat használja a kiválasztott kamerákat" + }, + "override_front_resolution": { + "description": "Felülbírálja az előlapi kamera felbontását", + "name": "Előlapi felbontás felülbírálása" + }, + "front_custom_frame_rate": { + "name": "Egyéni előlapi képkocka sebesség", + "description": "Felülírja az előlapi kamera képkocka sebességét" + }, + "custom_resolution": { + "name": "Egyéni felbontás", + "description": "Egyéni kamera felbontás beállítása, szélesség x magasság (pl. 1920x1080).\nAz egyéni felbontásnak támogatottnak kell lennie az eszközön" + }, + "override_back_resolution": { + "name": "Hátlapi felbontás felülbírálása", + "description": "Felülírja a kamera felbontását a hátlapi kamera esetében" + }, + "back_custom_frame_rate": { + "name": "Egyéni hátlapi képkocka sebesség", + "description": "Felülírja az hátlapi kamera képkocka sebességét" + }, + "immersive_camera_preview": { + "name": "Magával ragadó előnézet", + "description": "Megakadályozza, hogy a Snapchat levágja a kamera előnézetét\nEz egyes készülékeken a kamera villogását okozhatja" + } + }, + "description": "Igazítsd a megfelelő beállításokat a tökéletes felvételhez" + }, + "streaks_reminder": { + "properties": { + "remaining_hours": { + "name": "Hátralévő idő", + "description": "Az értesítés megjelenítéséig hátralévő idő (órákban)" + }, + "group_notifications": { + "name": "Csoportos értesítések", + "description": "Értesítések csoportosítása egyetlen értesítésbe" + }, + "interval": { + "name": "Intervallum", + "description": "Az egyes emlékeztetők közötti időköz (órákban)" + } + }, + "description": "Rendszeresen értesít a sorozataidról", + "name": "Sorozat emlékeztető" + }, + "experimental": { + "name": "Kísérleti", + "description": "Kísérleti funkciók", + "properties": { + "native_hooks": { + "properties": { + "disable_bitmoji": { + "name": "Bitmoji kikapcsolása", + "description": "Barátok Bitmoji-jának kikapcsolása" + }, + "composer_hooks": { + "properties": { + "bypass_camera_roll_limit": { + "name": "Kameragörgő korlát kikerülése", + "description": "Növeli a kameragörgőről küldhető média maximális mennyiségét" + }, + "composer_console": { + "description": "Lehetővé teszi JavaScript kód végrehajtását a Composerben (csak arm64)", + "name": "Összetevő Konzol" + }, + "composer_logs": { + "name": "Összetevő Naplók", + "description": "A Composer konzolnaplóit átirányítja a SnapEnhance felé" + }, + "show_first_created_username": { + "name": "Első felhasználónév megjelenítése", + "description": "Megjeleníti, hogy milyen felhasználónévvel regisztrált a profilján" + } + }, + "name": "Összetevő Horgok", + "description": "Kód befecskendezése a Composer keresztplatformú UI keretrendszerbe" + }, + "custom_emoji_font": { + "name": "Egyedi emoji betűtípus", + "description": "Engedélyezi az egyedi emoji betűtípust. Csak .ttf fájlokkal működik" + } + }, + "name": "Natív hookok", + "description": "Nem biztonságos funkciók, amelyek a Snapchat natív kódjához kapcsolódnak" + }, + "spoof": { + "name": "Hamisítás", + "description": "Meghamisít számos információt rólad", + "properties": { + "play_store_installer_package_name": { + "description": "Felülírja a telepítőcsomag nevét com.android.vending-re", + "name": "Play áruház telepítő csomag neve" + }, + "remove_mock_location_flag": { + "description": "Megakadályozza, hogy a Snapchat felismerje a Mock helyét", + "name": "Távolítsa el a Mock Location Flag-et" + }, + "remove_vpn_transport_flag": { + "name": "VPN szállítási zászló eltávolítása", + "description": "Megakadályozza, hogy a Snapchat felismerje a VPN-eket" + } + } + }, + "infinite_story_boost": { + "name": "Végtelen történet Boost", + "description": "Történet Boost limit megkerülése" + }, + "e2ee": { + "name": "Végpontok közötti titkosítás", + "description": "Titkosítja az üzeneteidet AES-el egy megosztott titkos kulcsot használva\nBizonyosodj meg róla hogy lementsd a kulcsodat egy biztonságos helyre!", + "properties": { + "encrypted_message_indicator": { + "name": "Titkosított Üzenet Jelző", + "description": "Hozzáad egy 🔒 emoji-t a titkosított üzenetek mellé" + }, + "force_message_encryption": { + "name": "Üzenet titkosítás kényszerítése", + "description": "Csak akkor akadályozza meg a titkosított üzenetek küldését olyan személyeknek, akiknek nincs engedélyezve az E2E titkosítás, ha több beszélgetés van kiválasztva" + } + } + }, + "hidden_snapchat_plus_features": { + "name": "Engedélyezi a rejtett Snapchat Plusz funkciókat", + "description": "Engedélyezi a még nem megjelent/béta Snapchat Plusz funkciókat\nRégebbi Snapchat verziókon lehetséges hogy nem működik" + }, + "convert_message_locally": { + "name": "Üzenet helyi átalakítása", + "description": "Átalakítja a snapeket külső média helyi csevegésre. Ez a chat letöltés kontextusmenüjében jelenik meg" + }, + "meo_passcode_bypass": { + "description": "A My Eyes Only jelszó megkerülése\nEz csak akkor működik, ha a jelszót korábban helyesen adtad meg", + "name": "My Eyes Only jelszó megkerülése" + }, + "no_friend_score_delay": { + "name": "Nincs Friend Score késleltetés", + "description": "Eltávolítja a késleltetést a Friends Score megtekintésekor" + }, + "call_recorder": { + "name": "Hívásrögzítő", + "description": "Automatikusan rögzíti a hanghívásokat" + }, + "edit_message": { + "description": "Lehetővé teszi az üzenetek szerkesztését a beszélgetésekben", + "name": "Üzenetek szerkesztése" + }, + "media_file_picker": { + "name": "Média fájlválasztó", + "description": "Lehetővé teszi, hogy bármelyik videó/hangfájlt kiválaszd a galériából" + }, + "story_logger": { + "description": "A barátok sztorijainak története", + "name": "Sztori naplózó" + }, + "account_switcher": { + "name": "Fiókváltó", + "properties": { + "auto_backup_current_account": { + "name": "Automatikus mentés jelenlegi fióknál", + "description": "Automatikusan biztonsági mentést készít az aktuális fiókról kijelentkezéskor vagy fiókváltáskor" + } + }, + "description": "Lehetővé teszi a fiókok közötti váltást kijelentkezés nélkül\nA menü megnyitásához nyomd meg hosszan a keresés ikonját a Bitmoji profilod mellett\nMegjegyzés: ez a funkció kísérleti jellegű, és a jövőben valószínűleg változni fog" + }, + "add_friend_source_spoof": { + "name": "Barát forrás hozzáaadásának átírásának", + "description": "Meghamisítja a Barátkérés forrását" + }, + "prevent_forced_logout": { + "name": "Kényszerített kijelentkezés megakadályozása", + "description": "Megakadályozza, hogy a Snapchat kijelentkezzen, amikor egy másik eszközön jelentkezel be" + }, + "app_lock": { + "name": "App Zár", + "description": "Megakadályozza a Snapchat hozzáférését jelszó nélkül", + "properties": { + "lock_on_resume": { + "name": "Zár a folytatásnál", + "description": "Az alkalmazás zárolása annak újbóli megnyitásakor" + } + } + }, + "custom_streaks_expiration_format": { + "name": "Egyedi sorozat lejárati formátum", + "description": "Egyedi sorozat lejárati formátum\n\nElérhető változók:\n- %c: Sorozatok Száma\n- %e: Homokóra Emoji\n- %d: Napok\n- %h: Órák\n- %m: Perc\n- %s: Másodperc\n- %w: Hátralévő Idő" + }, + "best_friend_pinning": { + "name": "Legjobb barát kitűzés", + "description": "Engedi, hogy megjelöld legeslegjobb barátodként. Fontos: Csak te látod" + }, + "cof_experiments": { + "name": "COF kísérletek", + "description": "Engedélyezi a kiadatlan/béta Snapchat funkciókat" + }, + "context_menu_fix": { + "name": "Kontextus menü javítás", + "description": "Próbálja megjavítani a Friend Feed menüt, mivel ha a készülék offline állapotban van, nem jelenik meg megfelelően" + } + } + }, + "scripting": { + "properties": { + "developer_mode": { + "name": "Fejlesztői mód", + "description": "Megjeleníti a hibakeresési információkat a Snapchat felhasználói felületén" + }, + "integrated_ui": { + "name": "Integrált UI", + "description": "Lehetővé teszi, hogy a szkriptek egyéni UI komponenseket adjanak a Snapchathez" + }, + "module_folder": { + "name": "Modul mappa", + "description": "A mappa, ahol a szkriptek találhatók" + }, + "auto_reload": { + "name": "Automatikus újratöltés", + "description": "Automatikusan újratölti a szkripteket, amikor azok megváltoznak" + }, + "disable_log_anonymization": { + "name": "Napló anonimizálás kikapcsolása", + "description": "Letiltja a naplók anonimizálását" + } + }, + "description": "Egyéni szkriptek futtatása a SnapEnhance bővítéséhez", + "name": "Szkriptelés" + }, + "friend_tracker": { + "name": "Barát figyelő", + "properties": { + "allow_running_in_background": { + "name": "Futás a háttérben engedélyezése", + "description": "Engedi, hogy a követő fusson a háttérben. Megjegyzés: Ez jelentősen meríti az akkumulátorodat" + }, + "record_messaging_events": { + "description": "Feljegyzi az üzenet eventeket, mint snapchat megnyitása, üzenet elolvasása, stb.", + "name": "Figyeli az üzenet eventeket" + }, + "auto_purge": { + "name": "Automatikus tisztítás", + "description": "Automatikusan törli a gyorsítótárazott eseményeket és régebbi egy adott időnél" + } + }, + "description": "Figyeli a barátaid tevékenységét Snapchaten" + } + }, + "options": { + "app_appearance": { + "always_light": "Mindig világos", + "always_dark": "Mindig sötét" + }, + "friend_feed_menu_buttons": { + "auto_download": "⬇️ Automatikus letöltés", + "auto_save": "💬 Üzenetek automatikus mentése", + "stealth": "👻 Lopakodó üzemmód", + "conversation_info": "👤 Beszélgetés információk", + "e2e_encryption": "🔒 Végpontok közötti titkosítás használata", + "unsaveable_messages": "⬇️ Nem menthető üzenetek", + "mark_snaps_as_seen": "👀 Snapek megjelölése látottként", + "mark_stories_as_seen_locally": "👀 Snapek megjelölése látottként helyileg", + "auto_open_snaps": "📷 Automatikus Snaps Megnyitás" + }, + "auto_download_sources": { + "friend_snaps": "Barát Snap-ek", + "friend_stories": "Barát történetek", + "public_stories": "Nyilvános történetek", + "spotlight": "Spotlight" + }, + "logging": { + "started": "Elkezdődött", + "success": "Siker", + "progress": "Előrehaladás", + "failure": "Sikertelen" + }, + "notifications": { + "chat_screenshot": "Képernyőkép", + "chat_screen_record": "Képernyőfelvétel", + "snap_replay": "Snap újrajátszás", + "chat": "Chat", + "chat_reply": "Chat Válasz", + "snap": "Snap", + "typing": "Gépel", + "stories": "Történetek", + "initiate_audio": "Bejövő hívás", + "abandon_audio": "Nem fogadott hívás", + "initiate_video": "Bejövő videóhívás", + "abandon_video": "Nem fogadott videóhívás", + "chat_reaction": "DM reakció", + "group_chat_reaction": "Csoportos reakció", + "camera_roll_save": "Kamera tekercs mentése", + "speaking": "Beszél" + }, + "gallery_media_send_override": { + "ORIGINAL": "Eredeti", + "SNAP": "Snap", + "NOTE": "Hangüzenet", + "always_ask": "Mindig kérdezze meg" + }, + "hide_ui_components": { + "hide_chat_call_buttons": "Hívás gombok eltávolítása", + "hide_voice_record_button": "Hangfelvétel gomb eltávolítása", + "hide_profile_call_buttons": "Profil hívógombok eltávolítása", + "hide_unread_chat_hint": "Olvasatlan csevegési utalás eltávolítása", + "hide_stickers_button": "Matricák gomb eltávolítása", + "hide_live_location_share_button": "Élő helymegosztó gomb eltávolítása", + "hide_post_to_story_buttons": "A Snap elküldése előtti Post to Story gombok eltávolítása" + }, + "home_tab": { + "map": "Térkép", + "chat": "Chat", + "camera": "Kamera", + "discover": "Felfedezés", + "spotlight": "Spotlight" + }, + "disable_story_sections": { + "friends": "Barátok", + "following": "Követve", + "discover": "Felfedezés" + }, + "edit_text_override": { + "bypass_text_input_limit": "Szövegbeviteli korlát megkerülése", + "multi_line_chat_input": "Többsoros csevegés bemenet" + }, + "old_bitmoji_selfie": { + "2d": "2D bitmoji", + "3d": "3D bitmoji" + }, + "disable_confirmation_dialogs": { + "remove_friend": "Barát eltávolítása", + "block_friend": "Barát letiltása", + "ignore_friend": "Barát ignorálása", + "clear_conversation": "Beszélgetés törlése a Friend Feedből", + "hide_friend": "Barát elrejtése", + "hide_conversation": "Beszélgetés elrejtése", + "erase_message": "Üzenet Törlése" + }, + "auto_purge": { + "1_month": "1 hónap", + "3_months": "3 hónap", + "6_months": "6 hónap", + "3_hours": "3 óra", + "6_hours": "6 óra", + "12_hours": "12 óra", + "1_day": "1 nap", + "3_days": "3 nap", + "1_week": "1 hét", + "2_weeks": "2 hét", + "never": "Soha", + "1_hour": "1 óra" + }, + "bypass_video_length_restriction": { + "split": "Szétválasztott média", + "single": "Egyetlen média" + }, + "disable_permission_requests": { + "read_contacts": "Névjegyek elolvasása", + "camera": "Kamera", + "notifications": "Értesítések", + "read_media_images": "Médiaképek olvasása", + "nearby_devices": "Közeli eszközök", + "phone_calls": "Telefonhívások", + "read_media_video": "Média videó olvasása", + "microphone": "Mikrofon", + "location": "Helymeghatározás" + }, + "message_indicators": { + "encryption_indicator": "Hozzáad egy 🔒 ikont a csak neked küldött üzenetek mellé", + "director_mode_indicator": "Hozzáad egy ✏️ ikont a snaphez, ha azokat rendezői módban küldték el, amivel galériaképeket lehet snapeket elküldeni", + "platform_indicator": "Hozzáadja a platform ikonját, amelyről a médiát küldték (pl. Android, iOS, Web)", + "location_indicator": "Hozzáad egy 📍 ikont a snapekhez, ha a helymeghatározással lett elküldve", + "ovf_editor_indicator": "Jelzi, ha egy snapet küldtek az OVF szerkesztő segítségével" + }, + "hide_story_suggestions": { + "hide_my_stories": "Sztorijaim elrejtése", + "hide_suggested_friend_stories": "Javasolt barát sztorik elrejtése" + }, + "strip_media_metadata": { + "remove_audio_note_duration": "Hangüzenet időtartamának eltávolítása", + "remove_audio_note_transcript_capability": "Hangüzenet átírási képesség eltávolítása", + "hide_extras": "Extrák elrejtése (pl. említések)", + "hide_caption_text": "Képaláírás elrejtése", + "hide_snap_filters": "Snap szűrők elrejtése" + }, + "auto_reload": { + "snapchat_only": "Csak Snapchat", + "all": "Összes (Snapchat + SnapEnhance)" + }, + "disable_cameras": { + "front": "Előlapi kamera", + "back": "Hátlapi kamera" + }, + "path_format": { + "create_author_folder": "Mappa létrehozása minden szerző számára", + "create_source_folder": "Mappa létrehozása minden médiaforrás típushoz", + "append_hash": "Egyedi hash hozzáadása a fájlnévhez", + "append_source": "Adja hozzá a médiaforrást a fájlnévhez", + "append_username": "Adja hozzá a felhasználónevet a fájl nevéhez", + "append_date_time": "Adja hozzá a dátumot és az időt a fájl nevéhez" + }, + "add_friend_source_spoof": { + "added_by_community": "Közösség szerint", + "added_by_username": "Felhasználónév szerint", + "added_by_mention": "Említés szerint", + "added_by_group_chat": "Csoportos chat szerint", + "added_by_qr_code": "QR kód szerint", + "added_by_quick_add": "Gyors hozzáadásból (nagy esélye a kitiltásnak)" + }, + "auto_mark_as_read": { + "conversation_read": "Az üzenet küldésekor jelölje a beszélgetést olvasottként", + "snap_reply": "Azokat a Snap-eket jelölje olvasottnak, amelyekre válaszol" + }, + "friend_mutation_notifier": { + "birthday_changes": "Értesítés, ha valaki megváltoztatja a születésnapját", + "bitmoji_avatar_changes": "Értesítés, ha valaki megváltoztatja a Bitmoji avatarát", + "bitmoji_scene_changes": "Értesítés, ha valaki megváltoztatja a Bitmoji jelenetét", + "remove_friend": "Értesítés, ha valaki eltávolít téged barátként", + "bitmoji_selfie_changes": "Értesítés, ha valaki megváltoztatja a Bitmoji önarcképét", + "bitmoji_background_changes": "Értesítés, ha valaki megváltoztatja a Bitmoji háttérképét" + } + } + }, + "friend_menu_option": { + "preview": "Előnézet", + "stealth_mode": "Lopakodó Üzemmód", + "auto_download_blacklist": "Automatikus Letöltési Kivételek", + "anti_auto_save": "Automatikus Mentés Megakadályozása", + "mark_stories_as_seen_locally": "Megjelöli a látott sztorikat helyileg", + "mark_snaps_as_seen": "Snapek megjelölése látottként" + }, + "chat_action_menu": { + "preview_button": "Előnézet", + "download_button": "Letöltés", + "delete_logged_message_button": "Naplózott üzenet törlése", + "convert_message": "Üzenet átalakítása", + "edit_message": "Üzenet szerkesztése", + "show_chat_edit_history": "Chat szerkesztési előzmények megjelenítése" + }, + "opera_context_menu": { + "download": "Média letöltése", + "sent_at": "Elküldve {date}", + "created_at": "Létrehozva {date}", + "expires_at": "Lejár {date}", + "media_size": "Média mérete: {size}", + "media_duration": "Média hossza: {duration} mp", + "show_debug_info": "Hibakeresési infók megjelenítése" + }, + "modal_option": { + "profile_info": "Profil Infó", + "close": "Bezár" + }, + "gallery_media_send_override": { + "multiple_media_toast": "Egyszerre csak egy médiát küldhet" + }, + "conversation_preview": { + "streak_expiration": "Lejár {day} nap {hour} óra {minute} perc múlva", + "total_messages": "Összes kapott/küldött üzenetek: {count}", + "title": "Előnézet", + "unknown_user": "Ismeretlen felhasználó", + "no_messages": "Nincsenek üzenetek!" + }, + "profile_info": { + "title": "Profil Infó", + "mutable_username": "Némítható felhasználónév", + "display_name": "Megjelenített név", + "added_date": "Hozzáadás dátuma", + "birthday": "Születésnap : {month} {day}", + "friendship": "Barátság", + "add_source": "Forrás hozzáadása", + "snapchat_plus": "Snapchat Plusz", + "snapchat_plus_state": { + "subscribed": "Feliratkozva", + "not_subscribed": "Nincs feliratkozás" + }, + "hidden_birthday": "Születésnap : Rejtett", + "first_created_username": "Először létrehozott felhasználónév" + }, + "chat_export": { + "dialog_negative_button": "Mégsem", + "dialog_positive_button": "Exportálás", + "exported_to": "Exportálva ide {path}", + "exporting_chats": "Üzenetek exportálása...", + "processing_chats": "{amount} beszélgetések feldolgozása...", + "export_fail": "Nem sikerült exportálni a beszélgetést {conversation}", + "writing_output": "Kimenet írása...", + "finished": "Kész! Most már ezt bezárhatod.", + "no_messages_found": "Nincsenek üzenetek!", + "exporting_message": "Expotálás {conversation}...", + "exporter_dialog": { + "message_type_filter_title": "Üzenetek szűrése típus szerint", + "select_conversations_title": "Beszélgetések kiválasztása", + "text_field_selection": "{amount} kiválasztva", + "text_field_selection_all": "Összes", + "export_file_format_title": "Exportálási fájlformátum", + "download_medias_title": "Médiák letöltése", + "amount_of_messages_title": "Üzenetek száma (hagyd üresen az összesért)" + } + }, + "button": { + "ok": "OK", + "positive": "Igen", + "negative": "Nem", + "cancel": "Mégsem", + "open": "Megnyitás", + "download": "Letöltés" + }, + "profile_picture_downloader": { + "button": "Profilkép letöltése", + "title": "Profilkép letöltő", + "avatar_option": "Avatár", + "background_option": "Háttér" + }, + "download_processor": { + "attachment_type": { + "snap": "Snap", + "sticker": "Matrica", + "external_media": "Külső média", + "note": "Megjegyzés", + "original_story": "Eredeti történet", + "gif": "GIF" + }, + "select_attachments_title": "Válaszd ki a csatolmányokat", + "download_started_toast": "A letöltés megkezdődött", + "unsupported_content_type_toast": "A tartalom típusa nem támogatott!", + "failed_no_longer_available_toast": "A média már nem érhető el", + "no_attachments_toast": "Nem találhatóak csatolmányok!", + "already_queued_toast": "A média már várólistán van!", + "already_downloaded_toast": "Ez már le lett töltve!", + "download_toast": "Letöltés {path}...", + "processing_toast": "Feldolgozás {path}...", + "failed_generic_toast": "Letöltés sikertelen", + "failed_to_create_preview_toast": "Előnézet létrehozása sikertelen", + "failed_processing_toast": "Nem sikerült feldolgozni {error}", + "failed_gallery_toast": "Nem sikerült lementeni a galériába {error}", + "dash_dialog": { + "download_all": "Összes letötlése", + "segment_text": "Szegmens {from} - {to}", + "title": "Dash média letöltése" + }, + "dash_no_chapter": "Nincsenek fejezetek" + }, + "streaks_reminder": { + "notification_title": "Sorozatok", + "notification_text": "El fogod veszíteni a sorozatodat {friend}-el/al {hoursLeft} múlva" + }, + "material3_strings": { + "date_input_invalid_not_allowed": "Helytelen dátum", + "date_range_input_invalid_range_input": "Helytelen dátum intervallum", + "date_range_picker_day_in_range": "Kiválasztott", + "date_input_invalid_for_pattern": "Helytelen dátum", + "date_picker_today_description": "Ma", + "date_input_invalid_year_range": "Helytelen év", + "date_range_picker_start_headline": "Tőle", + "date_range_picker_title": "Dátumtartomány kiválasztása", + "date_picker_switch_to_calendar_mode": "Naptár", + "date_range_picker_end_headline": "Neki", + "date_picker_switch_to_input_mode": "Bemenet", + "date_range_picker_scroll_to_next_month": "Következő hónap", + "date_range_picker_scroll_to_previous_month": "Előző hónap" + }, + "media_download_source": { + "chat_media": "Beszélgetés média", + "merged": "Egyesített", + "story_logger": "Sztori naplózó", + "none": "Nincs", + "story": "Sztory", + "pending": "Függőben", + "spotlight": "Reflektorfény", + "message_logger": "Üzenetnaplózó", + "voice_call": "Hanghívás", + "profile_picture": "Profilkép", + "public_story": "Nyilvános sztori" + }, + "actions": { + "clean_snapchat_cache": { + "name": "Snapchat gyorsítótárának törlése", + "description": "Kitörli a Snapchat gyorsítótárját" + }, + "manage_friend_list": { + "name": "Barátlista kezelése", + "description": "Barátaid listájának importálása/exportálása biztonsági mentéskor" + }, + "export_chat_messages": { + "description": "Beszélgetési üzenetek exportálása JSON/HTML/TXT fájlba", + "name": "Chat üzenetek exportálása" + }, + "bulk_messaging_action": { + "description": "Olyan műveleteket végez, mint a barátok törlése vagy a beszélgetések tömeges törlése", + "name": "Tömeges üzenetküldési művelet" + }, + "regen_mappings": { + "name": "Leképezések újragenerálása", + "description": "Manuálisan regenerálja a leképezéseket" + }, + "change_language": { + "name": "Nyelv módosítása", + "description": "A SnapEnhance nyelvének módosítása" + }, + "export_memories": { + "name": "Emlékek exportálása", + "description": "Emlékek exportálása ZIP fájlba" + }, + "file_imports": { + "name": "Fájl importálások", + "description": "Importálj fájlokat, hogy a Snapchatben használd őket" + }, + "friend_tracker": { + "description": "Figyeld a barátaidat Snapchaten", + "name": "Barát figyelő" + }, + "logger_history": { + "name": "Naplózó előzmények", + "description": "Nézd meg az előményeket a naplózott üzenetekről" + } + }, + "content_type": { + "STICKER": "Matrica", + "STATUS_COUNTDOWN": "Visszaszámlálás", + "TINY_SNAP": "Apró Snap", + "STATUS_CONVERSATION_CAPTURE_RECORD": "Képernyőfelvétel", + "LOCATION": "Helyszín", + "NOTE": "Hangüzenet", + "CHAT": "Csevegés", + "SNAP": "Snap", + "STATUS": "Állapot", + "STATUS_CALL_MISSED_VIDEO": "Nem fogadott videóhívás", + "FAMILY_CENTER_ACCEPT": "Családi központ elfogadás", + "STATUS_PLUS_GIFT": "Status Plus ajándék", + "MAP_REACTION": "Térkép reakció", + "EXTERNAL_MEDIA": "Külső média", + "STATUS_CONVERSATION_CAPTURE_SCREENSHOT": "Képernyőkép", + "STATUS_CALL_MISSED_AUDIO": "Nem fogadott hanghívás", + "CREATIVE_TOOL_ITEM": "Kreatív eszköz elem", + "FAMILY_CENTER_INVITE": "Családi központ meghívó", + "FAMILY_CENTER_LEAVE": "Családi központ elhagyás", + "STATUS_SAVE_TO_CAMERA_ROLL": "Mentve a képeid közé", + "LIVE_LOCATION_SHARE": "Élő helymegosztás", + "SHARE": "Megosztás" + }, + "better_notifications": { + "button": { + "download": "Letöltés", + "mark_as_read": "Megjelölés olvasottként", + "reply": "Válasz" + } + }, + "half_swipe_notifier": { + "notification_content_group": "{friend} épp most ugrott be félig a {group}, {duration} másodpercre", + "notification_channel_name": "Fél húzás", + "notification_content_dm": "{friend} épp most ugrott be félig a chatedbe {duration} másodpercre" + }, + "mark_as_seen": { + "no_unseen_snaps_toast": "Nem találtunk nem látott Snapeket!", + "seen_toast": "Megjelölve látottként!", + "unseen_toast": "Megjelölve nem látottként!", + "already_seen_toast": "Már meg lett jelölve látottként!", + "already_unseen_toast": "Már meg lett jelölve nem látottként!" + }, + "friendship_link_type": { + "mutual": "Kölcsönös", + "outgoing": "Kimenő", + "deleted": "Törölve", + "blocked": "Letiltva", + "suggested": "Javasolt", + "incoming": "Bejövő", + "incoming_follower": "Bejövő követő", + "following": "Követve" + }, + "bulk_messaging_action": { + "selection_dialog_continue_button": "Folytatás", + "progress_status": "{index} feldolgozása {total}-ból", + "confirmation_dialog": { + "message": "Ez az összes kiválasztott barátod érinti. Ezt a műveletet nem lehet visszacsinálni.", + "title": "Biztos vagy benne?" + }, + "choose_action_title": "Művelet kiválasztása", + "actions": { + "remove_friends": "Barátok eltávolítása", + "clear_conversations": "Világos beszélgetések" + } + }, + "call_start_confirmation": { + "dialog_title": "Hívás indítása", + "dialog_message": "Biztos, hogy hívást akarsz indítani?" + }, + "scopes": { + "friend": "Barát", + "group": "Csoport" + }, + "end_to_end_encryption": { + "toolbox": { + "shared_key_fingerprint": "Az ujjlenyomatod:\n\n{fingerprint}\n\nEllenőrizd, hogy egyezik-e a barátod ujjlenyomatával!", + "no_shared_key": "Ezzel a barátoddal még nincs közös titkotok. Kattints az alábbi gombra, hogy új titok létrehozását kezdeményezd.", + "initiate_exchange_button": "Kulcscsere kezdeményezése" + }, + "confirmation_dialogs": { + "confirmation_1": "FIGYELMEZTETÉS: Ez felülírja a meglévő kulcsot. Elveszted a hozzáférést az összes titkosított üzenethez ezzel a barátoddal. Biztos, hogy folytatni akarod?", + "confirmation_2": "TÉNYLEG biztos vagy benne, hogy folytatni akarod? Ez az utolsó esélyed, hogy visszalépj.", + "title": "Végpontok közti titkosítás" + }, + "unencrypted_conversation_send_failure_toast": "Nem küldhetsz titkosított tartalmat titkosított és titkosítatlan beszélgetésekbe egyaránt!", + "accept_public_key_failure_toast": "Nem sikerült elfogadni a publikus kulcsot", + "accept_secret_key_failure_toast": "Nem sikerült elfogadni a titkos kulcsot", + "accept_public_key_button": "Publikus kulcs elfogadása", + "outgoing_pk_message": "Kulcs cserélési kérelem", + "incoming_pk_message": "Kaptál egy publikus kulcs kérelmet. Kattints lejjeb az elfogadáshot.", + "native_hooks_send_failure_toast": "Nem sikerült elküldeni! Kérlek, engedélyezd a Natív hookok használatát a beállításokban.", + "no_participants_to_encrypt_toast": "Nincsenek barátaid ebben a beszélgetésben, akikkel üzeneteket titkosíthatnál!", + "encryption_failed_toast": "Nem sikerült titkosítani az üzenetet! További részletekért nézd meg a logcat-et.", + "accept_public_key_success_toast": "Publikus kulcs elfogadva!", + "accept_secret_key_success_toast": "Siker! Most már küldhetsz és fogadhatsz titkosított üzeneteket ezzel a barátoddal.", + "accept_secret_button": "Titok elfogadása", + "outgoing_secret_message": "Kulcs cserélésre a válasz", + "incoming_secret_message": "A barátod elfogadta a publikus kulcsot. Kattints lejjebb az elfogadáshoz." + }, + "friend_mutation_observer": { + "bitmoji_avatar_changed": "{username} megváltoztatta a Bitmoji avatarát", + "friend_removed": "{username} eltávolított téged barátként", + "birthday_changed": "{username} megváltoztatta a születésnapját: {oldBirthday} -ról {newBirthday} -re", + "bitmoji_scene_changed": "{username} megváltoztatta a Bitmoji-jelenetét", + "notification_channel_name": "Barát Mutáció Megfigyelő", + "birthday_removed": "{username} eltávolította a születésnapját ({birthday})", + "birthday_added": "{username} hozzáadta a születésnapját ({birthday})", + "bitmoji_selfie_changed": "{username} megváltoztatta a Bitmoji önarcképét", + "bitmoji_background_changed": "{username} megváltoztatta a Bitmoji háttérképét" + }, + "biometric_auth": { + "unlock_button": "Feloldás", + "title": "Snapchat feloldása", + "subtitle": "Kérlek, azonosítsd magad a Snapchat feloldásához" + }, + "auto_open_snaps": { + "title": "Automatikus Snaps Megnyitás", + "notification_content": "{count} Snaps megnyitva" + } +} diff --git a/common/src/main/assets/lang/id.json b/common/src/main/assets/lang/id.json new file mode 100644 index 0000000000..b8c54cb06d --- /dev/null +++ b/common/src/main/assets/lang/id.json @@ -0,0 +1,269 @@ +{ + "setup": { + "dialogs": { + "select_language": "Pilih bahasa", + "save_folder": "SnapEnhance memerlukan izin Penyimpanan untuk mengunduh dan Menyimpan Media dari Snapchat.\nSilakan pilih lokasi di mana media harus diunduh.", + "select_save_folder_button": "Pilih folder" + }, + "mappings": { + "dialog": "Menghasilkan pemetaan, ini mungkin memakan waktu beberapa saat ...", + "generate_failure_no_snapchat": "SnapEnhance tidak dapat mendeteksi Snapchat, coba instal ulang Snapchat.", + "generate_failure": "Terjadi kesalahan saat mencoba membuat pemetaan, silakan coba lagi." + }, + "permissions": { + "dialog": "Untuk melanjutkan, Anda harus memenuhi persyaratan berikut:", + "notification_access": "Akses Notifikasi", + "battery_optimization": "Optimasi Baterai", + "display_over_other_apps": "Tampilkan Di Atas Aplikasi Lain", + "request_button": "Meminta" + } + }, + "manager": { + "routes": { + "features": "Fitur", + "home": "Rumah", + "home_settings": "Pengaturan", + "home_logs": "Log", + "social": "Sosial", + "scripts": "Skrip", + "tasks": "Tugas", + "manage_rule_feature": "Kelola fitur aturan", + "manage_scope": "Kelola ruang lingkup", + "file_imports": "Impor file", + "messaging_preview": "Pratinjau", + "better_location": "Lokasi yang lebih baik", + "friend_tracker": "Pelacak teman", + "edit_rule": "Edit aturan", + "logger_history": "Riwayat logger", + "logged_stories": "Cerita yang dicatat", + "theming": "Tema", + "edit_theme": "Edit Tema", + "manage_repos": "Kelola repositori" + }, + "sections": { + "features": { + "disabled": "Lepas", + "export_option": "Ekspor", + "import_option": "Impor", + "reset_option": "Ulang awal" + }, + "better_location": { + "search_bar": "Cari", + "latitude_dialog_hint": "Lintang", + "save_dialog_button": "Simpan" + }, + "home_settings": { + "actions_title": "Aksi", + "debug_title": "Debugg", + "success_toast": "Selesai!", + "export_button": "Ekspor", + "clear_button": "Bersihkan" + }, + "manage_scope": { + "streaks_expiration_text_expired": "Kadaluarsa", + "rules_title": "Aturan" + }, + "home": { + "update_button": "Unduhan" + }, + "social": { + "empty_hint": "(kosong)", + "friends_tab": "Teman²", + "groups_tab": "Grup²" + } + }, + "dialogs": { + "add_friend": { + "category_groups": "Grup", + "category_friends": "Teman-teman", + "search_hint": "Pencarian" + }, + "scripting_warning": { + "title": "Bahaya" + } + } + }, + "features": { + "properties": { + "downloader": { + "properties": { + "ffmpeg_options": { + "properties": { + "threads": { + "name": "Utas" + } + } + } + }, + "name": "Download" + }, + "camera": { + "name": "Kamera" + }, + "rules": { + "name": "Garis" + }, + "scripting": { + "name": "Skrip" + }, + "experimental": { + "properties": { + "spoof": { + "name": "Tipuan" + } + } + }, + "global": { + "properties": { + "better_location": { + "properties": { + "coordinates": { + "name": "Kordinat" + } + } + } + } + } + }, + "options": { + "logging": { + "started": "Dimulai", + "success": "Berhasil", + "progress": "Proses", + "failure": "Kegagalan" + }, + "auto_download_sources": { + "spotlight": "Menyoroti" + }, + "notifications": { + "chat": "Obrolan", + "snap": "snap", + "stories": "Cerita", + "typing": "Mengetik", + "chat_screenshot": "Tangkapan Layar", + "speaking": "Berbicara" + }, + "home_tab": { + "map": "Map", + "chat": "Obrolan", + "spotlight": "Menyoroti", + "camera": "Kamera", + "discover": "Tampilan" + }, + "gallery_media_send_override": { + "SNAP": "snap" + }, + "disable_story_sections": { + "following": "Mengikuti", + "friends": "Teman²", + "discover": "Tampilan" + }, + "disable_permission_requests": { + "location": "Lokasi", + "camera": "Kamera", + "microphone": "Mikropon", + "notifications": "Notifikasi" + }, + "auto_purge": { + "never": "Tidak Pernah" + } + } + }, + "scopes": { + "friend": "Teman", + "group": "Grup" + }, + "content_type": { + "SNAP": "Jepret", + "STATUS": "Cerita", + "LOCATION": "Lokasi", + "STATUS_COUNTDOWN": "Hitung mundur", + "CHAT": "Obrolan", + "STICKER": "Stiker", + "SHARE": "Bagikan", + "STATUS_CONVERSATION_CAPTURE_SCREENSHOT": "Tangkapan layar" + }, + "material3_strings": { + "date_range_picker_day_in_range": "Terpilih", + "date_range_picker_end_headline": "Untuk", + "date_picker_switch_to_calendar_mode": "Kalender", + "date_picker_switch_to_input_mode": "Masukan", + "date_picker_today_description": "Hari ini", + "date_range_picker_start_headline": "Dari" + }, + "button": { + "positive": "Iya", + "open": "Buka", + "cancel": "Batal", + "download": "Unduhan", + "ok": "Oke", + "negative": "Tidak" + }, + "biometric_auth": { + "unlock_button": "Buka Kunci" + }, + "conversation_preview": { + "title": "Pratinjau" + }, + "download_processor": { + "attachment_type": { + "gif": "Gifs", + "sticker": "Stiker", + "note": "Catatan", + "snap": "Jepret" + } + }, + "friend_menu_option": { + "preview": "Pratinjau" + }, + "modal_option": { + "close": "Tutup" + }, + "chat_action_menu": { + "preview_button": "Pratinjau", + "download_button": "Unduhan" + }, + "media_download_source": { + "none": "Tidak ada", + "merged": "Digabung", + "pending": "Ditunda", + "story": "Cerita", + "spotlight": "Sorotan" + }, + "friendship_link_type": { + "blocked": "Diblokir", + "suggested": "Disarankan", + "mutual": "Saling", + "deleted": "Dihapus", + "incoming": "Akan datang", + "following": "Mengikuti" + }, + "better_notifications": { + "button": { + "download": "Unduhan", + "reply": "Balas" + } + }, + "chat_export": { + "exporter_dialog": { + "text_field_selection_all": "Semua" + }, + "dialog_positive_button": "Ekspor", + "dialog_negative_button": "Batal" + }, + "profile_info": { + "snapchat_plus_state": { + "subscribed": "Berlangganan" + }, + "friendship": "Persahabatan" + }, + "bulk_messaging_action": { + "selection_dialog_continue_button": "Lanjutkan" + }, + "streaks_reminder": { + "notification_title": "Garis -garis" + }, + "profile_picture_downloader": { + "background_option": "Latar belakang" + } +} diff --git a/common/src/main/assets/lang/it_IT.json b/common/src/main/assets/lang/it_IT.json new file mode 100644 index 0000000000..6c3f5ea2cc --- /dev/null +++ b/common/src/main/assets/lang/it_IT.json @@ -0,0 +1,139 @@ +{ + "friend_menu_option": { + "preview": "Anteprima", + "stealth_mode": "Modalità Stealth", + "auto_download_blacklist": "Scarica Automaticamente la Lista Nera", + "anti_auto_save": "Anti-Salvataggio Automatico" + }, + "chat_action_menu": { + "preview_button": "Anteprima", + "download_button": "Scarica", + "delete_logged_message_button": "Elimina Messaggio Caricato" + }, + "opera_context_menu": { + "download": "Scarica Media" + }, + "modal_option": { + "profile_info": "Info Profilo", + "close": "Chiudi" + }, + "gallery_media_send_override": { + "multiple_media_toast": "Puoi inviare soltanto un media per volta" + }, + "conversation_preview": { + "streak_expiration": "scade tra {day} giorni, {hour} ore, {minute} minuti", + "total_messages": "Messaggi inviati/ricevuti totali: {count}", + "title": "Anteprima", + "unknown_user": "Utente Sconosciuto" + }, + "profile_info": { + "title": "Info sul Profilo", + "display_name": "Nome Visualizzato", + "added_date": "Data di Aggiunta", + "birthday": "Compleanno: {day} {month}" + }, + "chat_export": { + "dialog_negative_button": "Annulla", + "dialog_positive_button": "Esporta", + "exported_to": "Esportato a {path}", + "exporting_chats": "Esportando le Chat...", + "processing_chats": "Elaborando {amount} conversazioni...", + "export_fail": "Impossibile esportare la conversazione {conversation}", + "writing_output": "Scrivendo il risultato...", + "finished": "Fatto! Ora puoi chiudere questa finestra.", + "no_messages_found": "Nessun messaggio trovato!", + "exporting_message": "Esportando {conversation}..." + }, + "button": { + "ok": "OK", + "positive": "Sì", + "negative": "No", + "cancel": "Annulla", + "open": "Apri" + }, + "download_processor": { + "download_started_toast": "Download avviato", + "unsupported_content_type_toast": "Tipo di contenuto non supportato!", + "failed_no_longer_available_toast": "Media non più disponibile", + "already_queued_toast": "Media già in coda!", + "already_downloaded_toast": "Media già scaricato!", + "download_toast": "Scaricando {path}...", + "processing_toast": "Elaborando {path}...", + "failed_generic_toast": "Impossibile scaricare", + "failed_to_create_preview_toast": "Impossibile creare l'anteprima" + }, + "manager": { + "sections": { + "social": { + "streaks_expiration_short": "{hours}h" + }, + "tasks": { + "no_tasks": "Nessuna attività", + "failed_to_open_file": "Errore nell'apertura del file" + }, + "features": { + "disabled": "Disabilitato", + "export_option": "Esporta", + "import_option": "Importa", + "config_import_success_toast": "Configurazione importata con successo", + "saved_config_snackbar": "Configurazione salvata" + }, + "home_settings": { + "success_toast": "Fatto!", + "clear_button": "Pulisci", + "debug_title": "Debug", + "actions_title": "Azioni", + "export_button": "Esporta" + }, + "home": { + "update_button": "Scarica", + "quick_actions_title": "Azioni rapide", + "update_title": "Aggiorna SnapEnhance", + "update_content": "La versione {version} è disponibile!" + }, + "manage_rule_feature": { + "disable_state_option": "Disabilitato", + "whitelist_state_option": "Nessuno eccetto..." + } + }, + "routes": { + "tasks": "Attività", + "home_logs": "Log", + "home_settings": "Impostazioni", + "social": "Social", + "logged_stories": "Log registrati", + "messaging_preview": "Anteprima", + "features": "Caratteristiche", + "home": "Home", + "logger_history": "Cronologia Log", + "better_location": "Migliora la geolocalizzazione", + "file_imports": "Importa file", + "edit_rule": "Modifica regola", + "scripts": "Scripts", + "edit_theme": "Modifica Tema" + } + }, + "setup": { + "mappings": { + "dialog": "Per funzionare dinamicamente con un'ampia gamma di versioni di Snapchat, la mappatura è necessaria per far funzionare correttamente SnapEnhance, questo non dovrebbe durare più di 5 secondi.", + "generate_failure_no_snapchat": "SnapEnhance non è in grado di rilevare Snapchat, per favore prova a reinstallare Snapchat.", + "generate_failure": "C'è stato un errore durante la generazione della mappatura, prova di nuovo." + }, + "dialogs": { + "select_save_folder_button": "Seleziona cartella", + "select_language": "Seleziona la lingua", + "save_folder": "SnapEnhance richiede i permessi di archiviazione per scaricare e salvare i file da Snapchat.\nScegli una cartella dove salvare i file." + }, + "permissions": { + "dialog": "Per continuare devi rispettare i seguenti requisiti:", + "notification_access": "Accesso alle notifiche", + "battery_optimization": "Ottimizzazione batteria", + "display_over_other_apps": "Mostra sopra altre app", + "request_button": "Richiedi" + } + }, + "scopes": { + "friend": "Amico", + "group": "Gruppo" + } +} diff --git a/common/src/main/assets/lang/ja_JP.json b/common/src/main/assets/lang/ja_JP.json new file mode 100644 index 0000000000..651c55d1aa --- /dev/null +++ b/common/src/main/assets/lang/ja_JP.json @@ -0,0 +1,29 @@ +{ + "setup": { + "dialogs": { + "save_folder": "SnapEnhanceは、Snapchatからメディアをダウンロードして保存するためにストレージ許可が必要です。\nメディアをダウンロードする場所を選択してください。", + "select_save_folder_button": "フォルダを選択" + }, + "mappings": { + "dialog": "スナップチャットのさまざまなバージョンを動的にサポートするためには、SnapEnhanceが正常に機能するためには、マッピングが必要です。これには5秒以上かかりません。" + } + }, + "manager": { + "dialogs": { + "scripting_warning": { + "content": "SnapEnhance にはスクリプト ツールが含まれており、デバイス上でユーザー定義のコードを実行できます。 細心の注意を払い、既知の信頼できるソースからのみモジュールをインストールしてください。 未承認または未検証のモジュールは、システムにセキュリティ上のリスクをもたらす可能性があります。" + } + } + }, + "features": { + "properties": { + "experimental": { + "properties": { + "account_switcher": { + "description": "アカウントを切り替えることができますログアウトせずに Bitmoji プロフィールの横にある検索アイコンを長押ししてメニューを開きます 注: この機能は実験的なものであり、将来変更される可能性があります" + } + } + } + } + } +} diff --git a/common/src/main/assets/lang/ko_KR.json b/common/src/main/assets/lang/ko_KR.json new file mode 100644 index 0000000000..679ee5de80 --- /dev/null +++ b/common/src/main/assets/lang/ko_KR.json @@ -0,0 +1,17 @@ +{ + "setup": { + "dialogs": { + "select_save_folder_button": "폴더 선택", + "select_language": "언어 선택", + "save_folder": "SnapEnhance에는 Snapchat에서 미디어를 다운로드하고 저장하려면 저장소 권한이 필요합니다.\n미디어를 다운로드할 위치를 선택하세요." + }, + "mappings": { + "dialog": "매핑을 생성하는 중입니다. 다소 시간이 걸릴 수 있습니다...", + "generate_failure_no_snapchat": "SnapEnhance가 Snapchat을 감지할 수 없습니다. Snapchat을 다시 설치해 보십시오.", + "generate_failure": "매핑을 생성하는 중에 오류가 발생했습니다. 다시 시도해 주세요." + }, + "permissions": { + "dialog": "계속하려면 다음 요구 사항을 충족해야 합니다." + } + } +} diff --git a/common/src/main/assets/lang/la.json b/common/src/main/assets/lang/la.json new file mode 100644 index 0000000000..96fd731aed --- /dev/null +++ b/common/src/main/assets/lang/la.json @@ -0,0 +1,239 @@ +{ + "manager": { + "routes": { + "edit_rule": "Rulus scribere", + "features": "Posse", + "tasks": "Scribere", + "home": "Villa", + "home_settings": "Deus maximae", + "home_logs": "Papyrus", + "social": "Socius", + "logger_history": "Logger History", + "logged_stories": "Initium Stories", + "friend_tracker": "Amicus Tracker", + "messaging_preview": "Praevius", + "scripts": "Scriptores", + "manage_scope": "Curo Scope" + }, + "sections": { + "home": { + "update_title": "SnapEnhance Update", + "update_content": "Versione {version} praesto est!", + "update_button": "Download" + }, + "home_logs": { + "clear_logs_button": "Patet Acta publica omnia", + "export_logs_button": "Acta publica exportare", + "saved_logs_success_toast": "Acta servata feliciter", + "saved_logs_failure_toast": "Non servare omnia", + "no_logs_hint": "Nulla omnia praesto", + "saving_logs_toast": "Salvis lignis, hoc potest capere aliquantulum..." + }, + "home_settings": { + "actions_title": "Actiones", + "message_logger_title": "Nuntius Logger", + "debug_title": "Debugere", + "success_toast": "Finis!", + "message_logger_summary": "{messageCount} nuntia\n{storyCount} fabulas", + "export_button": "Exportare", + "clear_button": "Patet", + "view_logger_history_button": "View Logger History" + }, + "tasks": { + "no_tasks": "Non opus", + "merge_files_toast": "Bus {numerare} imagini", + "remove_selected_tasks_title": "Certus esne munus electum tollere?", + "delete_files_option": "Etiam delete lima", + "remove_selected_tasks_confirm": "Aufer opera {numerare}?", + "remove_all_tasks_confirm": "Omnia opera amove?", + "remove_all_tasks_title": "Certus esne munus electum tollere?" + }, + "features": { + "disabled": "debilitatum", + "export_option": "Exportare", + "import_option": "Importare", + "reset_option": "Reset", + "config_export_success_toast": "Mando emitur feliciter", + "saved_config_snackbar": "Mando salvus", + "config_import_success_toast": "Mando importari feliciter", + "config_import_failure_toast": "Deficio ad config {errorem} importare" + }, + "social": { + "friends_tab": "amici", + "groups_tab": "Groups", + "empty_hint": "(vacuus)", + "streaks_expiration_short": "{horas} h" + }, + "manage_scope": { + "e2ee_title": "Finis-ad-finem Encryption", + "rules_title": "Rulese", + "logged_stories_button": "Show Logged Storiese", + "participants_text": "{numerare} participantium", + "not_found": "Non inveni", + "streaks_title": "Striae", + "streaks_length_text": "Longitudo: {length}", + "streaks_expiration_text": "Expirat in {eta}", + "streaks_expiration_text_expired": "Expiratus", + "reminder_button": "Set Lorem", + "delete_scope_confirm_dialog_title": "Visne delere a {scope}?" + }, + "logged_stories": { + "story_failed_to_load": "Deficio ad onus", + "no_stories": "Non fabulas invenerunt", + "save_from_cache_button": "Nisi ex Cache" + }, + "messaging_preview": { + "bridge_connection_failed": "Defecit coniungere ad Snapchat per pontem ministerium", + "bridge_init_failed": "Non initialize quantitatem pontis", + "message_fetch_failed": "Deficio arcessere", + "no_message_hint": "Nulla nuntius", + "save_selection_option": "Salvum Electio", + "unsave_selection_option": "Electio unsave", + "unsave_all_option": "Invidere All", + "mark_selection_as_seen_option": "Mark lectus Snap ut videtur", + "mark_all_as_seen_option": "Nota omnia Snaps ut videtur", + "save_all_option": "Servare All" + } + }, + "dialogs": { + "scripting_warning": { + "content": "SnapEnhance instrumentum scriptionis includit, supplicium usoris definiti codicis in fabrica permittens. Summa cautione utere et tantum modulos ex notis, certis auctoribus institue. Moduli non legitimi vel non probati possunt pericula securitatis in ratiocinatione tua ponere." + } + } + }, + "setup": { + "dialogs": { + "select_language": "Lingua cupere", + "select_save_folder_button": "Ordnerus cupere", + "save_folder": "SnapEnhance requirit PRAECLUSIO permissiones ut download et salvum Media ex Snapchat.\nElige locum ubi instrumentorum communicationis socialis debet esse recepta." + }, + "permissions": { + "notification_access": "Nervus alloware", + "battery_optimization": "Akkutis perfectio", + "display_over_other_apps": "Druber cuper", + "request_button": "Voluntas", + "dialog": "Pergere debes aptare sequentia requisita:" + }, + "mappings": { + "dialog": "Generating Mappings, hoc modicum tempus capere potest.", + "generate_failure_no_snapchat": "SnapEnhance Snapchat deprehendere potuit, Aliquam Snapchat reinstalling.", + "generate_failure": "Error occurrit dum mappings generare conatur, iterum conare." + } + }, + "scopes": { + "friend": "Amicus", + "group": "Magnas" + }, + "features": { + "properties": { + "downloader": { + "properties": { + "custom_path_format": { + "description": "Denota est consuetudo semita forma pro media downloaded\n\nPraesto variabiles:\n - %username%\n - %source%\n - %hash%\n - %date_time%" + }, + "download_context_menu": { + "description": "Permittit te ut nuntia ex colloquio vel historia utens tabulas contexta notas extrahere/praevisum.\nLong torcular in bullarum et opprimere download" + }, + "opera_download_button": { + "description": "Bullam download in summo angulo dextro addit cum Snap spectabat.\nLong torcular in bullarum et opprimere download" + } + } + }, + "user_interface": { + "properties": { + "hide_friend_feed_entry": { + "description": "Amicus certus in amicum ab amico Feed\nUtere socialis tab administrare haec factura" + } + } + }, + "experimental": { + "properties": { + "e2ee": { + "description": "Encryptas epistulas tuas cum AES clavis secreto communi utens\nFac ut clavem tuam alicubi incolumem serves!", + "properties": { + "force_message_encryption": { + "description": "Prohibet mittens nuntios encrypted ad eos qui non habent E2E Encryption solum cum multiplex colloquia eliguntur" + } + } + }, + "account_switcher": { + "description": "Permittit ut commutandum inter rationes sine colligationem e\nLongum premunt in icone investigationis proximam ad profile Bitmoji ut tabulam aperiant\nNota: Haec factura experimentalis est et in futuro verisimile mutatio est" + }, + "custom_streaks_expiration_format": { + "description": "Virgae Expiration forma customizes\n\nPraesto variabiles:\n - %c: Virgae Comitis\n - %e: Hourglass Emoji\n - %d: Diebus\n - % h: Horae\n - %m: Minutes\n - %s: Secundi\n -% w: Reliquis Tempus" + }, + "best_friend_pinning": { + "description": "Permittit tibi amicum suspendere, ut numero unus amicus. Nota: Tantum potes videre te amicissimo confixi" + }, + "meo_passcode_bypass": { + "description": "Bypass in oculis meis tantum passcode\nHoc modo operatur, si passcode ante recte ingressus est" + } + } + }, + "global": { + "properties": { + "hide_active_music": { + "description": "Prohibet Snapchat scire cupis musicam audire\nHoc tibi permittet ut globuli volumen imperium adhibeas snaps ut musicam audias" + }, + "media_upload_quality": { + "properties": { + "force_video_upload_source_quality": { + "description": "Copiae Snapchat utendi fons qualis est cum uploading videos\nNota quaeso quod hoc metadata ex instrumentis non removeat" + }, + "custom_image_upload_format": { + "description": "Morem imaginis onerationis\nformat\nSelect a lossless format (sicut PNG)\npro optima qualitate" + } + } + }, + "video_playback_rate_slider": { + "description": "Lapsus lapsus addit in menu contextus operae ut ratem video playback mutare\nNota: Mutationes tantum applicare ad subsequent videos" + }, + "better_location": { + "properties": { + "spoof_battery_level": { + "description": "Spoofs pila gradu tuom in tabula\nValorem debet esse inter 0 et 100" + } + } + } + } + }, + "camera": { + "properties": { + "custom_resolution": { + "description": "Consuetudo constituit cameram solutionis, latitudinis x altitudinis (v.g. 1920x1080).\nConsuetudo resolutio tuom sustentari debet" + }, + "immersive_camera_preview": { + "description": "Prohibet Snapchat ex tondentes Camerae preview\nHoc faciat cameram flammis quibusdam machinis" + } + } + }, + "messaging": { + "properties": { + "better_notifications": { + "properties": { + "stacked_media_messages": { + "description": "Multa media nuntia in unum textum notificatio coniungit cum praevideri non possunt. Utere respectu Chat Praevius" + } + } + }, + "bypass_message_action_restrictions": { + "description": "Permittit te agere cum frangeretur sine aperto aut nuntio inexplicabili salvare" + } + } + } + }, + "options": { + "message_indicators": { + "director_mode_indicator": "Iconem addit ad tortum cum missis utens Modus Directoris, qui adhiberi potest imagines gallery mittere ut tortum." + } + } + }, + "end_to_end_encryption": { + "confirmation_dialogs": { + "confirmation_1": "MONITUM: Haec rescribere clavem tuam existentem. Accessum solves ad omnes epistulas encryptas ab hoc amico. Certus esne vis permanere?" + }, + "toolbox": { + "no_shared_key": "Secretum communi cum amico nondum habes. Preme infra novam inchoare." + } + } +} diff --git a/common/src/main/assets/lang/lv_LV.json b/common/src/main/assets/lang/lv_LV.json new file mode 100644 index 0000000000..c9054f2530 --- /dev/null +++ b/common/src/main/assets/lang/lv_LV.json @@ -0,0 +1,44 @@ +{ + "setup": { + "mappings": { + "generate_failure_no_snapchat": "SnapEnhance nespēja atrast Snapchat, lūdzu, mēģiniet atkārtoti instalēt Snapchat.", + "dialog": "Lai dinamiski atbalstītu plašu Snapchat versiju klāstu, lai SnapEnhance darbotos pareizi, ir nepieciešamas kartēšanas, un tas neaizņems vairāk par 5 sekundēm.", + "generate_failure": "Mēģinot ģenerēt kartēšanu, radās kļūda, lūdzu, mēģiniet vēlreiz." + }, + "dialogs": { + "select_save_folder_button": "Atlasiet mapi", + "select_language": "Atlasiet valodu", + "save_folder": "SnapEnhance ir nepieciešamas glabāšanas atļaujas, lai lejupielādētu un saglabātu multivides no Snapchat.\nLūdzu, izvēlieties atrašanās vietu, kur multivides līdzekļi jālejupielādē." + }, + "permissions": { + "dialog": "Lai turpinātu, jums ir jāatbilst šādām prasībām:", + "notification_access": "Paziņojuma piekļuve", + "battery_optimization": "Akumulatora optimizācija", + "display_over_other_apps": "Displejs virs citām lietotnēm", + "request_button": "Pieprasījums" + } + }, + "manager": { + "routes": { + "logged_stories": "Story Žurnāls", + "features": "Funkcijas", + "home": "Sākums", + "home_settings": "Iestatijumi", + "logger_history": "Žurnāla Vēsture", + "home_logs": "Žurnāli", + "tasks": "Uzdevumi", + "social": "Sociāls" + }, + "sections": { + "home": { + "update_title": "SnapEnhance atjauninājums", + "update_content": "Versija {version} ir pieejama!", + "update_button": "Lejuplādēt" + } + } + }, + "scopes": { + "friend": "Draugs", + "group": "Grupa" + } +} diff --git a/common/src/main/assets/lang/ml_IN.json b/common/src/main/assets/lang/ml_IN.json new file mode 100644 index 0000000000..9d5a8eddb0 --- /dev/null +++ b/common/src/main/assets/lang/ml_IN.json @@ -0,0 +1,937 @@ +{ + "rules": { + "properties": { + "stealth": { + "options": { + "blacklist": "സ്റ്റെൽത്ത് മോഡിൽ നിന്ന് ഒഴിവാക്കുക", + "whitelist": "സ്റ്റെൽത്ത് മോഡ്" + }, + "description": "നിങ്ങൾ അവരുടെ സ്നാപ്പുകൾ/ചാറ്റുകൾ, സംഭാഷണങ്ങൾ എന്നിവ തുറന്നിട്ടുണ്ടെന്ന് അറിയുന്നതിൽ നിന്ന് ആരെയും തടയുന്നു", + "name": "സ്റ്റെൽത്ത് മോഡ്" + }, + "auto_download": { + "options": { + "whitelist": "സ്വയമേവ ഡൗൺലോഡ് ചെയ്യുന്നു", + "blacklist": "ഓട്ടോ ഡൗൺലോഡിൽ നിന്ന് ഒഴിവാക്കുക" + }, + "description": "സ്നാപ്പുകൾ കാണുമ്പോൾ അവ സ്വയമേവ ഡൗൺലോഡ് ചെയ്യുക", + "name": "യാന്ത്രിക ഡൗൺലോഡ്" + }, + "auto_save": { + "name": "സ്വയമേവ സംരക്ഷിക്കുക", + "description": "ചാറ്റ് സന്ദേശങ്ങൾ കാണുമ്പോൾ അവ സംരക്ഷിക്കുന്നു", + "options": { + "blacklist": "സ്വയമേവ സംരക്ഷിക്കുന്നതിൽ നിന്ന് ഒഴിവാക്കുക", + "whitelist": "സ്വയമേവ സംരക്ഷിക്കുക" + } + }, + "e2e_encryption": { + "name": "E2E എൻക്രിപ്ഷൻ ഉപയോഗിക്കുക" + }, + "pin_conversation": { + "name": "സംഭാഷണം പിൻ ചെയ്യുക" + }, + "unsaveable_messages": { + "name": "സംരക്ഷിക്കാൻ കഴിയാത്ത സന്ദേശങ്ങൾ", + "options": { + "blacklist": "സംരക്ഷിക്കാനാകാത്ത സന്ദേശങ്ങളിൽ നിന്ന് ഒഴിവാക്കുക", + "whitelist": "സംരക്ഷിക്കാൻ കഴിയാത്ത സന്ദേശങ്ങൾ" + }, + "description": "മറ്റ് ആളുകൾ ചാറ്റിൽ സന്ദേശങ്ങൾ സംരക്ഷിക്കുന്നത് തടയുന്നു" + }, + "hide_friend_feed": { + "name": "ഫ്രണ്ട് ഫീഡിൽ നിന്ന് മറയ്ക്കുക" + } + }, + "modes": { + "blacklist": "ബ്ലാക്ക്‌ലിസ്റ്റ് മോഡ്", + "whitelist": "വൈറ്റ്‌ലിസ്റ്റ് മോഡ്" + } + }, + "manager": { + "routes": { + "home_logs": "രേഖകൾ", + "scripts": "സ്ക്രിപ്റ്റുകൾ", + "home_settings": "ക്രമീകരണങ്ങൾ", + "features": "ഫീച്ചറുകൾ", + "home": "വീട്", + "tasks": "ചുമതലകൾ", + "social": "സാമൂഹിക", + "messaging_preview": "മുഴുവനായും" + }, + "sections": { + "social": { + "streaks_expiration_short": "{hours}h" + }, + "features": { + "disabled": "അപ്രാപ്തമാക്കി" + }, + "tasks": { + "no_tasks": "ജോലികളൊന്നുമില്ല" + }, + "home": { + "update_content": "വേർഷൻ {version} ഇപ്പോൾ ലഭ്യമാണ്", + "update_title": "സ്നാപ്ഇൻഹാൻസ് പുതുക്കൽ", + "update_button": "ശേഖരിക്കുക" + }, + "home_logs": { + "no_logs_hint": "ലോഗ്സ് ലഭ്യമല്ല", + "saving_logs_toast": "ലോഗുകൾ സേവ് ചെയുന്നു, അൽപ്പ സമയം കാത്തിരിക്കുക", + "clear_logs_button": "ക്ലിയർ ലോഗ്‌സ്", + "export_logs_button": "എക്സ്പോർട്ട് ലോക്‌സ്", + "saved_logs_success_toast": "വിജയകരമായി ലോഗ്‌സ് സേവ് ചെയ്തു", + "saved_logs_failure_toast": "Logs സേവ് ചെയ്യുന്നത് പരാജയപ്പെട്ടു" + }, + "home_settings": { + "actions_title": "പ്രവർത്തികൾ", + "debug_title": "തെറ്റ്" + } + }, + "dialogs": { + "scripting_warning": { + "content": "SnapEnhance-ൽ ഒരു സ്‌ക്രിപ്റ്റിംഗ് ടൂൾ ഉൾപ്പെടുന്നു, ഇത് നിങ്ങളുടെ ഉപകരണത്തിൽ ഉപയോക്തൃ-നിർവചിച്ച കോഡ് നടപ്പിലാക്കാൻ അനുവദിക്കുന്നു. അതീവ ജാഗ്രത പാലിക്കുക, അറിയപ്പെടുന്നതും വിശ്വസനീയവുമായ ഉറവിടങ്ങളിൽ നിന്നുള്ള മൊഡ്യൂളുകൾ മാത്രം ഇൻസ്റ്റാൾ ചെയ്യുക. അംഗീകൃതമല്ലാത്തതോ സ്ഥിരീകരിക്കാത്തതോ ആയ മൊഡ്യൂളുകൾ നിങ്ങളുടെ സിസ്റ്റത്തിന് സുരക്ഷാ അപകടങ്ങൾ സൃഷ്ടിച്ചേക്കാം.", + "title": "മുന്നറിയിപ്പ്" + }, + "add_friend": { + "title": "സുഹൃത്തിനെയോ ഗ്രൂപ്പിനെയോ ചേർക്കുക", + "search_hint": "തിരയുക", + "fetch_error": "ഡാറ്റ ലഭ്യമാക്കുന്നതിൽ പരാജയപ്പെട്ടു", + "category_friends": "സുഹൃത്തുക്കൾ", + "category_groups": "ഗ്രൂപ്പുകൾ" + } + } + }, + "setup": { + "mappings": { + "dialog": "വൈവിധ്യമാർന്ന Snapchat പതിപ്പുകളെ ചലനാത്മകമായി പിന്തുണയ്ക്കുന്നതിന്, SnapEnhance ശരിയായി പ്രവർത്തിക്കുന്നതിന് മാപ്പിംഗുകൾ ആവശ്യമാണ്, ഇതിന് 5 സെക്കൻഡിൽ കൂടുതൽ എടുക്കരുത്.", + "generate_failure_no_snapchat": "SnapEnhance-ന് Snapchat കണ്ടെത്താനായില്ല, Snapchat വീണ്ടും ഇൻസ്റ്റാൾ ചെയ്യാൻ ശ്രമിക്കുക.", + "generate_failure": "മാപ്പിംഗുകൾ സൃഷ്ടിക്കാൻ ശ്രമിക്കുമ്പോൾ ഒരു പിശക് സംഭവിച്ചു, ദയവായി വീണ്ടും ശ്രമിക്കുക." + }, + "dialogs": { + "select_save_folder_button": "ഫോൾഡർ തിരഞ്ഞെടുക്കുക", + "save_folder": "സ്നാപ്ചാറ്റിൽ നിന്ന് മീഡിയ ഡൗൺലോഡ് ചെയ്യാനും സംരക്ഷിക്കാനും SnapEnhance-ന് സ്റ്റോറേജ് അനുമതികൾ ആവശ്യമാണ്\nമീഡിയ ഡൗൺലോഡ് ചെയ്യേണ്ട സ്ഥലം തിരഞ്ഞെടുക്കുക.", + "select_language": "ഭാഷ തിരഞ്ഞെടുക്കുക" + }, + "permissions": { + "battery_optimization": "ബാറ്ററി ഒപ്റ്റിമൈസേഷൻ", + "display_over_other_apps": "മറ്റ് ആപ്പുകളിൽ പ്രദർശിപ്പിക്കുക", + "notification_access": "അറിയിപ്പ് ആക്സസ്", + "dialog": "തുടരുന്നതിന് നിങ്ങൾ ഇനിപ്പറയുന്ന ആവശ്യകതകൾ പാലിക്കേണ്ടതുണ്ട്:", + "request_button": "അഭ്യർത്ഥിക്കുക" + } + }, + "material3_strings": { + "date_range_input_invalid_range_input": "അസാധുവായ തീയതി ശ്രേണി", + "date_range_picker_scroll_to_previous_month": "കഴിഞ്ഞ മാസം", + "date_picker_switch_to_input_mode": "ഇൻപുട്ട്", + "date_range_picker_day_in_range": "തിരഞ്ഞെടുത്തു", + "date_input_invalid_for_pattern": "അസാധുവായ തീയതി", + "date_picker_today_description": "ഇന്ന്", + "date_picker_switch_to_calendar_mode": "കലണ്ടർ", + "date_range_picker_end_headline": "ലേക്ക്", + "date_range_picker_scroll_to_next_month": "അടുത്ത മാസം", + "date_input_invalid_year_range": "അസാധുവായ വർഷം", + "date_range_picker_start_headline": "നിന്ന്", + "date_range_picker_title": "തീയതി ശ്രേണി തിരഞ്ഞെടുക്കുക", + "date_input_invalid_not_allowed": "അസാധുവായ തീയതി" + }, + "features": { + "properties": { + "user_interface": { + "properties": { + "streak_expiration_info": { + "description": "സ്ട്രീക്ക് കൗണ്ടറിന് അടുത്തായി ഒരു സ്ട്രീക്ക് എക്‌സ്‌പറേഷൻ ടൈമർ കാണിക്കുന്നു", + "name": "സ്ട്രീക്ക് കാലഹരണപ്പെടൽ വിവരം കാണിക്കുക" + }, + "bootstrap_override": { + "description": "ഉപയോക്തൃ ഇന്റർഫേസ് ബൂട്ട്സ്ട്രാപ്പ് ക്രമീകരണങ്ങൾ അസാധുവാക്കുന്നു", + "properties": { + "app_appearance": { + "description": "സ്ഥിരമായ ആപ്പ് രൂപഭാവം സജ്ജമാക്കുന്നു", + "name": "ആപ്പ് രൂപഭാവം" + }, + "home_tab": { + "name": "ഹോം ടാബ്", + "description": "Snapchat തുറക്കുമ്പോൾ സ്റ്റാർട്ടപ്പ് ടാബ് അസാധുവാക്കുന്നു" + } + }, + "name": "ബൂട്ട്സ്ട്രാപ്പ് അസാധുവാക്കുക" + }, + "opera_media_quick_info": { + "description": "ഓപ്പറ വ്യൂവർ സന്ദർഭ മെനുവിൽ സൃഷ്ടിച്ച തീയതി പോലുള്ള മീഡിയയുടെ ഉപയോഗപ്രദമായ വിവരങ്ങൾ കാണിക്കുന്നു", + "name": "ഓപ്പറ മീഡിയ ദ്രുത വിവരങ്ങൾ" + }, + "map_friend_nametags": { + "description": "സ്നാപ്പ്മാപ്പിലെ സുഹൃത്തുക്കളുടെ നെയിംടാഗുകൾ മെച്ചപ്പെടുത്തുന്നു", + "name": "മെച്ചപ്പെടുത്തിയ ചങ്ങാതി മാപ്പ് നെയിംടാഗുകൾ" + }, + "vertical_story_viewer": { + "name": "ലംബമായ സ്റ്റോറി വ്യൂവർ", + "description": "എല്ലാ സ്റ്റോറികൾക്കും വെർട്ടിക്കൽ സ്റ്റോറി വ്യൂവർ പ്രവർത്തനക്ഷമമാക്കുന്നു" + }, + "friend_feed_menu_buttons": { + "name": "ഫ്രണ്ട് ഫീഡ് മെനു ബട്ടണുകൾ", + "description": "ഫ്രണ്ട് ഫീഡ് മെനുവിൽ കാണിക്കേണ്ട ബട്ടണുകൾ തിരഞ്ഞെടുക്കുക" + }, + "disable_spotlight": { + "description": "സ്പോട്ട്‌ലൈറ്റ് പേജ് പ്രവർത്തനരഹിതമാക്കുന്നു", + "name": "സ്പോട്ട്ലൈറ്റ് പ്രവർത്തനരഹിതമാക്കുക" + }, + "friend_feed_message_preview": { + "name": "ഫ്രണ്ട് ഫീഡ് സന്ദേശ പ്രിവ്യൂ", + "description": "ഫ്രണ്ട് ഫീഡിലെ അവസാന സന്ദേശങ്ങളുടെ പ്രിവ്യൂ കാണിക്കുന്നു", + "properties": { + "amount": { + "name": "തുക", + "description": "പ്രിവ്യൂ ചെയ്യാനുള്ള സന്ദേശങ്ങളുടെ അളവ്" + } + } + }, + "enable_friend_feed_menu_bar": { + "description": "പുതിയ ഫ്രണ്ട് ഫീഡ് മെനു ബാർ പ്രവർത്തനക്ഷമമാക്കുന്നു", + "name": "ഫ്രണ്ട് ഫീഡ് മെനു ബാർ" + }, + "old_bitmoji_selfie": { + "name": "പഴയ ബിറ്റ്‌മോജി സെൽഫി", + "description": "പഴയ Snapchat പതിപ്പുകളിൽ നിന്ന് Bitmoji സെൽഫികൾ തിരികെ കൊണ്ടുവരുന്നു" + }, + "hide_ui_components": { + "description": "ഏത് UI ഘടകങ്ങളാണ് മറയ്ക്കേണ്ടതെന്ന് തിരഞ്ഞെടുക്കുക", + "name": "UI ഘടകങ്ങൾ മറയ്ക്കുക" + }, + "prevent_message_list_auto_scroll": { + "name": "സന്ദേശ ലിസ്റ്റ് സ്വയമേവ സ്ക്രോൾ ചെയ്യുന്നത് തടയുക", + "description": "ഒരു സന്ദേശം അയയ്‌ക്കുമ്പോൾ/സ്വീകരിക്കുമ്പോൾ സന്ദേശ പട്ടിക താഴേക്ക് സ്ക്രോൾ ചെയ്യുന്നതിൽ നിന്ന് തടയുന്നു" + }, + "edit_text_override": { + "name": "വാചകം തിരുത്തുക", + "description": "ടെക്സ്റ്റ് ഫീൽഡ് പെരുമാറ്റം അസാധുവാക്കുന്നു" + }, + "enable_app_appearance": { + "description": "മറഞ്ഞിരിക്കുന്ന ആപ്പ് രൂപഭാവ ക്രമീകരണം പ്രവർത്തനക്ഷമമാക്കുന്നു\nപുതിയ Snapchat പതിപ്പുകളിൽ ആവശ്യമില്ലായിരിക്കാം", + "name": "ആപ്പ് രൂപഭാവ ക്രമീകരണങ്ങൾ പ്രവർത്തനക്ഷമമാക്കുക" + }, + "snap_preview": { + "name": "സ്നാപ്പ് പ്രിവ്യൂ", + "description": "ചാറ്റിൽ കാണാത്ത സ്നാപ്പുകൾക്ക് അടുത്തായി ഒരു ചെറിയ പ്രിവ്യൂ പ്രദർശിപ്പിക്കുന്നു" + }, + "hide_friend_feed_entry": { + "description": "ഫ്രണ്ട് ഫീഡിൽ നിന്ന് ഒരു പ്രത്യേക സുഹൃത്തിനെ മറയ്ക്കുന്നു\nഈ ഫീച്ചർ മാനേജ് ചെയ്യാൻ സോഷ്യൽ ടാബ് ഉപയോഗിക്കുക", + "name": "ഫ്രണ്ട് ഫീഡ് എൻട്രി മറയ്ക്കുക" + }, + "hide_streak_restore": { + "description": "ഫ്രണ്ട് ഫീഡിലെ Restore ബട്ടൺ മറയ്ക്കുന്നു", + "name": "സ്ട്രീക്ക് വീണ്ടെടുക്കൽ മറയ്ക്കുക" + } + }, + "name": "ഉപയോക്തൃ ഇന്റർഫേസ്", + "description": "Snapchat-ന്റെ രൂപവും ഭാവവും മാറ്റുക" + }, + "camera": { + "properties": { + "hevc_recording": { + "name": "HEVC റെക്കോർഡിംഗ്", + "description": "വീഡിയോ റെക്കോർഡിംഗിനായി HEVC (H.265) കോഡെക് ഉപയോഗിക്കുന്നു" + }, + "immersive_camera_preview": { + "description": "ക്യാമറ പ്രിവ്യൂ ക്രോപ്പ് ചെയ്യുന്നതിൽ നിന്ന് Snapchat തടയുന്നു\nചില ഉപകരണങ്ങളിൽ ക്യാമറ മിന്നാൻ ഇത് കാരണമായേക്കാം", + "name": "ഇമ്മേഴ്‌സീവ് പ്രിവ്യൂ" + }, + "black_photos": { + "description": "എടുത്ത ഫോട്ടോകൾക്ക് പകരം കറുപ്പ് പശ്ചാത്തലം നൽകുന്നു\nവീഡിയോകളെ ബാധിക്കില്ല", + "name": "കറുത്ത ഫോട്ടോകൾ" + }, + "force_camera_source_encoding": { + "description": "ക്യാമറ ഉറവിട എൻകോഡിംഗിനെ നിർബന്ധിക്കുന്നു", + "name": "നിർബന്ധിത ക്യാമറ ഉറവിട എൻകോഡിംഗ്" + } + }, + "description": "മികച്ച സ്നാപ്പിനായി ശരിയായ ക്രമീകരണങ്ങൾ ക്രമീകരിക്കുക", + "name": "ക്യാമറ" + }, + "global": { + "properties": { + "disable_confirmation_dialogs": { + "name": "സ്ഥിരീകരണ ഡയലോഗുകൾ പ്രവർത്തനരഹിതമാക്കുക", + "description": "തിരഞ്ഞെടുത്ത പ്രവർത്തനങ്ങൾ സ്വയമേവ സ്ഥിരീകരിക്കുന്നു" + }, + "disable_google_play_dialogs": { + "name": "Google Play സേവന ഡയലോഗുകൾ പ്രവർത്തനരഹിതമാക്കുക", + "description": "Google Play സേവനങ്ങളുടെ ലഭ്യത ഡയലോഗുകൾ കാണിക്കുന്നതിൽ നിന്ന് തടയുക" + }, + "disable_snap_splitting": { + "name": "സ്നാപ്പ് വിഭജനം പ്രവർത്തനരഹിതമാക്കുക", + "description": "Snaps ഒന്നിലധികം ഭാഗങ്ങളായി വിഭജിക്കപ്പെടുന്നത് തടയുന്നു\nനിങ്ങൾ അയയ്ക്കുന്ന ചിത്രങ്ങൾ വീഡിയോകളായി മാറും" + }, + "spotlight_comments_username": { + "name": "സ്പോട്ട്ലൈറ്റ് അഭിപ്രായങ്ങളുടെ ഉപയോക്തൃനാമം", + "description": "സ്‌പോട്ട്‌ലൈറ്റ് അഭിപ്രായങ്ങളിൽ രചയിതാവിന്റെ ഉപയോക്തൃനാമം കാണിക്കുന്നു" + }, + "auto_updater": { + "description": "പുതിയ അപ്ഡേറ്റുകൾക്കായി സ്വയമേവ പരിശോധിക്കുന്നു", + "name": "യാന്ത്രിക അപ്‌ഡേറ്റർ" + }, + "disable_metrics": { + "name": "മെട്രിക്‌സ് പ്രവർത്തനരഹിതമാക്കുക", + "description": "Snapchat-ലേക്ക് നിർദ്ദിഷ്ട അനലിറ്റിക് ഡാറ്റ അയയ്ക്കുന്നത് തടയുന്നു" + }, + "bypass_video_length_restriction": { + "description": "സിംഗിൾ: ഒരൊറ്റ വീഡിയോ അയയ്ക്കുന്നു\nവിഭജിക്കുക: എഡിറ്റ് ചെയ്ത ശേഷം വീഡിയോകൾ വിഭജിക്കുക", + "name": "വീഡിയോ ദൈർഘ്യ നിയന്ത്രണങ്ങൾ മറികടക്കുക" + }, + "snapchat_plus": { + "name": "സ്നാപ്ചാറ്റ് പ്ലസ്", + "description": "Snapchat പ്ലസ് ഫീച്ചറുകൾ പ്രവർത്തനക്ഷമമാക്കുന്നു\nചില സെർവർ-വശങ്ങളുള്ള സവിശേഷതകൾ പ്രവർത്തിച്ചേക്കില്ല" + }, + "block_ads": { + "name": "പരസ്യങ്ങൾ തടയുക", + "description": "പരസ്യങ്ങൾ പ്രദർശിപ്പിക്കുന്നത് തടയുന്നു" + } + }, + "name": "ആഗോള", + "description": "ഗ്ലോബൽ സ്‌നാപ്ചാറ്റ് ക്രമീകരണങ്ങൾ മാറ്റുക" + }, + "downloader": { + "properties": { + "force_voice_note_format": { + "name": "ഫോഴ്സ് വോയ്സ് നോട്ട് ഫോർമാറ്റ്", + "description": "വോയ്‌സ് നോട്ടുകൾ ഒരു നിർദ്ദിഷ്‌ട ഫോർമാറ്റിൽ സേവ് ചെയ്യാൻ നിർബന്ധിക്കുന്നു" + }, + "ffmpeg_options": { + "properties": { + "constant_rate_factor": { + "description": "വീഡിയോ എൻകോഡറിനായി സ്ഥിരമായ നിരക്ക് ഘടകം സജ്ജമാക്കുക\nlibx264-ന് 0 മുതൽ 51 വരെ", + "name": "സ്ഥിരമായ നിരക്ക് ഘടകം" + }, + "custom_audio_codec": { + "name": "ഇഷ്‌ടാനുസൃത ഓഡിയോ കോഡെക്", + "description": "ഒരു ഇഷ്‌ടാനുസൃത ഓഡിയോ കോഡെക് സജ്ജീകരിക്കുക (ഉദാ. AAC)" + }, + "video_bitrate": { + "name": "വീഡിയോ ബിറ്റ്റേറ്റ്", + "description": "വീഡിയോ ബിറ്റ്റേറ്റ് (kbps) സജ്ജമാക്കുക" + }, + "threads": { + "description": "ഉപയോഗിക്കേണ്ട ത്രെഡുകളുടെ അളവ്", + "name": "ത്രെഡുകൾ" + }, + "custom_video_codec": { + "description": "ഒരു ഇഷ്‌ടാനുസൃത വീഡിയോ കോഡെക് സജ്ജീകരിക്കുക (ഉദാ. libx264)", + "name": "ഇഷ്‌ടാനുസൃത വീഡിയോ കോഡെക്" + }, + "audio_bitrate": { + "name": "ഓഡിയോ ബിറ്റ്റേറ്റ്", + "description": "ഓഡിയോ ബിറ്റ്റേറ്റ് (kbps) സജ്ജമാക്കുക" + }, + "preset": { + "name": "പ്രീസെറ്റ്", + "description": "പരിവർത്തനത്തിന്റെ വേഗത സജ്ജമാക്കുക" + } + }, + "name": "FFmpeg ഓപ്ഷനുകൾ", + "description": "അധിക FFmpeg ഓപ്ഷനുകൾ വ്യക്തമാക്കുക" + }, + "prevent_self_auto_download": { + "description": "നിങ്ങളുടെ സ്വന്തം സ്നാപ്പുകൾ സ്വയമേവ ഡൗൺലോഡ് ചെയ്യുന്നതിൽ നിന്ന് തടയുന്നു", + "name": "സ്വയം യാന്ത്രിക ഡൗൺലോഡ് തടയുക" + }, + "download_profile_pictures": { + "description": "പ്രൊഫൈൽ പേജിൽ നിന്ന് പ്രൊഫൈൽ ചിത്രങ്ങൾ ഡൗൺലോഡ് ചെയ്യാൻ നിങ്ങളെ അനുവദിക്കുന്നു", + "name": "പ്രൊഫൈൽ ചിത്രങ്ങൾ ഡൗൺലോഡ് ചെയ്യുക" + }, + "auto_download_sources": { + "description": "സ്വയമേവ ഡൗൺലോഡ് ചെയ്യാനുള്ള ഉറവിടങ്ങൾ തിരഞ്ഞെടുക്കുക", + "name": "സ്വയമേവ ഡൗൺലോഡ് ഉറവിടങ്ങൾ" + }, + "allow_duplicate": { + "name": "ഡ്യൂപ്ലിക്കേറ്റ് അനുവദിക്കുക", + "description": "ഒരേ മീഡിയ ഒന്നിലധികം തവണ ഡൗൺലോഡ് ചെയ്യാൻ അനുവദിക്കുന്നു" + }, + "custom_path_format": { + "description": "ഡൗൺലോഡ് ചെയ്‌ത മീഡിയയ്‌ക്കായി ഒരു ഇഷ്‌ടാനുസൃത പാത്ത് ഫോർമാറ്റ് വ്യക്തമാക്കുക\n\nലഭ്യമായ വേരിയബിളുകൾ:\n - %ഉപയോക്തൃനാമം%\n - %ഉറവിടം%\n - %ഹാഷ്%\n - %തീയതി സമയം%", + "name": "ഇഷ്‌ടാനുസൃത പാത്ത് ഫോർമാറ്റ്" + }, + "force_image_format": { + "name": "ഫോഴ്സ് ഇമേജ് ഫോർമാറ്റ്", + "description": "ചിത്രങ്ങൾ ഒരു നിർദ്ദിഷ്ട ഫോർമാറ്റിൽ സംരക്ഷിക്കാൻ നിർബന്ധിക്കുന്നു" + }, + "opera_download_button": { + "description": "ഒരു Snap കാണുമ്പോൾ മുകളിൽ വലത് കോണിൽ ഒരു ഡൗൺലോഡ് ബട്ടൺ ചേർക്കുന്നു", + "name": "ഓപ്പറ ഡൗൺലോഡ് ബട്ടൺ" + }, + "save_folder": { + "name": "ഫോൾഡർ സംരക്ഷിക്കുക", + "description": "എല്ലാ മീഡിയയും ഡൗൺലോഡ് ചെയ്യേണ്ട ഡയറക്‌ടറി തിരഞ്ഞെടുക്കുക" + }, + "merge_overlays": { + "name": "ഓവർലേകൾ ലയിപ്പിക്കുക", + "description": "ഒരു സ്‌നാപ്പിന്റെ ടെക്‌സ്‌റ്റും മീഡിയയും ഒരു ഫയലായി സംയോജിപ്പിക്കുന്നു" + }, + "logging": { + "description": "മീഡിയ ഡൗൺലോഡ് ചെയ്യുമ്പോൾ ടോസ്റ്റുകൾ കാണിക്കുന്നു", + "name": "ലോഗിംഗ്" + }, + "path_format": { + "name": "പാത്ത് ഫോർമാറ്റ്", + "description": "ഫയൽ പാത്ത് ഫോർമാറ്റ് വ്യക്തമാക്കുക" + } + }, + "description": "Snapchat മീഡിയ ഡൗൺലോഡ് ചെയ്യുക", + "name": "ഡൗൺലോഡർ" + }, + "experimental": { + "properties": { + "spoof": { + "properties": { + "remove_mock_location_flag": { + "name": "മോക്ക് ലൊക്കേഷൻ ഫ്ലാഗ് നീക്കം ചെയ്യുക", + "description": "മോക്ക് ലൊക്കേഷൻ കണ്ടെത്തുന്നതിൽ നിന്ന് Snapchat തടയുന്നു" + }, + "remove_vpn_transport_flag": { + "description": "VPN-കൾ കണ്ടെത്തുന്നതിൽ നിന്ന് Snapchat തടയുന്നു", + "name": "VPN ട്രാൻസ്പോർട്ട് ഫ്ലാഗ് നീക്കം ചെയ്യുക" + }, + "play_store_installer_package_name": { + "description": "com.android.vending എന്നതിലേക്ക് ഇൻസ്റ്റാളർ പാക്കേജിന്റെ പേര് അസാധുവാക്കുന്നു", + "name": "പ്ലേ സ്റ്റോർ ഇൻസ്റ്റാളർ പാക്കേജിന്റെ പേര്" + } + }, + "description": "നിങ്ങളെക്കുറിച്ചുള്ള വിവിധ വിവരങ്ങൾ കബളിപ്പിക്കുക", + "name": "സ്പൂഫ്" + }, + "native_hooks": { + "properties": { + "disable_bitmoji": { + "description": "ചങ്ങാതിമാരുടെ പ്രൊഫൈൽ ബിറ്റ്മോജി പ്രവർത്തനരഹിതമാക്കുന്നു", + "name": "ബിറ്റ്‌മോജി പ്രവർത്തനരഹിതമാക്കുക" + } + }, + "description": "Snapchat-ന്റെ നേറ്റീവ് കോഡിലേക്ക് ഹുക്ക് ചെയ്യുന്ന സുരക്ഷിതമല്ലാത്ത ഫീച്ചറുകൾ", + "name": "നേറ്റീവ് ഹുക്കുകൾ" + }, + "convert_message_locally": { + "description": "പ്രാദേശികമായി ബാഹ്യ മീഡിയ ചാറ്റ് ചെയ്യാൻ സ്നാപ്പുകൾ പരിവർത്തനം ചെയ്യുന്നു. ചാറ്റ് ഡൗൺലോഡ് സന്ദർഭ മെനുവിൽ ഇത് ദൃശ്യമാകും", + "name": "സന്ദേശം പ്രാദേശികമായി പരിവർത്തനം ചെയ്യുക" + }, + "story_logger": { + "name": "സ്റ്റോറി ലോഗർ", + "description": "സുഹൃത്തുക്കളുടെ കഥകളുടെ ചരിത്രം നൽകുന്നു" + }, + "infinite_story_boost": { + "name": "അനന്തമായ കഥ ബൂസ്റ്റ്", + "description": "സ്റ്റോറി ബൂസ്റ്റ് പരിധി കാലതാമസം മറികടക്കുക" + }, + "no_friend_score_delay": { + "name": "ഫ്രണ്ട് സ്‌കോർ കാലതാമസം ഇല്ല", + "description": "ഒരു ഫ്രണ്ട്സ് സ്കോർ കാണുമ്പോഴുള്ള കാലതാമസം നീക്കം ചെയ്യുന്നു" + }, + "e2ee": { + "properties": { + "encrypted_message_indicator": { + "name": "എൻക്രിപ്റ്റ് ചെയ്ത സന്ദേശ സൂചകം", + "description": "എൻക്രിപ്റ്റ് ചെയ്ത സന്ദേശങ്ങൾക്ക് അടുത്തായി ഒരു 🔒 ഇമോജി ചേർക്കുന്നു" + }, + "force_message_encryption": { + "name": "നിർബന്ധിത സന്ദേശ എൻക്രിപ്ഷൻ", + "description": "ഒന്നിലധികം സംഭാഷണങ്ങൾ തിരഞ്ഞെടുക്കുമ്പോൾ മാത്രം E2E എൻക്രിപ്ഷൻ പ്രവർത്തനക്ഷമമാക്കാത്ത ആളുകൾക്ക് എൻക്രിപ്റ്റ് ചെയ്ത സന്ദേശങ്ങൾ അയക്കുന്നത് തടയുന്നു" + } + }, + "name": "എൻഡ്-ടു-എൻഡ് എൻക്രിപ്ഷൻ", + "description": "പങ്കിട്ട രഹസ്യ കീ ഉപയോഗിച്ച് AES ഉപയോഗിച്ച് നിങ്ങളുടെ സന്ദേശങ്ങൾ എൻക്രിപ്റ്റ് ചെയ്യുന്നു\nനിങ്ങളുടെ താക്കോൽ സുരക്ഷിതമായി എവിടെയെങ്കിലും സൂക്ഷിക്കുന്നത് ഉറപ്പാക്കുക!" + }, + "add_friend_source_spoof": { + "name": "സുഹൃത്ത് ഉറവിട സ്പൂഫ് ചേർക്കുക", + "description": "ഒരു സുഹൃത്ത് അഭ്യർത്ഥനയുടെ ഉറവിടം കബളിപ്പിക്കുന്നു" + }, + "hidden_snapchat_plus_features": { + "name": "മറഞ്ഞിരിക്കുന്ന Snapchat പ്ലസ് ഫീച്ചറുകൾ", + "description": "റിലീസ് ചെയ്യാത്ത/ബീറ്റ Snapchat പ്ലസ് ഫീച്ചറുകൾ പ്രവർത്തനക്ഷമമാക്കുന്നു\nപഴയ Snapchat പതിപ്പുകളിൽ പ്രവർത്തിച്ചേക്കില്ല" + }, + "prevent_forced_logout": { + "name": "നിർബന്ധിത ലോഗ്ഔട്ട് തടയുക", + "description": "നിങ്ങൾ മറ്റൊരു ഉപകരണത്തിൽ ലോഗിൻ ചെയ്യുമ്പോൾ നിങ്ങളെ ലോഗ് ഔട്ട് ചെയ്യുന്നതിൽ നിന്ന് Snapchat തടയുന്നു" + }, + "meo_passcode_bypass": { + "name": "എന്റെ കണ്ണുകൾ മാത്രം പാസ്‌കോഡ് ബൈപാസ്", + "description": "മൈ ഐസ് ഒൺലി പാസ്‌കോഡ് ബൈപാസ് ചെയ്യുക\nമുമ്പ് പാസ്‌കോഡ് ശരിയായി നൽകിയിട്ടുണ്ടെങ്കിൽ മാത്രമേ ഇത് പ്രവർത്തിക്കൂ" + } + }, + "description": "പരീക്ഷണാത്മക സവിശേഷതകൾ", + "name": "പരീക്ഷണാത്മകം" + }, + "messaging": { + "properties": { + "notification_blacklist": { + "name": "നോട്ടിഫിക്കേഷൻ ബ്ലാക്ക്‌ലിസ്റ്റ്", + "description": "ബ്ലോക്ക് ചെയ്യേണ്ട അറിയിപ്പുകൾ തിരഞ്ഞെടുക്കുക" + }, + "prevent_message_sending": { + "name": "സന്ദേശം അയയ്ക്കുന്നത് തടയുക", + "description": "ചില തരത്തിലുള്ള സന്ദേശങ്ങൾ അയക്കുന്നത് തടയുന്നു" + }, + "message_logger": { + "properties": { + "message_filter": { + "name": "സന്ദേശ ഫിൽട്ടർ", + "description": "ഏതൊക്കെ സന്ദേശങ്ങളാണ് ലോഗിൻ ചെയ്യേണ്ടതെന്ന് തിരഞ്ഞെടുക്കുക (എല്ലാ സന്ദേശങ്ങൾക്കും ശൂന്യം)" + }, + "auto_purge": { + "description": "നിർദ്ദിഷ്‌ട സമയത്തേക്കാൾ പഴയ കാഷെ ചെയ്‌ത സന്ദേശങ്ങൾ സ്വയമേവ ഇല്ലാതാക്കുന്നു", + "name": "യാന്ത്രിക ശുദ്ധീകരണം" + }, + "keep_my_own_messages": { + "name": "എന്റെ സ്വന്തം സന്ദേശങ്ങൾ സൂക്ഷിക്കുക", + "description": "നിങ്ങളുടെ സ്വന്തം സന്ദേശങ്ങൾ ഇല്ലാതാക്കുന്നതിൽ നിന്ന് തടയുന്നു" + } + }, + "description": "സന്ദേശങ്ങൾ ഇല്ലാതാക്കുന്നത് തടയുന്നു", + "name": "സന്ദേശ ലോഗർ" + }, + "anonymous_story_viewing": { + "name": "അജ്ഞാത കഥ കാണൽ", + "description": "നിങ്ങൾ അവരുടെ കഥ കണ്ടുവെന്ന് അറിയുന്നതിൽ നിന്ന് ആരെയും തടയുന്നു" + }, + "loop_media_playback": { + "name": "ലൂപ്പ് മീഡിയ പ്ലേബാക്ക്", + "description": "സ്നാപ്പുകൾ / സ്റ്റോറികൾ കാണുമ്പോൾ മീഡിയ പ്ലേബാക്ക് ലൂപ്പ് ചെയ്യുന്നു" + }, + "disable_replay_in_ff": { + "description": "ഫ്രണ്ട് ഫീഡിൽ നിന്ന് ദീർഘനേരം അമർത്തി വീണ്ടും പ്ലേ ചെയ്യാനുള്ള കഴിവ് പ്രവർത്തനരഹിതമാക്കുന്നു", + "name": "FF-ൽ റീപ്ലേ പ്രവർത്തനരഹിതമാക്കുക" + }, + "call_start_confirmation": { + "name": "കോൾ ആരംഭ സ്ഥിരീകരണം", + "description": "ഒരു കോൾ ആരംഭിക്കുമ്പോൾ ഒരു സ്ഥിരീകരണ ഡയലോഗ് കാണിക്കുന്നു" + }, + "half_swipe_notifier": { + "properties": { + "min_duration": { + "name": "കുറഞ്ഞ ദൈർഘ്യം", + "description": "പകുതി സ്വൈപ്പിന്റെ ഏറ്റവും കുറഞ്ഞ ദൈർഘ്യം (സെക്കൻഡിൽ)" + }, + "max_duration": { + "description": "പകുതി സ്വൈപ്പിന്റെ പരമാവധി ദൈർഘ്യം (സെക്കൻഡിൽ)", + "name": "പരമാവധി ദൈർഘ്യം" + } + }, + "name": "ഹാഫ് സ്വൈപ്പ് നോട്ടിഫയർ", + "description": "ആരെങ്കിലും സംഭാഷണത്തിലേക്ക് പാതി സ്വൈപ്പ് ചെയ്യുമ്പോൾ നിങ്ങളെ അറിയിക്കും" + }, + "bypass_screenshot_detection": { + "description": "നിങ്ങൾ സ്‌ക്രീൻഷോട്ട് എടുക്കുമ്പോൾ സ്‌നാപ്ചാറ്റ് കണ്ടെത്തുന്നതിൽ നിന്ന് തടയുന്നു", + "name": "ബൈപാസ് സ്ക്രീൻഷോട്ട് കണ്ടെത്തൽ" + }, + "gallery_media_send_override": { + "description": "ഗാലറിയിൽ നിന്ന് അയയ്‌ക്കുമ്പോൾ മീഡിയ ഉറവിടം കബളിപ്പിക്കുന്നു", + "name": "ഗാലറി മീഡിയ അയയ്‌ക്കുക അസാധുവാക്കുക" + }, + "strip_media_metadata": { + "description": "ഒരു സന്ദേശമായി അയയ്‌ക്കുന്നതിന് മുമ്പ് മീഡിയയുടെ മെറ്റാഡാറ്റ നീക്കംചെയ്യുന്നു", + "name": "സ്ട്രിപ്പ് മീഡിയ മെറ്റാഡാറ്റ" + }, + "better_notifications": { + "description": "ലഭിച്ച അറിയിപ്പുകളിൽ കൂടുതൽ വിവരങ്ങൾ ചേർക്കുന്നു", + "name": "മികച്ച അറിയിപ്പുകൾ" + }, + "auto_save_messages_in_conversations": { + "name": "സന്ദേശങ്ങൾ സ്വയമേവ സംരക്ഷിക്കുക", + "description": "സംഭാഷണങ്ങളിലെ എല്ലാ സന്ദേശങ്ങളും സ്വയമേവ സംരക്ഷിക്കുന്നു" + }, + "bypass_message_retention_policy": { + "name": "ബൈപാസ് സന്ദേശം നിലനിർത്തൽ നയം", + "description": "സന്ദേശങ്ങൾ കണ്ടതിന് ശേഷം ഇല്ലാതാക്കുന്നത് തടയുന്നു" + }, + "prevent_story_rewatch_indicator": { + "name": "സ്റ്റോറി റീവാച്ച് ഇൻഡിക്കേറ്റർ തടയുക", + "description": "നിങ്ങൾ അവരുടെ കഥ വീണ്ടും കണ്ടുവെന്ന് അറിയുന്നതിൽ നിന്ന് ആരെയും തടയുന്നു" + }, + "hide_bitmoji_presence": { + "name": "ബിറ്റ്‌മോജി സാന്നിധ്യം മറയ്ക്കുക", + "description": "ചാറ്റിൽ ആയിരിക്കുമ്പോൾ നിങ്ങളുടെ ബിറ്റ്‌മോജി പോപ്പ് അപ്പ് ചെയ്യുന്നത് തടയുന്നു" + }, + "unlimited_snap_view_time": { + "name": "അൺലിമിറ്റഡ് സ്നാപ്പ് കാഴ്ച സമയം", + "description": "സ്നാപ്പുകൾ കാണുന്നതിനുള്ള സമയ പരിധി നീക്കം ചെയ്യുന്നു" + }, + "hide_typing_notifications": { + "description": "നിങ്ങൾ ഒരു സന്ദേശം ടൈപ്പുചെയ്യുന്നത് അറിയുന്നതിൽ നിന്ന് ആരെയും തടയുന്നു", + "name": "ടൈപ്പിംഗ് അറിയിപ്പുകൾ മറയ്ക്കുക" + }, + "hide_peek_a_peek": { + "description": "നിങ്ങൾ ഒരു ചാറ്റിലേക്ക് പകുതി സ്വൈപ്പ് ചെയ്യുമ്പോൾ അറിയിപ്പ് അയയ്ക്കുന്നത് തടയുന്നു", + "name": "പീക്ക്-എ-പീക്ക് മറയ്ക്കുക" + } + }, + "name": "സന്ദേശമയയ്ക്കൽ", + "description": "നിങ്ങൾ സുഹൃത്തുക്കളുമായി ഇടപഴകുന്ന രീതി മാറ്റുക" + }, + "streaks_reminder": { + "properties": { + "group_notifications": { + "description": "ഗ്രൂപ്പ് അറിയിപ്പുകൾ ഒറ്റ ഒന്നായി", + "name": "ഗ്രൂപ്പ് അറിയിപ്പുകൾ" + }, + "interval": { + "name": "ഇടവേള", + "description": "ഓരോ ഓർമ്മപ്പെടുത്തലുകൾക്കിടയിലുള്ള ഇടവേള (മണിക്കൂറുകൾ)" + }, + "remaining_hours": { + "name": "ശേഷിക്കുന്ന സമയം", + "description": "അറിയിപ്പിന് മുമ്പുള്ള ശേഷിക്കുന്ന സമയം കാണിക്കുന്നു" + } + }, + "description": "നിങ്ങളുടെ സ്ട്രീക്കുകളെക്കുറിച്ച് ആനുകാലികമായി നിങ്ങളെ അറിയിക്കുന്നു", + "name": "സ്ട്രീക്കുകൾ ഓർമ്മപ്പെടുത്തൽ" + }, + "rules": { + "description": "വ്യക്തിഗത ആളുകൾക്കായി സ്വയമേവയുള്ള സവിശേഷതകൾ നിയന്ത്രിക്കുക", + "name": "നിയമങ്ങൾ" + }, + "scripting": { + "description": "SnapEnhance വിപുലീകരിക്കാൻ ഇഷ്ടാനുസൃത സ്ക്രിപ്റ്റുകൾ പ്രവർത്തിപ്പിക്കുക", + "name": "സ്ക്രിപ്റ്റിംഗ്", + "properties": { + "developer_mode": { + "name": "ഡെവലപ്പർ മോഡ്", + "description": "Snapchat-ന്റെ UI-യിൽ ഡീബഗ് വിവരങ്ങൾ കാണിക്കുന്നു" + }, + "module_folder": { + "name": "മൊഡ്യൂൾ ഫോൾഡർ", + "description": "സ്ക്രിപ്റ്റുകൾ സ്ഥിതി ചെയ്യുന്ന ഫോൾഡർ" + }, + "auto_reload": { + "name": "യാന്ത്രികമായി വീണ്ടും ലോഡുചെയ്യുക", + "description": "സ്ക്രിപ്റ്റുകൾ മാറുമ്പോൾ അവ സ്വയമേവ റീലോഡ് ചെയ്യുന്നു" + }, + "integrated_ui": { + "name": "ഇന്റഗ്രേറ്റഡ് യുഐ", + "description": "Snapchat-ലേക്ക് ഇഷ്‌ടാനുസൃത UI ഘടകങ്ങൾ ചേർക്കാൻ സ്‌ക്രിപ്റ്റുകളെ അനുവദിക്കുന്നു" + }, + "disable_log_anonymization": { + "name": "ലോഗ് അജ്ഞാതമാക്കൽ പ്രവർത്തനരഹിതമാക്കുക", + "description": "ലോഗുകളുടെ അജ്ഞാതവൽക്കരണം പ്രവർത്തനരഹിതമാക്കുന്നു" + } + } + } + }, + "notices": { + "internal_behavior": "⚠ ഇത് Snapchat ആന്തരിക സ്വഭാവത്തെ തകർത്തേക്കാം", + "ban_risk": "⚠ ഈ സവിശേഷത വിലക്കുകൾക്ക് കാരണമായേക്കാം", + "unstable": "⚠ അസ്ഥിരമാണ്" + }, + "options": { + "friend_feed_menu_buttons": { + "auto_download": "⬇️ ഓട്ടോ ഡൗൺലോഡ്", + "auto_save": "💬 സന്ദേശങ്ങൾ സ്വയമേവ സംരക്ഷിക്കുക", + "unsaveable_messages": "⬇️ സംരക്ഷിക്കാനാവാത്ത സന്ദേശങ്ങൾ", + "stealth": "👻 സ്റ്റെൽത്ത് മോഡ്", + "conversation_info": "👤 സംഭാഷണ വിവരം", + "e2e_encryption": "🔒 E2E എൻക്രിപ്ഷൻ ഉപയോഗിക്കുക", + "mark_snaps_as_seen": "👀 Snaps കണ്ടതായി അടയാളപ്പെടുത്തുക", + "mark_stories_as_seen_locally": "👀 പ്രാദേശികമായി കാണുന്ന കഥകൾ അടയാളപ്പെടുത്തുക" + }, + "path_format": { + "create_author_folder": "ഓരോ രചയിതാവിനും ഫോൾഡർ സൃഷ്ടിക്കുക", + "create_source_folder": "ഓരോ മീഡിയ ഉറവിട തരത്തിനും ഫോൾഡർ സൃഷ്‌ടിക്കുക", + "append_hash": "ഫയലിന്റെ പേരിൽ ഒരു അദ്വിതീയ ഹാഷ് ചേർക്കുക", + "append_username": "ഫയലിന്റെ പേരിലേക്ക് ഉപയോക്തൃനാമം ചേർക്കുക", + "append_date_time": "ഫയലിന്റെ പേരിൽ തീയതിയും സമയവും ചേർക്കുക", + "append_source": "ഫയലിന്റെ പേരിലേക്ക് മീഡിയ ഉറവിടം ചേർക്കുക" + }, + "auto_download_sources": { + "friend_stories": "സുഹൃത്ത് കഥകൾ", + "public_stories": "പൊതു കഥകൾ", + "spotlight": "സ്പോട്ട്ലൈറ്റ്", + "friend_snaps": "സുഹൃത്ത് സ്നാപ്സ്" + }, + "logging": { + "progress": "പുരോഗതി", + "failure": "പരാജയം", + "started": "ആരംഭിച്ചു", + "success": "വിജയം" + }, + "notifications": { + "chat_screenshot": "സ്ക്രീൻഷോട്ട്", + "chat_screen_record": "സ്ക്രീൻ റെക്കോർഡ്", + "snap_replay": "സ്നാപ്പ് റീപ്ലേ", + "camera_roll_save": "ക്യാമറ റോൾ സേവ്", + "chat": "ചാറ്റ്", + "chat_reply": "ചാറ്റ് മറുപടി", + "snap": "സ്നാപ്പ്", + "typing": "ടൈപ്പിംഗ്", + "stories": "കഥകൾ", + "group_chat_reaction": "ഗ്രൂപ്പ് പ്രതികരണം", + "initiate_audio": "ഇൻകമിംഗ് ഓഡിയോ കോൾ", + "abandon_audio": "മിസ്‌ഡ് ഓഡിയോ കോൾ", + "abandon_video": "മിസ്‌ഡ് വീഡിയോ കോൾ", + "chat_reaction": "ഡിഎം പ്രതികരണം", + "initiate_video": "ഇൻകമിംഗ് വീഡിയോ കോൾ" + }, + "gallery_media_send_override": { + "ORIGINAL": "ഒറിജിനൽ", + "NOTE": "ഓഡിയോ കുറിപ്പ്", + "SNAP": "സ്നാപ്പ്" + }, + "strip_media_metadata": { + "hide_snap_filters": "സ്നാപ്പ് ഫിൽട്ടറുകൾ മറയ്ക്കുക", + "hide_extras": "എക്സ്ട്രാകൾ മറയ്ക്കുക (ഉദാ. പരാമർശങ്ങൾ)", + "remove_audio_note_transcript_capability": "ഓഡിയോ നോട്ട് ട്രാൻസ്ക്രിപ്റ്റ് ശേഷി നീക്കം ചെയ്യുക", + "hide_caption_text": "അടിക്കുറിപ്പ് വാചകം മറയ്ക്കുക", + "remove_audio_note_duration": "ഓഡിയോ നോട്ട് ദൈർഘ്യം നീക്കം ചെയ്യുക" + }, + "hide_ui_components": { + "hide_chat_call_buttons": "ചാറ്റ് കോൾ ബട്ടണുകൾ നീക്കം ചെയ്യുക", + "hide_live_location_share_button": "തത്സമയ ലൊക്കേഷൻ പങ്കിടൽ ബട്ടൺ നീക്കം ചെയ്യുക", + "hide_voice_record_button": "വോയ്സ് റെക്കോർഡ് ബട്ടൺ നീക്കം ചെയ്യുക", + "hide_profile_call_buttons": "പ്രൊഫൈൽ കോൾ ബട്ടണുകൾ നീക്കം ചെയ്യുക", + "hide_stickers_button": "സ്റ്റിക്കറുകൾ ബട്ടൺ നീക്കം ചെയ്യുക", + "hide_unread_chat_hint": "വായിക്കാത്ത ചാറ്റ് സൂചന നീക്കം ചെയ്യുക" + }, + "home_tab": { + "map": "മാപ്പ്", + "chat": "ചാറ്റ്", + "camera": "ക്യാമറ", + "discover": "കണ്ടെത്തുക", + "spotlight": "സ്പോട്ട്ലൈറ്റ്" + }, + "add_friend_source_spoof": { + "added_by_mention": "പരാമർശം വഴി", + "added_by_group_chat": "ഗ്രൂപ്പ് ചാറ്റ് വഴി", + "added_by_qr_code": "QR കോഡ് വഴി", + "added_by_community": "കമ്മ്യൂണിറ്റി പ്രകാരം", + "added_by_username": "ഉപയോക്തൃനാമം പ്രകാരം" + }, + "bypass_video_length_restriction": { + "single": "ഏക മാധ്യമം", + "split": "സ്പ്ലിറ്റ് മീഡിയ" + }, + "old_bitmoji_selfie": { + "2d": "2D ബിറ്റ്‌മോജി", + "3d": "3D ബിറ്റ്‌മോജി" + }, + "disable_confirmation_dialogs": { + "block_friend": "സുഹൃത്തിനെ തടയുക", + "ignore_friend": "സുഹൃത്തിനെ അവഗണിക്കുക", + "hide_friend": "സുഹൃത്തിനെ മറയ്ക്കുക", + "hide_conversation": "സംഭാഷണം മറയ്ക്കുക", + "remove_friend": "സുഹൃത്തിനെ നീക്കം ചെയ്യുക", + "clear_conversation": "ഫ്രണ്ട് ഫീഡിൽ നിന്ന് സംഭാഷണം മായ്‌ക്കുക" + }, + "auto_reload": { + "snapchat_only": "Snapchat മാത്രം", + "all": "എല്ലാം (Snapchat SnapEnhance)" + }, + "edit_text_override": { + "multi_line_chat_input": "മൾട്ടി ലൈൻ ചാറ്റ് ഇൻപുട്ട്", + "bypass_text_input_limit": "ബൈപാസ് ടെക്സ്റ്റ് ഇൻപുട്ട് പരിധി" + }, + "auto_purge": { + "never": "ഒരിക്കലുമില്ല", + "1_hour": "1 മണിക്കൂർ", + "3_hours": "3 മണിക്കൂർ", + "6_hours": "6 മണിക്കൂർ", + "12_hours": "12 മണിക്കൂർ", + "1_day": "1 ദിവസം", + "3_days": "3 ദിവസം", + "1_week": "1 ആഴ്ച", + "2_weeks": "2 ആഴ്ച", + "1_month": "1 മാസം", + "3_months": "3 മാസം", + "6_months": "6 മാസം" + }, + "app_appearance": { + "always_light": "എപ്പോഴും വെളിച്ചം", + "always_dark": "എപ്പോഴും ഇരുട്ട്" + } + } + }, + "content_type": { + "NOTE": "ഓഡിയോ കുറിപ്പ്", + "STATUS": "പദവി", + "STATUS_SAVE_TO_CAMERA_ROLL": "ക്യാമറ റോളിൽ സംരക്ഷിച്ചു", + "STATUS_CONVERSATION_CAPTURE_SCREENSHOT": "സ്ക്രീൻഷോട്ട്", + "STATUS_CONVERSATION_CAPTURE_RECORD": "സ്ക്രീൻ റെക്കോർഡ്", + "STATUS_CALL_MISSED_VIDEO": "മിസ്‌ഡ് വീഡിയോ കോൾ", + "SNAP": "സ്നാപ്പ്", + "STATUS_COUNTDOWN": "കൗണ്ട്ഡൗൺ", + "STICKER": "സ്റ്റിക്കർ", + "LOCATION": "സ്ഥാനം", + "STATUS_CALL_MISSED_AUDIO": "മിസ്‌ഡ് ഓഡിയോ കോൾ", + "CHAT": "ചാറ്റ്", + "EXTERNAL_MEDIA": "ബാഹ്യ മാധ്യമങ്ങൾ", + "CREATIVE_TOOL_ITEM": "ക്രിയേറ്റീവ് ടൂൾ ഇനം", + "FAMILY_CENTER_INVITE": "കുടുംബ കേന്ദ്രം ക്ഷണം", + "FAMILY_CENTER_ACCEPT": "കുടുംബ കേന്ദ്രം സ്വീകരിക്കുക", + "FAMILY_CENTER_LEAVE": "ഫാമിലി സെന്റർ ലീവ്", + "STATUS_PLUS_GIFT": "സ്റ്റാറ്റസ് പ്ലസ് സമ്മാനം", + "TINY_SNAP": "ചെറിയ സ്നാപ്പ്", + "LIVE_LOCATION_SHARE": "തത്സമയ ലൊക്കേഷൻ പങ്കിടുക" + }, + "profile_info": { + "snapchat_plus_state": { + "not_subscribed": "സബ്സ്ക്രൈബ് ചെയ്തിട്ടില്ല", + "subscribed": "സബ്സ്ക്രൈബ് ചെയ്തു" + }, + "title": "പ്രൊഫൈൽ വിവരം", + "snapchat_plus": "സ്നാപ്ചാറ്റ് പ്ലസ്", + "friendship": "സൗഹൃദം", + "add_source": "ഉറവിടം ചേർക്കുക", + "birthday": "ജന്മദിനം : {മാസം} {ദിവസം}", + "display_name": "പ്രദർശന നാമം", + "added_date": "ചേർത്ത തീയതി", + "first_created_username": "ആദ്യം സൃഷ്ടിച്ച ഉപയോക്തൃനാമം", + "hidden_birthday": "ജന്മദിനം: മറഞ്ഞിരിക്കുന്നു", + "mutable_username": "മാറ്റാവുന്ന ഉപയോക്തൃനാമം" + }, + "friendship_link_type": { + "mutual": "പരസ്പരമുള്ള", + "blocked": "തടഞ്ഞു", + "deleted": "ഇല്ലാതാക്കി", + "following": "പിന്തുടരുന്നു", + "suggested": "നിർദ്ദേശിച്ചു", + "incoming_follower": "ഇൻകമിംഗ് ഫോളോവർ", + "outgoing": "ഔട്ട്ഗോയിംഗ്", + "incoming": "ഇൻകമിംഗ്" + }, + "media_download_source": { + "public_story": "പൊതു കഥ", + "spotlight": "സ്പോട്ട്ലൈറ്റ്", + "profile_picture": "പ്രൊഫൈൽ ചിത്രം", + "chat_media": "ചാറ്റ് മീഡിയ", + "story": "കഥ", + "merged": "ലയിപ്പിച്ചു", + "none": "ഒന്നുമില്ല", + "pending": "തീർപ്പാക്കാത്തത്", + "story_logger": "സ്റ്റോറി ലോഗർ" + }, + "chat_action_menu": { + "download_button": "ഡൗൺലോഡ്", + "delete_logged_message_button": "ലോഗിൻ ചെയ്ത സന്ദേശം ഇല്ലാതാക്കുക", + "preview_button": "പ്രിവ്യൂ", + "convert_message": "സന്ദേശം പരിവർത്തനം ചെയ്യുക" + }, + "modal_option": { + "close": "അടയ്ക്കുക", + "profile_info": "പ്രൊഫൈൽ വിവരം" + }, + "conversation_preview": { + "streak_expiration": "{day} ദിവസം {hour} മണിക്കൂർ {minute} മിനിറ്റിനുള്ളിൽ കാലഹരണപ്പെടുന്നു", + "total_messages": "ആകെ അയച്ച/ലഭിച്ച സന്ദേശങ്ങൾ: {count}", + "title": "പ്രിവ്യൂ", + "unknown_user": "അജ്ഞാത ഉപയോക്താവ്" + }, + "opera_context_menu": { + "created_at": "{date}-ന് സൃഷ്‌ടിച്ചത്", + "expires_at": "{date}-ന് കാലഹരണപ്പെടുന്നു", + "media_duration": "മീഡിയ ദൈർഘ്യം: {duration} ms", + "show_debug_info": "ഡീബഗ് വിവരം കാണിക്കുക", + "sent_at": "{date}-ന് അയച്ചു", + "media_size": "മീഡിയ വലുപ്പം: {size}", + "download": "മീഡിയ ഡൗൺലോഡ് ചെയ്യുക" + }, + "bulk_messaging_action": { + "progress_status": "{total}-ന്റെ {index} പ്രോസസ്സ് ചെയ്യുന്നു", + "selection_dialog_continue_button": "തുടരുക", + "actions": { + "remove_friends": "സുഹൃത്തുക്കളെ നീക്കം ചെയ്യുക", + "clear_conversations": "വ്യക്തമായ സംഭാഷണങ്ങൾ" + }, + "confirmation_dialog": { + "message": "ഇത് തിരഞ്ഞെടുത്ത എല്ലാ സുഹൃത്തുക്കളെയും ബാധിക്കും. ഈ പ്രവർത്തനം പഴയപടിയാക്കാനാകില്ല.", + "title": "നിങ്ങൾക്ക് ഉറപ്പാണോ?" + }, + "choose_action_title": "ഒരു പ്രവർത്തനം തിരഞ്ഞെടുക്കുക" + }, + "chat_export": { + "exporter_dialog": { + "select_conversations_title": "സംഭാഷണങ്ങൾ തിരഞ്ഞെടുക്കുക", + "text_field_selection": "{തുക} തിരഞ്ഞെടുത്തു", + "text_field_selection_all": "എല്ലാം", + "export_file_format_title": "ഫയൽ ഫോർമാറ്റ് കയറ്റുമതി ചെയ്യുക", + "download_medias_title": "Medias ഡൗൺലോഡ് ചെയ്യുക", + "message_type_filter_title": "തരം അനുസരിച്ച് സന്ദേശങ്ങൾ ഫിൽട്ടർ ചെയ്യുക", + "amount_of_messages_title": "സന്ദേശങ്ങളുടെ അളവ് (എല്ലാവർക്കും ശൂന്യമായി വിടുക)" + }, + "dialog_negative_button": "റദ്ദാക്കുക", + "dialog_positive_button": "കയറ്റുമതി", + "exported_to": "{path}-ലേക്ക് കയറ്റുമതി ചെയ്തു", + "exporting_chats": "ചാറ്റുകൾ കയറ്റുമതി ചെയ്യുന്നു...", + "export_fail": "സംഭാഷണം {conversation} കയറ്റുമതി ചെയ്യാനായില്ല", + "writing_output": "ഔട്ട്പുട്ട് എഴുതുന്നു...", + "finished": "ചെയ്തു! നിങ്ങൾക്ക് ഇപ്പോൾ ഈ ഡയലോഗ് അടയ്ക്കാം.", + "no_messages_found": "സന്ദേശങ്ങളൊന്നും കണ്ടെത്തിയില്ല!", + "exporting_message": "{conversation} എക്‌സ്‌പോർട്ട് ചെയ്യുന്നു...", + "processing_chats": "{amount} സംഭാഷണങ്ങൾ പ്രോസസ്സ് ചെയ്യുന്നു..." + }, + "button": { + "ok": "ശരി", + "positive": "അതെ", + "negative": "ഇല്ല", + "cancel": "റദ്ദാക്കുക", + "download": "ഡൗൺലോഡ്", + "open": "തുറക്കുക" + }, + "better_notifications": { + "button": { + "reply": "മറുപടി", + "download": "ഡൗൺലോഡ്", + "mark_as_read": "വായിച്ചതായി അടയാളപ്പെടുത്തുക" + } + }, + "profile_picture_downloader": { + "button": "പ്രൊഫൈൽ ചിത്രം ഡൗൺലോഡ് ചെയ്യുക", + "avatar_option": "അവതാർ", + "background_option": "പശ്ചാത്തലം", + "title": "പ്രൊഫൈൽ ചിത്രം ഡൗൺലോഡർ" + }, + "call_start_confirmation": { + "dialog_title": "കോൾ ആരംഭിക്കുക", + "dialog_message": "നിങ്ങൾക്ക് ഒരു കോൾ ആരംഭിക്കണമെന്ന് തീർച്ചയാണോ?" + }, + "half_swipe_notifier": { + "notification_channel_name": "പകുതി സ്വൈപ്പ് ചെയ്യുക", + "notification_content_group": "{friend} {group}-ലേക്ക് {duration} സെക്കൻഡ് പകുതി സ്വൈപ്പ് ചെയ്തു", + "notification_content_dm": "{സുഹൃത്ത്} നിങ്ങളുടെ ചാറ്റിലേക്ക് {duration} സെക്കൻഡ് പകുതി സ്വൈപ്പ് ചെയ്തു" + }, + "download_processor": { + "attachment_type": { + "snap": "സ്നാപ്പ്", + "sticker": "സ്റ്റിക്കർ", + "external_media": "ബാഹ്യ മാധ്യമങ്ങൾ", + "note": "കുറിപ്പ്", + "original_story": "ഒറിജിനൽ സ്റ്റോറി" + }, + "download_started_toast": "ഡൗൺലോഡ് ആരംഭിച്ചു", + "unsupported_content_type_toast": "പിന്തുണയ്‌ക്കാത്ത ഉള്ളടക്ക തരം!", + "failed_no_longer_available_toast": "മീഡിയ ഇനി ലഭ്യമല്ല", + "no_attachments_toast": "അറ്റാച്ചുമെന്റുകളൊന്നും കണ്ടെത്തിയില്ല!", + "already_queued_toast": "മാധ്യമങ്ങൾ ഇതിനകം ക്യൂവിലാണ്!", + "already_downloaded_toast": "മീഡിയ ഇതിനകം ഡൗൺലോഡ് ചെയ്‌തു!", + "download_toast": "{path} ഡൗൺലോഡ് ചെയ്യുന്നു...", + "failed_generic_toast": "ഡൗൺലോഡ് ചെയ്യാനായില്ല", + "failed_to_create_preview_toast": "പ്രിവ്യൂ സൃഷ്ടിക്കുന്നതിൽ പരാജയപ്പെട്ടു", + "failed_gallery_toast": "ഗാലറിയിൽ സംരക്ഷിക്കുന്നതിൽ പരാജയപ്പെട്ടു {പിശക്}", + "select_attachments_title": "ഡൗൺലോഡ് ചെയ്യാൻ അറ്റാച്ച്‌മെന്റുകൾ തിരഞ്ഞെടുക്കുക", + "processing_toast": "{path} പ്രോസസ്സ് ചെയ്യുന്നു...", + "failed_processing_toast": "പ്രോസസ്സിംഗ് പരാജയപ്പെട്ടു {പിശക്}" + }, + "streaks_reminder": { + "notification_title": "വരകൾ", + "notification_text": "{hoursLeft} മണിക്കൂറിനുള്ളിൽ {സുഹൃത്തുമായുള്ള നിങ്ങളുടെ സ്ട്രീക്ക് നിങ്ങൾക്ക് നഷ്ടപ്പെടും" + }, + "gallery_media_send_override": { + "multiple_media_toast": "നിങ്ങൾക്ക് ഒരു സമയം ഒരു മീഡിയ മാത്രമേ അയയ്ക്കാൻ കഴിയൂ" + }, + "friend_menu_option": { + "mark_snaps_as_seen": "Snaps കണ്ടതായി അടയാളപ്പെടുത്തുക", + "mark_stories_as_seen_locally": "പ്രാദേശികമായി കാണുന്ന കഥകൾ അടയാളപ്പെടുത്തുക", + "preview": "പ്രിവ്യൂ", + "stealth_mode": "സ്റ്റെൽത്ത് മോഡ്", + "auto_download_blacklist": "ഓട്ടോ ഡൗൺലോഡ് ബ്ലാക്ക്‌ലിസ്റ്റ്", + "anti_auto_save": "ആന്റി ഓട്ടോ സേവ്" + }, + "scopes": { + "friend": "സുഹൃത്ത്", + "group": "കുട്ടായ്മ" + } +} diff --git a/common/src/main/assets/lang/nb_NO.json b/common/src/main/assets/lang/nb_NO.json new file mode 100644 index 0000000000..b73ac1de26 --- /dev/null +++ b/common/src/main/assets/lang/nb_NO.json @@ -0,0 +1,211 @@ +{ + "manager": { + "routes": { + "home_logs": "Loggføring", + "scripts": "Skript", + "home_settings": "Innstillinger", + "features": "Funksjoner", + "home": "Hjem", + "tasks": "Gjøremål", + "logger_history": "Loggføringshistorie", + "logged_stories": "Loggede historier", + "messaging_preview": "Forhåndsvisning", + "social": "Sosialt", + "manage_scope": "Administrer omfang", + "better_location": "Bedre Posisjon", + "file_imports": "Fil-importer", + "edit_rule": "Rediger Regel", + "friend_tracker": "Vennesporing" + }, + "sections": { + "social": { + "streaks_expiration_short": "{hours}t", + "friends_tab": "Venner", + "groups_tab": "Grupper", + "empty_hint": "(tom)" + }, + "features": { + "disabled": "Deaktivert", + "config_import_success_toast": "Importering av konfigurasjon vellykket", + "config_export_success_toast": "Eksportering av konfigurasjon vellykket", + "export_option": "Eksporter", + "reset_option": "Tilbakestill", + "config_import_failure_toast": "Feil under importering av konfigurasjon {error}", + "config_export_failure_toast": "Feil under eksportering av konfigurasjon {error}", + "saved_config_snackbar": "Konfigurasjon lagret", + "import_option": "Importer" + }, + "tasks": { + "no_tasks": "Ingen gjøremål", + "remove_all_tasks_title": "Er du sikker på at du vil fjerne alle gjøremål?", + "failed_to_open_file": "Feil under åpning av fil", + "merge_files_toast": "Slår sammen {count} filer", + "remove_selected_tasks_title": "Er du sikker på at du vil fjerne valgte gjøremål?", + "delete_files_option": "Slett filer også", + "remove_selected_tasks_confirm": "Fjern {count} gjøremål?", + "remove_all_tasks_confirm": "Fjern alle gjøremål?" + }, + "home": { + "update_content": "Versjon {versjon} er tilgjengelig!", + "update_button": "Last ned", + "update_title": "SnapEnhance Oppdatering", + "debug_build_summary_title": "Du kjører et feilsøkingsbyggversjon av SnapEnhance", + "debug_build_summary_date": "Byggversjonsdato: {date} ({days} dager siden)", + "quick_actions_title": "Kjappe Handlinger", + "debug_build_summary_content": "Versjon {versionName} ({versionCode})", + "version_title": "v{versionName} · av rhunk" + }, + "home_logs": { + "no_logs_hint": "Ingen logger tilgjengelig", + "clear_logs_button": "Slett Logg", + "export_logs_button": "Eksporter Logg", + "saving_logs_toast": "Lagrer logg, dette kan ta en stund ...", + "saved_logs_success_toast": "Logg lagret vellykket", + "saved_logs_failure_toast": "Feil under lagring av logg" + }, + "home_settings": { + "actions_title": "Handlinger", + "success_toast": "Ferdig!", + "message_logger_summary": "{messageCount} meldinger\n{storyCount} historier", + "message_logger_title": "Meldingslogger", + "export_button": "Eksporter", + "clear_button": "Rensk", + "view_logger_history_button": "Se logghistorikk", + "debug_title": "Feilsøking" + }, + "manage_scope": { + "logged_stories_button": "Vis loggede historier", + "streaks_expiration_text_expired": "Utgått", + "e2ee_title": "Ende-til-ende-kryptering", + "streaks_length_text": "Lengde: {length}", + "streaks_expiration_text": "Utgår om {eta}", + "delete_scope_confirm_dialog_title": "Er du sikker på at du vil slette en {scope}?", + "rules_title": "Regler", + "streaks_title": "Streaks", + "reminder_button": "Sett påminnelse", + "participants_text": "{count} deltakere", + "not_found": "Ikke funnet" + }, + "logged_stories": { + "story_failed_to_load": "Feil under lasting", + "no_stories": "Ingen historier funnet" + } + }, + "dialogs": { + "add_friend": { + "search_hint": "Søk", + "category_friends": "Venner", + "category_groups": "Grupper", + "title": "Legg til venn eller gruppe", + "fetch_error": "Kunne ikke hente data" + }, + "scripting_warning": { + "title": "Advarsel", + "content": "SnapEnhance inkluderer et skriptverktøy som tillater utføring av brukerdefinert kode på enheten din. Vær ekstremt forsiktig og installer kun moduler fra kjente, pålitelige kilder. Uautoriserte eller uverifiserte moduler kan utgjøre sikkerhetsrisikoer for systemet ditt." + } + } + }, + "setup": { + "mappings": { + "dialog": "For å dynamisk støtte et bredt spekter av Snapchat-versjoner, er kartlegging nødvendig for at SnapEnhance skal fungere ordentlig, dette bør ikke ta mer enn 5 sekunder.", + "generate_failure_no_snapchat": "SnapEnhance kunne ikke oppdage Snapchat, prøv å installere Snapchat på nytt.", + "generate_failure": "Det oppstod en feil under forsøk på å generere kartlegginger. Prøv igjen." + }, + "dialogs": { + "select_save_folder_button": "Velg mappe", + "select_language": "Velg språk", + "save_folder": "SnapEnhance krever lagrings tillatelser for og laste ned og lagre media fra snapchaf. Vennligst velg hvor filene skal lagres" + }, + "permissions": { + "battery_optimization": "Batterioptimalisering", + "display_over_other_apps": "Vis over andre apper", + "notification_access": "Varslingstilgang", + "request_button": "Forespør", + "dialog": "For å fortsette må du oppfylle følgende krav:" + } + }, + "rules": { + "modes": { + "blacklist": "Svartelistingsmodus", + "whitelist": "Hvitlistingsmodus" + }, + "properties": { + "auto_save": { + "options": { + "whitelist": "Automatisk lagring", + "blacklist": "Ekskluder fra Automatisk lagring" + }, + "name": "Automatisk lagring", + "description": "Lagrer chatmeldinger når du ser dem" + }, + "auto_download": { + "name": "Automatisk nedlasting", + "options": { + "blacklist": "Ekskluder fra Automatisk nedlasting", + "whitelist": "Automatisk nedlasting" + }, + "description": "Automatisk last ned snapper når du ser dem" + }, + "unsaveable_messages": { + "name": "Meldinger som ikke kan lagres", + "description": "Forhindrer meldinger fra å bli lagret i chat av andre personer", + "options": { + "blacklist": "Ekskluder fra Meldinger som ikke kan lagres" + } + }, + "stealth": { + "description": "Forhindrer noen fra å vite at du har åpnet snappene/chattene og samtalene dems", + "options": { + "whitelist": "Smugmodus", + "blacklist": "Ekskluder fra smugmodus" + }, + "name": "Smugmodus" + } + }, + "toasts": { + "disabled": "{ruleName} deaktivert", + "enabled": "{ruleName} aktivert" + } + }, + "features": { + "properties": { + "downloader": { + "properties": { + "ffmpeg_options": { + "properties": { + "video_bitrate": { + "name": "Videobitrate", + "description": "Sett videobitraten (kbps)" + }, + "threads": { + "name": "Tråder" + }, + "audio_bitrate": { + "name": "Lydbitrate", + "description": "Sett lydbitraten (kbps)" + }, + "custom_video_codec": { + "name": "Egendefinert videokodek" + } + } + } + } + }, + "user_interface": { + "properties": { + "bootstrap_override": { + "properties": { + "home_tab": { + "name": "Hjemmefane" + } + } + } + } + } + } + }, + "scopes": { + "friend": "Venn", + "group": "Gruppe" + } +} diff --git a/common/src/main/assets/lang/nl.json b/common/src/main/assets/lang/nl.json new file mode 100644 index 0000000000..bc38366b6d --- /dev/null +++ b/common/src/main/assets/lang/nl.json @@ -0,0 +1,461 @@ +{ + "setup": { + "dialogs": { + "select_language": "Taal Selecteren", + "save_folder": "SnapEnhance vereist opslagrechten om media van Snapchat te downloaden en op te slaan.\nKies de downloadlocatie.", + "select_save_folder_button": "Selecteer Map" + }, + "mappings": { + "dialog": "Calibreren, dit kan even duren...", + "generate_failure_no_snapchat": "SnapEnhance kon Snapchat niet detecteren, probeer Snapchat opnieuw te installeren.", + "generate_failure": "Er is een fout opgetreden tijdens het calibreren, probeer het opnieuw." + }, + "permissions": { + "dialog": "Om door te gaan moet je voldoen aan de volgende vereisten:", + "notification_access": "Toegang tot meldingen", + "battery_optimization": "Accu optimalisatie", + "display_over_other_apps": "Weergeven vóór andere apps", + "request_button": "Aanvraag" + } + }, + "manager": { + "routes": { + "features": "Functies", + "home": "Startscherm", + "home_settings": "Instellingen", + "home_logs": "Logs", + "social": "Sociaal", + "scripts": "Scripts", + "manage_scope": "Scope beheren", + "messaging_preview": "Voorbeeld", + "tasks": "Taken", + "logger_history": "Log geschiedenis", + "logged_stories": "Gelogde Stories", + "friend_tracker": "Vrienden vinder", + "edit_rule": "Regel bewerken", + "better_location": "Betere Locatie", + "file_imports": "Geïmporteerde bestanden", + "theming": "Thema's", + "edit_theme": "Thema bewerken", + "manage_repos": "Repositories Beheren" + }, + "sections": { + "features": { + "disabled": "Uitgeschakeld", + "export_option": "Exporteer", + "import_option": "Impoteer", + "reset_option": "Opnieuw instellen", + "config_export_success_toast": "Config succesvol geëxporteerd", + "config_import_success_toast": "Config succesvol geïmporteerd", + "config_import_failure_toast": "Gefaald om config te importeren {error}", + "saved_config_snackbar": "Config opgeslagen", + "config_export_failure_toast": "Configuratie kan niet worden geëxporteerd {error}" + }, + "social": { + "streaks_expiration_short": "{hours}u", + "friends_tab": "Vrienden", + "groups_tab": "Groepen", + "empty_hint": "(leeg)" + }, + "tasks": { + "no_tasks": "Geen taken", + "remove_all_tasks_confirm": "Verwijder alle taken?", + "remove_selected_tasks_title": "Weet je zeker dat je de geselecteerde taak wilt verwijderen?", + "remove_all_tasks_title": "Weet je zeker dat je alle taken wilt verwijderen?", + "merge_files_toast": "{count} bestand(en) samenvoegen", + "remove_selected_tasks_confirm": "{count} taken verwijderen?", + "delete_files_option": "Verwijderen ook bestanden", + "failed_to_open_file": "Bestand kan niet worden geopend" + }, + "home": { + "update_title": "SnapEnhance Update", + "update_content": "Versie {versie} is beschikbaar!", + "update_button": "Download", + "version_title": "v{versionName} · door rhunk", + "debug_build_summary_title": "Je gebruikt een debug versie van SnapEnhance", + "debug_build_summary_content": "Versie {versionName} ({versionCode})", + "debug_build_summary_date": "Build datum: {date} ({days} dagen geleden)", + "quick_actions_title": "Snelle Acties" + }, + "home_logs": { + "no_logs_hint": "Geen logs beschikbaar", + "export_logs_button": "Exporteer logs", + "saving_logs_toast": "Logs opslaan, dit kan een ogenblik duren ...", + "saved_logs_success_toast": "Logs opslagen gelukt", + "saved_logs_failure_toast": "Gefaald om logs op te slaan", + "clear_logs_button": "Logs wissen" + }, + "home_settings": { + "actions_title": "Acties", + "message_logger_title": "Berichten logger", + "success_toast": "Klaar!", + "export_button": "Exporteren", + "debug_title": "Debug", + "view_logger_history_button": "Bekijk Logs Geschiedenis", + "message_logger_summary": "{messageCount} berichten\n{storyCount} verhalen", + "clear_button": "Wissen" + }, + "manage_scope": { + "logged_stories_button": "Toon gelogde verhalen", + "e2ee_title": "Eind-tot-eind encryptie", + "rules_title": "Regels", + "participants_text": "{aantal} deelnemers", + "not_found": "Niet gevonden", + "streaks_title": "Reeksen", + "streaks_length_text": "Lengte: {lengte}", + "streaks_expiration_text": "Verloopt in {eta}", + "streaks_expiration_text_expired": "Verlopen", + "reminder_button": "Zet herinnering", + "delete_scope_confirm_dialog_title": "Weet u zeker dat u {scope} wilt verwijderen?" + }, + "logged_stories": { + "story_failed_to_load": "Gefaald om te laden", + "no_stories": "Geen verhalen gevonden", + "save_from_cache_button": "Opslaan uit Cache" + }, + "messaging_preview": { + "message_fetch_failed": "Gefaald op berichten op te halen", + "no_message_hint": "Geen bericht", + "save_selection_option": "Selectie opslaan", + "save_all_option": "Alles Opslaan", + "mark_selection_as_seen_option": "Snap selecteren als gezien", + "bridge_connection_failed": "Kan geen verbinding maken met de brug. Zorg dat Snapchat op de achtergrond aanstaat", + "bridge_init_failed": "Kan de berichtenbrug niet initialiseren. Zorg dat Snapchat op de achtergrond aanstaat", + "unsave_selection_option": "Selectie niet opslaan", + "unsave_all_option": "Alles niet opslaan", + "mark_all_as_seen_option": "Markeer alle Snaps als gezien", + "delete_selection_option": "Selectie verwijderen", + "delete_all_option": "Alles verwijderen" + }, + "logger_history": { + "reverse_order_checkbox": "Omgekeerde Volgorde", + "list_friend_format": "Vriend {name}", + "list_group_format": "Groep {name}", + "no_more_messages": "Geen berichten meer", + "chat_attachment": "Bijlage {index}", + "empty_message": "Leeg chatbericht", + "message_parse_failed": "Kan het bericht niet parseren", + "unknown_sender": "Onbekende Afzender", + "download_attachment_failed_toast": "Kan bijlage niet downloaden" + }, + "better_location": { + "save_dialog_button": "Opslaan", + "save_coordinates_dialog_title": "Coördinaten Opslaan", + "saved_name_dialog_hint": "Naam Opgeslagen", + "delete_dialog_title": "Verwijder Opgeslagen Coördinaat", + "latitude_dialog_hint": "Latitude", + "longitude_dialog_hint": "Longitude", + "spoof_location_toggle": "Locatie Spoofen", + "choose_location_button": "Locatie Kiezen", + "teleport_to_friend_button": "Teleporteer naar Vriend", + "no_friends_found": "Geen vrienden gevonden", + "suspend_location_updates": "Locatie Updates Pauzeren", + "saved_coordinates_title": "Coördinaten Opgeslagen", + "no_saved_coordinates_hint": "Geen opgeslagen coördinaten", + "delete_dialog_message": "Weet je zeker dat je dit opgeslagen coördinaat wilt verwijderen?", + "teleport_to_friend_title": "Teleporteer naar Vriend", + "spoofed_coordinates_title": "Lat {latitude}, Lng {longitude}", + "search_bar": "Zoeken", + "no_friends_map": "Geen vrienden op de kaart" + }, + "file_imports": { + "file_not_found": "Bestand niet gevonden", + "file_import_failed": "Bestand kan niet worden geïmporteerd: {error}", + "file_delete_failed": "Bestand kan niet worden verwijderd", + "file_imported": "Bestand succesvol geïmporteerd", + "no_files_hint": "Hier kan je bestanden importeren om in Snapchat te gebruiken. Druk op de onderstaande knop om een bestand te importeren.", + "import_file_button": "Bestand Importeren" + }, + "theming": { + "no_themes_hint": "Geen thema's gevonden" + } + }, + "dialogs": { + "add_friend": { + "title": "Vriend of groep toevoegen", + "search_hint": "Zoeken", + "fetch_error": "Kan gegevens niet ophalen", + "category_groups": "Groepen", + "category_friends": "Vrienden" + }, + "scripting_warning": { + "title": "Waarschuwing", + "content": "SnapEnhance bevat een scripting-tool, waarmee door de gebruiker gedefinieerde code op uw apparaat kan worden uitgevoerd. Wees uiterst voorzichtig en installeer alleen modules van bekende, betrouwbare bronnen. Niet-geautoriseerde of niet-geverifieerde modules kunnen beveiligingsrisico's voor uw systeem opleveren." + }, + "reset_config": { + "title": "Configuratie resetten", + "content": "Weet u zeker dat u de configuratie wilt resetten?", + "success_toast": "Configuratie succesvol gereset" + }, + "messaging_action": { + "title": "Kies inhoudstypen om te verwerken", + "select_all_button": "Alles Selecteren" + }, + "export_config": { + "content": "Wil je de configuratie met gevoelige data exporteren? (Zoals locatie coördinaten, etc.)", + "title": "Gevoelige Data Exporteren?" + }, + "file_imports": { + "no_files_settings_hint": "Geen bestanden gevonden. Zorg dat je de benodigde bestanden hebt geïmporteerd in de Geïmporteerde Bestanden sectie", + "settings_select_file_hint": "Selecteer een geïmporteerd bestand" + } + } + }, + "rules": { + "modes": { + "blacklist": "Zwarte lijst modus", + "whitelist": "Witte lijst modus" + }, + "properties": { + "auto_download": { + "name": "Automatisch downloaden", + "description": "Download Snaps automatisch tijdens het bekijken", + "options": { + "blacklist": "Uitsluiten van automatisch downloaden", + "whitelist": "Automatische download" + } + }, + "stealth": { + "name": "Verberg Modus", + "description": "Voorkomt dat iedereen weet dat je hun snaps/chats en gesprekken hebt geopend", + "options": { + "blacklist": "Uitsluiten van Verberg Modus", + "whitelist": "Verberg modus" + } + }, + "auto_save": { + "name": "Automatisch opslaan", + "description": "Slaat chatberichten op wanneer je ze bekijkt", + "options": { + "blacklist": "Uitsluiten van automatisch opslaan", + "whitelist": "Automatisch opslaan" + } + }, + "hide_friend_feed": { + "name": "Verbergen in Vrienden Feed" + }, + "e2e_encryption": { + "name": "Gebruik E2E Encryptie" + }, + "pin_conversation": { + "name": "Gesprek vastzetten" + }, + "unsaveable_messages": { + "name": "Berichten die niet kunnen worden opgeslagen", + "description": "Voorkomt dat berichten door andere mensen in de chat worden opgeslagen", + "options": { + "blacklist": "Sluit uit van onopslaanbare berichten", + "whitelist": "Berichten die niet kunnen worden opgeslagen" + } + }, + "auto_open_snaps": { + "name": "Snaps Automatisch Openen", + "description": "Opent Snaps automatisch bij ontvangst", + "options": { + "blacklist": "Uitsluiten van Automatisch Openen van Snaps", + "whitelist": "Snaps Automatisch Openen" + } + } + }, + "toasts": { + "enabled": "{ruleName} Ingeschakeld", + "disabled": "{ruleName} uitgeschakeld" + } + }, + "features": { + "notices": { + "unstable": "⚠ onstabiel", + "ban_risk": "⚠️ Deze functie kan bans veroorzaken", + "internal_behavior": "⚠️ Dit kan het interne gedrag van Snapchat breken" + }, + "properties": { + "downloader": { + "name": "Downloader", + "properties": { + "save_folder": { + "name": "Opslag locatie", + "description": "Selecteer de map waar alle media naar moeten worden gedownload" + }, + "auto_download_sources": { + "name": "Automatisch Bronnen downloaden", + "description": "Selecteer de bronnen waaruit je automatisch wilt downloaden" + }, + "prevent_self_auto_download": { + "name": "Eigen automatische download voorkomen", + "description": "Voorkomt dat je eigen snaps automatisch worden gedownload" + }, + "path_format": { + "name": "Pad Formaat", + "description": "Geef het bestandspad formaat op" + }, + "allow_duplicate": { + "name": "Duplicaten toestaan", + "description": "Staat toe om meerdere keren hetzelfde media te downloaden" + }, + "merge_overlays": { + "name": "Overlays Samenvoegen", + "description": "Combineert de tekst en de media van een Snap in een enkel bestand" + }, + "force_image_format": { + "name": "Forceer Afbeeldingsformaat", + "description": "Forceert dat afbeeldingen opgeslagen moeten worden in een gespecificeerd formaat" + }, + "force_voice_note_format": { + "name": "Forceer spraaknotitie formaat", + "description": "Forceert dat spraaknotities opgeslagen moeten worden in een opgegeven formaat" + }, + "download_profile_pictures": { + "name": "Download profielfoto's", + "description": "Maakt het mogelijk om profielfoto's van de profielpagina te downloaden" + }, + "ffmpeg_options": { + "name": "FFmpeg Opties", + "description": "Specificeer extra FFmpeg opties", + "properties": { + "threads": { + "name": "Threads", + "description": "Het aantal threads dat moet worden gebruikt" + }, + "preset": { + "name": "Voorinstelling", + "description": "Stel de snelheid van de conversie in" + }, + "video_bitrate": { + "name": "Video-bitsnelheid", + "description": "Stel de videobitsnelheid (kbps) in" + }, + "audio_bitrate": { + "description": "Stel de audiobitsnelheid (kbps) in", + "name": "Audio-bitsnelheid" + }, + "custom_video_codec": { + "name": "Aangepaste videocodec", + "description": "Stel een aangepaste videocodec in (bijvoorbeeld libx264)" + }, + "constant_rate_factor": { + "description": "Stel de constante snelheidsfactor voor de video-encoder in\nVan 0 tot 51 voor libx264", + "name": "Constante tarieffactor" + }, + "custom_audio_codec": { + "name": "Aangepaste audiocodec", + "description": "Stel een aangepaste audiocodec in (bijvoorbeeld AAC)" + } + } + }, + "download_context_menu": { + "description": "Hiermee kunt u berichten uit een gesprek of een verhaal downloaden/bekijken via het contextmenu.\nAls u lang op de knoppen drukt, wordt het downloaden geforceerd", + "name": "Contextmenu downloaden" + }, + "custom_path_format": { + "description": "Geef een aangepast padformaat op voor gedownloade media\n\nBeschikbare variabelen:\n - %username%\n - %source%\n - %hash%\n - %date_time%", + "name": "Aangepaste padindeling" + }, + "logging": { + "description": "Toont toast wanneer media worden gedownload", + "name": "Loggen" + }, + "opera_download_button": { + "name": "Opera-downloadknop", + "description": "Voegt een downloadknop toe in de rechterbovenhoek wanneer je een Snap bekijkt.\nAls u lang op de knoppen drukt, wordt het downloaden geforceerd" + } + }, + "description": "Snapchat-media downloaden" + }, + "user_interface": { + "description": "Verander het uiterlijk en gevoel van Snapchat", + "properties": { + "enable_app_appearance": { + "name": "Activeer App Uiterlijk Instellingen", + "description": "Schakelt de verborgen App Uiterlijk Instelling in\nMogelijk niet nodig in nieuwere Snapchat versies" + }, + "friend_feed_message_preview": { + "name": "Voorbeeld Vrienden Feed Bericht", + "description": "Toont een voorbeeld van de laatste berichten in de Vrienden Feed", + "properties": { + "amount": { + "name": "Hoeveelheid", + "description": "Het aantal berichten om een voorbeeld te krijgen" + } + } + }, + "bootstrap_override": { + "name": "Bootstrap overschrijven", + "description": "Overschrijft de gebruikersinterface bootstrap instellingen", + "properties": { + "app_appearance": { + "name": "App Uiterlijk", + "description": "Stelt een persistent App uiterlijk in" + }, + "home_tab": { + "name": "Startpagina tabblad", + "description": "Overschrijft het starttabblad bij het openen van Snapchat" + } + } + }, + "map_friend_nametags": { + "name": "Verbeterde Kaart Naamtags Van Vrienden", + "description": "Verbetert de naamtags van vrienden op de Snapmap" + }, + "streak_expiration_info": { + "name": "Toon Snapreeks vervaldatum info", + "description": "Toont een Snapreeks Verlooptimer naast de Snapreeks teller" + }, + "hide_friend_feed_entry": { + "name": "Verberg Vrienden Feed", + "description": "Verbergt een specifieke vriend uit de Vrienden Feed\nGebruik het sociale tabblad om deze functie te beheren" + }, + "hide_streak_restore": { + "name": "Snapreeksherstel verbergen", + "description": "Verbergt de Herstel knop in de vrienden feed" + }, + "hide_ui_components": { + "name": "Verberg UI Componenten", + "description": "Selecteer welke UI componenten te verbergen" + }, + "disable_spotlight": { + "name": "Spotlight uitschakelen", + "description": "Schakelt de Spotlight pagina uit" + } + }, + "name": "Gebruikersomgeving" + } + } + }, + "scopes": { + "friend": "Vriend", + "group": "Groep" + }, + "actions": { + "clean_snapchat_cache": { + "name": "Snapchat-cache opschonen", + "description": "Schoont de Snapchat-cache op" + }, + "export_chat_messages": { + "name": "Chatberichten exporteren", + "description": "Exporteert gespreksberichten naar een JSON/HTML/TXT-bestand" + }, + "manage_friend_list": { + "description": "Importeer/exporteer uw vriendenlijst bij het maken van een back-up", + "name": "Vriendenlijst beheren" + }, + "bulk_messaging_action": { + "name": "Bulkberichtactie", + "description": "Voert handelingen uit zoals het verwijderen van vrienden of het massaal verwijderen van gesprekken" + }, + "export_memories": { + "description": "Exporteert herinneringen naar een ZIP-bestand", + "name": "Herinneringen Exporteren" + }, + "change_language": { + "description": "Wijzig de taal van SnapEnhance", + "name": "Taal Wijzigen" + }, + "regen_mappings": { + "name": "Mappings Regenereren", + "description": "Mappings Handmatig Regenereren" + }, + "security_features": { + "name": "Beveiligingsfuncties" + } + } +} diff --git a/common/src/main/assets/lang/pl.json b/common/src/main/assets/lang/pl.json new file mode 100644 index 0000000000..3638c5859d --- /dev/null +++ b/common/src/main/assets/lang/pl.json @@ -0,0 +1,256 @@ +{ + "setup": { + "dialogs": { + "select_language": "Wybierz język", + "save_folder": "SnapEnhance wymaga uprawnień do przechowywania danych w celu pobierania i zapisywania mediów z Snapchat.\nProszę wybrać lokalizację, do której media powinny być pobierane.", + "select_save_folder_button": "Wybierz Folder" + }, + "mappings": { + "dialog": "Aby dynamicznie obsługiwać szeroki zakres wersji Snapchat, konieczne są mapowania, aby SnapEnhance działał poprawnie. To nie powinno zająć więcej niż 5 sekund.", + "generate_failure_no_snapchat": "SnapEnhance nie było w stanie wykryć Snapchata, spróbuj ponownie zainstalować Snapchat.", + "generate_failure": "Wystąpił błąd podczas próby generowania mapowań, spróbuj ponownie." + }, + "permissions": { + "dialog": "Aby kontynuować, musisz spełnić poniższe wymagania:", + "notification_access": "Dostęp do powiadomień", + "battery_optimization": "Optymalizacja baterii", + "display_over_other_apps": "Wyświetlanie nad innymi aplikacjami", + "request_button": "Wymagania" + } + }, + "manager": { + "routes": { + "features": "Cechy", + "home": "Strona główna", + "home_settings": "Ustawienia", + "home_logs": "Logi", + "social": "Społeczność", + "scripts": "Skrypty", + "messaging_preview": "Podgląd", + "tasks": "Zadania" + }, + "sections": { + "features": { + "disabled": "Wyłączony" + }, + "home": { + "update_title": "Aktualizacja SnapEnchance", + "update_content": "Wersja {version} jest dostępna!", + "update_button": "Pobierz" + }, + "home_logs": { + "no_logs_hint": "Brak Dostępnych Logów", + "clear_logs_button": "Wyczyść Logi", + "export_logs_button": "Eksportuj Logi", + "saved_logs_success_toast": "Logi Zapisane Pomyślnie", + "saved_logs_failure_toast": "Nie udało zapisać sie logów", + "saving_logs_toast": "Zapisywanie logów, To może chwile potrwać ..." + }, + "home_settings": { + "message_logger_summary": "{messageCount} Wiadomości\n{storyCount} Story", + "success_toast": "Gotowe!", + "export_button": "Eksport", + "actions_title": "Akcja", + "clear_button": "Czysto" + } + }, + "dialogs": { + "export_config": { + "title": "Eksportować wrażliwe dane?", + "content": "Czy chcesz wyeksportować konfigurację z wrażliwymi danymi? (Takie jak współrzędne lokalizacji itp.)" + }, + "file_imports": { + "settings_select_file_hint": "Wybierz zaimportowany plik", + "no_files_settings_hint": "Nie znaleziono plików. Upewnij się, że zaimportowałeś wymagane pliki w sekcji \"Importy plików\"" + }, + "scripting_warning": { + "title": "Ostrzeżenie", + "content": "SnapEnhance zawiera narzędzie skryptowe, umożliwiające wykonanie zdefiniowanego przez użytkownika kodu na Twoim urządzeniu. Używaj z szczególną ostrożnością i instaluj moduły z znanego, wiarygodnego źródła. Nieautoryzowane lub niezweryfikowane moduły mogą stwarzać zagrożenie dla twojego systemu." + }, + "reset_config": { + "success_toast": "Konfiguracja została pomyślnie zresetowana", + "title": "Uruchom ponownie konfigurację", + "content": "Czy na pewno chcesz zresetować konfigurację?" + }, + "messaging_action": { + "select_all_button": "Zaznacz wszystko", + "title": "Wybierz typy treści do przetworzenia" + } + } + }, + "friend_menu_option": { + "preview": "Podgląd", + "stealth_mode": "Tryb Stealth", + "auto_download_blacklist": "Automatyczne pobieranie czarnej listy", + "anti_auto_save": "Anty-automatyczne zapisywanie" + }, + "chat_action_menu": { + "preview_button": "Podgląd", + "download_button": "Pobierz", + "delete_logged_message_button": "Usuń zarejestrowaną wiadomość" + }, + "opera_context_menu": { + "download": "Pobierz media" + }, + "modal_option": { + "profile_info": "Info o profilu", + "close": "Zamknij" + }, + "gallery_media_send_override": { + "multiple_media_toast": "Możesz wysłać tylko jeden nośnik na raz" + }, + "conversation_preview": { + "streak_expiration": "wygasa za {day} dni {hour} godziny {minute} minuty", + "total_messages": "Łączna liczba wysłanych/odebranych wiadomości: {count}", + "title": "Podgląd", + "unknown_user": "Nieznany użytkownik" + }, + "profile_info": { + "title": "Info o profilu", + "display_name": "Wyświetl nazwę", + "added_date": "Dodaj datę", + "birthday": "Urodziny : {month} {day}" + }, + "chat_export": { + "dialog_negative_button": "Anuluj", + "dialog_positive_button": "Eksportuj", + "exported_to": "Wyeksportowano do {path}", + "exporting_chats": "Eksportowanie czatów...", + "processing_chats": "Przetwarzanie rozmów {amount}...", + "export_fail": "Nie udało się wyeksportować rozmowy {conversation}", + "writing_output": "Zapisywanie danych...", + "finished": "Gotowe! Teraz możesz zamknąć to okno.", + "no_messages_found": "Nie znaleziono wiadomości!", + "exporting_message": "Eksportowanie {conversation}..." + }, + "button": { + "ok": "Ok", + "positive": "Tak", + "negative": "Nie", + "cancel": "Anuluj", + "open": "Otwórz" + }, + "download_processor": { + "download_started_toast": "Rozpoczęto pobieranie", + "unsupported_content_type_toast": "Nieobsługiwany typ treści!", + "failed_no_longer_available_toast": "Media już niedostępne", + "already_queued_toast": "Media są już w kolejce!", + "already_downloaded_toast": "Media już pobrane!", + "download_toast": "Pobieranie {path}...", + "processing_toast": "Przetwarzanie {path}...", + "failed_generic_toast": "Pobieranie nie powiodło się", + "failed_to_create_preview_toast": "Nie można wczytać podglądu" + }, + "scopes": { + "friend": "Przyjaciel", + "group": "Grupa" + }, + "rules": { + "modes": { + "blacklist": "Tryb czarnej listy", + "whitelist": "Tryb białej listy" + }, + "properties": { + "stealth": { + "options": { + "whitelist": "Tryb ukrycia", + "blacklist": "Wyklucz z trybu ukrytego" + }, + "name": "Tryb ukrycia", + "description": "Uniemożliwia innym dowiedzenie się, że otworzyłeś ich Snapy/czaty oraz rozmowy" + }, + "auto_save": { + "name": "Automatyczne zapisywanie", + "options": { + "whitelist": "Automatyczne zapisywanie", + "blacklist": "Wyklucz z automatycznego zapisywania" + }, + "description": "Zapisuje wiadomości czatu podczas ich przeglądania" + }, + "auto_download": { + "options": { + "whitelist": "Automatyczne pobieranie", + "blacklist": "Wyklucz z automatycznego pobierania" + }, + "name": "Automatyczne pobieranie", + "description": "Automatycznie pobieraj snapy, gdy je wyswietlasz" + }, + "auto_open_snaps": { + "options": { + "whitelist": "Automatyczne Otwieranie Snapów", + "blacklist": "Wyklucz z automatycznie otwieranych Snapów" + }, + "description": "Automatycznie otwiera Snapy po ich otrzymaniu" + }, + "pin_conversation": { + "name": "Przypnij konwersacje" + }, + "hide_friend_feed": { + "name": "Ukryj z Kanału znajomych" + }, + "unsaveable_messages": { + "name": "Wiadomości, których nie można zapisać", + "options": { + "blacklist": "Wyklucz osoby z wiadomości, których nie można zapisać", + "whitelist": "Wiadomości, których nie można zapisać" + }, + "description": "Uniemożliwia zapisywanie wiadomości na czacie przez inne osoby" + }, + "e2e_encryption": { + "name": "Użyj szyfrowania E2E" + } + }, + "toasts": { + "enabled": "{ruleName} włączony", + "disabled": "{ruleName} wyłączony" + } + }, + "actions": { + "regen_mappings": { + "description": "Ręcznie wygeneruj ponownie mapowania", + "name": "Regeneruj mapowania" + }, + "change_language": { + "name": "Zmień język", + "description": "Zmień język SnapEnhance'a" + }, + "friend_tracker": { + "name": "Śledzenie znajomych", + "description": "Śledź swoich znajomych na Snapchacie" + }, + "file_imports": { + "name": "Import plików", + "description": "Importuj pliki do użytku w Snapchacie" + }, + "logger_history": { + "name": "Historia rejestratora", + "description": "Przeglądaj historię zarejestrowanych wiadomości" + }, + "clean_snapchat_cache": { + "description": "Czyści pamięć podręczną Snapchata", + "name": "Wyczyść pamięć podręczną Snapchata" + }, + "manage_friend_list": { + "name": "Zarządzaj listą znajomych", + "description": "Importuj/eksportuj listę znajomych podczas tworzenia kopii zapasowej" + }, + "export_chat_messages": { + "name": "Eksportuj wiadomości czatu", + "description": "Eksportuje wiadomości konwersacji do pliku JSON/HTML/TXT" + }, + "export_memories": { + "description": "Eksportuje wspomnienia do pliku ZIP", + "name": "Eksportuj wspomnienia" + }, + "bulk_messaging_action": { + "description": "Wykonuje operacje takie jak usuwanie znajomych czy masowe usuwanie rozmów", + "name": "Akcja przesyłania wiadomości zbiorczych" + } + }, + "features": { + "notices": { + "unstable": "⚠ Niestabilny", + "ban_risk": "⚠ Ta funkcja może powodować bany" + } + } +} diff --git a/common/src/main/assets/lang/pt.json b/common/src/main/assets/lang/pt.json new file mode 100644 index 0000000000..460992c13c --- /dev/null +++ b/common/src/main/assets/lang/pt.json @@ -0,0 +1,49 @@ +{ + "chat_action_menu": { + "preview_button": "Pré-visualização", + "download_button": "Descarregar" + }, + "modal_option": { + "profile_info": "Informações do Perfil", + "close": "Fechar" + }, + "conversation_preview": { + "streak_expiration": "expira em {day} dias {hour} horas {minute} minutos", + "title": "Pré-visualização", + "unknown_user": "Usuário Desconhecido" + }, + "profile_info": { + "title": "Informações do Perfil", + "display_name": "Nome de Exibição", + "birthday": "Aniversário: {month} {day}" + }, + "chat_export": { + "dialog_negative_button": "Cancelar", + "dialog_positive_button": "Exportar", + "exported_to": "Exportado para {path}", + "exporting_chats": "A Exportar Conversas...", + "processing_chats": "A Processar {amount} conversas...", + "export_fail": "Falha ao exportar a conversa {conversation}", + "finished": "Pronto! Já pode fechar este diálogo.", + "no_messages_found": "Nenhuma mensagem foi encontrada!", + "exporting_message": "A Exportar {conversation}..." + }, + "button": { + "ok": "Aceitar", + "positive": "Sim", + "negative": "Não", + "cancel": "Cancelar", + "open": "Abrir" + }, + "setup": { + "dialogs": { + "select_save_folder_button": "Selecione a pasta", + "select_language": "Selecione o idioma", + "save_folder": "SnapEnhance requer permissões de armazenamento para baixar e salvar mídia do Snapchat.\nEscolha o local para onde a mídia deve ser baixada." + }, + "mappings": { + "dialog": "Para suportar dinamicamente uma ampla variedade de versões do Snapchat, os mapeamentos são necessários para que o SnapEnhance funcione corretamente. Isso não deve levar mais de 5 segundos.", + "generate_failure_no_snapchat": "O SnapEnhance não conseguiu detectar o Snapchat. Tente reinstalar o Snapchat." + } + } +} diff --git a/common/src/main/assets/lang/ro.json b/common/src/main/assets/lang/ro.json new file mode 100644 index 0000000000..d68241e3c3 --- /dev/null +++ b/common/src/main/assets/lang/ro.json @@ -0,0 +1,1349 @@ +{ + "friend_menu_option": { + "preview": "Previzualizare", + "stealth_mode": "Modul Mascare", + "anti_auto_save": "Anti Salvare Automată", + "auto_download_blacklist": "Descărcare automată Lista neagră", + "mark_snaps_as_seen": "Marchează Snap-urile ca fiind văzute", + "mark_stories_as_seen_locally": "Marcați poveștile așa cum sunt văzute la nivel local" + }, + "modal_option": { + "profile_info": "Informații Profil", + "close": "Închide" + }, + "gallery_media_send_override": { + "multiple_media_toast": "Puteți trimite un singur fișier media la un moment dat" + }, + "conversation_preview": { + "streak_expiration": "expiră în {day} zile {hour} ore {minute} minute", + "total_messages": "Total mesaje trimise/primite: {count}", + "title": "Previzualizare", + "unknown_user": "Utilizator Necunoscut" + }, + "profile_info": { + "display_name": "Nume afișat", + "friendship": "Prietenie", + "title": "Informații profil", + "first_created_username": "Primul nume de utilizator creat", + "mutable_username": "Nume de utilizator modificabil", + "snapchat_plus_state": { + "not_subscribed": "Neabonat", + "subscribed": "Abonat" + }, + "added_date": "Data adăugată", + "birthday": "Zi de naștere: {month} {day}", + "hidden_birthday": "Zi de naștere: Ascuns", + "add_source": "Sursa adăugării", + "snapchat_plus": "Snapchat Plus" + }, + "chat_export": { + "dialog_negative_button": "Anulare", + "exported_to": "Exportat către {path}", + "exporting_chats": "Se exportă conversațiile...", + "processing_chats": "Se procesează {amount} de conversații...", + "export_fail": "Nu s-a putut exporta conversația {conversation}", + "writing_output": "Se scrie rezultatul...", + "finished": "Gata! Acum puteți să închideți acest dialog.", + "no_messages_found": "Niciun mesaj găsit!", + "exporting_message": "Se exportă {conversation}...", + "exporter_dialog": { + "text_field_selection": "{cantitatea} selectată", + "select_conversations_title": "Selectați Conversațiile", + "text_field_selection_all": "Toate", + "export_file_format_title": "Formatul fișierului de export", + "message_type_filter_title": "Filtrați mesajele după tip", + "amount_of_messages_title": "Cantitatea de mesaje (lăsați necompletat pentru toate)", + "download_medias_title": "Descărcare fișierele Media" + }, + "dialog_positive_button": "Exportă" + }, + "button": { + "ok": "OK", + "positive": "Da", + "negative": "Nu", + "cancel": "Anulare", + "open": "Deschide", + "download": "Descarcă" + }, + "download_processor": { + "download_started_toast": "Descărcarea a început", + "unsupported_content_type_toast": "Tip de conținut nesuportat!", + "failed_no_longer_available_toast": "Media nu mai e valabilă", + "already_queued_toast": "Media este deja în așteptare!", + "already_downloaded_toast": "Media deja descărcată!", + "download_toast": "Se descarcă {path}...", + "processing_toast": "Se procesează {path}...", + "failed_generic_toast": "Descărcarea a eșuat", + "failed_to_create_preview_toast": "Nu a reușit să se creeze previzualizarea", + "attachment_type": { + "external_media": "Media extern", + "note": "Notă", + "original_story": "Povestea originală", + "sticker": "Autocolant", + "snap": "Snap" + }, + "select_attachments_title": "Selectați atașamentele de descărcat", + "failed_gallery_toast": "Nu s-a salvat în galerie {error}", + "dash_dialog": { + "title": "Descărcare media dash", + "segment_text": "Segmentul {from} - {to}", + "download_all": "Descarcă toate" + }, + "no_attachments_toast": "Nu s-au găsit atașamente!", + "failed_processing_toast": "Procesarea eșuată {error}", + "dash_no_chapter": "Nu a fost găsit niciun capitol" + }, + "setup": { + "mappings": { + "generate_failure_no_snapchat": "SnapEnhance nu a putut detecta Snapchat, încercați să reinstalați Snapchat.", + "dialog": "Pentru a suporta dinamic o mai multe versiuni de Snapchat, mapările sunt necesare pentru SnapEnhance pentru a funcționa corect, nu ar trebui să dureze mai mult de 5 secunde.", + "generate_failure": "A apărut o eroare în timpul generării mapărilor, te rog încearcă din nou." + }, + "permissions": { + "dialog": "Pentru a continua trebuie să îndeplinești următoarele condiții:", + "notification_access": "Acces Notificări", + "battery_optimization": "Optimizare Baterie", + "display_over_other_apps": "Afișează Peste Alte Aplicații", + "request_button": "Cerere" + }, + "dialogs": { + "select_language": "Selectează Limba", + "save_folder": "SnapEnhance necesită acces la Stocare pentru a descărca si salva media din Snapchat.\nTe rog selectează locația unde ar trebui să fie descărcate.", + "select_save_folder_button": "Alege Dosarul" + } + }, + "manager": { + "routes": { + "home": "Acasă", + "logger_history": "Istoric Log-uri", + "scripts": "Șcenarii", + "home_settings": "Setări", + "tasks": "Sarcini", + "features": "Caracteristici", + "home_logs": "Log-uri", + "social": "Social", + "manage_scope": "Gestionează Scopul", + "messaging_preview": "Previzualizează", + "logged_stories": "Povești înregistrate" + }, + "sections": { + "social": { + "streaks_expiration_short": "{hours}h", + "friends_tab": "Prieteni", + "groups_tab": "Grupuri", + "empty_hint": "(gol)" + }, + "tasks": { + "no_tasks": "Fără actiuni", + "merge_files_toast": "Îmbinând {count} fișiere", + "remove_selected_tasks_title": "Ești sigur că vrei sa ștergi actiunile selectate?", + "delete_files_option": "De asemenea șterge fișierele", + "remove_selected_tasks_confirm": "Eliminați {count} sarcini?", + "remove_all_tasks_confirm": "Eliminați toate sarcinile?", + "remove_all_tasks_title": "Sigur doriți să eliminați toate sarcinile?" + }, + "features": { + "disabled": "Dezactivat", + "config_import_success_toast": "Configurația a fost importată cu succes", + "export_option": "Exportă", + "import_option": "Importă", + "reset_option": "Resetează", + "config_export_success_toast": "Configurația a fost exportată cu succes", + "saved_config_snackbar": "Configurația a fost salvată", + "config_import_failure_toast": "Nu s-a putut importa configurația {error}" + }, + "logger_history": { + "list_group_format": "Grup {name}", + "list_friend_format": "Prieten {name}", + "no_more_messages": "Nu mai sunt mesaje", + "reverse_order_checkbox": "Inversează ordinea", + "chat_attachment": "Atașament {index}", + "empty_message": "Mesaj de chat gol", + "message_parse_failed": "Nu s-a putut analiza mesajul", + "unknown_sender": "Expeditor necunoscut", + "download_attachment_failed_toast": "Atașamentul nu a putut fi descărcat" + }, + "home_settings": { + "export_button": "Exportă", + "actions_title": "Acțiuni", + "debug_title": "Depanare", + "success_toast": "Gata!", + "message_logger_summary": "{messageCount} mesaje\n{storyCount} povești", + "message_logger_title": "Salvator de mesaje", + "view_logger_history_button": "Vedeți istoricul înregistratorului", + "clear_button": "Curăță" + }, + "home": { + "update_content": "Versiunea {version} este disponibilă!", + "update_button": "Descarcă", + "update_title": "Actualizare SnapEnhance" + }, + "messaging_preview": { + "bridge_init_failed": "Nu s-a inițializat puntea de mesagerie", + "bridge_connection_failed": "Nu s-a putut conecta la Snapchat prin serviciul punte", + "message_fetch_failed": "Nu s-au putut prelua mesajele", + "no_message_hint": "Niciun mesaj", + "save_selection_option": "Salvați selecția", + "save_all_option": "Salvează tot", + "unsave_selection_option": "Anulați salvarea selecției", + "unsave_all_option": "Anulați salvarea tuturor", + "mark_selection_as_seen_option": "Marcați Snapul selectat ca văzut", + "mark_all_as_seen_option": "Marcați ca văzut toate Snapurile", + "delete_all_option": "Sterge tot", + "delete_selection_option": "Șterge selecția" + }, + "home_logs": { + "no_logs_hint": "Nu există jurnalele disponibile", + "clear_logs_button": "Ștergeți jurnalele", + "export_logs_button": "Exportă jurnalele", + "saving_logs_toast": "Se salvează jurnalele, poate dura ceva timp...", + "saved_logs_failure_toast": "Salvarea jurnalelor a eșuat", + "saved_logs_success_toast": "Jurnalele au fost salvate cu succes" + }, + "manage_scope": { + "e2ee_title": "Criptare end-to-end", + "rules_title": "Reguli", + "participants_text": "{count} participanți", + "not_found": "Nu a fost găsit", + "streaks_title": "Linii", + "streaks_length_text": "Lungime: {length}", + "logged_stories_button": "Afișați poveștile înregistrate", + "streaks_expiration_text": "Expiră în {eta}", + "streaks_expiration_text_expired": "Expirat", + "reminder_button": "Setați memento", + "delete_scope_confirm_dialog_title": "Sigur doriți să ștergeți un {scope}?" + }, + "logged_stories": { + "story_failed_to_load": "Încărcarea a eșuat", + "no_stories": "Nu s-au găsit povești", + "save_from_cache_button": "Salvați din Cache(memoria temporară)" + } + }, + "dialogs": { + "scripting_warning": { + "title": "Atenție", + "content": "SnapEnhance include un instrument de scriere, care permite executarea codului definit de utilizator pe dispozitivul dumneavoastră. Fiți extrem de precauți și instalați numai module din surse cunoscute și de încredere. Modulele neautorizate sau neverificate pot prezenta riscuri de securitate pentru sistemul dumneavoastră." + }, + "add_friend": { + "title": "Adaugă un Prieten sau un Grup", + "search_hint": "Caută", + "fetch_error": "Preluarea datelor a eșuat", + "category_groups": "Grupuri", + "category_friends": "Prieteni" + }, + "reset_config": { + "title": "Resetare configurări", + "success_toast": "Resetarea configurării cu succes", + "content": "Sigur doriți să resetați configurația?" + }, + "messaging_action": { + "title": "Alegeți tipurile de conținut de procesat", + "select_all_button": "Selectează tot" + } + } + }, + "rules": { + "properties": { + "auto_download": { + "description": "Descarcă automat Snap-uri în timpul vizualizării acestora", + "options": { + "whitelist": "Descărcare Automată", + "blacklist": "Exclude din Descărcare Automată" + }, + "name": "Descărcare automată" + }, + "stealth": { + "name": "Mod Ascuns", + "description": "Împiedică pe oricine să știe că le-ați deschis Snap-urile/Chat-urile și conversațiile", + "options": { + "blacklist": "Exclude din Modul Ascuns", + "whitelist": "Mod Ascuns" + } + }, + "auto_save": { + "description": "Salvează mesajele când sunt vizualizate", + "options": { + "blacklist": "Exclude din Salvare Automată", + "whitelist": "Salvare automată" + }, + "name": "Salvare Automată" + }, + "unsaveable_messages": { + "description": "Împiedică mesajele să fie salvate în chat de către alte persoane", + "name": "Mesaje Nesalvabile", + "options": { + "whitelist": "Mesaje nesalvate", + "blacklist": "Excludeți din Mesaje Nesalvate" + } + }, + "e2e_encryption": { + "name": "Utilizați criptarea E2E" + }, + "pin_conversation": { + "name": "Fixați conversația" + }, + "hide_friend_feed": { + "name": "Ascundeți din feedul prietenilor" + }, + "auto_open_snaps": { + "name": "Deschidere automată Snap-uri", + "description": "Deschide automat Snap-urile când le primiți", + "options": { + "blacklist": "Excludeți din deschiderea automată a snap-urilor", + "whitelist": "Deschidere automată Snap-uri" + } + } + }, + "modes": { + "blacklist": "Mod Lista Neagră", + "whitelist": "Modul Lista albă" + }, + "toasts": { + "enabled": "{ruleName} activat", + "disabled": "{ruleName} dezactivat" + } + }, + "features": { + "properties": { + "user_interface": { + "properties": { + "enable_friend_feed_menu_bar": { + "name": "Feed pentru prieteni bară de meniu", + "description": "Activează noua bară de meniu Friend pentru prieteni" + }, + "message_indicators": { + "name": "Indicatori de mesaje", + "description": "Adaugă pictograme indicatoare specifice la mesaje\nNotă: este posibil ca indicatorii să nu fie 100% precisi" + }, + "stealth_mode_indicator": { + "name": "Indicator mod ascuns", + "description": "Adaugă un emoji 👻 lângă conversații în modul ascuns" + }, + "vertical_story_viewer": { + "description": "Activează vizualizatorul vertical de povești pentru toate poveștile", + "name": "Vizualizator de Povești Vertical" + }, + "edit_text_override": { + "description": "Anulează comportamentul câmpului de text", + "name": "Modificați modificarea textului" + }, + "hide_streak_restore": { + "name": "Ascundeți restaurarea liniei fierbinți", + "description": "Ascunde butonul Restaurare din fluxul prietenilor" + }, + "enable_app_appearance": { + "description": "Activează setările ascunse ale aspectului aplicației\nEste posibil să nu fie necesar pentru versiunile Snapchat mai noi", + "name": "Activați Setările de aspect al aplicației" + }, + "snap_preview": { + "name": "Previzualizare Snapului", + "description": "Afișează o mică previzualizare lângă snapurile nevăzute în chat" + }, + "bootstrap_override": { + "name": "Suprascriere Bootstrap", + "description": "Ignoră setările de bootstrap a interfeței de utilizator", + "properties": { + "home_tab": { + "description": "Ignoră fila de pornire la deschiderea Snapchat", + "name": "Fila Acasă" + }, + "app_appearance": { + "name": "Aspectul aplicației", + "description": "Setează un aspect persistent al aplicației" + } + } + }, + "map_friend_nametags": { + "description": "Îmbunătățește numele prietenilor de pe mapa Snapchat", + "name": "Îmbunătățește numele prietenilor de pe mapă" + }, + "prevent_message_list_auto_scroll": { + "description": "Împiedică derularea listei de mesaje în jos la trimiterea/primirea unui mesaj", + "name": "Preveniți derularea automată a listei de mesaje" + }, + "hide_ui_components": { + "description": "Selectați ce componente ale interfeței de utilizator să ascundeți", + "name": "Ascundeți componentele interfeței de utilizator" + }, + "opera_media_quick_info": { + "name": "Informații rapide Opera Media", + "description": "Afișează informații utile despre media, cum ar fi data creării, în meniul contextual al vizualizatorului opera" + }, + "old_bitmoji_selfie": { + "description": "Readuce selfie-urile Bitmoji din versiuni mai vechi Snapchat", + "name": "Selfie vechi Bitmoji" + }, + "disable_spotlight": { + "name": "Dezactivați Spotlight", + "description": "Dezactivează pagina Spotlight" + }, + "friend_feed_message_preview": { + "properties": { + "amount": { + "name": "Cantitate", + "description": "Cantitatea de mesaje care trebuie previzualizate" + } + }, + "name": "Previzualizarea mesajului din feedul prietenilor", + "description": "Afișează o previzualizare a ultimelor mesaje din Fluxul prietenilor" + }, + "friend_feed_menu_buttons": { + "description": "Selectați ce butoane să afișați în meniul fluxul prietenilor", + "name": "Butoanele pentru meniul fluxul prietenilor" + }, + "streak_expiration_info": { + "name": "Afișați informații despre expirarea liniei fierbinți", + "description": "Afișează un temporizator de expirare a liniei fierbinți lângă contorul de linii" + }, + "hide_friend_feed_entry": { + "name": "Ascundeți intrarea în feedul prietenilor", + "description": "Ascunde un anumit prieten din Fluxul de prieteni\nUtilizați fila socială pentru a gestiona această funcție" + }, + "hide_story_suggestions": { + "name": "Ascundeți sugestiile de povești", + "description": "Elimină sugestiile din pagina Povestiri" + } + }, + "name": "Interfața cu utilizatorul", + "description": "Schimbați aspectul Snapchat" + }, + "messaging": { + "properties": { + "prevent_story_rewatch_indicator": { + "name": "Preveniți indicatorul de revizionare a poveștii", + "description": "Împiedică pe oricine să știe că le-ați revizionat povestea" + }, + "hide_typing_notifications": { + "name": "Ascunde Notificarea Scrie", + "description": "Împiedică pe oricine să știe că introduci un mesaj" + }, + "hide_peek_a_peek": { + "name": "Ascunde Peek-a-Peek", + "description": "Împiedică trimiterea notificărilor atunci când glisați pe jumătate într-o conversație" + }, + "bypass_screenshot_detection": { + "name": "Anulare Detectarea Capturii de Ecran", + "description": "Împiedică Snapchat să detecteze când faceți o captură de ecran" + }, + "anonymous_story_viewing": { + "name": "Vizionare anonimă a poveștii", + "description": "Împiedică pe oricine să știe că le-ai văzut povestea" + }, + "hide_bitmoji_presence": { + "name": "Ascundeți prezența Bitmoji", + "description": "Împiedică apariția Bitmoji-ului dvs. în timp ce sunteți într-o conversație" + }, + "half_swipe_notifier": { + "properties": { + "max_duration": { + "description": "Durata maximă a jumătății de glisare (în secunde)", + "name": "Durata maximă" + }, + "min_duration": { + "name": "Durata minimă", + "description": "Durata minimă a jumătății de glisare (în secunde)" + } + }, + "name": "Notificator glisare pe jumătate", + "description": "Anunță-mă când cineva glisează pe jumătate într-o conversație" + }, + "auto_save_messages_in_conversations": { + "description": "Salvează automat fiecare mesaj din conversații", + "name": "Salvare automată a mesajelor" + }, + "notification_blacklist": { + "description": "Selectați notificările care ar trebui să fie blocate", + "name": "Lista neagră de notificări" + }, + "unlimited_snap_view_time": { + "description": "Elimină limita de timp pentru vizualizarea snapurilor", + "name": "Timp nelimitat de vizualizare a Snapurilor" + }, + "call_start_confirmation": { + "name": "Confirmare pornire apel", + "description": "Afișează un dialog de confirmare la inițierea unui apel" + }, + "better_notifications": { + "description": "Adaugă mai multe informații în notificările primite", + "name": "Notificări mai bune" + }, + "message_logger": { + "name": "Înregistrare mesaje", + "description": "Împiedică ștergerea mesajelor", + "properties": { + "auto_purge": { + "name": "Curățare Automată", + "description": "Șterge automat mesajele din memoria temporală care sunt mai vechi decât perioada de timp specificată" + }, + "keep_my_own_messages": { + "name": "Păstrează-mi propriile mesaje", + "description": "Împiedică ștergerea propriilor mesaje" + }, + "message_filter": { + "name": "Filtru de mesaje", + "description": "Selectați ce mesaje ar trebui să fie înregistrate (gol pentru toate mesajele)" + } + } + }, + "bypass_message_retention_policy": { + "description": "Împiedică ștergerea mesajelor după ce le-ați vizualizat", + "name": "Ocoliți politica de păstrare a mesajelor" + }, + "remove_groups_locked_status": { + "description": "Vă permite să vizualizați informații despre grup după ce ați fost dat afară", + "name": "Eliminați starea grupurilor blocate" + }, + "auto_mark_as_read": { + "name": "Marcare automată ca citit", + "description": "Marchează automat mesajele/snapurile ca citite chiar și atunci când modul invizibil este activat" + }, + "loop_media_playback": { + "name": "Redare media în buclă", + "description": "Redare media în buclă când vizualizați snapurile/povestirile" + }, + "disable_replay_in_ff": { + "description": "Dezactivează capacitatea de a relua cu o apăsare lungă din fluxul prietenilor", + "name": "Dezactivați redarea în FF" + }, + "prevent_message_sending": { + "name": "Preveniți trimiterea mesajelor", + "description": "Împiedică trimiterea anumitor tipuri de mesaje" + }, + "gallery_media_send_override": { + "name": "Suprascrierea Trimiterii Media din Galerie", + "description": "Falsifică sursa media atunci când trimiteți din Galerie" + }, + "strip_media_metadata": { + "name": "Eliminarea Metadatelor Media", + "description": "Elimină metadatele media înainte de a le trimite ca mesaj" + }, + "bypass_message_action_restrictions": { + "name": "Ocoliți restricțiile privind acțiunea mesajului", + "description": "Vă permite să reacționați la un snap fără a-l deschide sau să salvați un mesaj care nu poate fi salvat" + }, + "friend_mutation_notifier": { + "name": "Notificator de modificări la lista de prieteni", + "description": "Te anunță când se schimbă ceva în profilul unui prieten" + } + }, + "description": "Schimbă cum te interacționezi cu prietenii", + "name": "Mesaje" + }, + "global": { + "properties": { + "snapchat_plus": { + "description": "Activează funcțiile Snapchat Plus\nEste posibil ca unele caracteristici ale serverului să nu funcționeze", + "name": "Snapchat Plus(premium)" + }, + "hide_active_music": { + "name": "Ascunde muzica activă", + "description": "Împiedică Snapchat să știe că asculți muzică\nAcest lucru vă va permite să faceți instantanee folosind butoanele de control al volumului în timp ce ascultați muzică" + }, + "better_location": { + "name": "Locație mai bună", + "description": "Îmbunătățește locația pentru Snapchat", + "properties": { + "spoof_battery_level": { + "name": "Falsifică nivelului bateriei", + "description": "Falsifică nivelul bateriei dispozitivului dumneavoastră pe hartă\nValoarea trebuie să fie între 0 și 100" + }, + "spoof_location": { + "name": "Falsificarea Locației", + "description": "Falsifică locația dumneavoastră la una specificată" + }, + "coordinates": { + "name": "Coordonatele", + "description": "Setați coordonatele locației falsificate" + }, + "always_update_location": { + "name": "Actualizați întotdeauna locația", + "description": "Forțați Snapchat să actualizeze locația chiar dacă nu sunt primite date GPS" + }, + "suspend_location_updates": { + "name": "Suspendați actualizările locației", + "description": "Adaugă un buton în setările hărții pentru a suspenda actualizările locației" + }, + "spoof_headphones": { + "description": "Falsifică starea de a asculta muzică pe hartă", + "name": "Falsificarea purtării Căștilor" + }, + "walk_radius": { + "name": "Raza de deplasare", + "description": "Mergeți la întâmplare în această rază (ft)" + } + } + }, + "disable_confirmation_dialogs": { + "description": "Confirmă automat acțiunile selectate", + "name": "Dezactivați casetele de dialog de confirmare" + }, + "disable_permission_requests": { + "name": "Dezactivați solicitările de permisiune", + "description": "Împiedică Snapchat să solicite permisiuni specifice" + }, + "disable_story_sections": { + "description": "Elimină secțiuni din pagina Povestiri\nPoate necesita o reîmprospătare pentru a funcționa corect", + "name": "Dezactivați secțiunile de poveste" + }, + "disable_memories_snap_feed": { + "description": "Împiedică Snapchat să arate amintiri recente atunci când glisați în sus în cameră", + "name": "Dezactivare Flux Snapuri din Amintiri" + }, + "spotlight_comments_username": { + "name": "Nume utilizator în comentarii Spotlight", + "description": "Afișează numele de utilizator al autorului în comentariile Spotlight" + }, + "auto_updater": { + "name": "Actualizare automată", + "description": "Verifică automat pentru noi actualizări" + }, + "disable_metrics": { + "name": "Dezactivare Metrice", + "description": "Blochează trimiterea de date analitice specifice către Snapchat" + }, + "default_video_playback_rate": { + "name": "Rata de redare video implicită", + "description": "Setează viteza implicită pentru redarea videoclipurilor\nValoarea trebuie să fie între 0,1 și 4,0" + }, + "video_playback_rate_slider": { + "name": "Glisor pentru rata de redare video", + "description": "Adaugă un glisor în meniul contextual al operei pentru a modifica rata de redare a videoclipurilor\nNotă: modificările se aplică numai videoclipurilor ulterioare" + }, + "disable_google_play_dialogs": { + "name": "Dezactivați casetele de dialog pentru serviciile Google Play", + "description": "Împiedicați afișarea dialogurilor privind disponibilitatea Serviciilor Google Play" + }, + "bypass_video_length_restriction": { + "name": "Ocoliți restricțiile privind durata videoclipului", + "description": "Singur: trimite un singur videoclip\nDivizare: împarte videoclipurile după editare" + }, + "block_ads": { + "description": "Împiedică afișarea reclamelor", + "name": "Blocați anunțurile" + }, + "default_volume_controls": { + "name": "Controale de volum implicite", + "description": "Forțează Snapchat să folosească controalele de volum ale sistemului" + }, + "disable_snap_splitting": { + "name": "Dezactivare divizare Snap", + "description": "Împiedică împărțirea instantaneelor în mai multe părți\nImaginile pe care le trimiteți se vor transforma în videoclipuri" + }, + "disable_custom_tabs": { + "name": "Dezactivați filele personalizate", + "description": "Deschide linkuri în aplicațiile acceptate și nu în browserul web" + } + }, + "name": "Global", + "description": "Modificați setările globale Snapchat" + }, + "downloader": { + "properties": { + "ffmpeg_options": { + "description": "Specificați opțiuni suplimentare FFmpeg", + "properties": { + "threads": { + "name": "Fire", + "description": "Cantitatea de fire de folosit" + }, + "preset": { + "name": "Presetat", + "description": "Setați viteza conversiei" + }, + "constant_rate_factor": { + "name": "Factor de rată constantă", + "description": "Setați factorul de rată constantă pentru codificatorul video\nDe la 0 la 51 pentru libx264" + }, + "custom_video_codec": { + "description": "Setați un codec video personalizat (de exemplu, libx264)", + "name": "Codec video personalizat" + }, + "custom_audio_codec": { + "name": "Codec audio personalizat", + "description": "Setați un codec audio personalizat (de exemplu, AAC)" + }, + "audio_bitrate": { + "name": "Rata de biți audio", + "description": "Setați rata de biți audio (kbps)" + }, + "video_bitrate": { + "name": "Rata de transfer video", + "description": "Setați rata de biți video (kbps)" + } + }, + "name": "Opțiuni FFmpeg" + }, + "save_folder": { + "name": "Salvare dosar", + "description": "Selectați directorul în care ar trebui să fie descărcate toate fișierele media" + }, + "prevent_self_auto_download": { + "description": "Împiedică descărcarea automată a Snapurilor proprii", + "name": "Preveniți descărcarea automată a fișierelor proprii" + }, + "path_format": { + "name": "Format cale", + "description": "Specificați formatul căii fișierului" + }, + "merge_overlays": { + "description": "Combină textul și media unui Snap într-un singur fișier", + "name": "Îmbinați suprapunerile" + }, + "download_profile_pictures": { + "description": "Vă permite să descărcați imagini de profil de pe pagina de profil", + "name": "Descărcați poze de profil" + }, + "opera_download_button": { + "description": "Adaugă un buton de descărcare în colțul din dreapta sus când vizualizați un Snap.\nApăsarea lungă a butoanelor va forța descărcarea", + "name": "Butonul de descărcare Opera" + }, + "download_context_menu": { + "name": "Descărcați meniul contextual", + "description": "Vă permite să descărcați/previzualizați mesaje dintr-o conversație sau o poveste folosind meniul contextual.\nApăsarea lungă a butoanelor va forța descărcarea" + }, + "logging": { + "description": "Arată notificări când se descarcă media", + "name": "Logare" + }, + "custom_path_format": { + "description": "Specificați un format de cale personalizat pentru conținutul media descărcat\n\nVariabile disponibile:\n- %nume de utilizator%\n- %sursă%\n- %hash%\n- %dată_timp%", + "name": "Format de cale personalizat" + }, + "force_voice_note_format": { + "name": "Formatare forțată a înregistrărilor audio vocale", + "description": "Forțează ca înregistrările audio vocale să fie salvate într-un format specificat" + }, + "allow_duplicate": { + "name": "Permite duplicarea", + "description": "Permite descărcarea aceluiași suport media de mai multe ori" + }, + "force_image_format": { + "description": "Forțează salvarea imaginilor într-un format specificat", + "name": "Forțați formatul imaginii" + }, + "auto_download_sources": { + "name": "Surse de descărcare automată", + "description": "Selectați sursele din care să descărcați automat" + } + }, + "description": "Descarcă conținut media de pe Snapchat", + "name": "Descărcător" + }, + "rules": { + "name": "Reguli", + "description": "Gestionați funcțiile automate pentru persoane individuale" + }, + "camera": { + "name": "Cameră foto", + "properties": { + "back_custom_frame_rate": { + "name": "Rată de cadre personalizată cameră din spate", + "description": "Ignoră rata de cadre a camerei din spate" + }, + "force_camera_source_encoding": { + "name": "Forțează Codificarea Sursă a Camerei", + "description": "Forțează codificarea sursei camerei" + }, + "disable_cameras": { + "name": "Dezactivează Camerele", + "description": "Previne utilizarea camerelor selectate de către Snapchat" + }, + "immersive_camera_preview": { + "name": "Previzualizare Întreruptoare", + "description": "Împiedică Snapchat să decupeze previzualizarea camerei\nAcest lucru poate face ca camera să pâlpâie pe unele dispozitive" + }, + "override_back_resolution": { + "name": "Suprascrieți Rezoluția Camerei Spate", + "description": "Suprascrie rezoluția camerei pentru camera din spate" + }, + "custom_resolution": { + "description": "Setează o rezoluție personalizată a camerei, lățime x înălțime (de exemplu, 1920x1080).\nRezoluția personalizată trebuie să fie acceptată de dispozitivul dumneavoastră", + "name": "Rezoluție personalizată" + }, + "front_custom_frame_rate": { + "name": "Rata de cadre personalizată față", + "description": "Ignoră rata de cadre a camerei frontale" + }, + "black_photos": { + "description": "Înlocuiește fotografiile capturate cu un fundal negru\nVideoclipurile nu sunt afectate", + "name": "Fotografii Negre" + }, + "override_front_resolution": { + "name": "Suprascrieți Rezoluția Camerei Frontale", + "description": "Suprascrie rezoluția camerei pentru camera frontală" + }, + "hevc_recording": { + "name": "Înregistrare HEVC", + "description": "Utilizează codecul HEVC (H.265) pentru înregistrarea video" + } + }, + "description": "Ajustați setările potrivite pentru un snap perfect" + }, + "streaks_reminder": { + "properties": { + "remaining_hours": { + "name": "Timp rămas", + "description": "Timpul rămas înainte ca notificarea să fie afișată (ore)" + }, + "group_notifications": { + "description": "Grupați notificările într-una singură", + "name": "Notificări de grup" + }, + "interval": { + "description": "Intervalul dintre fiecare memento (ore)", + "name": "Setează interval" + } + }, + "name": "Reamintire Streak-uri(pierderea liniilor fierbinți/focurilor)", + "description": "Vă anunță periodic despre liniile fierbinți ale dumneavoastră" + }, + "experimental": { + "properties": { + "infinite_story_boost": { + "description": "Evită întârzierea limitării Boost-ului de Povestire", + "name": "Boost nelimitat de Povestire" + }, + "account_switcher": { + "description": "Vă permite să comutați între conturi fără a vă deconecta\nApăsați lung pe pictograma de căutare de lângă profilul dumneavoastră Bitmoji pentru a deschide meniul\nNotă: această funcție este experimentală și probabil se va schimba în viitor", + "properties": { + "auto_backup_current_account": { + "description": "Face automat o copie de rezervă a contului curent la deconectare sau la schimbarea contului", + "name": "Backup automat pentru contul curent" + } + }, + "name": "Comutator de cont" + }, + "convert_message_locally": { + "description": "Transformă snapurile în chat media externă local. Aceasta apare în meniul contextual de descărcare prin chat", + "name": "Convertiți mesajul local" + }, + "meo_passcode_bypass": { + "name": "Ocolire Cod de Acces pentru \"Doar pentru ochii mei\"", + "description": "Ocolirea codului de acces pentru \"Doar pentru ochii mei\"\nAceasta va funcționa doar dacă codul de acces a fost introdus corect anterior" + }, + "spoof": { + "properties": { + "remove_vpn_transport_flag": { + "name": "Elimină Indicatorul de Transport VPN", + "description": "Împiedică Snapchat să detecteze VPN-urile" + }, + "remove_mock_location_flag": { + "description": "Previne detectarea de către Snapchat a locației false", + "name": "Elimină Indicatorul de Locație Falsă" + }, + "play_store_installer_package_name": { + "name": "Numele pachetului de instalare a magazinului Play", + "description": "Înlocuiește numele pachetului de instalare la com.android.vending" + } + }, + "name": "Falsifică", + "description": "Falsifică diverse informații despre tine" + }, + "native_hooks": { + "properties": { + "disable_bitmoji": { + "description": "Dezactivează Bitmoji-ul Profilului Prietenilor", + "name": "Dezactivează Bitmoji" + }, + "composer_hooks": { + "name": "Ganți Composer", + "properties": { + "composer_console": { + "description": "Permite executarea de cod JavaScript în Composer (doar pentru arhitectura arm64)", + "name": "Consolă Composer" + }, + "composer_logs": { + "name": "Jurnalele Composer", + "description": "Redirecționează jurnalele consolei Composer către SnapEnhance" + }, + "bypass_camera_roll_limit": { + "description": "Crește cantitatea maximă de media pe care o poți trimite din Ruloul Camerei", + "name": "Ocolește Limita Ruloului Camerei" + }, + "show_first_created_username": { + "name": "Afișează primul nume de utilizator creat", + "description": "Afișează primul nume de utilizator creat lângă numele de utilizator actual în pagina de profil" + } + }, + "description": "Injectează cod în framework-ul UI cross-platform Composer" + } + }, + "name": "Hook-uri Native(Un set de funcții încorporate care permit dezvoltatorilor să adauge funcționalități la componente)", + "description": "Caracteristici nesigure care se leagă de codul nativ al Snapchat-ului" + }, + "media_file_picker": { + "name": "Selector de Fișiere Media", + "description": "Vă permite să alegeți orice fișier video/audio din galerie" + }, + "story_logger": { + "description": "Oferă o istorie a poveștilor prietenilor", + "name": "Înregistrare Poveste" + }, + "edit_message": { + "description": "Vă permite să editați mesaje în conversații", + "name": "Editați mesajele" + }, + "e2ee": { + "description": "Criptează mesajele dumneavoastră cu AES folosind o cheie secretă partajată\nAsigurați-vă că păstrați cheia într-un loc sigur!", + "properties": { + "force_message_encryption": { + "description": "Împiedică trimiterea de mesaje criptate către persoane care nu au criptarea E2E activată numai atunci când sunt selectate mai multe conversații", + "name": "Forțați criptarea mesajelor" + }, + "encrypted_message_indicator": { + "name": "Indicator de mesaj criptat", + "description": "Adaugă un emoji 🔒 lângă mesajele criptate" + } + }, + "name": "Criptare End-To-End" + }, + "add_friend_source_spoof": { + "description": "Falsifică sursa unei solicitări de prietenie", + "name": "Falsificare Sursă Adăugare Prieten" + }, + "hidden_snapchat_plus_features": { + "name": "Funcții ascunse Snapchat Plus", + "description": "Activează funcțiile Snapchat Plus nelansate/beta\nEste posibil să nu funcționeze pe versiunile mai vechi Snapchat" + }, + "no_friend_score_delay": { + "name": "Nicio întârziere a scorului prietenului", + "description": "Elimină întârzierea la vizualizarea unui scor al prietenilor" + }, + "app_lock": { + "name": "Blocare aplicație", + "description": "Împiedică accesul la Snapchat fără un cod de acces", + "properties": { + "lock_on_resume": { + "name": "Blocare la Reluare", + "description": "Blochează aplicația când este redeschisă" + } + } + }, + "prevent_forced_logout": { + "name": "Preveniți deconectarea forțată", + "description": "Împiedică Snapchat să vă deconecteze atunci când vă conectați pe alt dispozitiv" + }, + "call_recorder": { + "name": "Înregistrare Apeluri", + "description": "Înregistrează automat apelurile audio" + }, + "custom_streaks_expiration_format": { + "name": "Format personalizat de expirare a liniilor fierbinți", + "description": "Personalizează formatul Streaks Expiration\n\nVariabile disponibile:\n- %c: Număr de linii\n- %e: Emoji cu clepsidra\n- %d: zile\n- %h: ore\n- %m: minute\n- %s: secunde\n- %w: Timp rămas" + }, + "best_friend_pinning": { + "name": "Fixarea celui mai bun prieten", + "description": "Vă permite să fixați un prieten drept cel mai bun prieten numărul unu. Notă: numai tu poți să-ți vezi cel mai bun prieten fixat" + } + }, + "description": "Caracteristici experimentale", + "name": "Experimentale" + }, + "scripting": { + "name": "Scriptare", + "properties": { + "integrated_ui": { + "name": "Interfață de utilizare integrată", + "description": "Permite scripturilor să adauge componente UI personalizate la Snapchat" + }, + "auto_reload": { + "description": "Reîncarcă automat scripturile când se schimbă", + "name": "Reîncărcare automată" + }, + "module_folder": { + "description": "Dosarul în care se află scripturile", + "name": "Folderul modulului" + }, + "disable_log_anonymization": { + "name": "Dezactivați anonimizarea jurnalului", + "description": "Dezactivează anonimizarea jurnalelor" + }, + "developer_mode": { + "name": "Modul dezvoltator", + "description": "Afișează informații de depanare pe interfața de utilizare a Snapchat" + } + }, + "description": "Rulați scripturi personalizate pentru a extinde SnapEnhance" + } + }, + "notices": { + "ban_risk": "⚠ Această funcție poate provoca interdicții ale conului de Snapchat", + "internal_behavior": "⚠ Acest lucru poate rupe comportamentul intern al Snapchat", + "unstable": "⚠ Instabil" + }, + "options": { + "friend_feed_menu_buttons": { + "mark_stories_as_seen_locally": "👀 Marchează Poveștile ca fiind văzute local", + "auto_download": "⬇️ Descărcare automată", + "auto_save": "💬 Salvare automată a mesajelor", + "mark_snaps_as_seen": "👀 Marchează Snap-ul ca fiind văzut", + "conversation_info": "👤 Informații despre conversație", + "e2e_encryption": "🔒 Utilizați criptarea E2E", + "stealth": "👻 Modul Furt", + "unsaveable_messages": "⬇️ Mesaje care nu pot fi salvate", + "auto_open_snaps": "📷 Deschidere automată a Snapurilor" + }, + "logging": { + "success": "Succes", + "started": "Pornit", + "progress": "Progres", + "failure": "Eșec" + }, + "strip_media_metadata": { + "remove_audio_note_transcript_capability": "Eliminați capacitatea de transcriere a notelor audio", + "hide_caption_text": "Ascunde textul subtitrării", + "hide_snap_filters": "Ascunde filtrele Snap", + "hide_extras": "Ascundeți extra (de exemplu, mențiuni)", + "remove_audio_note_duration": "Eliminare Durată Notă Audio" + }, + "disable_confirmation_dialogs": { + "clear_conversation": "Șterge Conversația din Feed-ul de Prieteni", + "hide_conversation": "Ascunde conversația", + "hide_friend": "Ascunde prietenul", + "ignore_friend": "Ignora prietenul", + "remove_friend": "Șterge prieten", + "block_friend": "Blocați prietenul", + "erase_message": "Șterge mesajul" + }, + "auto_reload": { + "snapchat_only": "Numai Snapchat", + "all": "Toate (Snapchat + SnapEnhance)" + }, + "auto_purge": { + "1_month": "1 Lună", + "2_weeks": "2 Săptămâni", + "6_hours": "6 Ore", + "1_day": "1 Zi", + "3_days": "3 Zile", + "3_months": "3 Luni", + "1_week": "1 Săptămână", + "6_months": "6 Luni", + "never": "Niciodată", + "1_hour": "1 Oră", + "3_hours": "3 Ore", + "12_hours": "12 Ore" + }, + "disable_permission_requests": { + "microphone": "Microfon", + "location": "Locație", + "notifications": "Notificări", + "read_media_images": "Citiți imagini media", + "camera": "Cameră", + "read_media_video": "Citiți videoclipul media", + "phone_calls": "Apeluri telefonice", + "read_contacts": "Citească Contactele", + "nearby_devices": "Dispozitive din apropiere" + }, + "notifications": { + "chat_screenshot": "Captură de ecran", + "stories": "Povești", + "chat_reaction": "Reacție în privat", + "typing": "Scrie", + "chat": "Conversație", + "chat_reply": "Răspuns la conversație", + "initiate_audio": "Apel audio primit", + "group_chat_reaction": "Reacția de grup", + "snap": "Snap", + "abandon_audio": "Apel audio pierdut", + "initiate_video": "Apel video primit", + "chat_screen_record": "Înregistrare ecran", + "snap_replay": "Redare Repetată a Snap-ului", + "camera_roll_save": "Salvare în Rola de Cameră", + "abandon_video": "Apel video pierdut" + }, + "path_format": { + "create_author_folder": "Creați un folder pentru fiecare autor", + "create_source_folder": "Creați un folder pentru fiecare tip de sursă media", + "append_source": "Adăugați sursa media la numele fișierului", + "append_hash": "Adăugați un hash unic la numele fișierului", + "append_username": "Adăugați numele de utilizator la numele fișierului", + "append_date_time": "Adăugați data și ora la numele fișierului" + }, + "auto_download_sources": { + "friend_snaps": "Snap-uri de la Prieteni", + "friend_stories": "Povești de la prieteni", + "public_stories": "Povești publice", + "spotlight": "Spotlight" + }, + "hide_ui_components": { + "hide_live_location_share_button": "Eliminați butonul de distribuire a locației live", + "hide_stickers_button": "Eliminare Butonul de Stickere", + "hide_unread_chat_hint": "Elimină Sugerarea de Chaturi Necitite", + "hide_profile_call_buttons": "Eliminați butoanele de apel din profil", + "hide_chat_call_buttons": "Eliminați butoanele de apel din conversație", + "hide_voice_record_button": "Eliminați butonul de înregistrare vocală" + }, + "hide_story_suggestions": { + "hide_suggested_friend_stories": "Ascunde poveștile prietenilor sugerați", + "hide_my_stories": "Ascunde-mi poveștile" + }, + "home_tab": { + "discover": "Descoperă", + "spotlight": "Spotlight", + "chat": "Conversație", + "camera": "Cameră", + "map": "Hartă" + }, + "add_friend_source_spoof": { + "added_by_username": "După numele de utilizator", + "added_by_mention": "După mențiune", + "added_by_qr_code": "După codul QR", + "added_by_group_chat": "După conversația de grup", + "added_by_community": "După comunitate" + }, + "disable_story_sections": { + "friends": "Prieteni", + "following": "Urmărire", + "discover": "Descoperă" + }, + "disable_cameras": { + "front": "Camera frontală", + "back": "Camera din spate" + }, + "message_indicators": { + "encryption_indicator": "Adaugă o pictogramă 🔒 lângă mesajele care au fost trimise numai ție", + "ovf_editor_indicator": "Indică dacă a fost trimis un snap folosind Editorul OVF", + "director_mode_indicator": "Adaugă o pictogramă ✏️ la snap-uri atunci când acestea au fost trimise folosind modul Director, care poate fi folosit pentru a trimite imaginile galeriei ca instantanee", + "platform_indicator": "Adaugă pictograma platformei de pe care a fost trimis un conținut media (de exemplu, Android, iOS, Web)", + "location_indicator": "Adaugă o pictogramă 📍 la snap-uri atunci când acestea au fost trimise cu locația activată" + }, + "gallery_media_send_override": { + "SNAP": "Snap", + "ORIGINAL": "Original", + "NOTE": "Notă audio", + "always_ask": "Intreaba mereu" + }, + "app_appearance": { + "always_light": "Întotdeauna Luminat", + "always_dark": "Întotdeauna întunecat" + }, + "edit_text_override": { + "multi_line_chat_input": "Intrare conversație pe mai multe linii", + "bypass_text_input_limit": "Ocoliți limita de introducere a textului" + }, + "old_bitmoji_selfie": { + "2d": "Bitmoji 2D", + "3d": "Bitmoji 3D" + }, + "bypass_video_length_restriction": { + "single": "Media unică", + "split": "Împărțiți media" + }, + "auto_mark_as_read": { + "conversation_read": "Marcați conversația ca citită atunci când trimiteți un mesaj", + "snap_reply": "Marchează snap-urile ca citite când răspunzi la ele" + }, + "friend_mutation_notifier": { + "remove_friend": "Notifică când cineva te șterge din lista de prieteni", + "birthday_changes": "Notifică când cineva își schimbă data de naștere", + "bitmoji_avatar_changes": "Notifică când cineva își schimbă avatarul Bitmoji", + "bitmoji_background_changes": "Notifică când cineva își schimbă fundalul Bitmoji", + "bitmoji_scene_changes": "Notifică când cineva își schimbă scena Bitmoji", + "bitmoji_selfie_changes": "Notifică când cineva își schimbă selfie-ul Bitmoji" + } + } + }, + "scopes": { + "friend": "Prieten", + "group": "Grup" + }, + "actions": { + "export_memories": { + "name": "Exportați Amintirile", + "description": "Exportă amintirile într-un fișier ZIP" + }, + "export_chat_messages": { + "name": "Exportați mesajele de chat", + "description": "Exportă mesajele de conversație într-un fișier JSON/HTML/TXT" + }, + "manage_friend_list": { + "name": "Gestionați lista de prieteni", + "description": "Importați/exportați lista de prieteni când faceți backup" + }, + "clean_snapchat_cache": { + "name": "Golire memorie temporară Snapchat", + "description": "Curățați memoria temporară de la Snapchat" + }, + "bulk_messaging_action": { + "name": "Acțiune de Trimitere în Masă a Mesajelor", + "description": "Efectuează operațiuni precum ștergerea prietenilor sau ștergerea în masă a conversațiilor" + }, + "regen_mappings": { + "description": "Regenerați manual mapările", + "name": "Regenerați mapările" + }, + "change_language": { + "name": "Schimbă limba", + "description": "Schimbați limba SnapEnhance" + } + }, + "content_type": { + "EXTERNAL_MEDIA": "Media externă", + "NOTE": "Notă audio", + "SNAP": "Snap", + "FAMILY_CENTER_ACCEPT": "Centrul de familie Accept", + "FAMILY_CENTER_LEAVE": "Părăsirea Centrului Familial", + "STATUS": "Stare", + "LOCATION": "Locație", + "STATUS_SAVE_TO_CAMERA_ROLL": "Salvat în camera foto", + "FAMILY_CENTER_INVITE": "Invitație la Centrul Familiei", + "CHAT": "Conversație", + "STICKER": "Autocolant", + "CREATIVE_TOOL_ITEM": "Element Instrument Creativ", + "STATUS_CONVERSATION_CAPTURE_SCREENSHOT": "Captură de ecran", + "STATUS_CALL_MISSED_VIDEO": "Apel video pierdut", + "STATUS_CALL_MISSED_AUDIO": "Apel audio pierdut", + "LIVE_LOCATION_SHARE": "Distribuirea locației live", + "STATUS_CONVERSATION_CAPTURE_RECORD": "Înregistrare ecran", + "STATUS_PLUS_GIFT": "Cadou Status Plus", + "TINY_SNAP": "Snap Mic", + "STATUS_COUNTDOWN": "Numărătoare inversă", + "MAP_REACTION": "Reacția pe hartă" + }, + "media_download_source": { + "profile_picture": "Poză de profil", + "chat_media": "Conversație Media", + "none": "Niciuna", + "story_logger": "Înregistrare Poveste", + "message_logger": "Mesaje înregistrate", + "merged": "Îmbinat", + "voice_call": "Apel vocal", + "pending": "În așteptare", + "story": "Poveste", + "public_story": "Povestea publică", + "spotlight": "Lumea snapchat" + }, + "opera_context_menu": { + "download": "Descărcați Media", + "sent_at": "Trimis la {date}", + "media_duration": "Durata media: {duration} milisecunde", + "created_at": "Creat la {date}", + "expires_at": "Expiră la {date}", + "media_size": "Dimensiune media: {size}", + "show_debug_info": "Afișați informațiile de depanare" + }, + "mark_as_seen": { + "no_unseen_snaps_toast": "Nu s-au găsit snap-uri nevăzute!", + "seen_toast": "Marcat ca văzut!", + "unseen_toast": "Marcat ca nevăzut!", + "already_seen_toast": "Deja marcat ca văzut!", + "already_unseen_toast": "Deja marcat ca nevăzut!" + }, + "end_to_end_encryption": { + "accept_public_key_failure_toast": "Nu s-a acceptat cheia publică", + "outgoing_pk_message": "Cerere de schimb de chei", + "outgoing_secret_message": "Răspuns la schimbul de chei", + "confirmation_dialogs": { + "title": "Criptare Final-Spre-Final", + "confirmation_2": "Ești CHIAR sigur că vrei să continui? Aceasta este ultima ta șansă să dai înapoi.", + "confirmation_1": "AVERTISMENT: Aceasta va suprascrie cheia existentă. Veți pierde accesul la toate mesajele criptate de la acest prieten. Ești sigur ca vrei să continui?" + }, + "toolbox": { + "shared_key_fingerprint": "Amprenta ta este:\n\n{amprentă}\n\nAsigurați-vă că verificați dacă se potrivește cu amprenta prietenului dumneavoastră!", + "no_shared_key": "Încă nu aveți un secret împărtășit cu acest prieten. Faceți clic mai jos pentru a iniția unul nou.", + "initiate_exchange_button": "Inițiați schimbul de chei" + }, + "no_participants_to_encrypt_toast": "Nu aveți prieteni în această conversație cu care să criptați mesajele!", + "encryption_failed_toast": "Nu s-a putut cripta mesajul! Verificați mesajele din log pentru mai multe detalii.", + "accept_secret_key_success_toast": "Terminat! Acum puteți trimite și primi mesaje criptate cu acest prieten.", + "accept_secret_key_failure_toast": "Nu s-a acceptat cheia secretă", + "unencrypted_conversation_send_failure_toast": "Nu puteți trimite conținut criptat atât la conversații criptate, cât și la conversații necriptate!", + "native_hooks_send_failure_toast": "Trimitere eșuată! Vă rugăm să activați Hook-urile Native în setările aplicației.", + "accept_secret_button": "Acceptați Secretul", + "incoming_pk_message": "Tocmai ați primit o solicitare de cheie publică. Faceți clic mai jos pentru a o accepta.", + "incoming_secret_message": "Prietenul tău tocmai a acceptat cheia ta publică. Faceți clic mai jos pentru a accepta secretul.", + "accept_public_key_success_toast": "Cheie publică acceptată cu succes!", + "accept_public_key_button": "Acceptați cheia publică" + }, + "material3_strings": { + "date_range_input_invalid_range_input": "Interval de date nevalid", + "date_range_picker_scroll_to_next_month": "Luna viitoare", + "date_input_invalid_for_pattern": "Dată nevalidă", + "date_range_picker_day_in_range": "Selectat", + "date_input_invalid_year_range": "An invalid", + "date_range_picker_end_headline": "La", + "date_range_picker_start_headline": "De la", + "date_range_picker_title": "Selectați intervalul de date", + "date_picker_switch_to_calendar_mode": "Agendă", + "date_picker_switch_to_input_mode": "Intrare", + "date_range_picker_scroll_to_previous_month": "Luna trecută", + "date_picker_today_description": "Azi", + "date_input_invalid_not_allowed": "Data invalidă" + }, + "chat_action_menu": { + "preview_button": "Previzualizare", + "download_button": "Descarcă", + "delete_logged_message_button": "Ștergeți mesajul salvat", + "convert_message": "Convertiți mesajul", + "edit_message": "Editați mesajul" + }, + "friendship_link_type": { + "mutual": "Reciproc", + "outgoing": "De ieșire", + "deleted": "Șters", + "incoming_follower": "Abonat primit", + "incoming": "Primire", + "blocked": "Blocat", + "suggested": "Sugerat", + "following": "Urmărești" + }, + "bulk_messaging_action": { + "confirmation_dialog": { + "title": "Ești sigur?", + "message": "Acest lucru va afecta toți prietenii selectați. Această acțiune nu poate fi anulată." + }, + "actions": { + "remove_friends": "Eliminați prietenii", + "clear_conversations": "Ștergeți conversațiile" + }, + "choose_action_title": "Alegeți o acțiune", + "progress_status": "Se procesează {index} din {total}", + "selection_dialog_continue_button": "Continuă" + }, + "better_notifications": { + "button": { + "reply": "Răspundeți", + "download": "Descărcați", + "mark_as_read": "Marcați ca citit" + } + }, + "streaks_reminder": { + "notification_title": "Linie fierbinte", + "notification_text": "Îți vei pierde linia fierbinte cu {friend} în {hourLeft} ore" + }, + "call_start_confirmation": { + "dialog_message": "Sigur vrei să inițiezi un apel?", + "dialog_title": "Începeți apelul" + }, + "half_swipe_notifier": { + "notification_content_group": "{friend} a trecut pe jumătate în {grup} timp de {durata} secunde", + "notification_channel_name": "Glisare pe jumătate", + "notification_content_dm": "{friend} a trecut pe jumătate în chat-ul tău timp de {durata} secunde" + }, + "biometric_auth": { + "unlock_button": "Deblocați", + "title": "Deblochează Snapchat", + "subtitle": "Vă rugăm să vă autentificați pentru a debloca Snapchat" + }, + "profile_picture_downloader": { + "button": "Descărcați poza de profil", + "title": "Descărcător de imagini de profil", + "avatar_option": "Avatar", + "background_option": "Fundal" + }, + "auto_open_snaps": { + "title": "Deschidere automată Snap-uri", + "notification_content": "{număr} Snap-uri deschise" + }, + "friend_mutation_observer": { + "notification_channel_name": "Observator de Mutare a Prietenilor", + "friend_removed": "{username} te-a șters din lista de prieteni", + "birthday_removed": "{username} a șters data de naștere ({birthday})", + "birthday_added": "{username} a adăugat data de naștere ({birthday})", + "birthday_changed": "{username} și-a schimbat data de naștere de la {oldBirthday} la {newBirthday}", + "bitmoji_avatar_changed": "{username} și-a schimbat avatarul Bitmoji", + "bitmoji_scene_changed": "{username} și-a schimbat scena Bitmoji", + "bitmoji_selfie_changed": "{username} și-a schimbat selfie-ul Bitmoji", + "bitmoji_background_changed": "{username} și-a schimbat fundalul Bitmoji" + } +} diff --git a/common/src/main/assets/lang/ru.json b/common/src/main/assets/lang/ru.json new file mode 100644 index 0000000000..fa7b03df17 --- /dev/null +++ b/common/src/main/assets/lang/ru.json @@ -0,0 +1,1353 @@ +{ + "setup": { + "dialogs": { + "select_language": "Выберите язык", + "save_folder": "SnapEnhance требует разрешения хранилища для загрузки и сохранения медиафайлов из Snapchat.\nВыберите место, куда следует загружать медиафайлы.", + "select_save_folder_button": "Выберите папку" + }, + "mappings": { + "dialog": "Составление карт, это может занять некоторое время ...", + "generate_failure_no_snapchat": "SnapEnhance не удалось обнаружить Snapchat, попробуйте переустановить Snapchat.", + "generate_failure": "Произошла ошибка при генерации сопоставлений, пожалуйста, попробуйте еще раз." + }, + "permissions": { + "notification_access": "Доступ к уведомлениям", + "display_over_other_apps": "Отображение поверх других приложений", + "dialog": "Для продолжения вам необходимо соответствовать следующим требованиям:", + "battery_optimization": "Оптимизация батареи", + "request_button": "Запрос" + } + }, + "manager": { + "sections": { + "tasks": { + "no_tasks": "Нет задач", + "remove_selected_tasks_title": "Вы уверены, что хотите удалить выбранные задачи?", + "remove_all_tasks_title": "Вы уверены, что хотите удалить все задачи?", + "delete_files_option": "Также удалить файлы", + "remove_selected_tasks_confirm": "Удалить {count} задач?", + "remove_all_tasks_confirm": "Убрать все задачи?", + "merge_files_toast": "Объединение {count} файла", + "failed_to_open_file": "Не удалось открыть файл" + }, + "home_logs": { + "clear_logs_button": "Очистить логи", + "saved_logs_failure_toast": "Не удалось сохранить логи", + "no_logs_hint": "Нет доступных логов", + "export_logs_button": "Экспорт логов", + "saving_logs_toast": "Сохранение логов, это может занять некоторое время...", + "saved_logs_success_toast": "Логи успешно сохранены" + }, + "home_settings": { + "export_button": "Экспорт", + "clear_button": "Очистить", + "view_logger_history_button": "Посмотреть логгер историй", + "message_logger_title": "Логгер сообщений", + "success_toast": "Сделанно!", + "message_logger_summary": "{messageCount} сообщений\n{storyCount} историй", + "actions_title": "Действия", + "debug_title": "Режим отладки" + }, + "home": { + "update_title": "Обновить SnapEnchance", + "update_content": "Версия {version} доступна!", + "update_button": "Скачать", + "debug_build_summary_content": "Версия {названиеВерсии} ({кодВерсии})", + "debug_build_summary_title": "Вы используете устранение ошибок от SnapEnhance", + "version_title": "v(названиеВерсии) · создал rhunk", + "debug_build_summary_date": "Назначить дату: {дата} ({дни} дней назад)", + "quick_actions_title": "Быстрые Действия" + }, + "features": { + "disabled": "Отключенно", + "export_option": "Экспорт", + "import_option": "Импорт", + "config_export_success_toast": "Конфигурация успешно экспортирована", + "config_import_success_toast": "Конфигурация успешно импортирована", + "config_import_failure_toast": "Не удалось импортировать конфигурацию {error}", + "saved_config_snackbar": "Конфигурация сохранена", + "reset_option": "Сброс", + "config_export_failure_toast": "Не удалось экспортировать конфигурацию {ошибка}" + }, + "manage_scope": { + "logged_stories_button": "Показать сохранённые истории", + "e2ee_title": "Сквозное шифрование", + "rules_title": "Правила", + "participants_text": "{count} участников", + "not_found": "Не найдено", + "streaks_length_text": "Длина: {length}", + "streaks_expiration_text": "Истекает через {eta}", + "streaks_title": "Серия", + "streaks_expiration_text_expired": "Истекло", + "reminder_button": "Установить напоминание", + "delete_scope_confirm_dialog_title": "Вы уверены, что хотите удалить {scope}?" + }, + "social": { + "streaks_expiration_short": "{hours} ч", + "friends_tab": "Друзья", + "groups_tab": "Группы", + "empty_hint": "(пусто)" + }, + "logged_stories": { + "story_failed_to_load": "Ошибка загрузки", + "no_stories": "Истории не найдены", + "save_from_cache_button": "Сохранить из кэша" + }, + "messaging_preview": { + "bridge_connection_failed": "Не удалось подключиться к Snapchat через службу моста", + "bridge_init_failed": "Не удалось инициализировать мост обмена сообщениями", + "message_fetch_failed": "Не удалось получить сообщения", + "no_message_hint": "Нет сообщений", + "save_selection_option": "Сохранить выбранное", + "save_all_option": "Сохранить все", + "mark_selection_as_seen_option": "Авторами выбранный Snap как просмотренный", + "mark_all_as_seen_option": "Отметить все Snap как просмотренные", + "delete_selection_option": "Удалить выбранное", + "delete_all_option": "Удалить все", + "unsave_all_option": "Не сохранять все", + "unsave_selection_option": "Отменить выбор" + }, + "logger_history": { + "list_friend_format": "Друг {name}", + "list_group_format": "Группа {name}", + "no_more_messages": "Больше нет сообщений", + "chat_attachment": "Вложение {index}", + "reverse_order_checkbox": "Обратный порядок", + "message_parse_failed": "Не удалось проанализировать сообщение", + "unknown_sender": "Неизвестный отправитель", + "download_attachment_failed_toast": "Не удалось загрузить вложение", + "empty_message": "Пустой чат" + }, + "manage_rule_feature": { + "disable_state_option": "Выведенный из строя", + "disable_state_subtext": "Друзья/группы не будут изменены", + "whitelist_state_option": "Ни одного исключения ...", + "whitelist_state_subtext": "Только {число} друзей/групп будет изменено этим правилом" + } + }, + "routes": { + "home_logs": "Логи", + "tasks": "Задания", + "features": "Функции", + "home": "Дом", + "home_settings": "Настройки", + "scripts": "Скрипты", + "logger_history": "История логов", + "messaging_preview": "Предварительный просмотр", + "logged_stories": "Сохранённые истории", + "manage_scope": "Управление область", + "social": "Соц. сети", + "better_location": "Улучшенная Локация", + "manage_rule_feature": "Изменить настройки", + "file_imports": "Импорт файлов", + "friend_tracker": "Остлеживание друга", + "edit_rule": "Изменить правила", + "theming": "Интерфейс", + "edit_theme": "Изменть Тему", + "manage_repos": "Управлять Хранилищами" + }, + "dialogs": { + "scripting_warning": { + "title": "Предупреждение", + "content": "SnapEnhance включает в себя инструмент сценариев, позволяющий выполнять пользовательский код на вашем устройстве. Будьте предельно осторожны и устанавливайте модули только из известных и надежных источников. Неавторизованные или непроверенные модули могут представлять угрозу безопасности вашей системы." + }, + "reset_config": { + "title": "Сбросить конфигурацию", + "content": "Вы уверены, что хотите сбросить конфигурацию?", + "success_toast": "Конфигурация успешно сброшена" + }, + "messaging_action": { + "title": "Выберите типы контента для обработки", + "select_all_button": "Выбрать все" + }, + "add_friend": { + "title": "Добавить друга или группу", + "search_hint": "Поиск", + "fetch_error": "Не удалось получить данные", + "category_groups": "Группы", + "category_friends": "Друзья" + } + } + }, + "scopes": { + "friend": "Друг", + "group": "Группа" + }, + "material3_strings": { + "date_range_input_invalid_range_input": "Неверный диапазон дат", + "date_range_picker_start_headline": "От", + "date_input_invalid_year_range": "Неверный год", + "date_input_invalid_not_allowed": "Недействительная дата", + "date_range_picker_end_headline": "К", + "date_range_picker_title": "Выберите диапазон дат", + "date_picker_switch_to_calendar_mode": "Календарь", + "date_picker_switch_to_input_mode": "Вход", + "date_picker_today_description": "Сегодня", + "date_input_invalid_for_pattern": "Недействительная дата", + "date_range_picker_scroll_to_previous_month": "Предыдущий месяц", + "date_range_picker_day_in_range": "Выбрано", + "date_range_picker_scroll_to_next_month": "В следующем месяце" + }, + "half_swipe_notifier": { + "notification_content_dm": "Пользователь {friend} только что наполовину перешел в ваш чат на {duration} сек", + "notification_channel_name": "Половина смахивания", + "notification_content_group": "Пользователь {friend} только что наполовину перешел в {группу} на {duration} сек" + }, + "conversation_preview": { + "total_messages": "Всего отправленных/полученных сообщений: {count}", + "title": "Предварительный просмотр", + "streak_expiration": "истекает через {day} дней {час} часов {минуту} минут", + "unknown_user": "Неизвестный пользователь" + }, + "rules": { + "properties": { + "stealth": { + "name": "Режим скрытности", + "description": "Не позволяет никому узнать, что вы открыли их Snap/чаты и разговоры", + "options": { + "blacklist": "Исключить из скрытого режима", + "whitelist": "Режим скрытности" + } + }, + "pin_conversation": { + "name": "Закрепить разговор" + }, + "e2e_encryption": { + "name": "Использовать шифрование E2E" + }, + "auto_save": { + "name": "Авто сохранение", + "description": "Сохраняет сообщения чата при их просмотре", + "options": { + "blacklist": "Исключить из автосохранения", + "whitelist": "Автосохранение" + } + }, + "unsaveable_messages": { + "name": "Несохраняемые сообщения", + "description": "Предотвращает сохранение сообщений в чате другими людьми", + "options": { + "blacklist": "Исключить из несохраняемых сообщений", + "whitelist": "Несохраняемые сообщения" + } + }, + "hide_friend_feed": { + "name": "Скрыть из ленты друзей" + }, + "auto_download": { + "options": { + "blacklist": "Исключить из автоматической загрузки", + "whitelist": "Автоматическая загрузка" + }, + "description": "Автоматически загружать Snap при их просмотре", + "name": "Авто загрузка" + }, + "auto_open_snaps": { + "name": "Автоматическое открытие Снапов", + "description": "Автоматическое открытие Снапов при их получении", + "options": { + "blacklist": "Исключить из автоматического открытия Снапов", + "whitelist": "Автоматическое открытие Снапов" + } + } + }, + "toasts": { + "enabled": "{ruleName} включено", + "disabled": "{ruleName} отключено" + }, + "modes": { + "blacklist": "Режим черного списка", + "whitelist": "Режим белого списка" + } + }, + "button": { + "ok": "ОК", + "open": "Открыть", + "positive": "Да", + "negative": "Нет", + "cancel": "Отмена", + "download": "Скачать" + }, + "end_to_end_encryption": { + "accept_public_key_button": "Принять открытый ключ", + "toolbox": { + "no_shared_key": "У вас еще нет общего секрета с этим другом. Нажмите ниже, чтобы начать новый.", + "shared_key_fingerprint": "Ваш отпечаток пальца:\n\n{fingerprint}\n\nОбязательно проверьте, совпадает ли он с отпечатком пальца вашего друга!", + "initiate_exchange_button": "Инициировать обмен ключами" + }, + "confirmation_dialogs": { + "title": "Сквозное шифрование", + "confirmation_1": "ВНИМАНИЕ: это приведет к перезаписи существующего ключа. Вы потеряете доступ ко всем зашифрованным сообщениям этого друга. Вы уверены что хотите продолжить?", + "confirmation_2": "Вы ДЕЙСТВИТЕЛЬНО уверены, что хотите продолжить? Это ваш последний шанс отступить." + }, + "unencrypted_conversation_send_failure_toast": "Вы не можете отправлять зашифрованный контент одновременно в зашифрованные и незашифрованные беседы!", + "no_participants_to_encrypt_toast": "У вас нет друзей в этом разговоре, с которыми можно шифровать сообщения!", + "accept_secret_key_success_toast": "Сделанно! Теперь вы можете отправлять и получать зашифрованные сообщения с этим другом.", + "accept_public_key_failure_toast": "Не удалось принять открытый ключ", + "accept_secret_button": "Принять секрет", + "encryption_failed_toast": "Не удалось зашифровать сообщение! Проверьте logcat для получения более подробной информации.", + "accept_public_key_success_toast": "Открытый ключ успешно принят!", + "outgoing_secret_message": "Ответ на обмен ключами", + "incoming_pk_message": "Вы только что получили запрос открытого ключа. Нажмите ниже, чтобы принять его.", + "incoming_secret_message": "Ваш друг только что принял ваш открытый ключ. Нажмите ниже, чтобы принять секрет.", + "native_hooks_send_failure_toast": "Не удалось отправить! Пожалуйста, включите Native Hooks в настройках.", + "accept_secret_key_failure_toast": "Не удалось принять секретный ключ", + "outgoing_pk_message": "Запрос на обмен ключами" + }, + "features": { + "properties": { + "downloader": { + "properties": { + "ffmpeg_options": { + "properties": { + "preset": { + "description": "Установите скорость конвертации", + "name": "Предустановка" + }, + "custom_audio_codec": { + "description": "Установите собственный аудиокодек (например, AAC)", + "name": "Пользовательский аудиокодек" + }, + "threads": { + "description": "Количество потоков, которые нужно использовать", + "name": "Потоки" + }, + "video_bitrate": { + "description": "Установите битрейт видео (кбит/с)", + "name": "Битрейт видео" + }, + "audio_bitrate": { + "description": "Установите битрейт аудио (кбит/с)", + "name": "Аудио битрейт" + }, + "custom_video_codec": { + "name": "Пользовательский видеокодек", + "description": "Установите собственный видеокодек (например, libx264)" + }, + "constant_rate_factor": { + "description": "Установите коэффициент постоянной скорости для видеокодера\n От 0 до 51 для libx264", + "name": "Фактор постоянной скорости" + } + }, + "description": "Укажите дополнительные параметры FFmpeg", + "name": "Параметры FFmpeg" + }, + "force_image_format": { + "name": "Принудительный формат изображения", + "description": "Принудительно сохраняет изображения в указанном формате" + }, + "download_profile_pictures": { + "name": "Загрузить изображения профиля", + "description": "Позволяет загружать изображения профиля со страницы профиля" + }, + "opera_download_button": { + "name": "Кнопка загрузки оперы", + "description": "Добавляет кнопку загрузки в правом верхнем углу при просмотре снимка.\nДлительное нажатие кнопки активирует загрузку" + }, + "logging": { + "name": "Ведение журнала", + "description": "Показывает всплывающие сообщения при загрузке мультимедиа" + }, + "auto_download_sources": { + "name": "Источники автоматической загрузки", + "description": "Выберите источники для автоматической загрузки" + }, + "download_context_menu": { + "description": "«Позволяет загружать/просматривать сообщения из разговора или истории с помощью контекстного меню.\nДлительное нажатие на кнопки приведет к принудительной загрузке»", + "name": "Загрузить контекстное меню" + }, + "save_folder": { + "name": "Сохранить папку", + "description": "Выберите каталог, в который должны быть загружены все медиафайлы" + }, + "prevent_self_auto_download": { + "name": "Запретить самостоятельную автоматическую загрузку", + "description": "Предотвращает автоматическую загрузку ваших собственных снимков" + }, + "path_format": { + "name": "Формат пути", + "description": "Укажите формат пути к файлу" + }, + "allow_duplicate": { + "name": "Разрешить дублирование", + "description": "Позволяет загружать один и тот же носитель несколько раз" + }, + "merge_overlays": { + "name": "Объединить наложения", + "description": "Объединяет текст и медиафайл Snap в один файл" + }, + "force_voice_note_format": { + "name": "Принудительный формат голосовой заметки", + "description": "Принудительное сохранение голосовых заметок в указанном формате" + }, + "custom_path_format": { + "name": "Пользовательский формат пути", + "description": "Укажите собственный формат пути для загруженных медия.\n\n Доступные переменные:\n - %username%\n - %source%\n - %hash% \n- %date_time%" + } + }, + "description": "Скачать Snapchat Media", + "name": "Загрузчик" + }, + "user_interface": { + "properties": { + "enable_app_appearance": { + "name": "Включить настройки внешнего вида приложения", + "description": "Включает скрытую настройку внешнего вида приложения\nМожет не потребоваться в новых версиях Snapchat" + }, + "friend_feed_message_preview": { + "description": "Показывает предварительный просмотр последних сообщений в ленте друзей", + "name": "Предварительный просмотр сообщения в ленте друзей", + "properties": { + "amount": { + "name": "Количество", + "description": "Количество сообщений для предварительного просмотра" + } + } + }, + "bootstrap_override": { + "name": "Переопределение Bootstrap", + "properties": { + "home_tab": { + "name": "Главная вкладка", + "description": "Переопределяет вкладку запуска при открытии Snapchat" + }, + "app_appearance": { + "name": "Внешний вид приложения", + "description": "Устанавливает постоянный внешний вид приложения" + } + }, + "description": "Переопределяет настройки пользовательского интерфейса Bootstrap" + }, + "hide_streak_restore": { + "description": "Скрывает кнопку восстановления в ленте друзей", + "name": "Скрыть восстановление знака в ленте друзей" + }, + "snap_preview": { + "description": "Отображает небольшой предварительный просмотр рядом с неоткрытыми Snap в чате", + "name": "Предварительный просмотр Snap" + }, + "prevent_message_list_auto_scroll": { + "description": "Предотвращает автоматическую прокрутку списка сообщений к нижней части при отправке/получении сообщения", + "name": "Предотвращение автоматической прокрутки списка сообщений" + }, + "streak_expiration_info": { + "name": "Показ информации о сроке действия знака", + "description": "Показывает таймер истечения срока действия знака рядом с счетчиком знаков" + }, + "hide_story_suggestions": { + "name": "Скрыть предложения историй", + "description": "Удаляет предложения с страницы историй" + }, + "hide_ui_components": { + "name": "Скрыть элементы пользовательского интерфейса", + "description": "Выберите, какие элементы пользовательского интерфейса скрыть" + }, + "opera_media_quick_info": { + "description": "Показывает полезную информацию о медиафайлах, такую как дата создания, в контекстном меню просмотра оперы", + "name": "Информация о медиафайлах в Опере" + }, + "old_bitmoji_selfie": { + "name": "Старые селфи Битмоджи", + "description": "Возвращает селфи с Битмоджи из более старых версий Snapchat" + }, + "disable_spotlight": { + "name": "Отключить \"Спотлайт\"", + "description": "Отключает страницу \"Спотлайт\"" + }, + "vertical_story_viewer": { + "name": "Вертикальный просмотр историй", + "description": "Включает вертикальный просмотр историй для всех историй" + }, + "message_indicators": { + "name": "Индикаторы сообщений", + "description": "Добавляет специфические значки индикаторов к сообщениям\nПримечание: индикаторы могут быть не на 100% точными" + }, + "edit_text_override": { + "name": "Переопределение текста редактирования", + "description": "Переопределяет поведение текстового поля" + }, + "friend_feed_menu_buttons": { + "description": "Выберите, какие кнопки показывать в меню ленты друзей", + "name": "Кнопки меню ленты друзей" + }, + "map_friend_nametags": { + "name": "Улучшенные теги имен друзей на карте Snap", + "description": "Улучшает теги имен друзей на карте Snapmap" + }, + "enable_friend_feed_menu_bar": { + "name": "Панель меню ленты друзей", + "description": "Включает новую панель меню ленты друзей" + }, + "stealth_mode_indicator": { + "name": "Индикатор режима невидимки", + "description": "Добавляет эмодзи 👻 рядом с беседами в режиме невидимки" + }, + "hide_friend_feed_entry": { + "name": "Скрыть запись о друге в ленте друзей", + "description": "Скрывает определенного друга из ленты друзей\nИспользуйте вкладку «Социальное» для управления этой функцией" + } + }, + "description": "Измените внешний вид Snapchat", + "name": "Пользовательский интерфейс" + }, + "experimental": { + "properties": { + "story_logger": { + "name": "Журнал истории", + "description": "Предоставляет историю историй друзей" + }, + "meo_passcode_bypass": { + "description": "Обход пароля \"Только мои глаза\"\nЭто сработает только в том случае, если пароль был введен правильно ранее", + "name": "Обход пароля «Только мои глаза»" + }, + "native_hooks": { + "properties": { + "disable_bitmoji": { + "description": "Отключает Bitmoji профиля друзей", + "name": "Отключить Bitmoji" + }, + "composer_hooks": { + "name": "Крюки композитора", + "description": "Встраивает код в кросс-платформенный UI-фреймворк Composer (только для архитектуры arm64)", + "properties": { + "bypass_camera_roll_limit": { + "name": "Обход ограничения на количество фотографий в галерее", + "description": "Увеличивает максимальное количество медиафайлов, которые можно отправить из галереи" + }, + "composer_logs": { + "name": "Логи Composer", + "description": "Перенаправляет журналы консоли Composer в SnapEnhance" + }, + "composer_console": { + "name": "Консоль Composer", + "description": "Позволяет выполнять JavaScript-код в Composer" + } + } + } + }, + "name": "Native Hooks", + "description": "Небезопасные функции, которые встраиваются в нативный код Snapchat" + }, + "spoof": { + "description": "Подделывает различную информацию о вас", + "properties": { + "play_store_installer_package_name": { + "name": "Имя пакета установщика Play Store", + "description": "Переопределяет имя пакета установщика на com.android.vending" + }, + "fingerprint": { + "name": "Отпечаток устройства", + "description": "Подделывает отпечаток вашего устройства" + }, + "remove_mock_location_flag": { + "description": "Предотвращает обнаружение фиктивного местоположения Snapchat", + "name": "Удалить флаг мокрого местоположения" + } + }, + "name": "Подделка" + }, + "edit_message": { + "description": "Позволяет редактировать сообщения в разговорах", + "name": "Редактирование сообщений" + }, + "e2ee": { + "name": "End-to-end шифрование", + "description": "Шифрует ваши сообщения с помощью AES с использованием общего секретного ключа\nОбязательно сохраните свой ключ в надежном месте!", + "properties": { + "encrypted_message_indicator": { + "name": "Индикатор зашифрованного сообщения", + "description": "Добавляет 🔒 эмодзи к зашифрованным сообщениям" + }, + "force_message_encryption": { + "name": "Принудительное шифрование сообщений", + "description": "Предотвращает отправку зашифрованных сообщений людям, у которых отключено конечно-конечное шифрование, только когда выбраны несколько бесед" + } + } + }, + "media_file_picker": { + "name": "Выбор медиафайла", + "description": "Позволяет выбирать любой видео/аудиофайл из галереи" + }, + "call_recorder": { + "name": "Запись вызова", + "description": "Автоматически записывает аудиовызовы" + }, + "account_switcher": { + "name": "Переключатель аккаунтов", + "description": "Позволяет переключаться между аккаунтами без выхода из системы\nДолгое нажатие на значок поиска рядом с вашим профилем Bitmoji для открытия меню\nПримечание: эта функция является экспериментальной и, вероятно, будет изменена в будущем", + "properties": { + "auto_backup_current_account": { + "name": "Автоматическое резервное копирование текущего аккаунта", + "description": "Автоматически создает резервную копию текущего аккаунта при выходе из системы или переключении аккаунтов" + } + } + }, + "infinite_story_boost": { + "name": "Бесконечное ускорение истории", + "description": "Обходит ограничение задержки ускорения истории" + }, + "no_friend_score_delay": { + "name": "Отключить задержку оценки друзей", + "description": "Убирает задержку при просмотре оценки друзей" + }, + "convert_message_locally": { + "description": "Локально преобразует снепы во внешние медиафайлы для чата. Это появляется в контекстном меню загрузки чата", + "name": "Преобразование сообщения локально" + }, + "add_friend_source_spoof": { + "name": "Подделка источника запроса на добавление в друзья", + "description": "Маскировка источника запроса на добавление в друзья" + }, + "prevent_forced_logout": { + "name": "Предотвращение принудительного выхода из системы", + "description": "Предотвращает выход из системы Snapchat при входе на другом устройстве" + }, + "hidden_snapchat_plus_features": { + "description": "Включает нереализованные/бета-версии функций Snapchat Plus\nМожет не работать в старых версиях Snapchat", + "name": "Скрытые функции Snapchat Plus" + }, + "app_lock": { + "description": "Запрещает доступ к Snapchat без пароля", + "properties": { + "lock_on_resume": { + "description": "Блокирует приложение при его повторном открытии", + "name": "Заблокировать при продолжении" + } + }, + "name": "Блокировка приложения" + }, + "custom_streaks_expiration_format": { + "name": "Пользовательский формат истечения Streaks", + "description": "Настройка формата истечения Стриков\n\nДоступные переменные:\n- %c: Количество Стриков\n- %e: Эмодзи песочных часов\n- %d: Дни\n- %h: Часы\n- %m: Минуты\n- %s: Секунды\n- %w: Оставшееся время" + } + }, + "name": "Экспериментальные", + "description": "Экспериментальные функции" + }, + "messaging": { + "properties": { + "call_start_confirmation": { + "name": "Подтверждение начала звонка", + "description": "Показывает диалоговое окно подтверждения при начале звонка" + }, + "bypass_screenshot_detection": { + "name": "Обход обнаружения снимка экрана", + "description": "Предотвращает обнаружение Snapchat, когда вы делаете снимок экрана" + }, + "unlimited_snap_view_time": { + "description": "Удаляет ограничение времени просмотра Snap", + "name": "Неограниченное время просмотра Snap" + }, + "anonymous_story_viewing": { + "description": "Предотвращает, чтобы кто-либо узнал, что вы просматривали их историю", + "name": "Анонимный просмотр историй" + }, + "prevent_story_rewatch_indicator": { + "name": "Предотвращение индикатора повторного просмотра истории", + "description": "Предотвращает, чтобы кто-либо узнал, что вы пересматривали их историю" + }, + "hide_bitmoji_presence": { + "name": "Скрыть присутствие Битмоджи", + "description": "Предотвращает появление вашего Битмоджи во время чата" + }, + "disable_replay_in_ff": { + "description": "Отключает возможность повтора с долгим нажатием в ленте друзей", + "name": "Отключить повтор в FF" + }, + "half_swipe_notifier": { + "name": "Уведомитель о половинчатом смахивании", + "properties": { + "min_duration": { + "description": "Минимальная длительность половинчатого смахивания (в секундах)", + "name": "Минимальная длительность" + }, + "max_duration": { + "name": "Максимальная длительность", + "description": "Максимальная длительность половинчатого смахивания (в секундах)" + } + }, + "description": "Уведомляет вас, когда кто-то половинчато смахивает в беседу" + }, + "notification_blacklist": { + "name": "Выберите уведомления, которые следует заблокировать", + "description": "Журнал сообщений" + }, + "message_logger": { + "name": "Предотвращает удаление сообщений", + "properties": { + "keep_my_own_messages": { + "description": "Автоочистка", + "name": "Предотвращает удаление ваших собственных сообщений" + }, + "auto_purge": { + "description": "Фильтр сообщений", + "name": "Автоматически удаляет кэшированные сообщения, которые старше указанного времени" + }, + "message_filter": { + "name": "Выберите, какие сообщения должны быть зарегистрированы (пусто для всех сообщений)", + "description": "Автосохранение сообщений" + } + }, + "description": "Сохранение собственных сообщений" + }, + "bypass_message_retention_policy": { + "description": "Обход ограничений на действия с сообщениями", + "name": "Предотвращает удаление сообщений после их просмотра" + }, + "remove_groups_locked_status": { + "description": "Глобальные", + "name": "Позволяет просматривать информацию о группе после исключения" + }, + "prevent_message_sending": { + "description": "Мгновенное удаление", + "name": "Предотвращает отправку определенных типов сообщений" + }, + "hide_peek_a_peek": { + "name": "Скрыть Peek-a-Peek", + "description": "Предотвращает отправку уведомления, когда вы половинчато смахиваете в чате" + }, + "hide_typing_notifications": { + "name": "Скрыть уведомления о наборе текста", + "description": "Предотвращает, чтобы кто-либо узнал, что вы набираете сообщение" + }, + "loop_media_playback": { + "name": "Повторное воспроизведение медиафайлов", + "description": "Повторное воспроизведение медиафайлов при просмотре Snap / Историй" + }, + "better_notifications": { + "description": "Черный список уведомлений", + "name": "Добавляет больше информации в полученные уведомления" + }, + "strip_media_metadata": { + "name": "Удаляет метаданные медиафайлов перед отправкой как сообщение", + "description": "Обход политики хранения сообщений" + }, + "bypass_message_action_restrictions": { + "name": "Позволяет реагировать на снеп без его открытия или сохранять сообщение, которое нельзя сохранить", + "description": "Удалить статус заблокированной группы" + }, + "auto_save_messages_in_conversations": { + "name": "Автоматически сохраняет каждое сообщение в разговорах", + "description": "Переопределение отправки медиафайлов из галереи" + }, + "gallery_media_send_override": { + "name": "Маскировка источника медиафайлов при отправке из галереи", + "description": "Удаление метаданных медиафайлов" + }, + "auto_mark_as_read": { + "description": "Автоматически помечает сообщения/снапы как прочитанные даже при включенном режиме невидимости", + "name": "Автоматическая отметка как прочитанное" + }, + "friend_mutation_notifier": { + "description": "Уведомляет вас, когда что-то меняется в профиле друга", + "name": "Уведомление о мутации друга" + } + }, + "description": "Изменяет ваш способ взаимодействия с друзьями", + "name": "Общение" + }, + "streaks_reminder": { + "properties": { + "interval": { + "description": "Интервал между каждым напоминанием (часы)", + "name": "Интервал" + }, + "remaining_hours": { + "name": "Оставшееся время", + "description": "Оставшееся время до показа уведомления (часы)" + }, + "group_notifications": { + "name": "Уведомления о группе", + "description": "Группирует уведомления в одно" + } + }, + "name": "Напоминания о серии", + "description": "Периодически уведомляет вас о ваших сериях" + }, + "global": { + "properties": { + "hide_active_music": { + "name": "Скрыть активную музыку", + "description": "Предотвращает Snapchat от знания о том, что вы слушаете музыку\nЭто позволит вам делать снимки с помощью кнопок управления громкостью во время прослушивания музыки" + }, + "better_location": { + "name": "Улучшает местоположение Snapchat", + "properties": { + "coordinates": { + "description": "Установите координаты поддельного местоположения", + "name": "Координаты" + }, + "always_update_location": { + "name": "Всегда обновлять местоположение", + "description": "Принудительное обновление местоположения даже при отсутствии данных GPS" + }, + "suspend_location_updates": { + "name": "Приостановить обновление местоположения", + "description": "Добавляет кнопку в настройки карты для приостановки обновления местоположения" + }, + "spoof_location": { + "name": "Маскирует ваше местоположение до указанного места", + "description": "Имитирует ваше местоположение до указанного места" + }, + "spoof_headphones": { + "name": "Поддельные наушники", + "description": "Подделывает статус прослушивания музыки на карте" + }, + "spoof_battery_level": { + "name": "Поддельный уровень заряда батареи", + "description": "Подделывает уровень заряда батареи вашего устройства на карте\nЗначение должно быть от 0 до 100" + } + }, + "description": "Маскировка местоположения" + }, + "disable_metrics": { + "description": "Блокирует отправку определенных аналитических данных в Snapchat", + "name": "Отключить метрики" + }, + "disable_memories_snap_feed": { + "name": "Отключить ленту моментов", + "description": "Предотвращает отображение последних моментов при свайпе вверх в камере" + }, + "spotlight_comments_username": { + "name": "Имя пользователя в комментариях", + "description": "Показывает имя автора в комментариях к 'Spotlight'" + }, + "disable_google_play_dialogs": { + "description": "Предотвращает отображение диалогов о доступности Google Play Services", + "name": "Отключить диалоги Google Play Services" + }, + "disable_snap_splitting": { + "name": "Отключить разделение снепов", + "description": "Предотвращает разделение снепов на несколько частей\nОтправленные вами изображения будут превращаться в видео" + }, + "snapchat_plus": { + "description": "Активирует функции Snapchat Plus\nНекоторые функции на стороне сервера могут не работать", + "name": "Snapchat Plus" + }, + "disable_confirmation_dialogs": { + "name": "Отключить диалоги подтверждения", + "description": "Автоматически подтверждает выбранные действия" + }, + "auto_updater": { + "name": "Автоматическое обновление", + "description": "Автоматически проверяет наличие новых обновлений" + }, + "disable_story_sections": { + "description": "Удаляет разделы на странице историй\nМожет потребоваться обновление для корректной работы", + "name": "Отключить разделы историй" + }, + "block_ads": { + "name": "Блокировать рекламу", + "description": "Предотвращает отображение рекламы" + }, + "disable_permission_requests": { + "name": "Отключить запросы разрешений", + "description": "Предотвращает запрос разрешений в Snapchat" + }, + "bypass_video_length_restriction": { + "name": "Обход ограничений длины видео", + "description": "Одиночный: отправляет одно видео\nРазделенный: разделяет видео после редактирования" + }, + "video_playback_rate_slider": { + "name": "Ползунок скорости воспроизведения видео", + "description": "Добавляет ползунок в контекстное меню оперы для изменения скорости воспроизведения видео\nПримечание: изменения применяются только к последующим видео" + }, + "default_volume_controls": { + "name": "Стандартные элементы управления громкостью", + "description": "Принудительно заставляет Snapchat использовать стандартные элементы управления громкостью системы" + }, + "default_video_playback_rate": { + "description": "Устанавливает стандартную скорость воспроизведения видео\nЗначение должно быть от 0.1 до 4.0", + "name": "Стандартная скорость воспроизведения видео" + } + }, + "name": "Настройки глобального Snapchat", + "description": "Лучшее местоположение" + }, + "camera": { + "properties": { + "force_camera_source_encoding": { + "name": "Принудительное кодирование источника камеры", + "description": "Принудительно кодирует источник камеры" + }, + "black_photos": { + "description": "Заменяет захваченные фотографии черным фоном\nВидео не затрагиваются", + "name": "Черные фотографии" + }, + "immersive_camera_preview": { + "name": "Иммерсивный предпросмотр", + "description": "Предотвращает обрезку предпросмотра камеры Snapchat\nЭто может вызвать мерцание камеры на некоторых устройствах" + }, + "override_front_resolution": { + "name": "Переопределение разрешения для передней камеры", + "description": "Переопределяет разрешение камеры для передней камеры" + }, + "custom_resolution": { + "description": "Устанавливает пользовательское разрешение камеры, ширина x высота (например, 1920x1080).\nПользовательское разрешение должно поддерживаться вашим устройством", + "name": "Пользовательское разрешение" + }, + "back_custom_frame_rate": { + "description": "Переопределяет частоту кадров задней камеры", + "name": "Пользовательский кадровый режим для задней камеры" + }, + "disable_cameras": { + "name": "Отключить камеры", + "description": "Предотвращает использование Snapchat выбранными камерами" + }, + "override_back_resolution": { + "name": "Переопределение разрешения для задней камеры", + "description": "Переопределяет разрешение камеры для задней камеры" + }, + "front_custom_frame_rate": { + "name": "Пользовательский кадровый режим для передней камеры", + "description": "Переопределяет частоту кадров передней камеры" + }, + "hevc_recording": { + "name": "Запись в формате HEVC", + "description": "Использует кодек HEVC (H.265) для записи видео" + } + }, + "name": "Камера", + "description": "Настройте правильные параметры для идеального снимка" + }, + "rules": { + "name": "Правила", + "description": "Управление автоматическими функциями для отдельных пользователей" + }, + "scripting": { + "properties": { + "module_folder": { + "name": "Папка модуля", + "description": "Папка, в которой находятся скрипты" + }, + "disable_log_anonymization": { + "description": "Отключает анонимизацию журналов", + "name": "Отключение анонимизации журнала" + }, + "developer_mode": { + "name": "Режим разработчика", + "description": "Отображает отладочную информацию на пользовательском интерфейсе Snapchat" + }, + "integrated_ui": { + "name": "Интегрированный пользовательский интерфейс", + "description": "Позволяет скриптам добавлять пользовательские компоненты интерфейса в Snapchat" + }, + "auto_reload": { + "name": "Автоматическая перезагрузка", + "description": "Автоматически перезагружает скрипты при их изменении" + } + }, + "name": "Скриптинг", + "description": "Запуск пользовательских скриптов для расширения SnapEnhance" + } + }, + "notices": { + "unstable": "⚠ Нестабильный", + "ban_risk": "⚠ Эта функция может стать причиной бана", + "internal_behavior": "⚠ Это может нарушить внутреннее поведение Snapchat" + }, + "options": { + "home_tab": { + "chat": "Чат", + "discover": "Открытие", + "spotlight": "Spotlight", + "map": "Карта", + "camera": "Камера" + }, + "auto_purge": { + "never": "Никогда", + "3_days": "3 Дня", + "1_month": "1 Месяц", + "1_hour": "1 Час", + "3_hours": "3 Часа", + "6_hours": "6 Часов", + "12_hours": "12 Часов", + "1_week": "1 Неделя", + "2_weeks": "2 Недели", + "3_months": "3 Месяца", + "6_months": "6 Месяцев", + "1_day": "1 День" + }, + "path_format": { + "append_hash": "Добавить уникальный хеш к имени файла", + "append_source": "Добавить источник медиафайла к имени файла", + "create_author_folder": "Создать папку для каждого автора", + "create_source_folder": "Создать папку для каждого типа источника медиафайла", + "append_username": "Добавить имя пользователя к имени файла", + "append_date_time": "Добавить дату и время к имени файла" + }, + "notifications": { + "chat": "Чат", + "chat_reply": "Ответ в чате", + "initiate_audio": "Входящий аудиовызов", + "chat_screenshot": "Снимок экрана", + "chat_screen_record": "Запись экрана", + "snap_replay": "Повтор Snap", + "camera_roll_save": "Сохранить в камеру", + "typing": "Печать", + "stories": "Истории", + "initiate_video": "Входящий видеовызов", + "abandon_video": "Пропущенный видеовызов", + "snap": "Snap", + "abandon_audio": "Пропущенный аудиовызов", + "chat_reaction": "Реакция в ЛС", + "group_chat_reaction": "Реакция в группе" + }, + "add_friend_source_spoof": { + "added_by_mention": "По упоминанию", + "added_by_username": "По имени пользователя", + "added_by_group_chat": "В групповом чате", + "added_by_qr_code": "По QR-коду", + "added_by_community": "По сообществу" + }, + "disable_confirmation_dialogs": { + "hide_friend": "Скрыть друга", + "hide_conversation": "Скрыть беседу", + "clear_conversation": "Очистить беседу из Ленты друзей", + "ignore_friend": "Игнорировать друга", + "remove_friend": "Удалить друга", + "block_friend": "Заблокировать друга", + "erase_message": "Удалить сообщение" + }, + "disable_cameras": { + "front": "Передняя камера", + "back": "Задняя камера" + }, + "disable_permission_requests": { + "nearby_devices": "Устройства поблизости", + "phone_calls": "Телефонные вызовы", + "notifications": "Уведомления", + "read_media_video": "Чтение медиа-видео", + "camera": "Камера", + "microphone": "Микрофон", + "read_media_images": "Чтение медиа-изображений", + "location": "Местоположение", + "read_contacts": "Чтение контактов" + }, + "friend_feed_menu_buttons": { + "stealth": "👻 Режим невидимки", + "auto_download": "⬇️ Автозагрузка", + "auto_save": "💬 Автосохранение сообщений", + "unsaveable_messages": "⬇️ Непроходимые сообщения", + "mark_stories_as_seen_locally": "👀 Отметить истории как просмотренные локально", + "conversation_info": "👤 Информация о беседе", + "e2e_encryption": "🔒 Использовать конечно-конечное шифрование", + "mark_snaps_as_seen": "👀 Отметить снапы как просмотренные", + "auto_open_snaps": "📷 Автоматическое открытие Снапов" + }, + "gallery_media_send_override": { + "ORIGINAL": "Оригинал", + "NOTE": "Аудио-заметка", + "SNAP": "Snap" + }, + "hide_ui_components": { + "hide_unread_chat_hint": "Убрать подсказку о непрочитанных сообщениях", + "hide_profile_call_buttons": "Убрать кнопки звонка в профиле", + "hide_chat_call_buttons": "Убрать кнопки звонка в чате", + "hide_live_location_share_button": "Убрать кнопку обмена местоположением в реальном времени", + "hide_stickers_button": "Убрать кнопку стикеров", + "hide_voice_record_button": "Убрать кнопку голосовой записи" + }, + "app_appearance": { + "always_light": "Всегда светлый", + "always_dark": "Всегда темный" + }, + "auto_download_sources": { + "friend_stories": "Истории друзей", + "public_stories": "Публичные истории", + "spotlight": "Spotlight", + "friend_snaps": "Snap друзей" + }, + "logging": { + "started": "Начало", + "success": "Успех", + "progress": "Прогресс", + "failure": "Ошибка" + }, + "auto_reload": { + "all": "Все (Snapchat + SnapEnhance)", + "snapchat_only": "Только Snapchat" + }, + "edit_text_override": { + "multi_line_chat_input": "Многострочный ввод в чат", + "bypass_text_input_limit": "Обход ограничения на количество вводимого текста" + }, + "message_indicators": { + "encryption_indicator": "Добавляет значок 🔒 рядом с сообщениями, отправленными только вам", + "platform_indicator": "Добавляет значок платформы, с которой было отправлено медиа (например, Android, iOS, веб)", + "location_indicator": "Добавляет значок 📍 к снэпам, если было включено местоположение при отправке", + "ovf_editor_indicator": "Указывает, был ли снэп отправлен с использованием редактора OVF", + "director_mode_indicator": "Добавляет значок ✏️ к снэпам, если они были отправлены с использованием режима режиссера, который можно использовать для отправки изображений из галереи в качестве снэпов" + }, + "strip_media_metadata": { + "hide_caption_text": "Скрыть текст подписи", + "hide_extras": "Скрыть дополнительные элементы", + "hide_snap_filters": "Скрыть фильтры Snap", + "remove_audio_note_duration": "Убрать длительность аудиозаметки", + "remove_audio_note_transcript_capability": "Убрать возможность транскрибации аудиозаметки" + }, + "hide_story_suggestions": { + "hide_suggested_friend_stories": "Скрыть предложенные истории друзей", + "hide_my_stories": "Скрыть Мои истории" + }, + "disable_story_sections": { + "following": "Подписан на", + "discover": "Исследовать", + "friends": "Друзья" + }, + "bypass_video_length_restriction": { + "single": "Одиночные медиафайлы", + "split": "Разделение медиафайлов" + }, + "old_bitmoji_selfie": { + "2d": "2D Битмоджи", + "3d": "3D Битмоджи" + }, + "auto_mark_as_read": { + "conversation_read": "Пометить беседу как прочитанную при отправке сообщения", + "snap_reply": "Отмечать снэпы как прочитанные при ответе на них" + }, + "friend_mutation_notifier": { + "bitmoji_selfie_changes": "Уведомление о изменении кем-то своего битмоджи-селфи", + "remove_friend": "Уведомление о том, что кто-то удалил вас из друзей", + "birthday_changes": "Уведомление о изменении дня рождения кого-то", + "bitmoji_avatar_changes": "Уведомление о изменении кем-то своего аватара Битмоджи", + "bitmoji_scene_changes": "Уведомление о изменении кем-то своего сценария Bitmoji", + "bitmoji_background_changes": "Уведомление о изменении кем-то своего фонового изображения в Bitmoji" + } + } + }, + "chat_export": { + "exporter_dialog": { + "message_type_filter_title": "Фильтровать сообщения по типу", + "text_field_selection_all": "Все", + "export_file_format_title": "Формат файла экспорта", + "select_conversations_title": "Выберите разговоры", + "text_field_selection": "{amount} выбрано", + "amount_of_messages_title": "Количество сообщений (оставьте поле пустым для всех)", + "download_medias_title": "Скачать медиафайлы" + }, + "processing_chats": "Обработка цепочек: {amount}...", + "export_fail": "Не удалось экспортировать разговор {conversation}", + "writing_output": "Запись вывода...", + "finished": "Сделанно! Теперь вы можете закрыть это диалоговое окно.", + "no_messages_found": "Сообщений не найдено!", + "exporting_message": "Экспорт {conversation}...", + "dialog_negative_button": "Отмена", + "dialog_positive_button": "Экспорт", + "exporting_chats": "Экспорт чатов...", + "exported_to": "Экспортировано в {path}" + }, + "call_start_confirmation": { + "dialog_title": "Начать звонок", + "dialog_message": "Вы уверены, что хотите начать звонок?" + }, + "actions": { + "export_memories": { + "name": "Экспорт воспоминаний", + "description": "Экспортирует воспоминания в ZIP-файл" + }, + "change_language": { + "description": "Изменяет язык SnapEnhance", + "name": "Изменение языка" + }, + "clean_snapchat_cache": { + "name": "Очистить кеш Snapchat", + "description": "Очищает кэш Snapchat" + }, + "manage_friend_list": { + "name": "Управление списком друзей", + "description": "Импортируйте/экспортируйте список друзей при резервном копировании" + }, + "export_chat_messages": { + "name": "Экспорт сообщений чата", + "description": "Экспортирует сообщения разговора в файл JSON/HTML/TXT" + }, + "regen_mappings": { + "name": "Восстановить сопоставления", + "description": "Восстановить сопоставления вручную" + }, + "bulk_messaging_action": { + "name": "Массовая рассылка сообщений", + "description": "Выполняет такие операции, как удаление друзей или массовое удаление бесед" + } + }, + "download_processor": { + "dash_no_chapter": "Глава не найдена", + "attachment_type": { + "snap": "Snap", + "original_story": "Оригинальная история", + "sticker": "наклейка", + "external_media": "Внешняя медиа", + "note": "Примечание" + }, + "no_attachments_toast": "Вложений не найдено!", + "already_queued_toast": "СМИ уже в очереди!", + "already_downloaded_toast": "Медиа уже загружено!", + "download_toast": "Загрузка {path}...", + "processing_toast": "Обработка {path}...", + "failed_generic_toast": "Не удалось скачать", + "failed_to_create_preview_toast": "Не удалось создать предварительный просмотр", + "failed_gallery_toast": "Не удалось сохранить в галерею {error}", + "unsupported_content_type_toast": "Неподдерживаемый тип контента!", + "failed_no_longer_available_toast": "Медиа больше не доступны", + "failed_processing_toast": "Не удалось обработать {error}", + "select_attachments_title": "Выберите вложения для загрузки", + "download_started_toast": "Загрузка началась", + "dash_dialog": { + "title": "Скачать тире медиа", + "download_all": "Скачать все", + "segment_text": "Сегмент {from} – {to}" + } + }, + "profile_info": { + "display_name": "Отображаемое имя", + "added_date": "Дата добавления", + "birthday": "День рождения: {месяц} {день}", + "hidden_birthday": "День рождения: Скрытый", + "snapchat_plus_state": { + "subscribed": "Подписан", + "not_subscribed": "Не подписан" + }, + "friendship": "Дружба", + "add_source": "Добавить источник", + "snapchat_plus": "Снапчат Плюс", + "first_created_username": "Первое созданное имя пользователя", + "mutable_username": "Изменяемое имя пользователя", + "title": "Информация о профиле" + }, + "friendship_link_type": { + "following": "Следующий", + "suggested": "Предложенный", + "incoming": "Входящий", + "incoming_follower": "Входящий подписчик", + "mutual": "Взаимный", + "outgoing": "Исходящий", + "blocked": "Заблокировано", + "deleted": "Удалено" + }, + "bulk_messaging_action": { + "choose_action_title": "Выберите действие", + "progress_status": "Обработка {index} из {total}", + "confirmation_dialog": { + "message": "Это повлияет на всех выбранных друзей. Это действие не может быть отменено.", + "title": "Вы уверены?" + }, + "actions": { + "clear_conversations": "Очистить разговоры", + "remove_friends": "Удалить друзей" + }, + "selection_dialog_continue_button": "Продолжать" + }, + "better_notifications": { + "button": { + "download": "Скачать", + "reply": "Ответить", + "mark_as_read": "Пометить, как прочитанное" + } + }, + "profile_picture_downloader": { + "button": "Загрузить изображение профиля", + "background_option": "Фон", + "title": "Загружает изображение профиля", + "avatar_option": "Аватар" + }, + "mark_as_seen": { + "seen_toast": "Отмечено как замеченное!", + "unseen_toast": "Помечено как невидимое!", + "already_seen_toast": "Уже отмечено как просмотренное!", + "already_unseen_toast": "Уже отмечено как невидимое!", + "no_unseen_snaps_toast": "Нет непросмотренных Snap!" + }, + "streaks_reminder": { + "notification_text": "Вы потеряете свою серию с {friend} через {hoursLeft} ч", + "notification_title": "Полосы" + }, + "content_type": { + "STICKER": "Стикер", + "NOTE": "Голосове сообщение", + "STATUS": "Статус", + "LOCATION": "локация", + "EXTERNAL_MEDIA": "Внешние носитель", + "STATUS_SAVE_TO_CAMERA_ROLL": "Сохранено в Галерею", + "STATUS_CONVERSATION_CAPTURE_SCREENSHOT": "Снимок экрана", + "STATUS_CONVERSATION_CAPTURE_RECORD": "Запись экрана", + "STATUS_CALL_MISSED_VIDEO": "Пропущенный видеовызов", + "CREATIVE_TOOL_ITEM": "Креативный элемент", + "FAMILY_CENTER_INVITE": "Приглашение в Центр семьи", + "FAMILY_CENTER_LEAVE": "Покинуть Центр семьи", + "STATUS_COUNTDOWN": "Обратный отсчёт", + "FAMILY_CENTER_ACCEPT": "Принятие приглашения в Центр семьи", + "STATUS_PLUS_GIFT": "Статус Plus подарок", + "TINY_SNAP": "Маленький Snap", + "MAP_REACTION": "Реакция на карту", + "LIVE_LOCATION_SHARE": "Поделиться местоположением в реальном времени", + "STATUS_CALL_MISSED_AUDIO": "Пропущенный аудиовызов", + "CHAT": "Чат", + "SNAP": "Snap" + }, + "media_download_source": { + "pending": "В ожидании", + "merged": "Объединённый", + "chat_media": "Медиа в чате", + "story": "История", + "none": "Ничего", + "public_story": "Публичная история", + "story_logger": "Журнал историй", + "message_logger": "Журнал сообщений", + "voice_call": "Голосовой вызов", + "profile_picture": "Аватар", + "spotlight": "Spotlight" + }, + "opera_context_menu": { + "media_size": "Размер медиа: {size}", + "download": "Скачать медиа", + "sent_at": "Отправлено {date}", + "created_at": "Создано {date}", + "expires_at": "Истекает {date}", + "media_duration": "Продолжительность медиа: {duration} мс", + "show_debug_info": "Показать отладочную информацию" + }, + "friend_menu_option": { + "mark_stories_as_seen_locally": "Отметить истории как просмотренные локально", + "preview": "Предварительный просмотр", + "mark_snaps_as_seen": "Отметить снэпы как просмотренные", + "stealth_mode": "Режим невидимки", + "auto_download_blacklist": "Черный список автозагрузки", + "anti_auto_save": "Анти-автосохранение" + }, + "chat_action_menu": { + "preview_button": "Предпросмотр", + "download_button": "Скачать", + "delete_logged_message_button": "Удалить созраненные сообщения", + "convert_message": "Преобразовать сообщение", + "edit_message": "Редактировать сообщение" + }, + "gallery_media_send_override": { + "multiple_media_toast": "Вы можете отправить только одно медиа за раз" + }, + "modal_option": { + "close": "Закрыть", + "profile_info": "Информация о профиле" + }, + "biometric_auth": { + "title": "Разблокировать Snapchat", + "subtitle": "Пожалуйста, авторизуйтесь, чтобы разблокировать Snapchat", + "unlock_button": "Разблокировать" + }, + "auto_open_snaps": { + "title": "Автоматическое открытие Снапов", + "notification_content": "Открыто {count} Снапов" + }, + "friend_mutation_observer": { + "birthday_changed": "{username} изменил свой день рождения с {oldBirthday} на {newBirthday}", + "bitmoji_avatar_changed": "{username} изменил свой аватар Bitmoji", + "bitmoji_scene_changed": "{username} изменил свою сцену Bitmoji", + "bitmoji_selfie_changed": "{username} изменил свою битмоджи-селфи", + "notification_channel_name": "Наблюдатель за изменениями в друзьях", + "friend_removed": "{username} удалил вас из друзей", + "birthday_removed": "{username} удалил свой день рождения ({birthday})", + "birthday_added": "{username} добавил свой день рождения ({birthday})", + "bitmoji_background_changed": "{username} изменил свой фон Bitmoji" + } +} diff --git a/common/src/main/assets/lang/sl_SI.json b/common/src/main/assets/lang/sl_SI.json new file mode 100644 index 0000000000..56a3d9a3b5 --- /dev/null +++ b/common/src/main/assets/lang/sl_SI.json @@ -0,0 +1,87 @@ +{ + "setup": { + "mappings": { + "generate_failure": "Pri ustvarjanju preslikav je prišlo do napake, prosimo poskusite znova.", + "generate_failure_no_snapchat": "SnapEnhance ne zaznava Snapchata. Poizkusite ga znova naložiti.", + "dialog": "Da bi dinamično podpiral širok razpon Snapchat verzij, so preslikave potrebne za pravilno delovanje Snapenhance-a; to ne bi smelo vzeti več kot 5 sekund." + }, + "permissions": { + "dialog": "Za nadaljevanje morate izpolnjevati naslednje zahteve:", + "notification_access": "Dostop do potisnih obvestil", + "display_over_other_apps": "Prikaz čez druge aplikacije", + "request_button": "Zahtevaj", + "battery_optimization": "Optimizacija baterije" + }, + "dialogs": { + "select_language": "Izberi jezik", + "select_save_folder_button": "Izberite mapo", + "save_folder": "SnapEnhance potrebuje dovoljenje za Dostop do datotek, da lahko nalaga in shranjuje datoteke iz Snapchata.\nIzberite mapo, v katero se bodo nalagale datoteke." + } + }, + "manager": { + "routes": { + "tasks": "Naloge", + "features": "Lastnosti", + "home": "Domov", + "home_logs": "Dnevniki", + "social": "Družbena omrežja", + "scripts": "Skripte", + "home_settings": "Nastavitve", + "logger_history": "Zgodovina zapisovalnika", + "logged_stories": "Zapisane zgodbe", + "manage_scope": "Upravljaj Scope", + "messaging_preview": "Predogled", + "better_location": "Izboljšana lokacija", + "manage_repos": "Upravljaj repozitorije", + "file_imports": "Uvoz datotek", + "friend_tracker": "Sledilec prijateljev", + "edit_rule": "Uredi pravilo", + "edit_theme": "Uredi temo" + }, + "sections": { + "tasks": { + "no_tasks": "Ni nalog", + "merge_button": "Združi", + "failed_to_open_file": "Napaka pri odpiranju datoteke" + }, + "features": { + "disabled": "Onemogočeno" + }, + "social": { + "streaks_expiration_short": "{hours} ur" + }, + "home": { + "update_title": "SnapEnhance nadgradnja", + "update_content": "Verzija {version} je na voljo!", + "update_button": "Prenos", + "debug_build_summary_title": "Uporabljate razhroščevalno različico SnapEnhancea", + "debug_build_summary_date": "Dan izgradnje: {date} ({days} days ago)", + "quick_actions_title": "Hitra dejanja", + "version_title": "v{versionName} · naredil rhunk", + "debug_build_summary_content": "Verzija {versionName} ({versionCode})" + }, + "home_logs": { + "no_logs_hint": "Ni dostopnih logov", + "clear_logs_button": "Počisti loge", + "export_logs_button": "Izvozi loge", + "saving_logs_toast": "Shranjevanje logov, to lahko vzame nekaj časa", + "saved_logs_success_toast": "Logi uspešno shranjeni", + "saved_logs_failure_toast": "Napaka pri shranjevanju zapisnikov" + }, + "home_settings": { + "success_toast": "Končano!", + "message_logger_summary": "{messageCount} sporočil\n{storyCount} zgodb", + "export_button": "Izvozi", + "clear_button": "Počisti", + "actions_title": "Dejanja", + "message_logger_title": "Zapisovalnik sporočil", + "debug_title": "Razhroščevanje", + "view_logger_history_button": "Ogled zgodovine zapiskov" + } + } + }, + "scopes": { + "friend": "Prijatelj", + "group": "Skupina" + } +} diff --git a/common/src/main/assets/lang/sv.json b/common/src/main/assets/lang/sv.json new file mode 100644 index 0000000000..b4289a3114 --- /dev/null +++ b/common/src/main/assets/lang/sv.json @@ -0,0 +1,839 @@ +{ + "setup": { + "dialogs": { + "select_save_folder_button": "Välj Mapp", + "select_language": "Välj språk", + "save_folder": "SnapEnhance kräver lagringsbehörigheter för att ladda ner och spara media från Snapchat.\nVälj var media ska laddas ner till." + }, + "mappings": { + "dialog": "För att dynamiskt stödja ett brett utbud av Snapchat Versioner, mappningar är nödvändiga för att SnapEnhance ska fungera korrekt, detta bör inte ta mer än 5 sekunder.", + "generate_failure_no_snapchat": "SnapEnhance kunde inte upptäcka Snapchat, försök att installera om Snapchat.", + "generate_failure": "Ett fel inträffade vid försök att generera mappningar, försök igen." + }, + "permissions": { + "dialog": "För att fortsätta måste du uppfylla följande krav:", + "notification_access": "Åtkomst till aviseringar", + "battery_optimization": "Batterioptimering", + "display_over_other_apps": "Visa över andra appar", + "request_button": "Begäran" + } + }, + "manager": { + "routes": { + "features": "Funktioner", + "home": "Hem", + "home_settings": "Inställningar", + "home_logs": "Loggar", + "social": "Socialt", + "scripts": "Skript", + "logged_stories": "Loggade berättelser", + "tasks": "Uppgifter", + "logger_history": "Logger Historia", + "manage_scope": "Hantera omfattning", + "messaging_preview": "Förhandsvisning" + }, + "sections": { + "features": { + "disabled": "Inaktiverad", + "export_option": "Exportera", + "import_option": "Importera", + "reset_option": "Återställ", + "saved_config_snackbar": "Konfiguration sparad" + }, + "social": { + "streaks_expiration_short": "{timmar}t" + }, + "tasks": { + "no_tasks": "Inga uppgifter", + "merge_files_toast": "Slår samman {count} filer", + "remove_selected_tasks_confirm": "Ta bort {count} uppgifter?", + "remove_all_tasks_confirm": "Ta bort alla upgifter?", + "remove_selected_tasks_title": "Är du säker på att du vill ta bort markerade uppgifter?", + "remove_all_tasks_title": "Är du säker på att du vill ta bort alla uppgifter?", + "delete_files_option": "Radera även filer" + }, + "logged_stories": { + "story_failed_to_load": "Misslyckades att ladda", + "no_stories": "Inga berättelser hittades", + "save_from_cache_button": "Spara från cachen" + }, + "messaging_preview": { + "bridge_connection_failed": "Det gick inte att ansluta till Snapchat via bryggtjänsten", + "bridge_init_failed": "Det gick inte att initiera meddelandebryggan", + "message_fetch_failed": "Det gick inte att hämta meddelanden", + "no_message_hint": "Inget meddelande", + "save_selection_option": "Spara urval", + "save_all_option": "Rädda alla", + "unsave_selection_option": "Ta bort val", + "unsave_all_option": "Ta bort alla", + "mark_selection_as_seen_option": "Markera vald Snap som sett", + "mark_all_as_seen_option": "Markera alla Snaps som sett", + "delete_selection_option": "Ta bort urval", + "delete_all_option": "Radera allt" + }, + "logger_history": { + "list_friend_format": "Vän {namn}", + "no_more_messages": "Inga fler meddelanden", + "list_group_format": "Grupp {name}", + "reverse_order_checkbox": "Omvänd ordning", + "chat_attachment": "Bilaga {index}", + "empty_message": "Tomt chattmeddelande", + "message_parse_failed": "Det gick inte att tolka meddelandet", + "unknown_sender": "Okänd avsändare", + "download_attachment_failed_toast": "Det gick inte att ladda ned bilagan" + }, + "home": { + "update_title": "SnapEnhance Updatering", + "update_content": "Version {version} är tillgänglig!", + "update_button": "Ladda ner" + }, + "home_logs": { + "clear_logs_button": "Rensa loggar", + "export_logs_button": "Exportera loggar", + "saved_logs_success_toast": "Loggarna sparades", + "saved_logs_failure_toast": "Det gick inte att spara loggar", + "no_logs_hint": "Inga loggar tillgängliga", + "saving_logs_toast": "Sparar loggar, detta kan ta ett tag ..." + }, + "home_settings": { + "debug_title": "Felsökning", + "export_button": "Exportera", + "clear_button": "Rensa", + "view_logger_history_button": "Visa Logger Historik", + "actions_title": "Handlingar", + "success_toast": "Klart!" + }, + "manage_scope": { + "rules_title": "Regler", + "reminder_button": "Sätta på påminelse", + "streaks_expiration_text_expired": "Upphörd" + } + }, + "dialogs": { + "add_friend": { + "category_friends": "Vänner", + "title": "Lägg till vän eller grupp", + "search_hint": "Sök", + "fetch_error": "Det gick inte att hämta data", + "category_groups": "Grupper" + }, + "scripting_warning": { + "title": "Varning", + "content": "SnapEnhance inkluderar ett skriptverktyg som tillåter exekvering av användardefinierad kod på din enhet. Var extremt försiktig och installera endast moduler från kända, pålitliga källor. Obehöriga eller overifierade moduler kan utgöra säkerhetsrisker för ditt system." + }, + "reset_config": { + "title": "Återställ konfiguration", + "content": "Är du säker på att du vill återställa konfigurationen?", + "success_toast": "Konfigurationsåterställningen lyckades" + } + } + }, + "rules": { + "properties": { + "auto_download": { + "name": "Automatisk nedladdning", + "description": "Ladda ned Snaps automatiskt när du tittar på dem", + "options": { + "blacklist": "Uteslut från automatisk nedladdning", + "whitelist": "Automatisk nedladdning" + } + }, + "unsaveable_messages": { + "name": "Meddelanden som inte går att spara", + "description": "Förhindrar att meddelanden sparas i chatten av andra", + "options": { + "blacklist": "Uteslut från meddelanden som inte går att spara", + "whitelist": "Meddelanden som inte går att spara" + } + }, + "auto_save": { + "description": "Sparar chattmeddelanden när du visar dem", + "name": "Automatisk sparning", + "options": { + "blacklist": "Uteslut från Autospara", + "whitelist": "Automatisk sparning" + } + }, + "stealth": { + "name": "Smygläge", + "description": "Hindrar någon från att veta att du har öppnat deras Snaps/chattar och konversationer", + "options": { + "blacklist": "Uteslut från Stealth Mode", + "whitelist": "Smygläge" + } + }, + "hide_friend_feed": { + "name": "Göm från Friend Feed" + }, + "e2e_encryption": { + "name": "Använd E2E-kryptering" + }, + "pin_conversation": { + "name": "Fäst konversation" + } + }, + "toasts": { + "enabled": "{ruleName} aktiverad", + "disabled": "{ruleName} inaktiverad" + }, + "modes": { + "blacklist": "Svartlista läge", + "whitelist": "Vitlista läge" + } + }, + "actions": { + "clean_snapchat_cache": { + "name": "Rengör Snapchat-cachen", + "description": "Rengör Snapchat-cachen" + }, + "manage_friend_list": { + "name": "Hantera vänlista", + "description": "Importera/exportera din vänlista vid säkerhetskopiering" + }, + "bulk_messaging_action": { + "description": "Genomför operationer som ta bort kompisar eller ta bort ett flertal conversationer", + "name": "Massutskick av meddelanden" + }, + "regen_mappings": { + "name": "Generera Mappar", + "description": "Manuellt Generera mappar" + }, + "export_memories": { + "description": "Exportera minnen till en ZIP fil", + "name": "Exportera minnen" + }, + "export_chat_messages": { + "name": "Exportera Chat Meddelanden", + "description": "Exporterar conversations meddelanden till en JSON/HTML/TXT fil" + }, + "change_language": { + "name": "Bytt språk", + "description": "Bytt språk av SnapEnhance" + } + }, + "features": { + "notices": { + "internal_behavior": "⚠ Detta kan förstöra Snapchats interna beteenden", + "ban_risk": "⚠ Denna funktion kan leda till avstängning", + "unstable": "⚠ Ostabilt" + }, + "properties": { + "downloader": { + "properties": { + "auto_download_sources": { + "description": "Välj vilka källor som ska automatiskt installeras från", + "name": "Automatiskt installera källor" + }, + "prevent_self_auto_download": { + "description": "Hindrar dina egna snaps från att bli nedladdade automatiskt", + "name": "Hindra automatisk nedladdning" + }, + "save_folder": { + "name": "Spara Mapp", + "description": "Välj en mapp som all media ska vara nedladdat till" + }, + "allow_duplicate": { + "description": "Tillåt samma media att bli installerat flera gånger", + "name": "Tillåt duplication" + }, + "download_context_menu": { + "description": "Gör att du kan ladda ner/förhandsgranska meddelanden från en konversation eller en story med hjälp av snabbmenyn.\nHåll ner knappen för att tvinga nedladdning", + "name": "Ladda ner Snabbmeny" + }, + "merge_overlays": { + "name": "Sammanfoga överlägg", + "description": "Kombinerar text och media från en Snap till en enda fil" + }, + "path_format": { + "name": "Sökvägsformat", + "description": "Ange filsökvägsformat" + }, + "ffmpeg_options": { + "properties": { + "threads": { + "description": "Antalet trådar som ska användas", + "name": "Trådar" + }, + "video_bitrate": { + "description": "Ställ in videobithastighet (kbps)", + "name": "Video bithastighet" + }, + "audio_bitrate": { + "name": "Ljud bithastighet", + "description": "Ställ in ljudets bithastighet (kbps)" + }, + "custom_video_codec": { + "name": "Anpassad videokodek", + "description": "Ange en anpassad videokodek (t.ex. libx264)" + }, + "custom_audio_codec": { + "description": "Ange en anpassad ljudkodek (t.ex. AAC)", + "name": "Anpassad ljudkodek" + }, + "preset": { + "name": "Förinställning", + "description": "Ställ in hastigheten för konverteringen" + }, + "constant_rate_factor": { + "name": "Konstant hastighet faktor", + "description": "Ange den konstanta hastighetsfaktorn för videokodaren\nFrån 0 till 51 för libx264" + } + }, + "name": "FFmpeg Alternativ", + "description": "Ange ytterligare FFmpeg Alternativ" + }, + "custom_path_format": { + "description": "Ange ett anpassat sökvägsformat för nedladdade media\n\nTillgängliga variabler:\n - %användarnamn%\n - %källa\n - %hash%\n - %datum_tid%", + "name": "Format för anpassad sökväg" + }, + "force_voice_note_format": { + "name": "Tvingar röstanteckningsformat", + "description": "Tvingar röstanteckningar att sparas i ett specificerat format" + }, + "logging": { + "description": "Visar toasts när media laddas ner", + "name": "Loggning" + }, + "force_image_format": { + "description": "Tvingar bilder att sparas i ett specificerat format", + "name": "Tvingar Bildformat" + }, + "download_profile_pictures": { + "name": "Ladda ner profilbilder", + "description": "Gör det möjligt att ladda ner profilbilder från profilsidan" + }, + "opera_download_button": { + "name": "Opera nedladdnings-knapp", + "description": "Lägger till en nedladdningsknapp i det övre högra hörnet när du visar en Snap.\nHåll in knappen för att tvinga nedladdning" + } + }, + "description": "Ladda ner Snapchats Media", + "name": "Nedladdare" + }, + "user_interface": { + "properties": { + "prevent_message_list_auto_scroll": { + "name": "Förhindra automatisk skrollning av meddelandelista", + "description": "Förhindrar att meddelandelistan skrollar längst ner när du skickar/tar emot ett meddelande" + }, + "snap_preview": { + "description": "Visar en liten förhandsgranskning bredvid osedda Snaps i chatten", + "name": "Förhandsvisning av snap" + }, + "bootstrap_override": { + "properties": { + "app_appearance": { + "name": "Appens utseende", + "description": "Ställer in ett beständigt App-utseende" + }, + "home_tab": { + "name": "Fliken Hem", + "description": "Överskriver startfliken när Snapchat öppnas" + } + }, + "name": "Bootstrap-överskrivning", + "description": "Överskriver användargränssnittets bootstrap-inställningar" + }, + "enable_app_appearance": { + "name": "Aktivera inställningar för apputseende", + "description": "Aktiverar de dolda Apputseendesinställningarna\nKanske inte nödvändigt på nyare Snapchat-versioner" + }, + "hide_friend_feed_entry": { + "description": "Döljer en viss vän från vänflödet\nAnvänd fliken Social för att hantera den här funktionen", + "name": "Dölj imnatning in vänflödet" + }, + "hide_streak_restore": { + "description": "Döljer knappen Återställ i vänflödet", + "name": "Göm Streak-Återställning" + }, + "hide_ui_components": { + "name": "Dölj komponenter i användargränssnittet", + "description": "Välj vilka UI-komponenter som ska döljas" + }, + "opera_media_quick_info": { + "description": "Visar användbar information om media, t.ex. skapelsedatum, i kontextmenyn i operafönstret", + "name": "Opera Media Snabbinformation" + }, + "disable_spotlight": { + "description": "Inaktiverar Spotlight-sidan", + "name": "Inaktivera Spotlight" + }, + "friend_feed_message_preview": { + "name": "Förhandsgranskning av meddelande från Vänflödet", + "description": "Visar en förhandsgranskning av de senaste meddelandena i vänflödet", + "properties": { + "amount": { + "name": "Antal", + "description": "Antalet meddelanden som ska förhandsgranskas" + } + } + }, + "map_friend_nametags": { + "description": "Förbättrar namntaggar av vänner på Snapmap", + "name": "Förbättrade Namntaggar på vänkartan" + }, + "streak_expiration_info": { + "name": "Visa info om utgång för Streak", + "description": "Visar en timer för Streakens utgång bredvid Streaks-räknaren" + }, + "old_bitmoji_selfie": { + "name": "Gamla Bitmoji-selfien", + "description": "Återinför Bitmoji-selfies från äldre Snapchat-versioner" + }, + "hide_story_suggestions": { + "name": "Dölj Story-förslag", + "description": "Tar bort förslag från Stories-fliken" + } + }, + "name": "Användargränssnitt", + "description": "Ändra utseende och känsla för Snapchat" + }, + "messaging": { + "properties": { + "hide_typing_notifications": { + "description": "Hindrar någon från att veta att du skriver ett meddelande", + "name": "Dölj skrivaviseringar" + }, + "hide_bitmoji_presence": { + "description": "Förhindrar att din Bitmoji dyker upp medan du är i chatten" + }, + "half_swipe_notifier": { + "properties": { + "min_duration": { + "description": "Minsta varaktighet för halva svep (i sekunder)", + "name": "Minsta varaktighet" + }, + "max_duration": { + "description": "Den maximala varaktigheten för halva svepningen (i sekunder)", + "name": "Maximal varaktighet" + } + }, + "name": "Halvsvep Notifier", + "description": "Meddelar dig när någon halvt sveper in i en konversation" + }, + "call_start_confirmation": { + "description": "Visar en bekräftelsedialog när du startar ett samtal", + "name": "Ring startbekräftelse" + }, + "prevent_message_sending": { + "name": "Förhindra att meddelanden skickas", + "description": "Förhindrar att vissa typer av meddelanden skickas" + }, + "better_notifications": { + "description": "Lägger till mer information i mottagna aviseringar", + "name": "Bättre aviseringar" + }, + "loop_media_playback": { + "name": "Slinga medieuppspelning", + "description": "Slingor medieuppspelning när du tittar på Snaps / Stories" + }, + "disable_replay_in_ff": { + "description": "Inaktiverar möjligheten att spela om med ett långt tryck från Friend Feed", + "name": "Inaktivera Replay i FF" + }, + "unlimited_snap_view_time": { + "name": "Obegränsad Snap View Time", + "description": "Tar bort tidsgränsen för visning av Snaps" + } + } + }, + "global": { + "properties": { + "disable_confirmation_dialogs": { + "name": "Inaktivera bekräftelsedialoger", + "description": "Bekräftar automatiskt valda åtgärder" + }, + "better_location": { + "properties": { + "always_update_location": { + "name": "Uppdatera alltid plats", + "description": "Tvinga Snapchat att uppdatera plats även om ingen GPS-data tas emot" + }, + "suspend_location_updates": { + "name": "Stäng av platsuppdateringar", + "description": "Lägger till en knapp i kartinställningarna för att avbryta platsuppdateringar" + }, + "spoof_battery_level": { + "name": "Spoof batterinivå", + "description": "Förfalskar batterinivån på din enhet på kartan\nVärdet måste vara mellan 0 och 100" + }, + "spoof_location": { + "name": "Spoof Plats", + "description": "Förfalskar din plats till en angiven" + }, + "coordinates": { + "description": "Ställ in koordinaterna för den falska platsen", + "name": "Koordinater" + }, + "spoof_headphones": { + "name": "Parodi hörlurar", + "description": "Förfalskar statusen för att lyssna på musik på kartan" + } + }, + "description": "Förbättrar Snapchat-platsen", + "name": "Bättre läge" + }, + "snapchat_plus": { + "name": "Snapchat Plus", + "description": "Aktiverar Snapchat Plus-funktioner\nVissa serversidiga funktioner kanske inte fungerar" + }, + "auto_updater": { + "name": "Automatisk uppdatering", + "description": "Söker automatiskt efter nya uppdateringar" + }, + "disable_metrics": { + "name": "Inaktivera mätvärden", + "description": "Blockerar att skicka specifik analysdata till Snapchat" + }, + "block_ads": { + "description": "Förhindrar att annonser visas", + "name": "Blockera annonser" + }, + "spotlight_comments_username": { + "description": "Visar författarens användarnamn i Spotlight-kommentarer", + "name": "Spotlight Kommentarer Användarnamn" + }, + "disable_snap_splitting": { + "description": "Förhindrar att Snaps delas upp i flera delar\nBilder du skickar förvandlas till videor", + "name": "Inaktivera Snap Splitting" + }, + "disable_memories_snap_feed": { + "name": "Inaktivera Memories Snap Feed", + "description": "Förhindrar att Snapchat visar senaste minnen när du sveper uppåt i kameran" + }, + "disable_google_play_dialogs": { + "description": "Förhindra att tillgänglighetsdialoger för Google Play-tjänster visas", + "name": "Inaktivera dialogrutor för Google Play-tjänster" + }, + "disable_story_sections": { + "name": "Inaktivera berättelsesektioner", + "description": "Tar bort avsnitt från sidan Berättelser\nKan kräva en uppdatering för att fungera korrekt" + }, + "default_video_playback_rate": { + "name": "Standard videouppspelningshastighet", + "description": "Ställer in standardhastigheten för uppspelning av videor\nVärdet måste vara mellan 0,1 och 4,0" + }, + "video_playback_rate_slider": { + "name": "Reglage för videouppspelningshastighet", + "description": "Lägger till ett skjutreglage i opera snabbmeny för att ändra videouppspelningshastigheten\nObs! Ändringar gäller endast för efterföljande videor" + }, + "default_volume_controls": { + "name": "Standardvolymkontroller", + "description": "Tvingar Snapchat att använda systemvolymkontroller" + }, + "disable_permission_requests": { + "name": "Inaktivera behörighetsförfrågningar", + "description": "Förhindrar Snapchat från att be om specifika behörigheter" + }, + "bypass_video_length_restriction": { + "name": "Förbigå videolängdsbegränsningar", + "description": "Singel: skickar en enda video\nDela: dela videor efter redigering" + } + } + }, + "rules": { + "name": "Regler", + "description": "Hantera automatiska funktioner för enskilda personer" + }, + "camera": { + "properties": { + "force_camera_source_encoding": { + "name": "Tvinga kamerakällkodning", + "description": "Framtvingar kamerans källkodning" + }, + "black_photos": { + "name": "Svarta foton", + "description": "Ersätter tagna bilder med svart bakgrund\nVideor påverkas inte" + }, + "immersive_camera_preview": { + "name": "Uppslukande förhandsvisning", + "description": "Förhindrar Snapchat från att beskära kameran förhandsvisning\nDetta kan göra att kameran flimrar på vissa enheter" + }, + "override_back_resolution": { + "name": "Åsidosätt bakupplösning", + "description": "Åsidosätter kameraupplösningen för den bakre kameran" + }, + "custom_resolution": { + "name": "Anpassad upplösning", + "description": "Ställer in en anpassad kameraupplösning, bredd x höjd (t.ex. 1920x1080).\nDen anpassade upplösningen måste stödjas av din enhet" + }, + "hevc_recording": { + "name": "HEVC-inspelning", + "description": "Använder HEVC (H.265) codec för videoinspelning" + }, + "override_front_resolution": { + "description": "Åsidosätter kameraupplösningen för den främre kameran", + "name": "Åsidosätt frontupplösning" + }, + "disable_cameras": { + "description": "Förhindrar att Snapchat använder de valda kamerorna", + "name": "Inaktivera kameror" + }, + "front_custom_frame_rate": { + "name": "Front anpassad bildhastighet", + "description": "Åsidosätter den främre kamerans bildfrekvens" + }, + "back_custom_frame_rate": { + "name": "Tillbaka Anpassad bildhastighet", + "description": "Åsidosätter den bakre kamerans bildhastighet" + } + }, + "name": "Kamera", + "description": "Justera rätt inställningar för den perfekta snäppet" + }, + "scripting": { + "properties": { + "auto_reload": { + "description": "Laddar automatiskt om skript när de ändras", + "name": "Ladda om automatiskt" + }, + "disable_log_anonymization": { + "description": "Inaktiverar anonymisering av loggar", + "name": "Inaktivera logganonymisering" + }, + "developer_mode": { + "description": "Visar felsökningsinformation på Snapchats användargränssnitt", + "name": "Utvecklarläge" + }, + "module_folder": { + "name": "Modulmapp", + "description": "Mappen där skripten finns" + }, + "integrated_ui": { + "description": "Tillåter skript att lägga till anpassade UI-komponenter till Snapchat", + "name": "Integrerat användargränssnitt" + } + }, + "name": "Skript", + "description": "Kör anpassade skript för att utöka SnapEnhance" + }, + "experimental": { + "properties": { + "native_hooks": { + "name": "Native Hooks", + "description": "Osäkra funktioner som kopplas in i Snapchats inbyggda kod", + "properties": { + "disable_bitmoji": { + "name": "Inaktivera Bitmoji", + "description": "Inaktiverar vänprofil Bitmoji" + } + } + }, + "spoof": { + "properties": { + "remove_vpn_transport_flag": { + "name": "Ta bort VPN-transportflagga", + "description": "Förhindrar att Snapchat upptäcker VPN" + }, + "remove_mock_location_flag": { + "name": "Ta bort Mock Location Flag", + "description": "Förhindrar Snapchat från att upptäcka skenplats" + }, + "play_store_installer_package_name": { + "name": "Paketnamn för Play Butik Installer", + "description": "Åsidosätter installationspaketets namn till com.android.vending" + } + }, + "description": "Förfalska diverse information om dig", + "name": "Parodi" + }, + "story_logger": { + "name": "Berättelselogger", + "description": "Ger en historia av vänners berättelser" + }, + "call_recorder": { + "name": "Samtalsinspelning", + "description": "Spelar automatiskt in ljudsamtal" + }, + "hidden_snapchat_plus_features": { + "description": "Aktiverar outgivna/beta Snapchat Plus-funktioner\nKanske inte fungerar på äldre Snapchat-versioner", + "name": "Dolda Snapchat Plus-funktioner" + }, + "prevent_forced_logout": { + "description": "Förhindrar att Snapchat loggar ut dig när du loggar in på en annan enhet", + "name": "Förhindra påtvingad utloggning" + }, + "media_file_picker": { + "name": "Mediafilväljare", + "description": "Låter dig välja valfri video-/ljudfil från galleriet" + }, + "account_switcher": { + "name": "Kontoväxling", + "description": "Låter dig växla mellan konton utan att logga ut\nTryck länge på sökikonen bredvid din Bitmoji-profil för att öppna menyn\nObs! Den här funktionen är experimentell och kommer sannolikt att ändras i framtiden", + "properties": { + "auto_backup_current_account": { + "name": "Automatisk säkerhetskopiering av aktuellt konto", + "description": "Säkerhetskopierar automatiskt det aktuella kontot när du loggar ut eller byter konto" + } + } + }, + "edit_message": { + "description": "Låter dig redigera meddelanden i konversationer", + "name": "Redigera meddelanden" + }, + "meo_passcode_bypass": { + "name": "Mina ögon förbigår bara lösenordet", + "description": "Förbigå My Eyes Only-lösenordet\nDetta fungerar bara om lösenordet har angetts korrekt tidigare" + }, + "e2ee": { + "properties": { + "encrypted_message_indicator": { + "description": "Lägger till en 🔒 emoji bredvid krypterade meddelanden", + "name": "Krypterad meddelandeindikator" + }, + "force_message_encryption": { + "name": "Forcera meddelandekryptering", + "description": "Förhindrar att krypterade meddelanden skickas till personer som inte har E2E-kryptering aktiverad endast när flera konversationer är valda" + } + }, + "name": "End-to-end-kryptering", + "description": "Krypterar dina meddelanden med AES med hjälp av en delad hemlig nyckel\nSe till att spara din nyckel på ett säkert ställe!" + }, + "add_friend_source_spoof": { + "name": "Lägg till vänkälla förfalskning", + "description": "Förfalskar källan till en vänförfrågan" + }, + "infinite_story_boost": { + "name": "Oändlig Story Boost", + "description": "Gå förbi Story Boost Limit-fördröjningen" + }, + "no_friend_score_delay": { + "name": "Ingen vänpoängfördröjning", + "description": "Tar bort fördröjningen vid visning av ett kompisresultat" + }, + "convert_message_locally": { + "name": "Konvertera meddelande lokalt", + "description": "Konverterar snaps till att chatta externt media lokalt. Detta visas i chattens snabbmeny" + } + }, + "name": "Experimentell", + "description": "Experimentella funktioner" + }, + "streaks_reminder": { + "properties": { + "interval": { + "name": "Intervall", + "description": "Intervallet mellan varje påminnelse (timmar)" + }, + "remaining_hours": { + "name": "Återstående tid", + "description": "Återstående tid innan meddelandet visas (timmar)" + }, + "group_notifications": { + "description": "Gruppera aviseringar till en enda", + "name": "Gruppmeddelanden" + } + }, + "name": "Påminnelse om streck", + "description": "Meddelar dig regelbundet om dina Streaks" + } + }, + "options": { + "notifications": { + "abandon_audio": "Missat ljudsamtal", + "initiate_audio": "Inkommande ljudsamtal", + "chat_screenshot": "Skärmdump", + "chat_screen_record": "Skärminspelning", + "chat": "Chatt", + "typing": "Skriver", + "snap_replay": "Snap Replay", + "camera_roll_save": "Spara kamerarulle", + "chat_reply": "Chatt Svara", + "snap": "Snap", + "stories": "Berättelser", + "chat_reaction": "DM-reaktion", + "group_chat_reaction": "Gruppreaktion", + "initiate_video": "Inkommande videosamtal", + "abandon_video": "Missat videosamtal" + }, + "friend_feed_menu_buttons": { + "auto_save": "💬 Spara meddelanden automatiskt", + "unsaveable_messages": "⬇️ Meddelanden som inte går att spara", + "stealth": "👻 Smygläge", + "mark_snaps_as_seen": "👀 Markera Snaps som sett", + "conversation_info": "👤 Konversationsinfo", + "auto_download": "⬇️ Automatisk nedladdning", + "mark_stories_as_seen_locally": "👀 Markera berättelser som ses lokalt", + "e2e_encryption": "🔒 Använd E2E-kryptering" + }, + "path_format": { + "create_source_folder": "Skapa mapp för varje typ av mediakälla", + "append_hash": "Lägg till en unik hash till filnamnet", + "append_source": "Lägg till mediekällan till filnamnet", + "append_username": "Lägg till användarnamnet i filnamnet", + "create_author_folder": "Skapa mapp för varje författare", + "append_date_time": "Lägg till datum och tid i filnamnet" + }, + "logging": { + "success": "Framgång", + "progress": "Framsteg", + "started": "Satte igång", + "failure": "Fel" + }, + "hide_ui_components": { + "hide_live_location_share_button": "Ta bort Live Location Share-knapp", + "hide_voice_record_button": "Ta bort röstinspelningsknappen", + "hide_chat_call_buttons": "Ta bort chattsamtalsknappar", + "hide_stickers_button": "Knappen Ta bort klistermärken", + "hide_unread_chat_hint": "Ta bort oläst chatttips", + "hide_profile_call_buttons": "Ta bort profilanropsknappar" + }, + "bypass_video_length_restriction": { + "single": "Enstaka media", + "split": "Dela media" + }, + "gallery_media_send_override": { + "ORIGINAL": "Original", + "NOTE": "Ljudnotering", + "SNAP": "Snap" + }, + "app_appearance": { + "always_light": "Alltid ljus", + "always_dark": "Alltid mörkt" + }, + "auto_download_sources": { + "friend_snaps": "Vän Snaps", + "friend_stories": "Vänberättelser", + "public_stories": "Offentliga berättelser", + "spotlight": "Strålkastare" + }, + "home_tab": { + "discover": "Upptäck", + "spotlight": "Strålkastare", + "chat": "Chatt", + "camera": "Kamera", + "map": "Karta" + }, + "hide_story_suggestions": { + "hide_suggested_friend_stories": "Dölj föreslagna vänberättelser", + "hide_my_stories": "Dölj mina berättelser" + }, + "add_friend_source_spoof": { + "added_by_username": "Efter användarnamn", + "added_by_mention": "Genom att nämna", + "added_by_qr_code": "Med QR-kod", + "added_by_community": "Efter gemenskap", + "added_by_group_chat": "Via gruppchatt" + }, + "strip_media_metadata": { + "remove_audio_note_transcript_capability": "Ta bort Audio Note Transcript Capability", + "remove_audio_note_duration": "Ta bort ljudanteckningslängd", + "hide_caption_text": "Dölj bildtext", + "hide_snap_filters": "Dölj Snap-filter", + "hide_extras": "Dölj extrafunktioner (t.ex. omnämnanden)" + } + } + }, + "scopes": { + "friend": "Vän", + "group": "Grupp" + }, + "streaks_reminder": { + "notification_text": "Du kommer att förlora din Streak med {friend} om {hoursLeft} timmar" + }, + "download_processor": { + "dash_dialog": { + "segment_text": "Segmentera {från} - {till}" + } + } +} diff --git a/common/src/main/assets/lang/swg.json b/common/src/main/assets/lang/swg.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/common/src/main/assets/lang/swg.json @@ -0,0 +1 @@ +{} diff --git a/common/src/main/assets/lang/ta_IN.json b/common/src/main/assets/lang/ta_IN.json new file mode 100644 index 0000000000..2b4ec00d96 --- /dev/null +++ b/common/src/main/assets/lang/ta_IN.json @@ -0,0 +1,1705 @@ +{ + "features": { + "properties": { + "messaging": { + "properties": { + "friend_mutation_notifier": { + "description": "நண்பரின் சுயவிவரத்தில் ஏதாவது மாறும்போது உங்களுக்கு அறிவிக்கும்", + "name": "நண்பர் பிறழ்வு அறிவிப்பு" + }, + "better_notifications": { + "name": "சிறந்த அறிவிப்புகள்", + "description": "பெறப்பட்ட அறிவிப்புகளில் கூடுதல் தகவல்களைச் சேர்க்கிறது", + "properties": { + "group_notifications": { + "name": "குழு அறிவிப்புகள்", + "description": "குழு அறிவிப்புகள் ஒற்றை" + }, + "chat_preview": { + "name": "அரட்டை முன்னோட்டம்", + "description": "அறிவிப்பில் பெறப்பட்ட செய்திகளின் முன்னோட்டத்தைக் காட்டுகிறது" + }, + "media_preview": { + "name": "மீடியா முன்னோட்டம்", + "description": "அறிவிப்பில் தேர்ந்தெடுக்கப்பட்ட ஊடக வகைகளின் முன்னோட்டத்தைக் காட்டுகிறது" + }, + "media_caption": { + "name": "ஊடக தலைப்பு", + "description": "அறிவிப்பில் ஊடகங்களின் இணைக்கப்பட்ட தலைப்பைக் காட்டுகிறது" + }, + "reply_button": { + "name": "பதில் பொத்தான்", + "description": "அறிவிப்புக்கு பதில் பொத்தானைச் சேர்க்கிறது" + }, + "smart_replies": { + "name": "அறிவுள்ள பதில்கள்", + "description": "அறிவிப்புகளுக்கு பரிந்துரைக்கப்பட்ட பதில்களைச் சேர்க்கிறது (Android 10+). பதில் பொத்தானுடன் இணைந்து பயன்படுத்தவும்" + }, + "download_button": { + "name": "பதிவிறக்க பொத்தான்", + "description": "அறிவிப்பிலிருந்து மீடியாவைப் பதிவிறக்க உங்களை அனுமதிக்கிறது" + }, + "mark_as_read_button": { + "description": "அறிவிப்பிலிருந்து படிக்க ஒரு செய்தியைக் குறிக்க உங்களை அனுமதிக்கிறது", + "name": "வாசிப்பு பொத்தானாக குறிக்கவும்" + }, + "mark_as_read_and_save_in_chat": { + "name": "அரட்டையில் படிக்கவும் சேமிக்கவும்", + "description": "அறிவிப்புக்கு அரட்டை பொத்தானைப் படித்து சேமிக்க ஒரு அடையாளத்தை சேர்க்கிறது" + }, + "friend_add_source": { + "name": "நண்பர் மூலத்தைச் சேர்க்கவும்", + "description": "அறிவிப்பில் நண்பர் கோரிக்கையின் மூலத்தைக் காட்டுகிறது" + }, + "stacked_media_messages": { + "name": "அடுக்கப்பட்ட மீடியா செய்திகள்", + "description": "பல ஊடக செய்திகளை முன்னோட்டமிட முடியாதபோது ஒரு உரை அறிவிப்பாக ஒருங்கிணைக்கிறது. அரட்டை முன்னோட்டத்துடன் இணைந்து பயன்படுத்தவும்" + } + } + }, + "notification_blacklist": { + "name": "அறிவிப்பு தடுப்புப்பட்டியல்", + "description": "தடுக்கப்பட வேண்டிய அறிவிப்புகளைத் தேர்ந்தெடுக்கவும்" + }, + "message_logger": { + "description": "செய்திகளை நீக்குவதைத் தடுக்கிறது", + "properties": { + "keep_my_own_messages": { + "name": "எனது சொந்த செய்திகளை வைத்திருங்கள்", + "description": "உங்கள் சொந்த செய்திகளை நீக்குவதைத் தடுக்கிறது" + }, + "auto_purge": { + "name": "ஆட்டோ தூய்மை", + "description": "குறிப்பிட்ட நேரத்தை விட பழைய தற்காலிக சேமிப்பு செய்திகளை தானாக நீக்குகிறது" + }, + "message_filter": { + "name": "செய்தி வடிகட்டி", + "description": "எந்த செய்திகள் உள்நுழைய வேண்டும் என்பதைத் தேர்ந்தெடுக்கவும் (எல்லா செய்திகளுக்கும் காலியாக)" + }, + "deleted_message_color": { + "name": "நீக்கப்பட்ட செய்தி நிறம்", + "description": "நீக்கப்பட்ட செய்திகளின் நிறத்தை அமைக்கிறது" + } + }, + "name": "செய்தி லாகர்" + }, + "auto_save_messages_in_conversations": { + "name": "தானாக சேமி செய்திகள்", + "description": "உரையாடல்களில் ஒவ்வொரு செய்தியையும் தானாகவே சேமிக்கிறது" + }, + "gallery_media_send_override": { + "name": "கேலரி மீடியா மேலெழுதலை அனுப்புகிறது", + "description": "கேலரியில் இருந்து அனுப்பும்போது ஊடக மூலத்தை ஏமாற்றுகிறது" + }, + "strip_media_metadata": { + "name": "உரி மீடியா மேனிலை தரவு", + "description": "செய்தியாக அனுப்புவதற்கு முன்பு மீடியாவின் மெட்டாடேட்டாவை நீக்குகிறது" + }, + "bypass_message_retention_policy": { + "name": "செய்தி தக்கவைப்பு கொள்கையைத் தவிர்த்து விடுங்கள்", + "description": "செய்திகளைப் பார்த்த பிறகு அவற்றை நீக்குவதைத் தடுக்கிறது" + }, + "bypass_message_action_restrictions": { + "name": "செய்தி நடவடிக்கை கட்டுப்பாடுகள்", + "description": "ஒரு புகைப்படத்தைத் திறக்காமல் அல்லது விரும்பத்தகாத செய்தியைச் சேமிக்க உங்களை அனுமதிக்கிறது" + }, + "remove_groups_locked_status": { + "name": "குழுக்கள் பூட்டப்பட்ட நிலையை அகற்று", + "description": "உதைக்கப்பட்ட பிறகு குழு தகவல்களைக் காண உங்களை அனுமதிக்கிறது" + }, + "double_tap_chat_action": { + "name": "டபுள் டேப் அரட்டை நடவடிக்கை", + "description": "அரட்டையில் ஒரு செய்தியை இருமுறை தட்டும்போது தனிப்பயன் செயலைச் செய்கிறது" + }, + "double_tap_chat_action_custom_emoji": { + "name": "இரட்டை தட்டு அரட்டை நடவடிக்கை தனிப்பயன் ஈமோசி எதிர்வினை", + "description": "இரட்டை தட்டு அரட்டை நடவடிக்கைக்கு தனிப்பயன் ஈமோசி எதிர்வினையை அமைக்கிறது" + }, + "bypass_screenshot_detection": { + "name": "பைபாச் திரைக்காட்சி கண்டறிதல்", + "description": "நீங்கள் திரை சாட்டை எடுக்கும்போது ச்னாப்சாட் கண்டறிவதைத் தடுக்கிறது" + }, + "anonymous_story_viewing": { + "name": "அநாமதேய கதை பார்வை", + "description": "நீங்கள் அவர்களின் கதையை நீங்கள் பார்த்தீர்கள் என்பதை அறிந்து கொள்வதைத் தடுக்கிறது" + }, + "hide_peek_a_peek": { + "description": "நீங்கள் அரட்டையில் பாதி ச்வைப் செய்யும்போது அறிவிப்பு அனுப்பப்படுவதைத் தடுக்கிறது", + "name": "பீக்-எ-பீக்கை மறைக்கவும்" + }, + "hide_bitmoji_presence": { + "name": "பிட்மோசி இருப்பை மறைக்கவும்", + "description": "அரட்டையில் இருக்கும்போது உங்கள் பிட்மோசியைத் தடுக்கிறது" + }, + "hide_typing_notifications": { + "name": "தட்டச்சு அறிவிப்புகளை மறைக்கவும்", + "description": "நீங்கள் ஒரு செய்தியைத் தட்டச்சு செய்கிறீர்கள் என்பதை அறிந்து கொள்வதைத் தடுக்கிறது" + }, + "unlimited_snap_view_time": { + "name": "வரம்பற்ற ச்னாப் பார்வை நேரம்", + "description": "ச்னாப்களைப் பார்ப்பதற்கான நேர வரம்பை நீக்குகிறது" + }, + "auto_mark_as_read": { + "name": "ஆட்டோ குறி படிக்க", + "description": "திருட்டுத்தனமான பயன்முறை இயக்கப்பட்டிருந்தாலும் கூட படித்தபடி செய்திகளை/புகைப்படங்களை தானாகவே குறிக்கிறது" + }, + "mark_snap_as_seen_button": { + "name": "பார்த்த பொத்தானை குறிக்கவும்", + "description": "ஒரு ச்னாப்பைப் பார்க்கும்போது அதைப் பார்க்கும்போது ஒரு பொத்தானைச் சேர்க்கிறது.\n திருட்டுத்தனமான பயன்முறை இயக்கப்பட்டிருந்தாலும் இது செயல்படும்" + }, + "skip_when_marking_as_seen": { + "name": "பார்த்தபடி குறிக்கும் போது தவிர்க்கவும்", + "description": "பார்த்தபடி ஒரு ச்னாப்பைக் குறிக்கும் போது தானாகவே அடுத்த புகைப்படத்திற்குச் செல்லுங்கள்.\n பார்த்த பொத்தானைப் போல மார்க் ச்னாப்புடன் இணைந்து பயன்படுத்தவும்" + }, + "loop_media_playback": { + "name": "லூப் மீடியா பிளேபேக்", + "description": "ச்னாப்ச் / கதைகளைப் பார்க்கும்போது மீடியா பிளேபேக்கை சுழற்றுங்கள்" + }, + "disable_replay_in_ff": { + "name": "FF இல் மறுபதிப்பு முடக்கு", + "description": "நண்பர் ஊட்டத்திலிருந்து நீண்ட செய்தித் தாள் மூலம் மீண்டும் இயக்கும் திறனை முடக்குகிறது" + }, + "half_swipe_notifier": { + "name": "அரை ச்வைப் அறிவிப்பாளர்", + "description": "யாரோ ஒரு உரையாடலில் பாதி ச்வைப் செய்யும்போது உங்களுக்கு அறிவிக்கிறது", + "properties": { + "min_duration": { + "name": "குறைந்தபட்ச காலம்", + "description": "அரை ச்வைப்பின் குறைந்தபட்ச காலம் (நொடிகளில்)" + }, + "max_duration": { + "name": "அதிகபட்ச காலம்", + "description": "அரை ச்வைப்பின் அதிகபட்ச காலம் (நொடிகளில்)" + } + } + }, + "call_start_confirmation": { + "name": "தொடக்க உறுதிப்படுத்தல் என்று அழைக்கவும்", + "description": "அழைப்பைத் தொடங்கும்போது உறுதிப்படுத்தல் உரையாடலைக் காட்டுகிறது" + }, + "unlimited_conversation_pinning": { + "name": "வரம்பற்ற உரையாடல் பின்னிங்", + "description": "உள்நாட்டில் வரம்பற்ற உரையாடல்களை பொருத்த உங்களை அனுமதிக்கிறது" + }, + "prevent_message_sending": { + "name": "செய்தி அனுப்புவதைத் தடுக்கவும்", + "description": "சில வகையான செய்திகளை அனுப்புவதைத் தடுக்கிறது" + }, + "prevent_story_rewatch_indicator": { + "name": "கதை மறுபரிசீலனை காட்டி தடுக்கவும்", + "description": "நீங்கள் அவர்களின் கதையை மறுபரிசீலனை செய்துள்ளீர்கள் என்பதை அறிந்து கொள்வதைத் தடுக்கிறது" + } + }, + "name": "செய்தியிடல்", + "description": "நண்பர்களுடன் நீங்கள் எவ்வாறு தொடர்பு கொள்கிறீர்கள் என்பதை மாற்றவும்" + }, + "global": { + "name": "உலகளாவிய", + "description": "உலகளாவிய ச்னாப்சாட் அமைப்புகளை மாற்றவும்", + "properties": { + "better_location": { + "name": "சிறந்த இடம்", + "description": "ச்னாப்சாட் இருப்பிடத்தை மேம்படுத்துகிறது", + "properties": { + "spoof_location": { + "name": "ஏமாற்றும் இடம்", + "description": "உங்கள் இருப்பிடத்தை ஒரு குறிப்பிட்ட ஒன்றுக்கு ஏமாற்றுகிறது" + }, + "coordinates": { + "name": "ஒருங்கிணைப்புகள்", + "description": "ஏமாற்றப்பட்ட இருப்பிடத்தின் ஆயங்களை அமைக்கவும்" + }, + "walk_radius": { + "name": "நடை ஆரம்", + "description": "தோராயமாக இந்த ஆரம் (அடி)" + }, + "always_update_location": { + "name": "இருப்பிடத்தை எப்போதும் புதுப்பிக்கவும்", + "description": "சி.பி.எச் தரவு எதுவும் பெறப்படாவிட்டாலும் இருப்பிடத்தைப் புதுப்பிக்க ச்னாப்சாட்டை கட்டாயப்படுத்தவும்" + }, + "suspend_location_updates": { + "name": "இருப்பிட புதுப்பிப்புகளை இடைநிறுத்துங்கள்", + "description": "உங்கள் இருப்பிடம் புதுப்பிக்கப்படுவதைத் தடுக்கிறது" + }, + "spoof_battery_level": { + "name": "பேட்டரி அளவை ஏமாற்றுங்கள்", + "description": "வரைபடத்தில் உங்கள் சாதனத்தின் பேட்டரி அளவை ஏமாற்றுகிறது\n மதிப்பு 0 முதல் 100 வரை இருக்க வேண்டும்" + }, + "spoof_headphones": { + "name": "ச்பூஃப் எட்ஃபோன்கள்", + "description": "வரைபடத்தில் இசையைக் கேட்பதன் நிலையை ஏமாற்றுகிறது" + }, + "show_battery_level": { + "name": "பேட்டரி அளவைக் காட்டு", + "description": "வரைபடத்தில் உங்கள் நண்பர்களின் பேட்டரி அளவைக் காட்டுகிறது" + } + } + }, + "snapchat_plus": { + "name": "ச்னாப்சாட் பிளச்", + "description": "ச்னாப்சாட் பிளச் அம்சங்களை இயக்குகிறது\n சில சேவையக பக்க நற்பொருத்தங்கள் வேலை செய்யாது" + }, + "media_upload_quality": { + "name": "மீடியா பதிவேற்றும் தகுதி", + "description": "மீடியா பதிவேற்ற தரத்தை மீறுகிறது", + "properties": { + "force_video_upload_source_quality": { + "name": "வீடியோ பதிவேற்ற மூல தரத்தை கட்டாயப்படுத்துங்கள்", + "description": "வீடியோக்களைப் பதிவேற்றும்போது மூல தரத்தைப் பயன்படுத்த ச்னாப்சாட்டை கட்டாயப்படுத்துகிறது\n இது ஊடகங்களிலிருந்து மெட்டாடேட்டாவை அகற்றாது என்பதை நினைவில் கொள்க" + }, + "disable_image_compression": { + "name": "பட சுருக்கத்தை முடக்கு", + "description": "மீடியாவைப் பதிவேற்றும்போது பட சுருக்கத்தை முடக்குகிறது" + }, + "custom_image_upload_format": { + "name": "தனிப்பயன் பட பதிவேற்ற வடிவம்", + "description": "தனிப்பயன் பட பதிவேற்ற வடிவமைப்பை அமைக்கிறது\n சிறந்த தரத்திற்கு இழப்பற்ற வடிவமைப்பைத் தேர்ந்தெடுக்கவும் (பி.என்.சி போன்றது)" + } + } + }, + "disable_confirmation_dialogs": { + "name": "உறுதிப்படுத்தல் உரையாடல்களை முடக்கு", + "description": "தேர்ந்தெடுக்கப்பட்ட செயல்களை தானாக உறுதிப்படுத்துகிறது" + }, + "auto_updater": { + "name": "ஆட்டோ புதுப்பிப்பாளர்", + "description": "புதிய புதுப்பிப்புகளை தானாகவே சரிபார்க்கிறது" + }, + "disable_metrics": { + "name": "அளவீடுகளை முடக்கு", + "description": "குறிப்பிட்ட பகுப்பாய்வு தரவை ச்னாப்சாட்டுக்கு அனுப்பும் தொகுதிகள்" + }, + "disable_story_sections": { + "name": "கதை பிரிவுகளை முடக்கு", + "description": "கதைகள் பக்கத்திலிருந்து பிரிவுகளை நீக்குகிறது\n சரியாக வேலை செய்ய புதுப்பிப்பு தேவைப்படலாம்" + }, + "block_ads": { + "name": "விளம்பர விளம்பரங்கள்", + "description": "விளம்பரங்கள் காண்பிக்கப்படுவதைத் தடுக்கிறது" + }, + "disable_custom_tabs": { + "name": "தனிப்பயன் தாவல்களை முடக்கு", + "description": "வலை உலாவியில் இல்லாமல் உதவி பயன்பாடுகளில் இணைப்புகளைத் திறக்கிறது" + }, + "disable_permission_requests": { + "name": "இசைவு கோரிக்கைகளை முடக்கு", + "description": "ச்னாப்சாட் குறிப்பிட்ட அனுமதிகளைக் கேட்பதைத் தடுக்கிறது" + }, + "disable_memories_snap_feed": { + "name": "நினைவுகளை முடக்கவும்", + "description": "நீங்கள் கேமராவில் ச்வைப் செய்யும்போது ச்னாப்சாட் அண்மைக் கால நினைவுகளைக் காண்பிப்பதைத் தடுக்கிறது" + }, + "spotlight_comments_username": { + "name": "ச்பாட்லைட் கருத்துரைகள் பயனர்பெயர்", + "description": "ச்பாட்லைட் கருத்துகளில் ஆசிரியர் பயனர்பெயரைக் காட்டுகிறது" + }, + "bypass_video_length_restriction": { + "name": "வீடியோ நீள கட்டுப்பாடுகள் பைபாச்", + "description": "ஒற்றை: ஒரு வீடியோவை அனுப்புகிறது\n பிளவு: திருத்திய பின் வீடியோக்களைப் பிரிக்கவும்" + }, + "default_video_playback_rate": { + "name": "இயல்புநிலை வீடியோ பிளேபேக் வீதம்", + "description": "வீடியோக்களின் பிளேபேக்கிற்கான இயல்புநிலை வேகத்தை அமைக்கிறது\n மதிப்பு 0.1 முதல் 4.0 வரை இருக்க வேண்டும்" + }, + "video_playback_rate_slider": { + "name": "வீடியோ பிளேபேக் வீத ச்லைடர்", + "description": "வீடியோ பிளேபேக் வீதத்தை மாற்ற ஓபரா சூழல் பட்டியலில் ஒரு ச்லைடரைச் சேர்க்கிறது\n குறிப்பு: மாற்றங்கள் அடுத்தடுத்த வீடியோக்களுக்கு மட்டுமே பொருந்தும்" + }, + "disable_google_play_dialogs": { + "name": "Google Play சேவைகள் உரையாடல்களை முடக்கு", + "description": "கூகிள் பிளே சேவைகள் கிடைக்கும் உரையாடல்கள் காண்பிக்கப்படுவதைத் தடுக்கவும்" + }, + "default_volume_controls": { + "name": "இயல்புநிலை தொகுதி கட்டுப்பாடுகள்", + "description": "கணினி தொகுதி கட்டுப்பாடுகளைப் பயன்படுத்த ச்னாப்சாட்டை கட்டாயப்படுத்துகிறது" + }, + "disable_telecom_framework": { + "name": "தொலைதொடர்பு கட்டமைப்பை முடக்கு", + "description": "ஆண்ட்ராய்டு டெலிகாம் கட்டமைப்பைப் பயன்படுத்துவதைத் தடுக்கிறது\n இது அழைப்பில் இருக்கும்போது இசையைக் கேட்க உங்களை அனுமதிக்கிறது" + }, + "hide_active_music": { + "name": "செயலில் உள்ள இசையை மறைக்கவும்", + "description": "நீங்கள் இசையைக் கேட்கிறீர்கள் என்பதை அறிந்து கொள்வதிலிருந்து ச்னாப்சாட்டைத் தடுக்கிறது\n இசையைக் கேட்கும்போது கட்டுப்பாட்டு தொகுதி பொத்தான்களைப் பயன்படுத்தி புகைப்படங்களை எடுக்க இது உங்களை அனுமதிக்கும்" + }, + "disable_snap_splitting": { + "description": "புகைப்படங்கள் பல பகுதிகளாகப் பிரிக்கப்படுவதைத் தடுக்கிறது\n நீங்கள் அனுப்பும் படங்கள் வீடியோக்களாக மாறும்", + "name": "ச்னாப் பிளவுகளை முடக்கு" + } + } + }, + "rules": { + "description": "தனிப்பட்ட நபர்களுக்கான தானியங்கி அம்சங்களை நிர்வகிக்கவும்", + "name": "விதிகள்" + }, + "camera": { + "name": "கேமரா", + "description": "சரியான புகைப்படத்திற்கு சரியான அமைப்புகளை சரிசெய்யவும்", + "properties": { + "disable_cameras": { + "name": "கேமராக்களை முடக்கு", + "description": "தேர்ந்தெடுக்கப்பட்ட கேமராக்களைப் பயன்படுத்துவதைத் தடுக்கிறது" + }, + "black_photos": { + "description": "கைப்பற்றப்பட்ட புகைப்படங்களை கருப்பு பின்னணியுடன் மாற்றுகிறது\n வீடியோக்கள் பாதிக்கப்படவில்லை", + "name": "கருப்பு புகைப்படங்கள்" + }, + "immersive_camera_preview": { + "name": "அதிவேக முன்னோட்டம்", + "description": "கேமரா முன்னோட்டத்தை பயிர் செய்வதைத் தடுக்கிறது\n இது சில சாதனங்களில் கேமரா ஒளிரும்" + }, + "override_front_resolution": { + "name": "முன் தீர்மானத்தை மீறவும்", + "description": "முன் கேமராவிற்கான கேமரா தெளிவுத்திறனை மீறுகிறது" + }, + "override_back_resolution": { + "name": "பின் தீர்மானத்தை மீறவும்", + "description": "பின் கேமராவிற்கான கேமரா தெளிவுத்திறனை மீறுகிறது" + }, + "custom_resolution": { + "name": "தனிப்பயன் தீர்மானம்", + "description": "தனிப்பயன் கேமரா தீர்மானம், அகலம் ஃச் உயரம் (எ.கா. 1920x1080) அமைக்கிறது.\n தனிப்பயன் தீர்மானத்தை உங்கள் சாதனத்தால் ஆதரிக்க வேண்டும்" + }, + "front_custom_frame_rate": { + "name": "முன் தனிப்பயன் பிரேம் வீதம்", + "description": "முன் கேமரா பிரேம் வீதத்தை மீறுகிறது" + }, + "back_custom_frame_rate": { + "name": "தனிப்பயன் பிரேம் வீதத்தை பின்", + "description": "பின் கேமரா பிரேம் வீதத்தை மீறுகிறது" + }, + "force_camera_source_encoding": { + "name": "ஃபோர்ச் கேமரா மூல குறியாக்கம்", + "description": "கேமரா மூல குறியாக்கத்தை கட்டாயப்படுத்துகிறது" + }, + "hevc_recording": { + "name": "HEVC பதிவு", + "description": "வீடியோ பதிவுக்காக HEVC (H.265) கோடெக்கைப் பயன்படுத்துகிறது" + } + } + }, + "streaks_reminder": { + "name": "ச்ட்ரீக்ச் நினைவூட்டல்", + "description": "உங்கள் கோடுகளைப் பற்றி அவ்வப்போது உங்களுக்கு அறிவிக்கிறது", + "properties": { + "interval": { + "name": "இடைவேளை", + "description": "ஒவ்வொரு நினைவூட்டலுக்கும் இடையிலான இடைவெளி (மணிநேரம்)" + }, + "remaining_hours": { + "name": "மீதமுள்ள நேரம்", + "description": "அறிவிப்பு காண்பிக்கப்படுவதற்கு முன் மீதமுள்ள நேரம் (மணிநேரம்)" + }, + "group_notifications": { + "name": "குழு அறிவிப்புகள்", + "description": "குழு அறிவிப்புகள் ஒற்றை" + } + } + }, + "experimental": { + "properties": { + "native_hooks": { + "properties": { + "composer_hooks": { + "name": "இசையமைப்பாளர் கொக்கிகள்", + "description": "இசையமைப்பாளர் குறுக்கு-தளம் இடைமுகம் கட்டமைப்பில் குறியீட்டை செலுத்துகிறது", + "properties": { + "show_first_created_username": { + "name": "முதலில் உருவாக்கிய பயனர்பெயர்", + "description": "சுயவிவரப் பக்கத்தில் தற்போதைய பயனர்பெயருக்கு அடுத்ததாக முதலில் உருவாக்கப்பட்ட பயனர்பெயரைக் காட்டுகிறது" + }, + "bypass_camera_roll_limit": { + "name": "பைபாச் கேமரா ரோல் வரம்பு", + "description": "கேமரா ரோலில் இருந்து நீங்கள் அனுப்பக்கூடிய அதிகபட்ச ஊடகங்களை அதிகரிக்கிறது" + }, + "composer_console": { + "name": "இசையமைப்பாளர் கன்சோல்", + "description": "இசையமைப்பாளரில் சாவாச்கிரிப்ட் குறியீட்டை இயக்க உங்களை அனுமதிக்கிறது (ARM64 மட்டும்)" + }, + "composer_logs": { + "name": "இசையமைப்பாளர் பதிவுகள்", + "description": "இசையமைப்பாளரின் கன்சோல் பதிவுகளை ச்னாபன்ஆன்சுக்கு திருப்பி விடுகிறது" + } + } + }, + "disable_bitmoji": { + "name": "பிட்மோசியை முடக்கு", + "description": "ஊனமுற்ற நண்பர்கள் சுயவிவரம் பிட்மோசி" + }, + "custom_emoji_font": { + "name": "தனிப்பயன் ஈமோசி எழுத்துரு", + "description": "தனிப்பயன் ஈமோசி எழுத்துருவைப் பயன்படுத்த உங்களை அனுமதிக்கிறது. .Ttf எழுத்துருக்களுடன் மட்டுமே வேலை செய்கிறது" + }, + "custom_shared_library": { + "name": "தனிப்பயன் பகிரப்பட்ட நூலகம்", + "description": "தனிப்பயன் பகிரப்பட்ட நூலகத்தை ச்னாப்சாட்டில் ஏற்றுகிறது. இந்த நற்பொருத்தம் சோதனை நோக்கங்களுக்காக மட்டுமே" + } + }, + "name": "சொந்த கொக்கிகள்", + "description": "ச்னாப்சாட்டின் சொந்த குறியீட்டில் இணைந்த பாதுகாப்பற்ற நற்பொருத்தங்கள்" + }, + "spoof": { + "description": "உங்களைப் பற்றிய பல்வேறு தகவல்களை ஏமாற்றுங்கள்", + "properties": { + "play_store_installer_package_name": { + "name": "கடை நிறுவி தொகுப்பு பெயரை விளையாடுங்கள்", + "description": "நிறுவி தொகுப்பு பெயரை com.android.vending க்கு மீறுகிறது" + }, + "remove_vpn_transport_flag": { + "name": "VPN போக்குவரத்து கொடியை அகற்று", + "description": "ச்னாப்சாட் VPN ஐக் கண்டறிவதைத் தடுக்கவும்" + }, + "remove_mock_location_flag": { + "name": "போலி இருப்பிடக் கொடியை அகற்று", + "description": "போலி இருப்பிடத்தைக் கண்டறிவதைத் தடுக்கிறது" + } + }, + "name": "ஏமாற்று" + }, + "convert_message_locally": { + "description": "வெளிப்புற ஊடகங்களை உள்நாட்டில் அரட்டை அடிக்க மாற்றுகிறது. இது அரட்டை பதிவிறக்க சூழல் பட்டியலில் தோன்றும்", + "name": "செய்தியை உள்ளூரில் மாற்றவும்" + }, + "media_file_picker": { + "description": "கேலரியில் இருந்து எந்த வீடியோ/ஆடியோ கோப்பையும் எடுக்க உங்களை அனுமதிக்கிறது", + "name": "மீடியா கோப்பு எடுப்பவர்" + }, + "story_logger": { + "name": "கதை லாகர்", + "description": "நண்பர்கள் கதைகளின் வரலாற்றை வழங்குகிறது" + }, + "call_recorder": { + "name": "ரெக்கார்டரை அழைக்கவும்", + "description": "ஆடியோ அழைப்புகளை தானாக பதிவு செய்கிறது" + }, + "account_switcher": { + "name": "கணக்கு ச்விட்சர்", + "description": "வெளியேறாமல் கணக்குகளுக்கு இடையில் மாற உங்களை அனுமதிக்கிறது\n மெனுவைத் திறக்க உங்கள் பிட்மோசி சுயவிவரத்திற்கு அடுத்த தேடல் ஐகானில் நீண்ட நேரம் அழுத்தவும்\n குறிப்பு: இந்த நற்பொருத்தம் சோதனை மற்றும் எதிர்காலத்தில் மாறும்", + "properties": { + "auto_backup_current_account": { + "name": "ஆட்டோ காப்புப்பிரதி நடப்பு கணக்கு", + "description": "வெளியேறும்போது அல்லது கணக்குகளை மாற்றும்போது நடப்புக் கணக்கை தானாக ஆதரிக்கிறது" + } + } + }, + "better_transcript": { + "name": "சிறந்த டிரான்ச்கிரிப்ட்", + "description": "குரல் குறிப்பு டிரான்ச்கிரிப்டை மேம்படுத்துகிறது", + "properties": { + "force_transcription": { + "name": "குரல் குறிப்பு டிரான்ச்கிரிப்சனை கட்டாயப்படுத்துங்கள்", + "description": "அனைத்து குரல் குறிப்புகளையும் படியெடுக்க அனுமதிக்கிறது" + }, + "preferred_transcription_lang": { + "name": "விருப்பமான டிரான்ச்கிரிப்சன் மொழி", + "description": "குரல் குறிப்பு டிரான்ச்கிரிப்டுக்கு விருப்பமான மொழி (எ.கா. en, es, fr)" + }, + "enhanced_transcript": { + "name": "மேம்படுத்தப்பட்ட டிரான்ச்கிரிப்ட்", + "description": "ஆழம்எல் ஐப் பயன்படுத்தி குரல் குறிப்பு டிரான்ச்கிரிப்டை மேம்படுத்துகிறது.\n இந்த அம்சத்தைப் பயன்படுத்துவதற்கு முன், நீங்கள் அவர்களின் தனியுரிமைக் கொள்கையைப் படித்திருக்கிறீர்கள் என்பதை உறுதிப்படுத்தவும்." + }, + "enhanced_transcript_in_notifications": { + "description": "ஆழம்எல் ஐப் பயன்படுத்தி அறிவிப்புகளில் குரல் குறிப்புகளை படியெடுக்கிறது. சிறந்த அறிவிப்புகளில் அரட்டை முன்னோட்ட நற்பொருத்தம் இயக்கப்பட வேண்டும்", + "name": "அறிவிப்புகளில் மேம்படுத்தப்பட்ட டிரான்ச்கிரிப்ட்" + } + } + }, + "voice_note_auto_play": { + "name": "குரல் குறிப்பு ஆட்டோ நாடகம்", + "description": "தற்போதைய ஒன் முடிந்ததும் தானாகவே அடுத்த குரல் குறிப்பை இயக்குகிறது" + }, + "friend_notes": { + "name": "நண்பர் குறிப்புகள்", + "description": "நண்பர்கள் சுயவிவரங்களில் குறிப்புகளைச் சேர்க்க உங்களை அனுமதிக்கிறது" + }, + "cof_experiments": { + "name": "COF சோதனைகள்", + "description": "வெளியிடப்படாத/பீட்டா ச்னாப்சாட் அம்சங்களை செயல்படுத்துகிறது" + }, + "edit_message": { + "name": "செய்திகளைத் திருத்தவும்", + "description": "உரையாடல்களில் செய்திகளைத் திருத்த உங்களை அனுமதிக்கிறது" + }, + "context_menu_fix": { + "name": "சூழல் பட்டியல் சரி", + "description": "சாதனம் ஆஃப்லைனில் இருக்கும்போது அதை சரியாகக் காட்ட முடியாது போல நண்பர் ஊட்ட மெனுவை சரிசெய்ய முயற்சி" + }, + "app_lock": { + "name": "பயன்பாட்டு பூட்டு", + "description": "கடவுக்குறியீடு இல்லாமல் ச்னாப்சாட் அணுகலைத் தடுக்கிறது", + "properties": { + "lock_on_resume": { + "name": "விண்ணப்பத்தை பூட்டவும்", + "description": "பயன்பாட்டை மீண்டும் திறக்கும்போது பூட்டுகிறது" + } + } + }, + "infinite_story_boost": { + "name": "எல்லையற்ற கதை பூச்ட்", + "description": "கதை பூச்ட் வரம்பு நேரந்தவறுகை" + }, + "meo_passcode_bypass": { + "name": "என் கண்கள் கடவுக்குறியீடு பைபாச் மட்டுமே", + "description": "என் கண்களை மட்டுமே கடந்து செல்கிறது\n கடவுக்குறியீடு இதற்கு முன்பு சரியாக உள்ளிடப்பட்டிருந்தால் மட்டுமே இது செயல்படும்" + }, + "no_friend_score_delay": { + "name": "நண்பர் மதிப்பெண் நேரந்தவறுகை இல்லை", + "description": "நண்பர்கள் மதிப்பெண்ணைப் பார்க்கும்போது தாமதத்தை நீக்குகிறது" + }, + "best_friend_pinning": { + "name": "சிறந்த நண்பர் பின்னிங்", + "description": "உங்கள் நம்பர் ஒன் சிறந்த நண்பராக ஒரு நண்பரை பின்னிணைக்க உங்களை அனுமதிக்கிறது. குறிப்பு: உங்கள் பின் செய்யப்பட்ட சிறந்த நண்பரை மட்டுமே நீங்கள் காணலாம்" + }, + "e2ee": { + "name": "இறுதி-இறுதி குறியாக்கம்", + "description": "பகிரப்பட்ட ரகசிய விசையைப் பயன்படுத்தி உங்கள் செய்திகளை AES உடன் குறியாக்குகிறது\n உங்கள் விசையை எங்காவது பாதுகாப்பாக சேமிப்பதை உறுதிப்படுத்திக் கொள்ளுங்கள்!", + "properties": { + "encrypted_message_indicator": { + "name": "மறைகுறியாக்கப்பட்ட செய்தி காட்டி", + "description": "மறைகுறியாக்கப்பட்ட செய்திகளுக்கு அடுத்ததாக ஒரு 🔒 ஈமோசியை சேர்க்கிறது" + }, + "force_message_encryption": { + "name": "செய்தி குறியாக்கத்தை கட்டாயப்படுத்துங்கள்", + "description": "பல உரையாடல்கள் தேர்ந்தெடுக்கப்படும்போது மட்டுமே E2E குறியாக்கம் இல்லாத நபர்களுக்கு மறைகுறியாக்கப்பட்ட செய்திகளை அனுப்புவதைத் தடுக்கிறது" + } + } + }, + "add_friend_source_spoof": { + "name": "நண்பர் மூல ஏமாற்று சேர்க்கவும்", + "description": "நண்பர் கோரிக்கையின் மூலத்தை ஏமாற்றுகிறது" + }, + "hidden_snapchat_plus_features": { + "name": "மறைக்கப்பட்ட ச்னாப்சாட் பிளச் நற்பொருத்தங்கள்", + "description": "வெளியிடப்படாத/பீட்டா ச்னாப்சாட் பிளச் அம்சங்களை செயல்படுத்துகிறது\n பழைய ச்னாப்சாட் பதிப்புகளில் வேலை செய்யக்கூடாது" + }, + "custom_streaks_expiration_format": { + "name": "தனிப்பயன் கோடுகள் காலாவதி வடிவம்", + "description": "ச்ட்ரீக்ச் காலாவதி வடிவமைப்பைத் தனிப்பயனாக்குகிறது\n\n கிடைக்கும் மாறிகள்:\n - %சி: ச்ட்ரீக்ச் எண்ணிக்கை\n - %E: மணிநேர கிளாச் ஈமோசி\n - %d: நாட்கள்\n - %s: மணிநேரம்\n - %மீ: நிமிடங்கள்\n - %s: விநாடிகள்\n - %W: மீதமுள்ள நேரம்" + }, + "prevent_forced_logout": { + "name": "கட்டாய வெளியேறுவதைத் தடுக்கவும்", + "description": "நீங்கள் வேறொரு சாதனத்தில் உள்நுழையும்போது ச்னாப்சாட் உங்களை உள்நுழைவதைத் தடுக்கிறது" + } + }, + "name": "சோதனை", + "description": "சோதனை நற்பொருத்தங்கள்" + }, + "scripting": { + "name": "ச்கிரிப்டிங்", + "description": "ச்னாபன்ஆன்சை நீட்டிக்க தனிப்பயன் ச்கிரிப்ட்களை இயக்கவும்", + "properties": { + "developer_mode": { + "name": "உருவாக்குபவர் பயன்முறை", + "description": "ச்னாப்சாட்டின் இடைமுகம் இல் பிழைத்திருத்த தகவலைக் காட்டுகிறது" + }, + "module_folder": { + "name": "தொகுதி கோப்புறை", + "description": "ச்கிரிப்ட்கள் அமைந்துள்ள கோப்புறை" + }, + "auto_reload": { + "name": "ஆட்டோ மறுஏற்றம்", + "description": "ச்கிரிப்ட்கள் மாறும்போது தானாகவே மீண்டும் ஏற்றும்" + }, + "disable_log_anonymization": { + "name": "பதிவு அநாமதேயத்தை முடக்கு", + "description": "பதிவுகளின் அநாமதேயத்தை முடக்குகிறது" + }, + "integrated_ui": { + "name": "ஒருங்கிணைந்த இடைமுகம்", + "description": "ச்னாப்சாட்டுக்கு தனிப்பயன் இடைமுகம் கூறுகளைச் சேர்க்க ச்கிரிப்ட்களை அனுமதிக்கிறது" + } + } + }, + "friend_tracker": { + "name": "நண்பர் டிராக்கர்", + "description": "ச்னாப்சாட்டில் நண்பரின் செயல்பாட்டை பதிவு செய்கிறது", + "properties": { + "record_messaging_events": { + "name": "செய்தியிடல் நிகழ்வுகளை பதிவு செய்யுங்கள்", + "description": "ஒரு ச்னாப்பைத் திறப்பது, செய்தியைப் படிப்பது போன்ற செய்தி நிகழ்வுகளை பதிவு செய்கிறது." + }, + "allow_running_in_background": { + "name": "பின்னணியில் இயங்க அனுமதிக்கவும்", + "description": "டிராக்கரை பின்னணியில் இயக்க அனுமதிக்கிறது. குறிப்பு: இது உங்கள் பேட்டரியை கணிசமாக வெளியேற்றும்" + }, + "auto_purge": { + "description": "குறிப்பிட்ட நேரத்தை விட பழமையான தற்காலிக சேமிப்பு நிகழ்வுகளை தானாக நீக்குகிறது", + "name": "ஆட்டோ தூய்மை" + } + } + }, + "downloader": { + "description": "ச்னாப்சாட் மீடியாவைப் பதிவிறக்கவும்", + "properties": { + "save_folder": { + "name": "கோப்புறையை சேமிக்கவும்", + "description": "எல்லா ஊடகங்களையும் பதிவிறக்கம் செய்ய வேண்டிய கோப்பகத்தைத் தேர்ந்தெடுக்கவும்" + }, + "auto_download_sources": { + "description": "தானாக பதிவிறக்கம் செய்ய ஆதாரங்களைத் தேர்ந்தெடுக்கவும்", + "name": "தானாக பதிவிறக்க ஆதாரங்கள்" + }, + "prevent_self_auto_download": { + "name": "தன்வய ஆட்டோ பதிவிறக்கத்தைத் தடுக்கவும்", + "description": "உங்கள் சொந்த புகைப்படங்களை தானாக பதிவிறக்கம் செய்வதைத் தடுக்கிறது" + }, + "path_format": { + "description": "கோப்பு பாதை வடிவமைப்பைக் குறிப்பிடவும்", + "name": "பாதை வடிவம்" + }, + "allow_duplicate": { + "name": "நகல் அனுமதிக்கவும்", + "description": "ஒரே ஊடகத்தை பல முறை பதிவிறக்கம் செய்ய அனுமதிக்கிறது" + }, + "merge_overlays": { + "name": "மேலடுக்குகளை ஒன்றிணைக்கவும்", + "description": "உரை மற்றும் ஒரு ச்னாப்பின் ஊடகத்தை ஒரே கோப்பில் ஒருங்கிணைக்கிறது" + }, + "force_image_format": { + "name": "பட வடிவத்தை கட்டாயப்படுத்துங்கள்", + "description": "படங்களை ஒரு குறிப்பிட்ட வடிவத்தில் சேமிக்க வேண்டும்" + }, + "force_voice_note_format": { + "name": "குரல் குறிப்பு வடிவத்தை கட்டாயப்படுத்துங்கள்", + "description": "ஒரு குறிப்பிட்ட வடிவத்தில் சேமிக்க வேண்டிய குரல் குறிப்புகள்" + }, + "auto_download_voice_notes": { + "name": "தானாக பதிவிறக்க குரல் குறிப்புகள்", + "description": "குரல் குறிப்புகளை இயக்கும்போது தானாகவே பதிவிறக்குகிறது" + }, + "download_profile_pictures": { + "name": "சுயவிவரப் படங்களைப் பதிவிறக்கவும்", + "description": "சுயவிவரப் பக்கத்திலிருந்து சுயவிவரப் படங்களை பதிவிறக்கம் செய்ய உங்களை அனுமதிக்கிறது" + }, + "opera_download_button": { + "name": "ஓபரா பதிவிறக்க பொத்தான்", + "description": "ஒரு ச்னாப்பைப் பார்க்கும்போது மேல் வலது மூலையில் ஒரு பதிவிறக்க பொத்தானைச் சேர்க்கிறது.\n பொத்தான்களில் நீண்ட செய்தித் தாள் பதிவிறக்கத்தை கட்டாயப்படுத்தும்" + }, + "download_context_menu": { + "description": "சூழல் மெனுவைப் பயன்படுத்தி உரையாடல் அல்லது கதையிலிருந்து செய்திகளைப் பதிவிறக்க/முன்னோட்டமிட உங்களை அனுமதிக்கிறது.\n பொத்தான்களில் நீண்ட செய்தித் தாள் பதிவிறக்கத்தை கட்டாயப்படுத்தும்", + "name": "சூழல் மெனுவைப் பதிவிறக்கவும்" + }, + "ffmpeg_options": { + "name": "FFMPEG விருப்பங்கள்", + "description": "கூடுதல் FFMPEG விருப்பங்களைக் குறிப்பிடவும்", + "properties": { + "threads": { + "name": "நூல்கள்", + "description": "பயன்படுத்த வேண்டிய நூல்களின் அளவு" + }, + "preset": { + "description": "மாற்றத்தின் வேகத்தை அமைக்கவும்", + "name": "முன்னமைவு" + }, + "constant_rate_factor": { + "name": "நிலையான வீத காரணி", + "description": "வீடியோ குறியாக்கிக்கான நிலையான வீத காரணியை அமைக்கவும்\n LIBX264 க்கு 0 முதல் 51 வரை" + }, + "video_bitrate": { + "name": "வீடியோ பிட்ரேட்", + "description": "வீடியோ பிட்ரேட்டை (கே.பி.பி.எச்) அமைக்கவும்" + }, + "audio_bitrate": { + "name": "ஆடியோ பிட்ரேட்", + "description": "ஆடியோ பிட்ரேட்டை (கே.பி.பி.எச்) அமைக்கவும்" + }, + "custom_video_codec": { + "description": "தனிப்பயன் வீடியோ கோடெக்கை அமைக்கவும் (எ.கா. LIBX264)", + "name": "தனிப்பயன் வீடியோ கோடெக்" + }, + "custom_audio_codec": { + "description": "தனிப்பயன் ஆடியோ கோடெக்கை அமைக்கவும் (எ.கா. AAC)", + "name": "தனிப்பயன் ஆடியோ கோடெக்" + } + } + }, + "logging": { + "name": "பதிவு", + "description": "ஊடகங்கள் பதிவிறக்கும் போது சிற்றுண்டிகளைக் காட்டுகிறது" + }, + "custom_path_format": { + "name": "தனிப்பயன் பாதை வடிவம்", + "description": "பதிவிறக்கம் செய்யப்பட்ட ஊடகங்களுக்கான தனிப்பயன் பாதை வடிவமைப்பைக் குறிப்பிடவும்\n\n கிடைக்கும் மாறிகள்:\n - %பயனர்பெயர் %\n - %மூல %\n - %ஆச் %\n - %தேதி_ நேரம் %" + } + }, + "name": "பதிவிறக்குபவர்" + }, + "user_interface": { + "name": "பயனர் இடைமுகம்", + "description": "ச்னாப்சாட்டின் தோற்றத்தையும் உணர்வையும் மாற்றவும்", + "properties": { + "enable_app_appearance": { + "name": "பயன்பாட்டு தோற்ற அமைப்புகளை இயக்கவும்", + "description": "மறைக்கப்பட்ட பயன்பாட்டு தோற்ற அமைப்பை இயக்குகிறது\n புதிய ச்னாப்சாட் பதிப்புகளில் தேவையில்லை" + }, + "custom_theme": { + "name": "தனிப்பயன் கருப்பொருள்", + "description": "ச்னாப்சாட்டின் வண்ணங்களைத் தனிப்பயனாக்குங்கள்\n குறிப்பு: நீங்கள் ஒரு இருண்ட கருப்பொருள் (AMOLED போன்ற) தேர்வுசெய்தால், சிறந்த முடிவுகளுக்கு ச்னாப்சாட் அமைப்புகளில் இருண்ட பயன்முறையை இயக்க வேண்டியிருக்கலாம்" + }, + "friend_feed_message_preview": { + "name": "நண்பர் ஊட்ட செய்தி முன்னோட்டம்", + "description": "நண்பர் ஊட்டத்தில் கடைசி செய்திகளின் முன்னோட்டத்தைக் காட்டுகிறது", + "properties": { + "amount": { + "name": "தொகை", + "description": "முன்னோட்டமிடுவதற்கான செய்திகளின் அளவு" + } + } + }, + "snap_preview": { + "name": "ச்னாப் முன்னோட்டம்", + "description": "அரட்டையில் காணப்படாத புகைப்படங்களுக்கு அடுத்ததாக ஒரு சிறிய முன்னோட்டத்தைக் காட்டுகிறது" + }, + "bootstrap_override": { + "name": "தொடக்கவார் மேலெழுதும்", + "description": "பயனர் இடைமுகம் தொடக்கவார் அமைப்புகளை மீறுகிறது", + "properties": { + "app_appearance": { + "name": "பயன்பாட்டு தோற்றம்", + "description": "தொடர்ச்சியான பயன்பாட்டு தோற்றத்தை அமைக்கிறது" + }, + "home_tab": { + "name": "முகப்பு தாவல்", + "description": "ச்னாப்சாட்டைத் திறக்கும்போது தொடக்க தாவலை மீறுகிறது" + }, + "simple_snapchat": { + "name": "எளிய ச்னாப்சாட்", + "description": "ச்னாப்சாட்டின் எளிமைப்படுத்தப்பட்ட பதிப்பை இயக்குகிறது" + } + } + }, + "map_friend_nametags": { + "name": "மேம்படுத்தப்பட்ட நண்பர் வரைபடம் பெயர்", + "description": "ச்னாப்மேப்பில் நண்பர்களின் பெயரிடல்களை மேம்படுத்துகிறது" + }, + "prevent_message_list_auto_scroll": { + "name": "செய்தி பட்டியல் ஆட்டோ சுருளைத் தடுக்கவும்", + "description": "செய்தியை அனுப்பும்போது/பெறும்போது செய்தி பட்டியலை ச்க்ரோலிங் செய்வதிலிருந்து கீழே தடுக்கிறது" + }, + "streak_expiration_info": { + "name": "ச்ட்ரீக் காலாவதி தகவலைக் காட்டு", + "description": "ச்ட்ரீக்ச் கவுண்டருக்கு அடுத்ததாக ச்ட்ரீக் காலாவதி நேரத்தைக் காட்டுகிறது" + }, + "hide_friend_feed_entry": { + "name": "நண்பர் தீவன நுழைவை மறைக்கவும்", + "description": "நண்பர் ஊட்டத்திலிருந்து ஒரு குறிப்பிட்ட நண்பரை மறைக்கிறார்\n இந்த அம்சத்தை நிர்வகிக்க சமூக தாவலைப் பயன்படுத்தவும்" + }, + "hide_streak_restore": { + "name": "ச்ட்ரீக் மீட்டமைப்பை மறைக்கவும்", + "description": "நண்பர் ஊட்டத்தில் மீட்டெடுக்கும் பொத்தானை மறைக்கிறது" + }, + "hide_quick_add_suggestions": { + "name": "விரைவான சேர் பரிந்துரைகளை மறைக்கவும்", + "description": "நண்பர் பரிந்துரைகளை விரைவாகச் சேர்க்கவும்" + }, + "hide_story_suggestions": { + "name": "கதை பரிந்துரைகளை மறைக்கவும்", + "description": "கதைகள் பக்கத்திலிருந்து பரிந்துரைகளை நீக்குகிறது" + }, + "hide_ui_components": { + "name": "இடைமுகம் கூறுகளை மறைக்கவும்", + "description": "எந்த இடைமுகம் கூறுகளை மறைக்க தேர்ந்தெடுக்கவும்" + }, + "opera_media_quick_info": { + "name": "ஓபரா மீடியா விரைவான செய்தி", + "description": "ஓபரா பார்வையாளர் சூழல் பட்டியலில் படைப்பு தேதி போன்ற ஊடகங்களின் பயனுள்ள தகவல்களைக் காட்டுகிறது" + }, + "old_bitmoji_selfie": { + "name": "பழைய பிட்மோசி செல்பி", + "description": "பழைய ச்னாப்சாட் பதிப்புகளிலிருந்து பிட்மோசி செல்பிசை மீண்டும் கொண்டு வருகிறது" + }, + "disable_spotlight": { + "name": "ச்பாட்லைட்டை முடக்கு", + "description": "ச்பாட்லைட் பக்கத்தை முடக்குகிறது" + }, + "friend_feed_menu_buttons": { + "name": "நண்பர் ஊட்ட பட்டியல் பொத்தான்கள்", + "description": "நண்பர் ஊட்ட பட்டியலில் எந்த பொத்தான்களைக் காட்ட வேண்டும் என்பதைத் தேர்ந்தெடுக்கவும்" + }, + "auto_close_friend_feed_menu": { + "name": "தானாக நெருங்கிய நண்பர் ஊட்ட பட்டியல்", + "description": "அமைத்தல் பொத்தானை அழுத்திய பின் நண்பர் ஊட்ட மெனுவை தானாக மூடுகிறது" + }, + "message_indicators": { + "name": "செய்தி குறிகாட்டிகள்", + "description": "செய்திகளில் குறிப்பிட்ட குறிகாட்டிகள் சின்னங்களைச் சேர்க்கிறது\n குறிப்பு: குறிகாட்டிகள் 100% துல்லியமாக இருக்காது" + }, + "stealth_mode_indicator": { + "name": "திருட்டுத்தனமான பயன்முறை காட்டி", + "description": "திருட்டுத்தனமான பயன்முறையில் உரையாடல்களுக்கு அடுத்ததாக ஒரு 👻 ஈமோசியை சேர்க்கிறது" + }, + "edit_text_override": { + "name": "உரை மேலெழுதலைத் திருத்தவும்", + "description": "உரை புல நடத்தை மீறுகிறது" + }, + "vertical_story_viewer": { + "name": "செங்குத்து கதை பார்வையாளர்", + "description": "எல்லா கதைகளுக்கும் செங்குத்து கதை பார்வையாளரை செயல்படுத்துகிறது" + }, + "enable_friend_feed_menu_bar": { + "name": "நண்பர் ஊட்ட பட்டியல் பட்டி", + "description": "புதிய நண்பர் ஊட்ட பட்டியல் பட்டியை இயக்குகிறது" + } + } + } + }, + "options": { + "app_appearance": { + "always_light": "எப்போதும் ஒளி", + "always_dark": "எப்போதும் இருண்ட" + }, + "custom_theme": { + "amoled_dark_mode": "அமோல்ட் டார்க் பயன்முறை", + "custom": "தனிப்பயன் கருப்பொருள்கள் (கருப்பொருள்களை நிர்வகிக்க விரைவான செயல்களைப் பயன்படுத்தவும்)", + "material_you_light": "பொருள் நீங்கள் ஒளி (Android 12+)", + "material_you_dark": "பொருள் நீங்கள் இருண்ட (Android 12+)" + }, + "friend_feed_menu_buttons": { + "auto_download": "தானி ஆட்டோ பதிவிறக்கம்", + "auto_save": "Moades செய்திகளை சேமிக்கவும்", + "unsaveable_messages": "Nastancantancease விரும்பத்தகாத செய்திகள்", + "auto_open_snaps": "தானி ஆட்டோ திறந்த புகைப்படங்கள்", + "stealth": "👻 திருட்டுத்தனமான பயன்முறை", + "mark_snaps_as_seen": "பார்த்தபடி குறிக்கவும்", + "mark_stories_as_seen_locally": "In உள்நாட்டில் காணப்பட்ட கதைகளை குறிக்கவும்", + "conversation_info": "Contice உரையாடல் செய்தி", + "e2e_encryption": "இ 2E குறியாக்கத்தைப் பயன்படுத்தவும்" + }, + "path_format": { + "create_source_folder": "ஒவ்வொரு ஊடக மூல வகைக்கும் கோப்புறையை உருவாக்கவும்", + "append_hash": "கோப்பு பெயரில் ஒரு தனித்துவமான ஆசைச் சேர்க்கவும்", + "append_source": "கோப்பு பெயரில் மீடியா மூலத்தைச் சேர்க்கவும்", + "append_date_time": "கோப்பு பெயரில் தேதி மற்றும் நேரத்தை சேர்க்கவும்", + "create_author_folder": "ஒவ்வொரு எழுத்தாளருக்கும் கோப்புறையை உருவாக்கவும்", + "append_username": "கோப்பு பெயரில் பயனர்பெயரைச் சேர்க்கவும்" + }, + "auto_download_sources": { + "friend_stories": "நண்பர் கதைகள்", + "public_stories": "பொதுக் கதைகள்", + "spotlight": "ச்பாட்லைட்", + "friend_snaps": "நண்பர் நொறுங்குகிறார்" + }, + "logging": { + "started": "தொடங்கியது", + "success": "செய்", + "progress": "முன்னேற்றம்", + "failure": "தோல்வி" + }, + "notifications": { + "chat_reply": "அரட்டை பதில்", + "snap": "ச்னாப்", + "typing": "தட்டச்சு", + "stories": "கதைகள்", + "speaking": "பேசும்", + "chat_reaction": "டி.எம் எதிர்வினை", + "group_chat_reaction": "குழு எதிர்வினை", + "initiate_audio": "உள்வரும் ஆடியோ அழைப்பு", + "abandon_audio": "தவறவிட்ட ஆடியோ அழைப்பு", + "abandon_video": "வீடியோ அழைப்பு தவறவிட்டது", + "chat_screenshot": "திரைக்காட்சி", + "chat_screen_record": "திரை பதிவு", + "snap_replay": "ச்னாப் ரீப்ளே", + "camera_roll_save": "கேமரா ரோல் சேமி", + "chat": "அரட்டை", + "initiate_video": "உள்வரும் வீடியோ அழைப்பு" + }, + "gallery_media_send_override": { + "always_ask": "எப்போதும் கேளுங்கள்", + "ORIGINAL": "அசல் மீடியா", + "NOTE": "ஆடியோ குறிப்பு", + "SNAP": "ச்னாப்", + "SAVEABLE_SNAP": "சேமிக்கக்கூடிய ச்னாப்" + }, + "strip_media_metadata": { + "hide_caption_text": "தலைப்பு உரையை மறைக்கவும்", + "hide_snap_filters": "ச்னாப் வடிப்பான்களை மறைக்கவும்", + "hide_extras": "கூடுதல் மறைக்க (எ.கா. குறிப்பிடுகிறது)", + "remove_audio_note_duration": "ஆடியோ குறிப்பு காலத்தை அகற்று", + "remove_audio_note_transcript_capability": "ஆடியோ குறிப்பு டிரான்ச்கிரிப்ட் திறனை அகற்று" + }, + "hide_ui_components": { + "hide_stickers_button": "ச்டிக்கர்கள் பொத்தானை அகற்று", + "hide_voice_record_button": "குரல் பதிவு பொத்தானை அகற்று", + "hide_unread_chat_hint": "படிக்காத அரட்டை குறிப்பை அகற்று", + "hide_post_to_story_buttons": "ச்னாப் அனுப்புவதற்கு முன் கதை பொத்தான்களுக்கு இடுகையை அகற்று", + "hide_snapchat_plus_gift_reminders": "உரையாடல்களில் ச்னாப்சாட் பிளச் பரிசு நினைவூட்டல்களை அகற்றவும்", + "hide_map_reactions": "வரைபட எதிர்வினைகளை அகற்று", + "hide_profile_call_buttons": "சுயவிவர அழைப்பு பொத்தான்களை அகற்று", + "hide_chat_call_buttons": "அரட்டை அழைப்பு பொத்தான்களை அகற்று", + "hide_live_location_share_button": "நேரடி இருப்பிட பங்கு பொத்தானை அகற்று", + "hide_billboard_prompt": "நண்பர்கள் ஊட்டத்தில் பில்போர்டு வரியில் அகற்றவும்" + }, + "hide_story_suggestions": { + "hide_suggested_friend_stories": "பரிந்துரைக்கப்பட்ட நண்பர் கதைகளை மறைக்கவும்", + "hide_my_stories": "எனது கதைகளை மறைக்கவும்" + }, + "home_tab": { + "map": "வரைபடம்", + "chat": "அரட்டை", + "camera": "கேமரா", + "discover": "கண்டுபிடி", + "spotlight": "ச்பாட்லைட்" + }, + "simple_snapchat": { + "always_enabled": "எப்போதும் இயக்கப்பட்டது", + "always_disabled": "எப்போதும் முடக்கப்பட்டது" + }, + "add_friend_source_spoof": { + "added_by_qr_code": "QR குறியீடு மூலம்", + "added_by_quick_add": "விரைவாகச் சேர்ப்பதன் மூலம் (தடைசெய்யப்படுவதற்கான அதிக ஆபத்து)", + "added_by_username": "பயனர்பெயர் மூலம்", + "added_by_mention": "குறிப்பிடுவதன் மூலம்", + "added_by_group_chat": "குழு அரட்டை மூலம்", + "added_by_community": "சமூகத்தால்" + }, + "disable_confirmation_dialogs": { + "remove_friend": "நண்பரை அகற்று", + "block_friend": "பிளாக் நண்பரை", + "ignore_friend": "நண்பரை புறக்கணிக்கவும்", + "hide_friend": "நண்பரை மறைக்க", + "hide_conversation": "உரையாடலை மறைக்கவும்", + "clear_conversation": "நண்பர் ஊட்டத்திலிருந்து தெளிவான உரையாடல்", + "erase_message": "செய்தியை அழிக்கவும்" + }, + "auto_reload": { + "snapchat_only": "ச்னாப்சாட் மட்டும்", + "all": "அனைத்தும் (ச்னாப்சாட் + ச்னாபன்ஆன்ச்)" + }, + "edit_text_override": { + "multi_line_chat_input": "பல வரி அரட்டை உள்ளீடு", + "bypass_text_input_limit": "உரை உள்ளீட்டு வரம்பு பைபாச்" + }, + "auto_purge": { + "never": "ஒருபோதும்", + "1_hour": "1 மணி நேரம்", + "6_hours": "6 மணி நேரம்", + "12_hours": "12 மணி நேரம்", + "1_day": "1 நாள்", + "3_days": "3 நாட்கள்", + "1_month": "1 மாதம்", + "3_months": "3 மாதங்கள்", + "6_months": "6 மாதங்கள்", + "3_hours": "3 மணி நேரம்", + "1_week": "1 வாரம்", + "2_weeks": "2 வாரங்கள்" + }, + "disable_story_sections": { + "friends": "நண்பர்கள்", + "suggested_stories": "பரிந்துரைக்கப்பட்ட கதைகள்", + "following": "பின்வருமாறு", + "discover": "கண்டுபிடி" + }, + "disable_cameras": { + "front": "முன் கேமரா", + "back": "பின் கேமரா" + }, + "disable_permission_requests": { + "notifications": "அறிவிப்புகள்", + "read_media_images": "மீடியா படங்களைப் படியுங்கள்", + "location": "இடம்", + "read_contacts": "தொடர்புகளைப் படியுங்கள்", + "nearby_devices": "அருகிலுள்ள சாதனங்கள்", + "phone_calls": "தொலைபேசி அழைப்புகள்", + "read_media_video": "மீடியா வீடியோவைப் படியுங்கள்", + "camera": "கேமரா", + "microphone": "ஒலிவாங்கி" + }, + "message_indicators": { + "encryption_indicator": "உங்களுக்கு மட்டுமே அனுப்பப்பட்ட செய்திகளுக்கு அடுத்ததாக ஒரு 🔒 ஐகானைச் சேர்க்கிறது", + "platform_indicator": "ஒரு மீடியா அனுப்பப்பட்ட இயங்குதள ஐகானைச் சேர்க்கவும் (I.N. ஆண்ட்ராய்டு, ஐஇமு, Web)", + "location_indicator": "இருப்பிடத்துடன் அனுப்பப்படும் போது புகைப்படங்களுக்கு ஒரு 📍 ஐகானைச் சேர்க்கிறது", + "ovf_editor_indicator": "OVF எடிட்டரைப் பயன்படுத்தி ஒரு ச்னாப் அனுப்பப்பட்டதா என்பதைக் குறிக்கிறது", + "director_mode_indicator": "இயக்குனர் பயன்முறையைப் பயன்படுத்தி அனுப்பப்படும்போது ச்னாப்சுக்கு ஒரு ic ஐகானைச் சேர்க்கிறது, இது கேலரி படங்களை ச்னாப்சாக அனுப்ப பயன்படுகிறது" + }, + "auto_mark_as_read": { + "conversation_read": "செய்தியை அனுப்பும்போது உரையாடலைப் படித்தபடி குறிக்கவும்", + "snap_reply": "அவர்களுக்கு பதிலளிக்கும் போது படித்தபடி குறிக்கவும்" + }, + "friend_mutation_notifier": { + "bitmoji_avatar_changes": "யாராவது தங்கள் பிட்மோசி அவதாரத்தை மாற்றும்போது அறிவிக்கவும்", + "bitmoji_background_changes": "யாராவது தங்கள் பிட்மோசி பின்னணியை மாற்றும்போது அறிவிக்கவும்", + "bitmoji_scene_changes": "யாராவது தங்கள் பிட்மோசி காட்சியை மாற்றும்போது அறிவிக்கவும்", + "remove_friend": "யாராவது உங்களை ஒரு நண்பராக நீக்கும்போது அறிவிக்கவும்", + "birthday_changes": "யாராவது தங்கள் பிறந்தநாளை மாற்றும்போது அறிவிக்கவும்", + "bitmoji_selfie_changes": "யாராவது தங்கள் பிட்மோசி செல்பியை மாற்றும்போது அறிவிக்கவும்" + }, + "snapchat_plus": { + "not_subscribed": "குழுசேரவில்லை", + "basic": "அடிப்படை", + "ad_free": "விளம்பரம் இலவசம்" + }, + "double_tap_chat_action": { + "like_message": "செய்தி போன்றது", + "copy_text": "இடைநிலைப்பலகைக்கு உரையை நகலெடுக்கவும்", + "delete_message": "செய்தியை நீக்கு", + "mark_as_read": "படித்தபடி குறி", + "custom_emoji_reaction": "தனிப்பயன் ஈமோசி எதிர்வினை" + }, + "bypass_video_length_restriction": { + "single": "ஒற்றை மீடியா", + "split": "பிளவு மீடியா" + }, + "old_bitmoji_selfie": { + "2d": "அது ஏற்படலாம்", + "3d": "திரும்பி வாருங்கள்" + } + }, + "notices": { + "unstable": "⚠ நிலையற்றது", + "ban_risk": "Feature இந்த நற்பொருத்தம் தடைகளை ஏற்படுத்தக்கூடும்", + "internal_behavior": "⚠ இது ச்னாப்சாட் உள் நடத்தையை உடைக்கக்கூடும்" + } + }, + "setup": { + "mappings": { + "generate_failure": "வரைபடங்களை உருவாக்க முயற்சிக்கும்போது பிழை ஏற்பட்டது, தயவுசெய்து மீண்டும் முயற்சிக்கவும்.", + "generate_failure_no_snapchat": "ச்னாபன்ஆன்ச் ச்னாப்சாட்டைக் கண்டறிய முடியவில்லை, தயவுசெய்து ச்னாப்சாட்டை மீண்டும் நிறுவ முயற்சிக்கவும்.", + "dialog": "வரைபடங்களை உருவாக்குதல், இதற்கு சிறிது நேரம் ஆகலாம் ..." + }, + "permissions": { + "dialog": "தொடர நீங்கள் பின்வரும் தேவைகளுக்கு பொருந்த வேண்டும்:", + "notification_access": "அறிவிப்பு அணுகல்", + "battery_optimization": "பேட்டரி தேர்வுமுறை", + "display_over_other_apps": "பிற பயன்பாடுகளில் காண்பி", + "request_button": "கோரிக்கை" + }, + "dialogs": { + "save_folder": "ச்னாபன்ஆன்சுக்கு ச்னாப்சாட்டிலிருந்து ஊடகங்களை பதிவிறக்கம் செய்து சேமிக்க சேமிப்பக அனுமதிகள் தேவை.\n மீடியா பதிவிறக்கம் செய்ய வேண்டிய இடத்தைத் தேர்வுசெய்க.", + "select_language": "மொழியைத் தேர்ந்தெடுக்கவும்", + "select_save_folder_button": "கோப்புறையைத் தேர்ந்தெடு" + } + }, + "scopes": { + "friend": "நண்பர்", + "group": "குழு" + }, + "manager": { + "routes": { + "features": "நற்பொருத்தங்கள்", + "manage_rule_feature": "விதி அம்சத்தை நிர்வகிக்கவும்", + "home": "வீடு", + "home_settings": "அமைப்புகள்", + "home_logs": "பதிவுகள்", + "logger_history": "லாகர் வரலாறு", + "logged_stories": "உள்நுழைந்த கதைகள்", + "friend_tracker": "நண்பர் டிராக்கர்", + "edit_rule": "விதியைத் திருத்து", + "file_imports": "கோப்பு இறக்குமதிகள்", + "theming": "தீமிங்", + "edit_theme": "கருப்பொருள் திருத்து", + "manage_repos": "களஞ்சியங்களை நிர்வகிக்கவும்", + "social": "சமூக", + "manage_scope": "நோக்கத்தை நிர்வகிக்கவும்", + "messaging_preview": "முன்னோட்டம்", + "scripts": "ச்கிரிப்ட்கள்", + "better_location": "சிறந்த இடம்", + "tasks": "பணிகள்" + }, + "sections": { + "home": { + "version_title": "V {versionName} · · · · · · · ரங்க்", + "update_title": "ச்னாபன்ஆன்ச் புதுப்பிப்பு", + "update_content": "பதிப்பு {version} கிடைக்கிறது!", + "update_button": "பதிவிறக்கம்", + "debug_build_summary_title": "நீங்கள் ச்னாபன்ஆன்சின் பிழைத்திருத்த கட்டமைப்பை இயக்குகிறீர்கள்", + "debug_build_summary_content": "பதிப்பு {versionName} ({versionCode})", + "debug_build_summary_date": "உருவாக்க தேதி: {date} ({days} நாட்கள் முன்பு)", + "quick_actions_title": "விரைவான செயல்கள்" + }, + "home_logs": { + "no_logs_hint": "பதிவுகள் எதுவும் கிடைக்கவில்லை", + "clear_logs_button": "பதிவுகளை அழிக்கவும்", + "export_logs_button": "ஏற்றுமதி பதிவுகள்", + "saving_logs_toast": "பதிவுகளைச் சேமித்தல், இதற்கு சிறிது நேரம் ஆகலாம் ...", + "saved_logs_success_toast": "பதிவுகள் வெற்றிகரமாக சேமிக்கப்பட்டன", + "saved_logs_failure_toast": "பதிவுகளைச் சேமிப்பதில் தோல்வி" + }, + "home_settings": { + "actions_title": "செயல்கள்", + "message_logger_title": "செய்தி லாகர்", + "debug_title": "பிழைத்திருத்தம்", + "success_toast": "முடிந்தது!", + "message_logger_summary": "{messageCount} செய்திகள்\n {storyCount} கதைகள்", + "export_button": "ஏற்றுமதி", + "clear_button": "தெளிவான", + "view_logger_history_button": "லாகர் வரலாற்றைக் காண்க" + }, + "tasks": { + "no_tasks": "பணிகள் இல்லை", + "merge_button": "ஒன்றிணைக்கவும்", + "failed_to_open_file": "கோப்பைத் திறக்கத் தவறிவிட்டது", + "merge_files_toast": "இணைத்தல் {count} கோப்புகள்", + "remove_selected_tasks_title": "தேர்ந்தெடுக்கப்பட்ட பணிகளை அகற்ற விரும்புகிறீர்களா?", + "remove_all_tasks_title": "எல்லா பணிகளையும் அகற்ற விரும்புகிறீர்களா?", + "delete_files_option": "கோப்புகளையும் நீக்கவும்", + "remove_selected_tasks_confirm": "{count} பணிகளை அகற்று?", + "remove_all_tasks_confirm": "எல்லா பணிகளையும் அகற்றவா?" + }, + "features": { + "disabled": "முடக்கப்பட்டது", + "export_option": "ஏற்றுமதி", + "import_option": "இறக்குமதி", + "reset_option": "மீட்டமை", + "config_export_success_toast": "கட்டமைப்பு வெற்றிகரமாக ஏற்றுமதி செய்யப்பட்டது", + "config_import_success_toast": "வெற்றிகரமாக இறக்குமதி செய்யப்பட்டது", + "config_import_failure_toast": "கட்டமைப்பு {error} இறக்குமதி செய்வதில் தோல்வி", + "config_export_failure_toast": "உள்ளமைவு {error} ஏற்றுமதி செய்வதில் தோல்வி", + "saved_config_snackbar": "கட்டமைப்பு சேமிக்கப்பட்டது", + "older_required": "இந்த அம்சத்திற்கு ச்னாப்சாட் வி {version} அல்லது அதற்கு மேற்பட்டது சரியாக வேலை செய்ய தேவைப்படுகிறது", + "newer_required": "இந்த அம்சத்திற்கு ச்னாப்சாட் வி {version} அல்லது சரியாக வேலை செய்ய புதியது தேவைப்படுகிறது", + "search_button": "தேடல்" + }, + "manage_rule_feature": { + "disable_state_option": "முடக்கப்பட்டது", + "disable_state_subtext": "நண்பர்கள்/குழுக்கள் எதுவும் பாதிக்கப்படாது", + "whitelist_state_option": "தவிர வேறு யாரும் இல்லை ...", + "whitelist_state_subtext": "{count} நண்பர்கள்/குழுக்கள் மட்டுமே இந்த விதியால் பாதிக்கப்படும்", + "whitelist_state_button": "அனுமதிக்கப்பட்ட நண்பர்கள்/குழுக்களைத் தேர்ந்தெடுக்கவும்", + "blacklist_state_option": "தவிர எல்லோரும் ...", + "blacklist_state_subtext": "{count} நண்பர்கள்/குழுக்கள் தவிர எல்லோரும் இந்த விதியால் பாதிக்கப்படுவார்கள்", + "blacklist_state_button": "விலக்கப்பட்ட நண்பர்கள்/குழுக்களைத் தேர்ந்தெடுக்கவும்", + "clear_list_button": "நண்பர்கள்/குழுக்கள் பட்டியல்", + "dialog_clear_confirmation_text": "பட்டியலை அழிக்க விரும்புகிறீர்களா?" + }, + "social": { + "friends_tab": "நண்பர்கள்", + "groups_tab": "குழுக்கள்", + "empty_hint": "(காலியாக)", + "streaks_expiration_short": "{hours} h" + }, + "manage_scope": { + "logged_stories_button": "உள்நுழைந்த கதைகளைக் காட்டு", + "e2ee_title": "இறுதி-இறுதி குறியாக்கம்", + "rules_title": "விதிகள்", + "participants_text": "{count} பங்கேற்பாளர்கள்", + "not_found": "கண்டுபிடிக்கப்படவில்லை", + "streaks_title": "கோடுகள்", + "streaks_length_text": "நீளம்: {length}", + "streaks_expiration_text": "{eta} இல் காலாவதியாகிறது", + "streaks_expiration_text_expired": "காலாவதியான", + "reminder_button": "நினைவூட்டலை அமைக்கவும்", + "delete_scope_confirm_dialog_title": "நீங்கள் ஒரு {scope} ஐ நீக்க விரும்புகிறீர்களா?", + "notes_placeholder": "குறிப்பைச் சேர்க்க சொடுக்கு செய்க" + }, + "logged_stories": { + "story_failed_to_load": "ஏற்றுவதில் தோல்வி", + "no_stories": "கதைகள் எதுவும் கிடைக்கவில்லை", + "save_from_cache_button": "தற்காலிக சேமிப்பிலிருந்து சேமிக்கவும்" + }, + "messaging_preview": { + "bridge_connection_failed": "பாலத்துடன் இணைக்கத் தவறிவிட்டது. ச்னாப்சாட் பின்னணியில் இயங்குவதை உறுதிசெய்க", + "bridge_init_failed": "செய்தியிடல் பாலத்தைத் தொடங்கத் தவறிவிட்டது. ச்னாப்சாட் பின்னணியில் இயங்குவதை உறுதிசெய்க", + "message_fetch_failed": "செய்திகளைப் பெறுவதில் தோல்வி", + "no_message_hint": "செய்தி இல்லை", + "save_selection_option": "தேர்வைச் சேமிக்கவும்", + "save_all_option": "அனைத்தையும் சேமி", + "unsave_selection_option": "மன்னிப்பு தேர்வு", + "unsave_all_option": "அனைவரையும் விடவும்", + "mark_selection_as_seen_option": "பார்த்தபடி தேர்ந்தெடுக்கப்பட்ட ச்னாப்", + "mark_all_as_seen_option": "பார்த்தபடி அனைத்து புகைப்படங்களையும் குறிக்கவும்", + "delete_selection_option": "தேர்வை நீக்கு", + "delete_all_option": "அனைத்தையும் நீக்கு" + }, + "logger_history": { + "list_friend_format": "நண்பர் {name}", + "list_group_format": "குழு {name}", + "no_more_messages": "மேலும் செய்திகள் இல்லை", + "reverse_order_checkbox": "தலைகீழ் ஒழுங்கு", + "chat_attachment": "இணைப்பு {index}", + "empty_message": "வெற்று அரட்டை செய்தி", + "message_parse_failed": "செய்தியை அலசத் தவறிவிட்டது", + "unknown_sender": "தெரியாத அனுப்புநர்", + "download_attachment_failed_toast": "இணைப்பைப் பதிவிறக்குவதில் தோல்வி" + }, + "file_imports": { + "import_file_button": "கோப்பு இறக்குமதி", + "file_not_found": "கோப்பு கிடைக்கவில்லை", + "file_import_failed": "கோப்பை இறக்குமதி செய்வதில் தோல்வி: {error}", + "file_imported": "கோப்பு வெற்றிகரமாக இறக்குமதி செய்யப்பட்டது", + "file_delete_failed": "கோப்பை நீக்குவதில் தோல்வி", + "no_files_hint": "ச்னாப்சாட்டில் பயன்படுத்த கோப்புகளை இங்கே இறக்குமதி செய்யலாம். ஒரு கோப்பை இறக்குமதி செய்ய கீழே உள்ள பொத்தானை அழுத்தவும்." + }, + "better_location": { + "spoofed_coordinates_title": "Lat {latitude}, lng {longitude}", + "save_coordinates_dialog_title": "ஒருங்கிணைப்புகளை சேமிக்கவும்", + "saved_name_dialog_hint": "சேமித்த பெயர்", + "latitude_dialog_hint": "அகலாங்கு", + "longitude_dialog_hint": "நெட்டாங்கு", + "save_dialog_button": "சேமி", + "choose_location_button": "இருப்பிடத்தைத் தேர்வுசெய்க", + "teleport_to_friend_button": "நண்பருக்கு டெலிபோர்ட்", + "spoof_location_toggle": "ஏமாற்றும் இடம்", + "suspend_location_updates": "இருப்பிட புதுப்பிப்புகளை இடைநிறுத்துங்கள்", + "saved_coordinates_title": "சேமித்த ஆயத்தொலைவுகள்", + "no_saved_coordinates_hint": "சேமித்த ஆயத்தொலைவுகள் இல்லை", + "delete_dialog_title": "சேமித்த ஒருங்கிணைப்பை நீக்கு", + "delete_dialog_message": "இந்த சேமித்த ஒருங்கிணைப்பை நீக்க விரும்புகிறீர்களா?", + "teleport_to_friend_title": "நண்பருக்கு டெலிபோர்ட்", + "search_bar": "தேடல்", + "no_friends_map": "வரைபடத்தில் நண்பர்கள் இல்லை", + "no_friends_found": "நண்பர்கள் எதுவும் கிடைக்கவில்லை" + }, + "theming": { + "no_themes_hint": "கருப்பொருள்கள் எதுவும் கிடைக்கவில்லை" + } + }, + "dialogs": { + "add_friend": { + "title": "நண்பர் அல்லது குழுவைச் சேர்க்கவும்", + "search_hint": "தேடல்", + "fetch_error": "தரவைப் பெறுவதில் தோல்வி", + "category_groups": "குழுக்கள்", + "category_friends": "நண்பர்கள்", + "participants_text": "{count} பங்கேற்பாளர்கள்" + }, + "scripting_warning": { + "title": "எச்சரிக்கை", + "content": "ச்னாபன்ஆன்ச் ஒரு ச்கிரிப்டிங் கருவியை உள்ளடக்கியது, இது உங்கள் சாதனத்தில் பயனர் வரையறுக்கப்பட்ட குறியீட்டை செயல்படுத்த அனுமதிக்கிறது. தீவிர எச்சரிக்கையைப் பயன்படுத்துங்கள் மற்றும் அறியப்பட்ட, நம்பகமான மூலங்களிலிருந்து தொகுதிகளை மட்டுமே நிறுவவும். அங்கீகரிக்கப்படாத அல்லது சரிபார்க்கப்படாத தொகுதிகள் உங்கள் கணினிக்கு பாதுகாப்பு அபாயங்களை ஏற்படுத்தக்கூடும்." + }, + "reset_config": { + "title": "கட்டமைப்பை மீட்டமைக்கவும்", + "content": "நீங்கள் நிச்சயமாக உள்ளமைவை மீட்டமைக்க விரும்புகிறீர்களா?", + "success_toast": "கட்டமைப்பு மீட்டமைக்கவும் வெற்றிகரமாக" + }, + "export_config": { + "title": "உணர்திறன் தரவை ஏற்றுமதி செய்யவா?", + "content": "முக்கியமான தரவுகளுடன் உள்ளமைவை ஏற்றுமதி செய்ய விரும்புகிறீர்களா? (இருப்பிட ஒருங்கிணைப்புகள் போன்றவை)" + }, + "messaging_action": { + "title": "செயலாக்க உள்ளடக்க வகைகளைத் தேர்வுசெய்க", + "select_all_button": "அனைத்தையும் தெரிவுசெய்" + }, + "file_imports": { + "no_files_settings_hint": "கோப்புகள் எதுவும் கிடைக்கவில்லை. கோப்பு இறக்குமதி பிரிவில் தேவையான கோப்புகளை நீங்கள் இறக்குமதி செய்துள்ளீர்கள் என்பதை உறுதிப்படுத்திக் கொள்ளுங்கள்", + "settings_select_file_hint": "இறக்குமதி செய்யப்பட்ட கோப்பைத் தேர்ந்தெடுக்கவும்" + } + } + }, + "rules": { + "toasts": { + "enabled": "{ruleName} இயக்கப்பட்டது", + "disabled": "{ruleName} முடக்கப்பட்டது" + }, + "modes": { + "blacklist": "பிளாக்லிச்ட் பயன்முறை", + "whitelist": "அனுமதிப்பட்டியல் பயன்முறை" + }, + "properties": { + "auto_download": { + "description": "புகைப்படங்களைப் பார்க்கும்போது தானாகவே பதிவிறக்கவும்", + "options": { + "blacklist": "ஆட்டோ பதிவிறக்கத்திலிருந்து விலக்கு", + "whitelist": "ஆட்டோ பதிவிறக்கம்" + }, + "name": "ஆட்டோ பதிவிறக்கம்" + }, + "stealth": { + "name": "திருட்டுத்தனமான பயன்முறை", + "description": "நீங்கள் அவர்களின் புகைப்படங்கள்/அரட்டைகள் மற்றும் உரையாடல்களைத் திறந்துவிட்டீர்கள் என்பதை அறிந்து கொள்வதைத் தடுக்கிறது", + "options": { + "blacklist": "திருட்டுத்தனமான பயன்முறையிலிருந்து விலக்கு", + "whitelist": "திருட்டுத்தனமான பயன்முறை" + } + }, + "auto_save": { + "description": "அரட்டை செய்திகளைப் பார்க்கும்போது அவற்றைச் சேமிக்கிறது", + "options": { + "blacklist": "ஆட்டோ சேமிப்பிலிருந்து விலக்கு", + "whitelist": "தானியங்கு சேமிப்பு" + }, + "name": "தானியங்கு சேமிப்பு" + }, + "unsaveable_messages": { + "name": "சுவையற்ற செய்திகள்", + "description": "செய்திகளை மற்றவர்களால் அரட்டையில் சேமிப்பதைத் தடுக்கிறது", + "options": { + "blacklist": "விரும்பத்தகாத செய்திகளிலிருந்து விலக்கு", + "whitelist": "சுவையற்ற செய்திகள்" + } + }, + "auto_open_snaps": { + "name": "ஆட்டோ திறந்த புகைப்படங்கள்", + "description": "ச்னாப்புகளைப் பெறும்போது தானாகவே திறக்கிறது", + "options": { + "blacklist": "ஆட்டோ திறந்த புகைப்படங்களிலிருந்து விலக்கு", + "whitelist": "ஆட்டோ திறந்த புகைப்படங்கள்" + } + }, + "hide_friend_feed": { + "name": "நண்பர் ஊட்டத்திலிருந்து மறைக்கவும்" + }, + "e2e_encryption": { + "name": "E2E குறியாக்கத்தைப் பயன்படுத்தவும்" + }, + "pin_conversation": { + "name": "பின் உரையாடல்" + } + } + }, + "actions": { + "clean_snapchat_cache": { + "name": "தூய்மை ச்னாப்சாட் கேச்", + "description": "ச்னாப்சாட் தற்காலிக சேமிப்பை தூய்மை செய்கிறது" + }, + "manage_friend_list": { + "name": "நண்பர் பட்டியலை நிர்வகிக்கவும்", + "description": "காப்புப் பிரதி எடுக்கும்போது உங்கள் நண்பர்கள் பட்டியலை இறக்குமதி/ஏற்றுமதி செய்யுங்கள்" + }, + "export_chat_messages": { + "name": "அரட்டை செய்திகளை ஏற்றுமதி செய்யுங்கள்", + "description": "உரையாடல் செய்திகளை JSON/HTML/TXT கோப்பில் ஏற்றுமதி செய்கிறது" + }, + "export_memories": { + "name": "நினைவுகள் ஏற்றுமதி", + "description": "நினைவுகளை ஒரு சிப் கோப்பில் ஏற்றுமதி செய்கிறது" + }, + "bulk_messaging_action": { + "name": "மொத்த செய்தியிடல் நடவடிக்கை", + "description": "நண்பர்களை நீக்குவது அல்லது உரையாடல்களை வெகுசன நீக்குதல் போன்ற செயல்பாடுகளைச் செய்கிறது" + }, + "regen_mappings": { + "name": "மேப்பிங்சை மீளுருவாக்கம் செய்யுங்கள்", + "description": "கைமுறையாக மேப்பிங்சை மீண்டும் உருவாக்கவும்" + }, + "change_language": { + "name": "மொழியை மாற்றவும்", + "description": "ச்னாபன்ஆன்சின் மொழியை மாற்றவும்" + }, + "security_features": { + "name": "பாதுகாப்பு நற்பொருத்தங்கள்", + "description": "பாதுகாப்பு அம்சங்களின் விருப்பங்களை மாற்றவும்" + }, + "file_imports": { + "name": "கோப்பு இறக்குமதிகள்", + "description": "ச்னாப்சாட்டில் பயன்படுத்த கோப்புகளை இறக்குமதி செய்யுங்கள்" + }, + "friend_tracker": { + "name": "நண்பர் டிராக்கர்", + "description": "ச்னாப்சாட்டில் உங்கள் நண்பர்களைக் கண்காணிக்கவும்" + }, + "logger_history": { + "name": "லாகர் வரலாறு", + "description": "உள்நுழைந்த செய்திகளின் வரலாற்றைக் காண்க" + }, + "theming": { + "name": "தீமிங்", + "description": "ச்னாப்சாட்டின் தோற்றத்தையும் உணர்வையும் தனிப்பயனாக்கவும்" + } + }, + "friend_menu_option": { + "mark_snaps_as_seen": "காணப்பட்டபடி குறி", + "mark_stories_as_seen_locally": "உள்நாட்டில் காணப்பட்ட கதைகளை குறிக்கவும்", + "preview": "முன்னோட்டம்", + "stealth_mode": "திருட்டுத்தனமான பயன்முறை", + "auto_download_blacklist": "தானாக பதிவிறக்க தடுப்புப்பட்டியல்", + "anti_auto_save": "ஆன்டி ஆட்டோ சேமிப்பு" + }, + "content_type": { + "CHAT": "அரட்டை", + "SNAP": "ச்னாப்", + "EXTERNAL_MEDIA": "வெளிப்புற ஊடகங்கள்", + "NOTE": "ஆடியோ குறிப்பு", + "STICKER": "ச்டிக்கர்", + "STATUS": "நிலை", + "SHARE": "பங்கு", + "LOCATION": "இடம்", + "STATUS_SAVE_TO_CAMERA_ROLL": "கேமரா ரோலில் சேமிக்கப்பட்டது", + "STATUS_CONVERSATION_CAPTURE_SCREENSHOT": "திரைக்காட்சி", + "STATUS_CONVERSATION_CAPTURE_RECORD": "திரை பதிவு", + "STATUS_CALL_MISSED_VIDEO": "வீடியோ அழைப்பு தவறவிட்டது", + "STATUS_CALL_MISSED_AUDIO": "தவறவிட்ட ஆடியோ அழைப்பு", + "CREATIVE_TOOL_ITEM": "படைப்பு கருவி உருப்படி", + "LIVE_LOCATION_SHARE": "நேரடி இருப்பிட பங்கு", + "FAMILY_CENTER_INVITE": "குடும்ப மைய அழைப்பு", + "FAMILY_CENTER_ACCEPT": "குடும்ப நடுவண் ஏற்றுக்கொள்கிறது", + "FAMILY_CENTER_LEAVE": "குடும்ப மைய விடுப்பு", + "STATUS_PLUS_GIFT": "நிலை மற்றும் பரிசு", + "TINY_SNAP": "சிறிய ச்னாப்", + "STATUS_COUNTDOWN": "கவுண்டவுன்", + "MAP_REACTION": "வரைபட எதிர்வினை" + }, + "media_download_source": { + "none": "எதுவுமில்லை", + "pending": "நிலுவையில் உள்ளது", + "chat_media": "சராசரி அரட்டை", + "story": "கதை", + "public_story": "பொது கதை", + "spotlight": "ச்பாட்லைட்", + "profile_picture": "சுயவிவர படம்", + "story_logger": "கதை லாகர்", + "message_logger": "செய்தி லாகர்", + "merged": "ஒன்றிணைந்தது", + "voice_call": "குரல் அழைப்பு" + }, + "opera_context_menu": { + "expires_at": "{date} இல் காலாவதியாகிறது", + "media_size": "ஊடக அளவு: {size}", + "media_duration": "மீடியா காலம்: {duration} எம்.எச்", + "show_debug_info": "பிழைத்திருத்த தகவலைக் காட்டு", + "download": "மீடியாவைப் பதிவிறக்கவும்", + "sent_at": "{date} இல் அனுப்பப்பட்டது", + "created_at": "{date} இல் உருவாக்கப்பட்டது" + }, + "modal_option": { + "profile_info": "சுயவிவர செய்தி", + "close": "மூடு" + }, + "gallery_media_send_override": { + "multiple_media_toast": "நீங்கள் ஒரு நேரத்தில் ஒரு ஊடகத்தை மட்டுமே அனுப்ப முடியும்" + }, + "mark_as_seen": { + "no_unseen_snaps_toast": "காணப்படாத புகைப்படங்கள் எதுவும் கிடைக்கவில்லை!", + "seen_toast": "பார்த்தபடி குறிக்கப்பட்டுள்ளது!", + "unseen_toast": "காணப்படாதது எனக் குறிக்கப்பட்டுள்ளது!", + "already_seen_toast": "ஏற்கனவே பார்த்தபடி குறிக்கப்பட்டுள்ளது!", + "already_unseen_toast": "ஏற்கனவே காணப்படாததாக குறிக்கப்பட்டுள்ளது!" + }, + "conversation_preview": { + "streak_expiration": "{day} நாட்கள் {hour} மணிநேரம் {minute} நிமிடங்களில் காலாவதியாகிறது", + "total_messages": "மொத்தம் அனுப்பப்பட்ட/பெறப்பட்ட செய்திகள்: {count}", + "title": "முன்னோட்டம்", + "unknown_user": "தெரியாத பயனர்", + "no_messages": "செய்திகள் எதுவும் கிடைக்கவில்லை!" + }, + "profile_info": { + "title": "சுயவிவர செய்தி", + "first_created_username": "முதலில் பயனர்பெயர் உருவாக்கப்பட்டது", + "mutable_username": "மாற்றக்கூடிய பயனர்பெயர்", + "display_name": "காட்சி பெயர்", + "added_date": "சேர்க்கப்பட்ட தேதி", + "birthday": "பிறந்த நாள்: {month} {day}", + "hidden_birthday": "பிறந்த நாள்: மறைக்கப்பட்டுள்ளது", + "friendship": "நட்பு", + "add_source": "மூலத்தைச் சேர்க்கவும்", + "snapchat_plus": "ச்னாப்சாட் பிளச்", + "snapchat_plus_state": { + "subscribed": "சந்தா", + "not_subscribed": "குழுசேரவில்லை" + } + }, + "friendship_link_type": { + "mutual": "பரச்பர", + "outgoing": "வெளிச்செல்லும்", + "blocked": "தடுக்கப்பட்டது", + "deleted": "நீக்கப்பட்டது", + "following": "பின்வருமாறு", + "suggested": "பரிந்துரைக்கப்பட்டது", + "incoming": "உள்வரும்", + "incoming_follower": "உள்வரும் பின்தொடர்பவர்" + }, + "bulk_messaging_action": { + "progress_status": "{மொத்தம் {குறியீட்டு செயலாக்கம்", + "selection_dialog_continue_button": "தொடரவும்", + "confirmation_dialog": { + "title": "நீங்கள் உறுதியாக இருக்கிறீர்களா?", + "message": "இது தேர்ந்தெடுக்கப்பட்ட அனைத்து நண்பர்களையும் பாதிக்கும். இந்த செயலை செயல்தவிர்க்க முடியாது." + }, + "actions": { + "remove_friends": "நண்பர்களை அகற்று", + "clear_conversations": "உரையாடல்களை அழிக்கவும்" + }, + "choose_action_title": "ஒரு செயலைத் தேர்வுசெய்க" + }, + "chat_export": { + "exporter_dialog": { + "select_conversations_title": "உரையாடல்களைத் தேர்ந்தெடுக்கவும்", + "text_field_selection": "{amount} தேர்ந்தெடுக்கப்பட்டது", + "text_field_selection_all": "அனைத்தும்", + "export_file_format_title": "கோப்பு வடிவத்தை ஏற்றுமதி செய்யுங்கள்", + "message_type_filter_title": "வகைப்படி செய்திகளை வடிகட்டவும்", + "amount_of_messages_title": "செய்திகளின் அளவு (அனைவருக்கும் காலியாக விடுங்கள்)", + "download_medias_title": "மீடியாசை பதிவிறக்கவும்" + }, + "dialog_negative_button": "ரத்துசெய்", + "dialog_positive_button": "ஏற்றுமதி", + "exported_to": "{path} க்கு ஏற்றுமதி செய்யப்பட்டது", + "exporting_chats": "அரட்டைகளை ஏற்றுமதி செய்கிறது ...", + "processing_chats": "செயலாக்கம் {amount} உரையாடல்கள் ...", + "export_fail": "உரையாடலை ஏற்றுமதி செய்வதில் தோல்வி {conversation}", + "writing_output": "எழுதுதல் வெளியீடு ...", + "finished": "முடிந்தது! நீங்கள் இப்போது இந்த உரையாடலை மூடலாம்.", + "no_messages_found": "செய்திகள் எதுவும் கிடைக்கவில்லை!", + "exporting_message": "ஏற்றுமதி {conversation} ..." + }, + "button": { + "ok": "சரி", + "positive": "ஆம்", + "negative": "இல்லை", + "cancel": "ரத்துசெய்", + "open": "திற", + "download": "பதிவிறக்கம்", + "send": "அனுப்பு" + }, + "better_notifications": { + "button": { + "reply": "பதில்", + "download": "பதிவிறக்கம்", + "mark_as_read": "படித்தபடி குறி" + } + }, + "profile_picture_downloader": { + "button": "சுயவிவரப் படத்தைப் பதிவிறக்கவும்", + "title": "சுயவிவரப் படம் பதிவிறக்குபவர்", + "avatar_option": "அவதார்", + "background_option": "பின்னணி" + }, + "call_start_confirmation": { + "dialog_title": "அழைப்பைத் தொடங்குங்கள்", + "dialog_message": "நீங்கள் நிச்சயமாக அழைப்பைத் தொடங்க விரும்புகிறீர்களா?" + }, + "half_swipe_notifier": { + "notification_channel_name": "அரை ச்வைப்", + "notification_content_dm": "{friend} உங்கள் அரட்டையில் {duration} விநாடிகளுக்கு அரை ச்வைப் செய்யப்பட்டது", + "notification_content_group": "{friend} {காலத்திற்கு {குழுவில் {group} காலத்திற்கு} வினாடிகளுக்கு" + }, + "download_processor": { + "attachment_type": { + "snap": "ச்னாப்", + "sticker": "ச்டிக்கர்", + "gif": "Gif", + "external_media": "வெளிப்புற ஊடகங்கள்", + "note": "குறிப்பு", + "original_story": "அசல் கதை" + }, + "select_attachments_title": "இணைப்புகளைத் தேர்ந்தெடுக்கவும்", + "download_started_toast": "பதிவிறக்கம் தொடங்கியது", + "unsupported_content_type_toast": "ஆதரிக்கப்படாத உள்ளடக்க வகை!", + "failed_no_longer_available_toast": "மீடியா இனி கிடைக்காது", + "no_attachments_toast": "இணைப்புகள் எதுவும் கிடைக்கவில்லை!", + "already_queued_toast": "மீடியா ஏற்கனவே வரிசையில்!", + "already_downloaded_toast": "மீடியா ஏற்கனவே பதிவிறக்கம் செய்யப்பட்டுள்ளது!", + "content_saved_toast": "சேமிக்கப்பட்டது!", + "download_toast": "பதிவிறக்கம் {path} ...", + "processing_toast": "செயலாக்கம் {path} ...", + "failed_generic_toast": "பதிவிறக்கம் செய்யத் தவறிவிட்டது", + "failed_to_create_preview_toast": "முன்னோட்டத்தை உருவாக்கத் தவறிவிட்டது", + "failed_processing_toast": "தோல்வியுற்ற செயலாக்கம் {error}", + "failed_gallery_toast": "கேலரிக்கு சேமிப்பு தோல்வியுற்றது {error}", + "dash_dialog": { + "title": "டாச் மீடியாவைப் பதிவிறக்கவும்", + "download_all": "அனைத்தையும் பதிவிறக்கவும்", + "segment_text": "பிரிவு {from} - {to}" + }, + "dash_no_chapter": "அத்தியாயம் எதுவும் கிடைக்கவில்லை" + }, + "streaks_reminder": { + "notification_title": "கோடுகள்", + "notification_text": "{friend} மணிநேரங்களில் {hoursLeft} உடன் உங்கள் ச்ட்ரீக்கை இழப்பீர்கள்" + }, + "biometric_auth": { + "title": "ச்னாப்சாட்டை திறக்கவும்", + "subtitle": "ச்னாப்சாட்டை திறக்க தயவுசெய்து அங்கீகரிக்கவும்", + "unlock_button": "திறக்க" + }, + "end_to_end_encryption": { + "toolbox": { + "no_shared_key": "இந்த நண்பருடன் உங்களிடம் இன்னும் பகிரப்பட்ட மறைபொருள் இல்லை. புதிய ஒன்றைத் தொடங்க கீழே சொடுக்கு செய்க.", + "shared_key_fingerprint": "உங்கள் கைரேகை:\n\n {fingerprint}\n\n இது உங்கள் நண்பரின் கைரேகைக்கு பொருந்துமா என்பதை சரிபார்க்கவும்!", + "initiate_exchange_button": "முக்கிய பரிமாற்றத்தைத் தொடங்கவும்" + }, + "confirmation_dialogs": { + "title": "இறுதி-இறுதி குறியாக்கம்", + "confirmation_1": "எச்சரிக்கை: இது உங்கள் இருக்கும் விசையை மேலெழுதும். இந்த நண்பரிடமிருந்து மறைகுறியாக்கப்பட்ட அனைத்து செய்திகளுக்கும் அணுகலை நீங்கள் இழக்க நேரிடும். நீங்கள் தொடர விரும்புகிறீர்களா?", + "confirmation_2": "நீங்கள் தொடர விரும்புகிறீர்கள் என்பதில் உறுதியாக இருக்கிறீர்களா? பின்வாங்க இது உங்களுக்கு கடைசி வாய்ப்பு." + }, + "unencrypted_conversation_send_failure_toast": "மறைகுறியாக்கப்பட்ட மற்றும் மறைகுறியாக்கப்பட்ட உரையாடல்களுக்கு நீங்கள் மறைகுறியாக்கப்பட்ட உள்ளடக்கத்தை அனுப்ப முடியாது!", + "native_hooks_send_failure_toast": "அனுப்பத் தவறிவிட்டது! அமைப்புகளில் சொந்த கொக்கிகள் இயக்கவும்.", + "no_participants_to_encrypt_toast": "செய்திகளை குறியாக்க இந்த உரையாடலில் உங்களுக்கு நண்பர்கள் யாரும் இல்லை!", + "encryption_failed_toast": "செய்தியை குறியாக்கத் தவறிவிட்டது! மேலும் விவரங்களுக்கு LogCat ஐ சரிபார்க்கவும்.", + "accept_public_key_success_toast": "பொது விசை வெற்றிகரமாக ஏற்றுக்கொள்ளப்பட்டது!", + "accept_secret_key_success_toast": "முடிந்தது! இந்த நண்பருடன் மறைகுறியாக்கப்பட்ட செய்திகளை இப்போது அனுப்பலாம் மற்றும் பெறலாம்.", + "accept_public_key_failure_toast": "பொது விசையை ஏற்கத் தவறிவிட்டது", + "accept_secret_key_failure_toast": "ரகசிய விசையை ஏற்கத் தவறிவிட்டது", + "accept_secret_button": "ரகசியத்தை ஏற்றுக்கொள்", + "outgoing_secret_message": "முக்கிய பரிமாற்ற பதில்", + "accept_public_key_button": "பொது விசையை ஏற்றுக்கொள்ளுங்கள்", + "outgoing_pk_message": "முக்கிய பரிமாற்ற கோரிக்கை", + "incoming_pk_message": "நீங்கள் ஒரு பொது முக்கிய கோரிக்கையைப் பெற்றுள்ளீர்கள். அதை ஏற்க கீழே சொடுக்கு செய்க.", + "incoming_secret_message": "உங்கள் நண்பர் உங்கள் பொது விசையை ஏற்றுக்கொண்டார். ரகசியத்தை ஏற்க கீழே சொடுக்கு செய்க." + }, + "auto_open_snaps": { + "title": "ஆட்டோ திறந்த புகைப்படங்கள்", + "notification_content": "{count} புகைப்படங்கள் திறக்கப்பட்டன" + }, + "friend_mutation_observer": { + "notification_channel_name": "நண்பர் பிறழ்வு பார்வையாளர்", + "friend_removed": "{பயனர்பெயர் you உங்களை ஒரு நண்பராக நீக்கிவிட்டது", + "birthday_removed": "{பயனர்பெயர் அவர்களின் பிறந்தநாளை ({username}) அகற்றிவிட்டது", + "birthday_added": "{username} அவர்களின் பிறந்தநாளைச் சேர்த்தது ({birthday})", + "birthday_changed": "{username} அவர்களின் பிறந்தநாளை {ஓல்டிபிர்தே இருந்து இலிருந்து {oldBirthday} ஆக மாற்றியுள்ளது", + "bitmoji_selfie_changed": "{username} அவர்களின் பிட்மோசி செல்பியை மாற்றியுள்ளது", + "bitmoji_avatar_changed": "{username} அவர்களின் பிட்மோசி அவதாரத்தை மாற்றியுள்ளது", + "bitmoji_background_changed": "{username} அவர்களின் பிட்மோசி பின்னணியை மாற்றியுள்ளது", + "bitmoji_scene_changed": "{username} அவர்களின் பிட்மோசி காட்சியை மாற்றியுள்ளது" + }, + "material3_strings": { + "date_range_picker_end_headline": "பெறுநர்", + "date_range_picker_title": "தேதி வரம்பைத் தேர்ந்தெடுக்கவும்", + "date_picker_switch_to_calendar_mode": "நாட்காட்டி", + "date_picker_switch_to_input_mode": "உள்ளீடு", + "date_range_picker_scroll_to_previous_month": "முந்தைய மாதம்", + "date_range_picker_scroll_to_next_month": "அடுத்த மாதம்", + "date_picker_today_description": "இன்று", + "date_range_picker_day_in_range": "தேர்ந்தெடுக்கப்பட்டது", + "date_input_invalid_for_pattern": "தவறான தேதி", + "date_input_invalid_year_range": "தவறான ஆண்டு", + "date_input_invalid_not_allowed": "தவறான தேதி", + "date_range_input_invalid_range_input": "தவறான தேதி வரம்பு", + "date_range_picker_start_headline": "இருந்து" + }, + "theming_attributes": { + "sigColorChatChat": "முக்கிய நண்பர் உரை நிறத்திற்கு உணவளிக்கவும்", + "sigColorTextPrimary": "முக்கிய உரை நிறம்", + "sigColorBackgroundSurface": "பின்னணி மேற்பரப்பு நிறம்", + "sigColorChatPendingSending": "இரண்டாம் நிலை நண்பர் உரை நிறத்திற்கு உணவளிக்கவும்", + "sigColorChatSnapWithSound": "ஒலி உரை நிறத்துடன் புகைப்படங்கள்", + "sigColorChatSnapWithoutSound": "ஒலி உரை நிறம் இல்லாமல் ஒடிக்கிறது", + "actionSheetDescriptionTextColor": "செயல் பட்டியல் விளக்கம் உரை நிறம்", + "sigColorBackgroundMain": "பின்னணி நிறம்", + "actionSheetBackgroundDrawable": "செயல் பட்டியல் பின்னணி நிறம்", + "actionSheetRoundedBackgroundDrawable": "செயல் பட்டியல் சுற்று பின்னணி நிறம்", + "sigColorIconPrimary": "செயல் பட்டியல் படவுரு நிறம்", + "sigExceptionColorCameraGridLines": "கேமரா கிரிட்லைன்ச் நிறம்", + "listDivider": "வகுப்பி வண்ணத்தை பட்டியலிடுங்கள்", + "sigColorIconSecondary": "இரண்டாம் நிலை படவுரு நிறம்", + "itemShapeFillColor": "உருப்படி வடிவம் நிரப்பு நிறம்", + "ringStartColor": "ரிங் ச்டார்ட் கலர்", + "sigColorLayoutPlaceholder": "தளவமைப்பு பிளேச்ஓல்டர் நிறம்", + "scButtonColor": "ச்னாப்சாட் பொத்தான் நிறம்", + "recipientPillBackgroundDrawable": "பெறுநர் மாத்திரை பின்னணி", + "boxBackgroundColor": "பெட்டி பின்னணி நிறம்", + "editTextColor": "உரை நிறத்தைத் திருத்தவும்", + "chipBackgroundColor": "சில்லு பின்னணி நிறம்", + "recipientInputStyle": "பெறுநர் உள்ளீட்டு நடை", + "rangeFillColor": "வரம்பு நிரப்பு நிறம்", + "pstsIndicatorColor": "பிஎச்டிஎச் காட்டி நிறம்", + "pstsTabBackground": "பிஎச்டிஎச் தாவல் பின்னணி", + "pstsDividerColor": "PSTS வகுப்பி நிறம்", + "statusBarBackground": "நிலை பட்டி பின்னணி நிறம்", + "strokeColor": "பக்கவாதம் நிறம்", + "tabTextColor": "தாவல் உரை நிறம்", + "statusBarForeground": "நிலை பட்டி முன்புற நிறம்", + "storyReplayViewRingColor": "கதை மறுபதிப்பு காட்சி வளைய நிறம்", + "sigColorButtonPrimary": "முதன்மை பொத்தான் நிறம்", + "sigColorStoryRingFriendsFeedStoryRing": "கதை வளைய நண்பர்கள் கதை வளைய நிறத்தை உண்பார்கள்", + "sigColorBaseAppYellow": "அடிப்படை பயன்பாடு மஞ்சள் நிறம்", + "sigColorBackgroundSurfaceTranslucent": "கசியும் பின்னணி மேற்பரப்பு நிறம்", + "sigColorStoryRingDiscoverTabThumbnailStoryRing": "ச்டோரி ரிங் டிச்கவர் தாவல் சிறுபடம் கதை மோதிரம் நிறம்", + "listBackgroundDrawable": "உரையாடல் பட்டியல் பின்னணி", + "sigColorChatConversationsLine": "உரையாடல்கள் வரி நிறம்", + "ringColor": "வளைய நிறம்" + }, + "send_override_dialog": { + "title": "மீடியாவை {type அச் ஆக அனுப்பவும்", + "duration": "காலம்: {duration}", + "saveable_snap_hint": "அரட்டையில் ச்னாப் சேமிக்கக்கூடியதாக ஆக்குங்கள்", + "unlimited_duration": "வரம்பற்றது" + }, + "chat_action_menu": { + "preview_button": "முன்னோட்டம்", + "download_button": "பதிவிறக்கம்", + "delete_logged_message_button": "உள்நுழைந்த செய்தியை நீக்கு", + "show_chat_edit_history": "அரட்டை திருத்து வரலாற்றைக் காட்டு", + "convert_message": "செய்தியை மாற்றவும்", + "edit_message": "செய்தியைத் திருத்தவும்" + } +} diff --git a/common/src/main/assets/lang/tr_TR.json b/common/src/main/assets/lang/tr_TR.json new file mode 100644 index 0000000000..1f892b01e3 --- /dev/null +++ b/common/src/main/assets/lang/tr_TR.json @@ -0,0 +1,1658 @@ +{ + "setup": { + "dialogs": { + "select_language": "Dil Seçin", + "save_folder": "SnapEnhance, Snapchat'ten Medya indirmek ve Kaydetmek için Depolama izinleri gerektirir.\nLütfen medyanın indirileceği konumu seçin.", + "select_save_folder_button": "Klasör Seç" + }, + "mappings": { + "dialog": "Eşlemeler oluşturuluyor, bu biraz zaman alabilir ...", + "generate_failure_no_snapchat": "SnapEnhance Snapchat'i algılayamadı, lütfen Snapchat'i yeniden yüklemeyi deneyin.", + "generate_failure": "Eşlemeleri oluşturmaya çalışırken bir hata oluştu, lütfen tekrar deneyin." + }, + "permissions": { + "dialog": "Devam etmek için aşağıdaki gerekliliklere uymanız gerekir:", + "notification_access": "Bildirim Erişimi", + "battery_optimization": "Pil Optimizasyonu", + "display_over_other_apps": "Diğer Uygulamaların Üzerinde Göster", + "request_button": "İstek" + } + }, + "manager": { + "routes": { + "features": "Özellikler", + "home": "Ana Sayfa", + "home_settings": "Ayarlar", + "home_logs": "Kayıtlar", + "social": "Sosyal", + "scripts": "Scriptler", + "tasks": "Görevler", + "logger_history": "Kaydedici Geçmişi", + "logged_stories": "Kaydedilen Hikayeler", + "manage_scope": "Kapsamı Yönet", + "messaging_preview": "Önizleme", + "friend_tracker": "Arkadaş Takibi", + "edit_rule": "Kuralı Düzenle", + "file_imports": "Dosya İçe Aktarımları", + "better_location": "Daha İyi Konum", + "edit_theme": "Temayı Düzenle", + "manage_repos": "Depoları Yönet", + "theming": "Temalandırma", + "manage_rule_feature": "Kural Yönetme Özelliği" + }, + "sections": { + "features": { + "disabled": "Devre Dışı", + "export_option": "Dışa Aktar", + "import_option": "İçe Aktar", + "reset_option": "Sıfırla", + "config_import_success_toast": "Konfigürasyon başarıyla içe aktarıldı", + "config_import_failure_toast": "Konfigürasyon içe aktarılamadı {error}", + "saved_config_snackbar": "Konfigürasyon kaydedildi", + "config_export_success_toast": "Konfigürasyon başarıyla dışa aktarıldı", + "config_export_failure_toast": "Konfigürasyon dışa aktarılamadı {error}" + }, + "social": { + "streaks_expiration_short": "{hours} saat", + "friends_tab": "Arkadaşlar", + "empty_hint": "(boş)", + "groups_tab": "Gruplar" + }, + "tasks": { + "no_tasks": "Görev yok", + "remove_selected_tasks_title": "Seçili görevleri kaldırmak istediğinizden emin misiniz?", + "remove_all_tasks_title": "Tüm görevleri kaldırmak istediğinizden emin misiniz?", + "delete_files_option": "Ayrıca dosyaları sil", + "remove_selected_tasks_confirm": "{count} görev kaldırılsın mı?", + "merge_files_toast": "{count} dosya birleştiriliyor", + "remove_all_tasks_confirm": "Tüm görevler kaldırılsın mı?", + "failed_to_open_file": "Dosya açılamadı" + }, + "home": { + "update_title": "SnapEnhance Güncellemesi", + "update_button": "İndir", + "update_content": "Sürüm {version} kullanılabilir!", + "debug_build_summary_content": "Sürüm {versionName} ({versionCode})", + "quick_actions_title": "Hızlı Eylemler", + "version_title": "rhunk tarafından - v{versionName}", + "debug_build_summary_title": "SnapEnhance'in bir hata ayıklama versiyonunu çalıştırıyorsunuz", + "debug_build_summary_date": "Oluşturma tarihi: {date} ({days} gün önce)" + }, + "home_logs": { + "clear_logs_button": "Günlükleri Temizle", + "no_logs_hint": "Mevcut günlük yok", + "export_logs_button": "Günlükleri Dışa Aktar", + "saving_logs_toast": "Günlükler kaydediliyor, bu biraz zaman alabilir ...", + "saved_logs_success_toast": "Günlükler başarıyla kaydedildi", + "saved_logs_failure_toast": "Günlükler kaydedilemedi" + }, + "home_settings": { + "actions_title": "Eylemler", + "message_logger_title": "Mesaj Kaydedici", + "debug_title": "Hata Ayıklama", + "success_toast": "Tamam!", + "message_logger_summary": "{messageCount} mesajlar\n{storyCount} hikayeler", + "export_button": "Dışa Aktar", + "clear_button": "Temizle", + "view_logger_history_button": "Günlük Geçmişini Görüntüle" + }, + "manage_scope": { + "logged_stories_button": "Kaydedilen Hikayeleri Göster", + "e2ee_title": "Uçtan Uca Şifreleme", + "rules_title": "Kurallar", + "not_found": "Bulunamadı", + "streaks_title": "Seriler", + "streaks_length_text": "Uzunluk: {length}", + "streaks_expiration_text": "{eta} içinde sona erecek", + "streaks_expiration_text_expired": "Süresi doldu", + "reminder_button": "Hatırlatıcı Ayarla", + "participants_text": "{sayı} katılımcı", + "delete_scope_confirm_dialog_title": "Bir {scope} silmek istediğinizden emin misiniz?" + }, + "logged_stories": { + "story_failed_to_load": "Yükleme başarısız oldu", + "no_stories": "Hikaye bulunamadı", + "save_from_cache_button": "Önbellekten Kaydet" + }, + "messaging_preview": { + "bridge_connection_failed": "Köprüye bağlanılamadı. Snapchat'in arka planda çalıştığından emin olun", + "message_fetch_failed": "Mesajlar alınamadı", + "save_all_option": "Tümünü Kaydet", + "unsave_selection_option": "Seçimi Kaydetmeyi Kaldır", + "unsave_all_option": "Tümünü Kaydet'i Kaldır", + "bridge_init_failed": "Mesajlaşma köprüsü başlatılamadı. Snapchat'in arka planda çalıştığından emin olun", + "no_message_hint": "Mesaj yok", + "save_selection_option": "Seçimi Kaydet", + "mark_selection_as_seen_option": "Seçilen Snap'i görüldü olarak işaretle", + "mark_all_as_seen_option": "Tüm Snap'leri görüldü olarak işaretle", + "delete_selection_option": "Seçimi Sil", + "delete_all_option": "Tümünü Sil" + }, + "logger_history": { + "list_friend_format": "Arkadaş {name}", + "list_group_format": "Grup {name}", + "no_more_messages": "Daha fazla mesaj yok", + "reverse_order_checkbox": "Ters Sıralama", + "chat_attachment": "Ek {index}", + "empty_message": "Boş Sohbet Mesajı", + "message_parse_failed": "Mesaj ayrıştırılamadı", + "unknown_sender": "Bilinmeyen Gönderen", + "download_attachment_failed_toast": "Ek indirilemedi" + }, + "file_imports": { + "import_file_button": "Dosya İçe Aktar", + "file_not_found": "Dosya bulunamadı", + "file_import_failed": "Dosya içe aktarılamadı: {error}", + "file_imported": "Dosya başarıyla içe aktarıldı", + "file_delete_failed": "Dosya silinemedi", + "no_files_hint": "Burada Snapchat'te kullanmak için dosyaları içe aktarabilirsiniz. Bir dosyayı içe aktarmak için aşağıdaki düğmeye basın." + }, + "better_location": { + "save_coordinates_dialog_title": "Koordinatları Kaydet", + "saved_name_dialog_hint": "İsim Kaydedildi", + "choose_location_button": "Konum Seçin", + "teleport_to_friend_button": "Arkadaşa Işınlan", + "no_saved_coordinates_hint": "Kayıtlı koordinat yok", + "delete_dialog_title": "Kayıtlı Koordinatı Sil", + "delete_dialog_message": "Bu kayıtlı koordinatı silmek istediğinizden emin misiniz?", + "teleport_to_friend_title": "Arkadaşa Işınlan", + "search_bar": "Ara", + "no_friends_map": "Haritada arkadaş yok", + "no_friends_found": "Arkadaş bulunamadı", + "save_dialog_button": "Kaydet", + "spoofed_coordinates_title": "{latitude} Enlem, {longitude} Boylam", + "spoof_location_toggle": "Sahte Konum", + "saved_coordinates_title": "Koordinatlar Kaydedildi", + "longitude_dialog_hint": "Boylam", + "latitude_dialog_hint": "Enlem", + "suspend_location_updates": "Konum Güncellemelerini Askıya Al" + }, + "theming": { + "no_themes_hint": "Tema bulunamadı" + }, + "manage_rule_feature": { + "disable_state_subtext": "Hiçbir arkadaş/grup etkilenmeyecektir", + "whitelist_state_option": "Kimse hariç ...", + "whitelist_state_subtext": "Bu kuraldan yalnızca {count} arkadaş/grup etkilenecektir", + "whitelist_state_button": "İzin verilen arkadaşları/grupları seçin", + "blacklist_state_option": "Herkes hariç...", + "blacklist_state_subtext": "Bu kuraldan {count} arkadaş/grup dışındaki herkes etkilenecektir", + "blacklist_state_button": "Hariç tutulan arkadaşları/grupları seçin", + "clear_list_button": "Arkadaş/grup listesini temizle", + "dialog_clear_confirmation_text": "Listeyi temizlemek istediğinizden emin misiniz?", + "disable_state_option": "Devre dışı" + } + }, + "dialogs": { + "add_friend": { + "title": "Arkadaş veya Grup Ekle", + "search_hint": "Ara", + "fetch_error": "Veri alınamadı", + "category_groups": "Gruplar", + "category_friends": "Arkadaşlar" + }, + "scripting_warning": { + "content": "SnapEnhance, cihazınızda kullanıcı tanımlı kodun yürütülmesine izin veren bir komut dosyası aracı içerir. Çok dikkatli olun ve modülleri yalnızca bilinen, güvenilir kaynaklardan yükleyin. Yetkisiz veya doğrulanmamış modüller sisteminiz için güvenlik riskleri oluşturabilir.", + "title": "Uyarı" + }, + "reset_config": { + "title": "Konfigürasyonu sıfırla", + "content": "Konfigürasyonu sıfırlamak istediğinizden emin misiniz?", + "success_toast": "Konfigürasyon başarıyla sıfırlandı" + }, + "messaging_action": { + "title": "İşlenecek içerik türlerini seçin", + "select_all_button": "Tümünü Seç" + }, + "file_imports": { + "no_files_settings_hint": "Dosya bulunamadı. Dosya İçe Aktarma bölümünde gerekli dosyaları içe aktardığınızdan emin olun", + "settings_select_file_hint": "İçe aktarılan bir dosya seçin" + }, + "export_config": { + "title": "Hassas Verileri Dışa Aktar?", + "content": "Konfigürasyonu hassas verilerle dışa aktarmak istiyor musunuz? (Konum koordinatları vb. gibi)" + } + } + }, + "rules": { + "modes": { + "blacklist": "Kara liste modu", + "whitelist": "Beyaz liste modu" + }, + "properties": { + "auto_download": { + "name": "Otomatik İndirme", + "description": "Snap'leri görüntülerken otomatik olarak indir", + "options": { + "blacklist": "Otomatik İndirmelerden Hariç Tut", + "whitelist": "Otomatik İndirme" + } + }, + "stealth": { + "name": "Gizli Mod", + "description": "Herhangi birinin Snap'lerini/Sohbetlerini ve konuşmalarını açtığınızı bilmesini engeller", + "options": { + "blacklist": "Gizli Moddan Hariç Tut", + "whitelist": "Gizli mod" + } + }, + "auto_save": { + "name": "Otomatik Kaydet", + "description": "Sohbet mesajlarını görüntülerken kaydeder", + "options": { + "blacklist": "Otomatik kaydetmelerden hariç tut", + "whitelist": "Otomatik kaydetme" + } + }, + "hide_friend_feed": { + "name": "Arkadaş Akışından Gizle" + }, + "e2e_encryption": { + "name": "E2E Şifreleme Kullanın" + }, + "pin_conversation": { + "name": "Konuşmayı Sabitle" + }, + "unsaveable_messages": { + "name": "Kaydedilemeyen Mesajlar", + "options": { + "blacklist": "Kaydedilemeyen Mesajlardan Hariç Tut", + "whitelist": "Kaydedilemeyen Mesajlar" + }, + "description": "Mesajların diğer kişiler tarafından sohbete kaydedilmesini önler" + }, + "auto_open_snaps": { + "description": "Snap'leri alırken otomatik olarak açar", + "options": { + "whitelist": "Snap'leri Otomatik Aç", + "blacklist": "Otomatik Açılan Snap'lerden Hariç Tut" + }, + "name": "Snap'leri Otomatik Aç" + } + }, + "toasts": { + "enabled": "{ruleName} etkin", + "disabled": "{ruleName} devre dışı" + } + }, + "features": { + "notices": { + "unstable": "⚠ Stabil Değil", + "ban_risk": "⚠ Bu özellik banlanmanıza neden olabilir", + "internal_behavior": "⚠ Bu Snapchat'in dahili davranışını bozabilir" + }, + "properties": { + "downloader": { + "name": "İndirici", + "description": "Snapchat Media'yı İndirin", + "properties": { + "save_folder": { + "name": "Kayıt Klasörü", + "description": "Tüm medyanın indirileceği dizini seçin" + }, + "auto_download_sources": { + "name": "Otomatik İndirme Kaynakları", + "description": "Otomatik olarak indirilecek kaynakları seçin" + }, + "prevent_self_auto_download": { + "name": "Kendi Kendine Otomatik İndirmeyi Önle", + "description": "Kendi Snap'lerinizin otomatik olarak indirilmesini önler" + }, + "path_format": { + "name": "Yol Formatı", + "description": "Dosya Yolu Formatını Belirleme" + }, + "allow_duplicate": { + "name": "Yinelenmesine İzin Ver", + "description": "Aynı medyanın birden çok kez indirilebilmesini sağlar" + }, + "merge_overlays": { + "name": "Kaplamaları Birleştirme", + "description": "Bir Snap'in Metnini ve ortamını tek bir dosyada birleştirir" + }, + "force_image_format": { + "name": "Görüntü Formatını Zorla", + "description": "Görüntülerin belirli bir Formatta kaydedilmesini zorlar" + }, + "force_voice_note_format": { + "name": "Ses Biçimini Zorla", + "description": "Sesli Notların belirli bir Formatta kaydedilmesini zorlar" + }, + "download_profile_pictures": { + "name": "Profil Resimlerini İndir", + "description": "Profil Resimlerini profil sayfasından indirmenize olanak sağlar" + }, + "ffmpeg_options": { + "name": "FFmpeg Ayarları", + "description": "Ek FFmpeg seçeneklerini belirleyin", + "properties": { + "threads": { + "name": "İş Parçacıkları", + "description": "Kullanılacak iş parçacığı miktarı" + }, + "preset": { + "name": "Ön Ayar", + "description": "Dönüştürme hızını ayarlayın" + }, + "constant_rate_factor": { + "name": "Sabit Hız Faktörü", + "description": "Video kodlayıcı için sabit hız faktörünü ayarlayın\nlibx264 için 0 ile 51 arasında" + }, + "video_bitrate": { + "name": "Video Bit Hızı", + "description": "Video bit hızını ayarlayın (kbps)" + }, + "audio_bitrate": { + "name": "Ses Bit Hızı", + "description": "Ses bit hızını ayarlayın (kbps)" + }, + "custom_video_codec": { + "name": "Özel Video Codec'i", + "description": "Özel bir Video Codec'i ayarlayın (örn. libx264)" + }, + "custom_audio_codec": { + "name": "Özel Ses Codec'i", + "description": "Özel bir Ses Codec'i ayarlayın (örn. AAC)" + } + } + }, + "logging": { + "name": "Günlük Kaydı", + "description": "Medya indirilirken tost mesajlarını gösterir" + }, + "custom_path_format": { + "description": "İndirilen medya için özel bir yol biçimi belirtin\n\nKullanılabilir değişkenler:\n - %username%\n - %source%\n - %hash%\n - %date_time%", + "name": "Özel Yol Formatı" + }, + "opera_download_button": { + "description": "Bir Snap görüntülerken sağ üst köşeye bir indirme düğmesi ekler.\nDüğmelere uzun basıldığında indirmeye zorlar", + "name": "Opera İndirme Düğmesi" + }, + "download_context_menu": { + "name": "İndirme Bağlam Menüsü", + "description": "Bağlam menüsünü kullanarak bir sohbetten veya hikayeden mesajları indirmenize/önizlemenize olanak tanır.\nDüğmelere uzun basmak indirmeye zorlar" + }, + "auto_download_voice_notes": { + "name": "Sesli Notları Otomatik İndir", + "description": "Sesli notları oynatırken otomatik olarak indirir" + } + } + }, + "user_interface": { + "name": "Kullanıcı Arayüzü", + "description": "Snapchat'in görünümünü ve hissini değiştirin", + "properties": { + "enable_app_appearance": { + "name": "Uygulama Görünüm Ayarlarını Etkinleştir", + "description": "Gizli Uygulama Görünümü Ayarını etkinleştirir\nDaha yeni Snapchat sürümlerinde gerekli olmayabilir" + }, + "friend_feed_message_preview": { + "name": "Arkadaş Akışı Mesaj Önizlemesi", + "description": "Arkadaş Akışındaki son mesajların önizlemesini gösterir", + "properties": { + "amount": { + "name": "Miktar", + "description": "Önizlemesi yapılacak mesaj miktarı" + } + } + }, + "bootstrap_override": { + "name": "Önyükleme Geçersiz Kılma", + "description": "Kullanıcı arayüzü önyükleme ayarlarını geçersiz kılar", + "properties": { + "app_appearance": { + "name": "Uygulama Görünümü", + "description": "Kalıcı bir Uygulama Görünümü ayarlar" + }, + "home_tab": { + "name": "Ana Sayfa Sekmesi", + "description": "Snapchat açılırken başlangıç sekmesini geçersiz kılar" + } + } + }, + "map_friend_nametags": { + "name": "Geliştirilmiş Arkadaş Haritası İsim Etiketleri", + "description": "Snapmap'teki arkadaşların İsim Etiketlerini iyileştirir" + }, + "streak_expiration_info": { + "name": "Seri Sona Erme Bilgisini Göster", + "description": "Seriler sayacının yanında bir Seri Sona Erme zamanlayıcısı gösterir" + }, + "hide_friend_feed_entry": { + "name": "Arkadaş Akışı Girişini Gizle", + "description": "Arkadaş Akışı'ndan belirli bir arkadaşı gizler\nBu özelliği yönetmek için sosyal sekmesini kullanın" + }, + "hide_streak_restore": { + "name": "Seri Geri Yüklemeyi Gizle", + "description": "Arkadaş akışındaki Seri Geri Yükle düğmesini gizler" + }, + "hide_ui_components": { + "name": "Kullanıcı Arayüzü Bileşenlerini Gizle", + "description": "Hangi kullanıcı arayüzü bileşenlerinin gizleneceğini seçin" + }, + "disable_spotlight": { + "name": "Spotlight'ı Devre Dışı Bırak", + "description": "Spotlight sayfasını devre dışı bırakır" + }, + "friend_feed_menu_buttons": { + "name": "Arkadaş Akışı Menü Düğmeleri", + "description": "Arkadaş Akışı Menü Çubuğunda hangi düğmelerin gösterileceğini seçin" + }, + "enable_friend_feed_menu_bar": { + "name": "Arkadaş Akışı Menü Çubuğu", + "description": "Yeni Arkadaş Akışı Menüsü Çubuğunu etkinleştirir" + }, + "opera_media_quick_info": { + "description": "Opera görüntüleyici içerik menüsünde oluşturma tarihi gibi medyanın yararlı bilgilerini gösterir", + "name": "Opera Medya Hızlı Bilgi" + }, + "vertical_story_viewer": { + "name": "Dikey Hikaye Görüntüleyici", + "description": "Tüm hikayeler için dikey hikaye görüntüleyiciyi etkinleştirir" + }, + "old_bitmoji_selfie": { + "name": "Eski Bitmoji Selfie'si", + "description": "Eski Snapchat sürümlerindeki Bitmoji Selfie'lerini geri getirir" + }, + "prevent_message_list_auto_scroll": { + "name": "Mesaj Listesi Otomatik Kaydırmayı Önle", + "description": "Mesaj gönderirken/alırken mesaj listesinin en alta kaymasını engeller" + }, + "edit_text_override": { + "name": "Metin Geçersiz Kılmayı Düzenle", + "description": "Metin alanı davranışını geçersiz kılar" + }, + "snap_preview": { + "name": "Snap Önizleme", + "description": "Sohbette açılmayan Snap'lerin yanında küçük bir önizleme görüntüler" + }, + "hide_story_suggestions": { + "name": "Hikaye Önerilerini Gizle", + "description": "Hikayeler sayfasından önerileri kaldırır" + }, + "stealth_mode_indicator": { + "name": "Gizli Mod Göstergesi", + "description": "Gizli modundaki konuşmaların yanına bir 👻 emojisi ekler" + }, + "message_indicators": { + "name": "Mesaj Göstergeleri", + "description": "Mesajlara belirli gösterge simgeleri ekler\nNot: Göstergeler %100 doğru olmayabilir" + }, + "auto_close_friend_feed_menu": { + "name": "Arkadaş Akışı Menüsünü Otomatik Olarak Kapat", + "description": "Bir ayar düğmesine basıldıktan sonra Arkadaş Akışı Menüsünü otomatik olarak kapatır" + }, + "custom_theme": { + "name": "Özel Tema", + "description": "Snapchat'in Renklerini Özelleştirme\nNot: Koyu bir tema seçerseniz (Amoled gibi), daha iyi sonuçlar için Snapchat ayarlarında karanlık modu etkinleştirmeniz gerekebilir" + } + } + }, + "messaging": { + "name": "Mesajlaşma", + "description": "Arkadaşlarınızla etkileşim şeklinizi değiştirin", + "properties": { + "anonymous_story_viewing": { + "name": "Anonim Hikaye Görüntüleme", + "description": "Herhangi birinin hikayelerini gördüğünüzü bilmesini engeller" + }, + "hide_bitmoji_presence": { + "name": "Bitmoji'yi Gizle", + "description": "Sohbet sırasında Bitmoji'nizin görünmesini engeller" + }, + "hide_typing_notifications": { + "name": "Yazıyor Bildirimlerini Gizle", + "description": "Herhangi birinin mesaj yazdığınızı bilmesini engeller" + }, + "unlimited_snap_view_time": { + "name": "Sınırsız Snap Görüntüleme Süresi", + "description": "Snap'leri görüntülemek için Zaman Sınırını kaldırır" + }, + "disable_replay_in_ff": { + "name": "AA'da Tekrar Oynatmayı Devre Dışı Bırak", + "description": "Arkadaş Akışından uzun basarak yeniden oynatma özelliğini devre dışı bırakır" + }, + "prevent_message_sending": { + "name": "Mesaj Gönderimini Önleme", + "description": "Belirli mesaj türlerinin gönderilmesini engeller" + }, + "better_notifications": { + "name": "Daha İyi Bildirimler", + "description": "Alınan bildirimlere daha fazla bilgi ekler", + "properties": { + "group_notifications": { + "name": "Grup Bildirimleri", + "description": "Bildirimleri tek bir bildirimde grupla" + }, + "media_preview": { + "description": "Bildirimde seçilen medya türlerinin önizlemesini gösterir", + "name": "Medya Önizlemesi" + }, + "friend_add_source": { + "name": "Arkadaş Ekleme Kaynağı", + "description": "Bildirimde bir arkadaşlık isteğinin kaynağını gösterir" + }, + "download_button": { + "name": "İndirme Düğmesi", + "description": "Bildirimden medya indirmenize izin verir" + }, + "chat_preview": { + "name": "Sohbet Önizlemesi", + "description": "Bildirimde alınan mesajların önizlemesini gösterir" + }, + "media_caption": { + "name": "Medya Başlığı", + "description": "Bildirimdeki ekli medya başlığını gösterir" + }, + "stacked_media_messages": { + "name": "Yığılmış Medya Mesajları", + "description": "Önizleme yapılamadığında birden fazla medya mesajını tek bir metin bildiriminde birleştirir. Sohbet Önizleme ile birlikte kullanın" + }, + "reply_button": { + "name": "Yanıt Düğmesi", + "description": "Bildirime bir yanıt düğmesi ekler" + }, + "mark_as_read_button": { + "name": "Okundu Olarak İşaretle Düğmesi", + "description": "Bir mesajı bildirimden okundu olarak işaretlemenizi sağlar" + }, + "mark_as_read_and_save_in_chat": { + "name": "Okundu Olarak İşaretle ve Sohbete Kaydet", + "description": "Bildirime okundu olarak işaretle ve sohbete kaydet düğmesi ekler" + }, + "smart_replies": { + "description": "Bildirimlere önerilen yanıtlar ekler (Android 10+). Yanıtla Düğmesi ile birlikte kullanın", + "name": "Akıllı Yanıtlar" + } + } + }, + "notification_blacklist": { + "name": "Bildirim Kara Listesi", + "description": "Engellenecek bildirimleri seçin" + }, + "message_logger": { + "name": "Mesaj Kaydedici", + "description": "Mesajların silinmesini önler", + "properties": { + "message_filter": { + "name": "Mesaj Filtresi", + "description": "Hangi mesajların günlüğe kaydedileceğini seçin (tüm mesajlar için boş bırakın)" + }, + "auto_purge": { + "description": "Belirtilen süreden daha eski olan önbelleğe alınmış mesajları otomatik olarak siler", + "name": "Otomatik Temizleme" + }, + "keep_my_own_messages": { + "name": "Kendi Mesajlarımı Sakla", + "description": "Kendi mesajlarınızın silinmesini önler" + } + } + }, + "auto_save_messages_in_conversations": { + "name": "Mesajları Otomatik Kaydet", + "description": "Görüşmelerdeki her mesajı otomatik olarak kaydeder" + }, + "gallery_media_send_override": { + "name": "Galeri Medya Göndermesini Geçersiz Kılma", + "description": "Galeri'den gönderirken medya kaynağını taklit eder" + }, + "call_start_confirmation": { + "name": "Arama Başlatma Onayı", + "description": "Arama başlatırken bir onay iletişim kutusu gösterir" + }, + "half_swipe_notifier": { + "properties": { + "min_duration": { + "name": "Minimum Süre", + "description": "Yarım kaydırmanın minimum süresi (saniye cinsinden)" + }, + "max_duration": { + "description": "Yarım kaydırmanın maksimum süresi (saniye cinsinden)", + "name": "Maksimum Süre" + } + }, + "name": "Yarım Kaydırma Bildiricisi", + "description": "Birisi konuşmaya yarım kaydırma yaptığında sizi bilgilendirir" + }, + "bypass_screenshot_detection": { + "description": "Snapchat'in ekran görüntüsü aldığınızı algılamasını engeller", + "name": "Ekran Görüntüsü Algılamayı Kapat" + }, + "strip_media_metadata": { + "description": "Mesaj olarak göndermeden önce medyanın meta verilerini kaldırır", + "name": "Medya Meta Verilerini Kaldır" + }, + "bypass_message_retention_policy": { + "name": "Mesaj Saklama Politikasını Atlayın", + "description": "Mesajların görüntülendikten sonra silinmesini önler" + }, + "prevent_story_rewatch_indicator": { + "name": "Hikaye Tekrar İzleme Göstergesini Önle", + "description": "Herhangi birinin hikayelerini tekrar izlediğinizi bilmesini engeller" + }, + "hide_peek_a_peek": { + "description": "Bir sohbete yarım kaydırma yaptığınızda bildirim gönderilmesini önler", + "name": "Peek-a-Peek'i Gizle" + }, + "loop_media_playback": { + "name": "Medya Oynatmayı Döngüye Al", + "description": "Snap'leri / Hikayeleri görüntülerken medya oynatmayı döngüye alır" + }, + "bypass_message_action_restrictions": { + "name": "Mesaj Eylemi Kısıtlamalarını Atla", + "description": "Bir snap'i açmadan tepki vermenizi veya kaydedilemeyen bir mesajı kaydetmenizi sağlar" + }, + "remove_groups_locked_status": { + "name": "Grupların Kilitli Durumunu Kaldır", + "description": "Atıldıktan sonra grup bilgilerini görüntülemenizi sağlar" + }, + "auto_mark_as_read": { + "name": "Otomatik Okundu Olarak İşaretle", + "description": "Gizli Mod etkinleştirildiğinde bile mesajları/snapleri otomatik olarak okundu olarak işaretler" + }, + "friend_mutation_notifier": { + "name": "Arkadaş Değişimi Bildiricisi", + "description": "Bir arkadaşınızın profilinde bir değişiklik olduğunda size bildirir" + }, + "unlimited_conversation_pinning": { + "description": "Sınırsız sayıda konuşmayı yerel olarak sabitlemenizi sağlar", + "name": "Sınırsız Konuşma Sabitleme" + }, + "mark_snap_as_seen_button": { + "name": "Snap'i Görüldü Olarak İşaretle Düğmesi", + "description": "Bir Snap'i görüntülerken görüldü olarak işaretlemek için bir düğme ekler.\nBu, Gizli Mod etkinleştirildiğinde bile çalışacaktır" + }, + "skip_when_marking_as_seen": { + "name": "Görüldü Olarak İşaretlerken Atla", + "description": "Bir Snap'i görüldü olarak işaretlerken otomatik olarak bir sonraki Snap'e atlar.\nSnap'i Görüldü Olarak İşaretle Düğmesi ile birlikte kullanın" + } + } + }, + "global": { + "name": "Genel", + "description": "Genel Snapchat Ayarlarını Değiştirin", + "properties": { + "snapchat_plus": { + "name": "Snapchat Plus", + "description": "Snapchat Plus özelliklerini etkinleştirir\nBazı Sunucu taraflı özellikler çalışmayabilir" + }, + "auto_updater": { + "name": "Otomatik Güncelleyici", + "description": "Yeni güncellemeleri otomatik olarak kontrol eder" + }, + "disable_metrics": { + "name": "Ölçümleri Devre Dışı Bırak", + "description": "Snapchat'e belirli analitik verilerin gönderilmesini engeller" + }, + "block_ads": { + "name": "Reklamları Engelle", + "description": "Reklamların görüntülenmesini engeller" + }, + "bypass_video_length_restriction": { + "name": "Video Uzunluğu Kısıtlamalarını Atlayın", + "description": "Tek: tek bir video gönderir\nBöl: videoları düzenledikten sonra böl" + }, + "disable_google_play_dialogs": { + "name": "Google Play Hizmetleri İletişim Kutularını Devre Dışı Bırak", + "description": "Google Play Hizmetleri mevcutluk iletişim kutularının gönderilmesini önlemer" + }, + "disable_snap_splitting": { + "name": "Snap Bölmeyi Devre Dışı Bırak", + "description": "Snap'lerin birden fazla parçaya bölünmesini önler\nGönderdiğiniz resimler videoya dönüşecek" + }, + "disable_confirmation_dialogs": { + "name": "Onay İletişim Kutularını Devre Dışı Bırak", + "description": "Seçilen eylemleri otomatik olarak onaylar" + }, + "spotlight_comments_username": { + "name": "Spotlight Yorumlar Kullanıcı Adı", + "description": "Spotlight yorumlarında yazar kullanıcı adını gösterir" + }, + "disable_story_sections": { + "name": "Hikaye Bölümlerini Devre Dışı Bırak", + "description": "Hikayeler sayfasından bölümleri kaldırır\nDüzgün çalışması için yenileme gerekebilir" + }, + "disable_memories_snap_feed": { + "description": "Kamerada yukarı kaydırdığınızda Snapchat'in son anıları göstermesini engeller", + "name": "Anılar Snap Feed'ini Devre Dışı Bırak" + }, + "default_video_playback_rate": { + "name": "Varsayılan Video Oynatma Hızı", + "description": "Videoların oynatılması için varsayılan hızı ayarlar\nDeğer 0.1 ile 4.0 arasında olmalıdır" + }, + "video_playback_rate_slider": { + "name": "Video Oynatma Hızı Kaydırıcısı", + "description": "Video oynatma hızını değiştirmek için opera içerik menüsüne bir kaydırıcı ekler\nNot: Değişiklikler yalnızca sonraki videolar için geçerlidir" + }, + "default_volume_controls": { + "name": "Varsayılan Ses Kontrolleri", + "description": "Snapchat'i sistem ses kontrollerini kullanmaya zorlar" + }, + "disable_permission_requests": { + "name": "İzin İsteklerini Devre Dışı Bırak", + "description": "Snapchat'in belirli izinleri istemesini engeller" + }, + "better_location": { + "description": "Snapchat Konumunu İyileştirir", + "properties": { + "spoof_location": { + "name": "Sahte Konum", + "description": "Konumunuzu belirli bir konumla değiştirir" + }, + "coordinates": { + "description": "Sahte konumun koordinatlarını ayarlayın", + "name": "Koordinatlar" + }, + "always_update_location": { + "name": "Konumu Her Zaman Güncelle", + "description": "GPS verisi alınmasa bile Snapchat'i konumu güncellemeye zorlama" + }, + "suspend_location_updates": { + "name": "Konum Güncellemelerini Askıya Al", + "description": "Konumunuzun güncellenmesini engeller" + }, + "spoof_battery_level": { + "name": "Sahte Pil Seviyesi", + "description": "Cihazınızın pil seviyesini harita üzerinde gösterir\nDeğer 0 ile 100 arasında olmalıdır" + }, + "spoof_headphones": { + "name": "Sahte Kulaklıklar", + "description": "Harita üzerinde müzik dinleme durumunu taklit eder" + }, + "walk_radius": { + "name": "Yürüyüş Yarıçapı", + "description": "Bu yarıçap (ft) içinde haritada rastgele dolaşın" + }, + "show_battery_level": { + "name": "Pil Seviyesini Göster", + "description": "Arkadaşlarınızın pil seviyesini haritada gösterir" + } + }, + "name": "Daha İyi Konum" + }, + "hide_active_music": { + "name": "Aktif Müziği Gizle", + "description": "Snapchat'in müzik dinlediğinizi bilmesini engeller\nBu sayede müzik dinlerken ses kontrol düğmelerini kullanarak fotoğraf çekebilirsiniz" + }, + "disable_custom_tabs": { + "name": "Özel Sekmeleri Devre Dışı Bırak", + "description": "Bağlantıları Web Tarayıcı yerine desteklenen uygulamalarda açar" + }, + "media_upload_quality": { + "name": "Medya Yükleme Kalitesi", + "description": "Medya yükleme kalitesini geçersiz kılar", + "properties": { + "force_video_upload_source_quality": { + "name": "Video Yükleme Kaynak Kalitesini Zorla", + "description": "Snapchat'i video yüklerken kaynak kalitesini kullanmaya zorlar\nLütfen bunun medyadan meta verileri kaldırmayabileceğini unutmayın" + }, + "disable_image_compression": { + "name": "Görüntü Sıkıştırmayı Devre Dışı Bırak", + "description": "Medya yüklerken görüntü sıkıştırmayı devre dışı bırakır" + }, + "custom_image_upload_format": { + "name": "Özel Resim Yükleme Formatı", + "description": "Özel bir resim yükleme biçimi ayarlar\nEn iyi kalite için kayıpsız bir format (PNG gibi) seçin" + } + } + }, + "disable_telecom_framework": { + "description": "Snapchat'in Android Telecom çerçevesini kullanmasını engeller\nBu, görüşme sırasında müzik dinlemenizi sağlar", + "name": "Telekom Çerçevesini Devre Dışı Bırak" + } + } + }, + "rules": { + "name": "Kurallar", + "description": "Tek tek kişiler için Otomatik Özellikleri Yönetme" + }, + "camera": { + "name": "Kamera", + "description": "Mükemmel çekim için doğru ayarları yapın", + "properties": { + "immersive_camera_preview": { + "name": "Sürükleyici Önizleme", + "description": "Snapchat'in Kamera önizlemesini Kırpmasını Önler\nBu, kameranın bazı cihazlarda titremesine neden olabilir" + }, + "force_camera_source_encoding": { + "name": "Kamera Kaynağı Kodlamasını Zorla", + "description": "Kamera kaynak kodlamasını zorlar" + }, + "hevc_recording": { + "name": "HEVC Kaydı", + "description": "Video kaydı için HEVC (H.265) codec bileşenini kullanır" + }, + "black_photos": { + "description": "Çekilen fotoğrafları siyah bir arka planla değiştirir\nVideolar etkilenmez", + "name": "Siyah Fotoğraflar" + }, + "override_front_resolution": { + "name": "Ön Çözünürlüğü Geçersiz Kılma", + "description": "Ön kamera için kamera çözünürlüğünü geçersiz kılar" + }, + "override_back_resolution": { + "name": "Arka Çözünürlüğü Geçersiz Kılma", + "description": "Arka kamera için kamera çözünürlüğünü geçersiz kılar" + }, + "custom_resolution": { + "name": "Özel Çözünürlük", + "description": "Özel bir kamera çözünürlüğü, genişlik x yükseklik (örn. 1920x1080) ayarlar.\nÖzel çözünürlük cihazınız tarafından desteklenmelidir" + }, + "disable_cameras": { + "name": "Kameraları Devre Dışı Bırak", + "description": "Snapchat'in seçilen kameraları kullanmasını engeller" + }, + "front_custom_frame_rate": { + "name": "Ön Kamera Özel Kare Hızı", + "description": "Ön kamera kare hızını geçersiz kılar" + }, + "back_custom_frame_rate": { + "name": "Arka Kamera Özel Kare Hızı", + "description": "Arka kamera kare hızını geçersiz kılar" + } + } + }, + "streaks_reminder": { + "name": "Seri Hatırlatma", + "description": "Seri'leriniz hakkında sizi periyodik olarak bilgilendirir", + "properties": { + "interval": { + "name": "Aralık", + "description": "Her hatırlatma arasındaki aralık (saat)" + }, + "remaining_hours": { + "name": "Kalan Süre", + "description": "Bildirim gösterilmeden önce kalan süre (saat)" + }, + "group_notifications": { + "name": "Bildirimleri Grupla", + "description": "Bildirimleri tek bir bildirimde gruplama" + } + } + }, + "experimental": { + "name": "Deneysel", + "description": "Deneysel özellikler", + "properties": { + "native_hooks": { + "name": "Yerel Kancalar", + "description": "Snapchat'in yerel koduna bağlanan Güvenli Olmayan Özellikler", + "properties": { + "disable_bitmoji": { + "name": "Bitmoji'yi Devre Dışı Bırak", + "description": "Arkadaş Profili Bitmoji'sini devre dışı bırakır" + }, + "composer_hooks": { + "properties": { + "bypass_camera_roll_limit": { + "name": "Film Rulosu Sınırını Atla", + "description": "Film rulosundan gönderebileceğiniz maksimum medya miktarını artırır" + }, + "composer_console": { + "name": "Composer Konsolu", + "description": "Composer'da JavaScript kodu çalıştırmanıza izin verir (yalnızca arm64)" + }, + "composer_logs": { + "name": "Composer Günlükleri", + "description": "Composer'ın konsol günlüklerini SnapEnhance'a yönlendirir" + }, + "show_first_created_username": { + "name": "İlk Oluşturulan Kullanıcı Adını Göster", + "description": "Profil sayfasında mevcut kullanıcı adının yanında ilk oluşturulan kullanıcı adını gösterir" + } + }, + "name": "Composer Kancaları", + "description": "Composer çapraz platform UI çerçevesine kod enjekte eder" + }, + "custom_emoji_font": { + "name": "Özel Emoji Fontu", + "description": "Özel bir emoji yazı tipi kullanmanızı sağlar. Yalnızca .ttf yazı tipleriyle çalışır" + }, + "custom_shared_library": { + "name": "Özel Paylaşımlı Kütüphane", + "description": "Snapchat'e özel bir paylaşımlı kütüphane yükler. Bu özellik yalnızca test amaçlıdır" + } + } + }, + "spoof": { + "name": "Taklit", + "description": "Hakkınızdaki çeşitli bilgileri taklit eder", + "properties": { + "remove_mock_location_flag": { + "name": "Sahte Konum İşaretini Kaldır", + "description": "Snapchat'in Mock konumunu algılamasını engeller" + }, + "remove_vpn_transport_flag": { + "description": "Snapchat'in VPN'leri algılamasını engeller", + "name": "VPN Aktarım İşaretini Kaldır" + }, + "play_store_installer_package_name": { + "description": "Yükleyici paket adını com.android.vending olarak geçersiz kılar", + "name": "Play Store Yükleyici Paket Adı" + } + } + }, + "infinite_story_boost": { + "name": "Sonsuz Hikaye Takviyesi", + "description": "Hikaye Takviye Limiti gecikmesini atlayın" + }, + "meo_passcode_bypass": { + "name": "My Eyes Only Şifresini Kır", + "description": "My Eyes Only şifresini atlayın\nBu yalnızca parola daha önce doğru girilmişse çalışacaktır" + }, + "no_friend_score_delay": { + "name": "Arkadaş Puanı Gecikmesi Yok", + "description": "Arkadaş Skoru görüntülenirken yaşanan gecikmeyi kaldırır" + }, + "e2ee": { + "name": "Uçtan-Uca Şifreleme", + "description": "Paylaşılan bir gizli anahtar kullanarak mesajlarınızı AES ile şifreler\nAnahtarınızı güvenli bir yere kaydettiğinizden emin olun!", + "properties": { + "encrypted_message_indicator": { + "name": "Şifrelenmiş Mesaj Göstergesi", + "description": "Şifrelenmiş mesajların yanına bir 🔒 emojisi ekler" + }, + "force_message_encryption": { + "name": "Mesaj Şifrelemeyi Zorla", + "description": "Yalnızca birden fazla konuşma seçildiğinde E2E Şifrelemesi etkin olmayan kişilere şifreli mesaj gönderilmesini engeller" + } + } + }, + "add_friend_source_spoof": { + "name": "Arkadaş Kaynağı Taklidi Ekle", + "description": "Arkadaşlık İsteğinin kaynağını taklit eder" + }, + "hidden_snapchat_plus_features": { + "name": "Gizli Snapchat Plus Özellikleri", + "description": "Yayınlanmamış/beta Snapchat Plus özelliklerini etkinleştirir\nEski Snapchat sürümlerinde çalışmayabilir" + }, + "prevent_forced_logout": { + "name": "Zorla Oturum Kapatmayı Önleme", + "description": "Başka bir cihazdan giriş yaptığınızda Snapchat'in oturumunuzu kapatmasını engeller" + }, + "convert_message_locally": { + "description": "Snap'leri yerel olarak sohbet harici ortamına dönüştürür. Bu, sohbet indirme içerik menüsünde görünür", + "name": "Mesajı Yerel Olarak Dönüştür" + }, + "story_logger": { + "description": "Arkadaş hikayelerinin bir tarihçesini sunar", + "name": "Hikaye Kaydedici" + }, + "media_file_picker": { + "name": "Medya Dosyası Seçici", + "description": "Galeriden herhangi bir video/ses dosyası seçmenizi sağlar" + }, + "call_recorder": { + "description": "Sesli aramaları otomatik olarak kaydeder", + "name": "Arama Kaydedici" + }, + "account_switcher": { + "name": "Hesap Değiştirici", + "description": "Oturumu kapatmadan hesaplar arasında geçiş yapmanızı sağlar\nMenüyü açmak için Bitmoji profilinizin yanındaki arama simgesine uzun basın\nNot: Bu özellik deneyseldir ve muhtemelen gelecekte değişecektir", + "properties": { + "auto_backup_current_account": { + "name": "Mevcut Hesabı Otomatik Olarak Yedekle", + "description": "Oturumu kapatırken veya hesap değiştirirken mevcut hesabı otomatik olarak yedekler" + } + } + }, + "edit_message": { + "name": "Mesajları Düzenle", + "description": "Görüşmelerdeki mesajları düzenlemenizi sağlar" + }, + "app_lock": { + "properties": { + "lock_on_resume": { + "description": "Uygulama yeniden başlatıldığında otomatik olarak kilitler", + "name": "Yeniden Başlatmada Kilitle" + } + }, + "name": "Uygulama Kilidi", + "description": "Snapchat'e şifre olmadan erişimi engeller" + }, + "custom_streaks_expiration_format": { + "name": "Özel Serilerin Sona Erme Biçimi", + "description": "Seri Sona Erme biçimini özelleştirir\n\nKullanılabilir değişkenler:\n - %c: Seri Sayısı\n - %e: Kum Saati Emojisi\n - %d: Gün\n - %h: Saat\n - %m: Dakika\n - %s: Saniye\n - %w: Kalan Süre" + }, + "best_friend_pinning": { + "description": "Bir arkadaşınızı bir numaralı en iyi arkadaşınız olarak sabitlemenizi sağlar. Not: Sabitlediğiniz en iyi arkadaşınızı sadece siz görebilirsiniz", + "name": "En İyi Arkadaş Sabitleme" + }, + "cof_experiments": { + "name": "COF Deneyleri", + "description": "Yayınlanmamış/beta Snapchat özelliklerini etkinleştirir" + }, + "context_menu_fix": { + "name": "Bağlam Menüsü Düzeltme", + "description": "Cihaz çevrimdışı olduğunda doğru şekilde görüntülenemediği için Arkadaş Besleme Menüsünü onarmaya çalışın" + }, + "better_transcript": { + "properties": { + "force_transcription": { + "name": "Sesli not transkriptine zorla", + "description": "Tüm sesli notların yazıya dökülmesini sağlar" + }, + "preferred_transcription_lang": { + "name": "Tercih Edilen Transkripsiyon Dili", + "description": "Sesli not metni için tercih edilen dil (örn. EN, ES, FR)" + }, + "enhanced_transcript": { + "name": "Geliştirilmiş Transkript", + "description": "DeepL adresini kullanarak sesli not dökümünü iyileştirir.\nBu özelliği kullanmadan önce lütfen gizlilik politikasını okuduğunuzdan emin olun." + }, + "enhanced_transcript_in_notifications": { + "description": "DeepL adresini kullanarak bildirimlerdeki sesli notları yazıya döker. Bu, Sohbet Önizleme özelliğinin Daha İyi Bildirimler'de etkinleştirilmesini gerektirir", + "name": "Bildirimlerde Geliştirilmiş Transkript" + } + }, + "name": "Daha İyi Transkript", + "description": "Sesli not transkriptini iyileştirir" + }, + "voice_note_auto_play": { + "name": "Sesli Not Otomatik Oynatma", + "description": "Geçerli sesli not bittikten sonra otomatik olarak bir sonraki sesli notu çalar" + } + } + }, + "scripting": { + "name": "Komut Dosyaları", + "description": "SnapEnhance'i genişletmek için özel komut dosyaları çalıştırın", + "properties": { + "developer_mode": { + "name": "Geliştirici Modu", + "description": "Snapchat'in kullanıcı arayüzünde hata ayıklama bilgilerini gösterir" + }, + "module_folder": { + "name": "Modül Klasörü", + "description": "Komut dosyalarının bulunduğu klasör" + }, + "integrated_ui": { + "name": "Entegre Kullanıcı Arayüzü", + "description": "Komut dosyalarının Snapchat'e özel kullanıcı arayüzü bileşenleri eklemesine izin verir" + }, + "disable_log_anonymization": { + "description": "Günlüklerin anonimleştirilmesini devre dışı bırakır", + "name": "Günlük Anonimleştirmeyi Devre Dışı Bırak" + }, + "auto_reload": { + "description": "Değiştiklerinde komut dosyalarını otomatik olarak yeniden yükler", + "name": "Otomatik Yeniden Yükleme" + } + } + }, + "friend_tracker": { + "name": "Arkadaş Takibi", + "description": "Arkadaşınızın Snapchat'teki etkinliğini kaydeder", + "properties": { + "allow_running_in_background": { + "name": "Arka Planda Çalışmaya İzin Ver", + "description": "İzleyicinin arka planda çalışmasına izin verir. Not: Bu, pilinizi önemli ölçüde tüketecektir" + }, + "record_messaging_events": { + "name": "Mesajlaşma Olaylarını Kaydetme", + "description": "Snap açma, mesaj okuma gibi mesajlaşma olaylarını kaydeder." + }, + "auto_purge": { + "name": "Otomatik Temizleme", + "description": "Belirtilen süreden daha eski olan önbelleğe alınmış olayları otomatik olarak siler" + } + } + } + }, + "options": { + "app_appearance": { + "always_light": "Daima Aydınlık", + "always_dark": "Daima Koyu" + }, + "friend_feed_menu_buttons": { + "auto_download": "⬇️ Otomatik İndirme", + "auto_save": "💬 Mesajları Otomatik Kaydetme", + "stealth": "👻 Gizli Mod", + "conversation_info": "👤 Konuşma Bilgileri", + "e2e_encryption": "🔒 E2E Şifreleme Kullan", + "mark_stories_as_seen_locally": "👀 Hikayeleri görüldü olarak işaretleyin", + "mark_snaps_as_seen": "👀 Snap'leri görüldü olarak işaretleyin", + "unsaveable_messages": "⬇️ Kaydedilemeyen Mesajlar", + "auto_open_snaps": "📷 Snap'leri Otomatik Aç" + }, + "path_format": { + "create_author_folder": "Her kullanıcı için klasör oluştur", + "create_source_folder": "Her medya kaynağı türü için klasör oluşturma", + "append_hash": "Dosya adına benzersiz bir hash ekle", + "append_source": "Medya kaynağını dosya adına ekleyin", + "append_username": "Dosya adına kullanıcı adını ekle", + "append_date_time": "Dosya adına tarih ve saati ekle" + }, + "auto_download_sources": { + "friend_snaps": "Arkadaş Snapleri", + "friend_stories": "Arkadaş Hikayeleri", + "public_stories": "Herkese Açık Hikayeler", + "spotlight": "Spotlight" + }, + "logging": { + "started": "Başladı", + "success": "Başarılı", + "progress": "İlerleme", + "failure": "Başarısız" + }, + "notifications": { + "chat_screenshot": "Ekran Görüntüsü", + "chat_screen_record": "Ekran Kaydı", + "snap_replay": "Snap Tekrar Oynatma", + "camera_roll_save": "Film Rulosu Kaydı", + "chat": "Sohbet", + "chat_reply": "Sohbet Cevabı", + "snap": "Snap", + "typing": "Yazıyor", + "stories": "Hikayeler", + "chat_reaction": "DM Tepkisi", + "group_chat_reaction": "Grup Tepkisi", + "initiate_audio": "Gelen Sesli Arama", + "abandon_audio": "Cevapsız Sesli Arama", + "initiate_video": "Gelen Görüntülü Arama", + "abandon_video": "Cevapsız Görüntülü Arama", + "speaking": "Konuşma" + }, + "gallery_media_send_override": { + "ORIGINAL": "Orijinal Medya", + "NOTE": "Sesli Not", + "SNAP": "Snap", + "always_ask": "Her Zaman Sor", + "SAVEABLE_SNAP": "Kaydedilebilir Snap" + }, + "hide_ui_components": { + "hide_profile_call_buttons": "Profil Arama Düğmelerini Kaldır", + "hide_chat_call_buttons": "Sohbet Arama Düğmelerini Kaldır", + "hide_live_location_share_button": "Canlı Konum Paylaş Düğmesini Kaldır", + "hide_stickers_button": "Çıkartmalar Butonunu Kaldır", + "hide_voice_record_button": "Ses Kayıt Butonunu Kaldır", + "hide_unread_chat_hint": "Okunmamış Sohbet İpucunu Kaldır", + "hide_post_to_story_buttons": "Snap göndermeden önce Hikayeye Gönder düğmelerini kaldırın", + "hide_billboard_prompt": "Arkadaş Akışındaki Reklam Panolarını Kaldır", + "hide_snapchat_plus_gift_reminders": "Sohbetlerdeki Snapchat Plus hediye hatırlatıcılarını kaldır", + "hide_map_reactions": "Harita Tepkilerini Kaldır" + }, + "home_tab": { + "map": "Harita", + "chat": "Sohbet", + "camera": "Kamera", + "discover": "Keşfet", + "spotlight": "Spotlight" + }, + "add_friend_source_spoof": { + "added_by_username": "Kullanıcı Adına Göre", + "added_by_mention": "Bahsetmeye Göre", + "added_by_group_chat": "Grup Sohbetine Göre", + "added_by_qr_code": "QR Kodu'na Göre", + "added_by_community": "Topluluğa Göre", + "added_by_quick_add": "Hızlı Ekleme ile (yüksek yasaklanma riski)" + }, + "bypass_video_length_restriction": { + "single": "Tek medya", + "split": "Bölünmüş medya" + }, + "auto_reload": { + "snapchat_only": "Sadece Snapchat", + "all": "Tümü (Snapchat + SnapEnhance)" + }, + "strip_media_metadata": { + "remove_audio_note_duration": "Ses Notası Süresini Kaldır", + "remove_audio_note_transcript_capability": "Ses Notu Transkript Özelliğini Kaldır", + "hide_extras": "Ekstraları Gizle (örn. bahsedenler)", + "hide_caption_text": "Başlık Metnini Gizle", + "hide_snap_filters": "Snap Filtrelerini Gizle" + }, + "auto_purge": { + "1_day": "1 Gün", + "1_week": "1 Hafta", + "1_month": "1 Ay", + "2_weeks": "2 Hafta", + "never": "Asla", + "1_hour": "1 Saat", + "3_hours": "3 Saat", + "6_months": "6 Ay", + "3_days": "3 Gün", + "6_hours": "6 Saat", + "3_months": "3 Ay", + "12_hours": "12 Saat" + }, + "disable_confirmation_dialogs": { + "hide_conversation": "Konuşmayı Gizle", + "clear_conversation": "Arkadaş Akışından Konuşmayı Temizle", + "remove_friend": "Arkadaşı Kaldır", + "hide_friend": "Arkadaşı Gizle", + "ignore_friend": "Arkadaşı Yoksay", + "block_friend": "Arkadaşı Engelle", + "erase_message": "Mesajı Sil" + }, + "edit_text_override": { + "bypass_text_input_limit": "Metin Giriş Sınırını Atla", + "multi_line_chat_input": "Çok Hatlı Sohbet Girişi" + }, + "old_bitmoji_selfie": { + "2d": "2D Bitmoji", + "3d": "3D Bitmoji" + }, + "hide_story_suggestions": { + "hide_suggested_friend_stories": "Önerilen arkadaş hikayelerini gizle", + "hide_my_stories": "Hikayelerimi Gizle" + }, + "disable_story_sections": { + "friends": "Arkadaşlar", + "discover": "Keşfet", + "following": "Takip Edilen", + "suggested_stories": "Önerilen Hikayeler" + }, + "disable_cameras": { + "front": "Ön Kamera", + "back": "Arka Kamera" + }, + "disable_permission_requests": { + "read_media_images": "Medya Görsellerini Oku", + "microphone": "Mikrofon", + "read_contacts": "Kişileri Oku", + "nearby_devices": "Yakındaki Cihazlar", + "phone_calls": "Telefon Çağrıları", + "notifications": "Bildirimler", + "read_media_video": "Medya Videosunu Oku", + "camera": "Kamera", + "location": "Konum" + }, + "message_indicators": { + "encryption_indicator": "Sadece size gönderilen mesajların yanına bir 🔒 simgesi ekler", + "platform_indicator": "Medyanın gönderildiği platform simgesini ekler (örn. Android, iOS, Web)", + "ovf_editor_indicator": "OVF Editor kullanılarak bir snap gönderilip gönderilmediğini gösterir", + "director_mode_indicator": "Galeri görüntülerini snap olarak göndermek için kullanılabilen Yönetmen Modu kullanılarak gönderildiklerinde snap'lere bir ✏️ simgesi ekler", + "location_indicator": "Konum etkinleştirilerek gönderildiklerinde snap'lere bir 📍 simgesi ekler" + }, + "auto_mark_as_read": { + "conversation_read": "Mesaj gönderirken konuşmayı okundu olarak işaretle", + "snap_reply": "Yanıt verirken snap'leri okundu olarak işaretle" + }, + "friend_mutation_notifier": { + "bitmoji_selfie_changes": "Birisi Bitmoji selfie'sini değiştirdiğinde bildir", + "bitmoji_scene_changes": "Birisi Bitmoji sahnesini değiştirdiğinde bildir", + "bitmoji_avatar_changes": "Birisi Bitmoji avatarını değiştirdiğinde bildir", + "remove_friend": "Birisi sizi arkadaşlıktan çıkardığında bildir", + "birthday_changes": "Birisi doğum gününü değiştirdiğinde bildir", + "bitmoji_background_changes": "Birisi Bitmoji arka planını değiştirdiğinde bildir" + }, + "custom_theme": { + "amoled_dark_mode": "Amoled Karanlık Mod", + "custom": "Özel Temalar (Temaları yönetmek için Hızlı Eylemleri kullanın)", + "material_you_light": "Material You Aydınlık (Android 12+)", + "material_you_dark": "Material You Karanlık (Android 12+)" + } + } + }, + "friend_menu_option": { + "preview": "Önizleme", + "stealth_mode": "Gizli Mod", + "auto_download_blacklist": "Kara Liste Otomatik İndirme", + "anti_auto_save": "Anti Otomatik Kaydetme", + "mark_snaps_as_seen": "Snap'leri Görüldü Olarak İşaretle", + "mark_stories_as_seen_locally": "Hikayeleri yerel olarak görüldü olarak işaretle" + }, + "chat_action_menu": { + "preview_button": "Önizleme", + "download_button": "İndir", + "delete_logged_message_button": "Kaydedilen Mesajı Sil", + "convert_message": "Mesajı Dönüştür", + "edit_message": "Mesajı Düzenle", + "show_chat_edit_history": "Sohbet Düzenleme Geçmişini Göster" + }, + "opera_context_menu": { + "download": "Medyayı İndir", + "media_duration": "Medya süresi: {duration} ms", + "show_debug_info": "Hata Ayıklama Bilgilerini Göster", + "expires_at": "{date}'de sona erer", + "created_at": "{date}'te oluşturuldu", + "sent_at": "{date}'de gönderildi", + "media_size": "Medya boyutu: {size}" + }, + "modal_option": { + "profile_info": "Profil Bilgisi", + "close": "Kapat" + }, + "gallery_media_send_override": { + "multiple_media_toast": "Bir seferde yalnızca bir medya gönderebilirsiniz" + }, + "conversation_preview": { + "streak_expiration": "{day} gün {hour} saat {minute} dakika içinde sona eriyor", + "total_messages": "Toplam gönderilen/alınan mesajlar: {count}", + "title": "Önizleme", + "unknown_user": "Bilinmeyen Kullanıcı", + "no_messages": "Mesaj bulunamadı!" + }, + "profile_info": { + "title": "Profil Bilgisi", + "first_created_username": "İlk Oluşturulan Kullanıcı Adı", + "mutable_username": "Değiştirilebilir Kullanıcı Adı", + "display_name": "Görünen İsim", + "added_date": "Eklenme Tarihi", + "birthday": "Doğum Günü: {day} {month}", + "friendship": "Arkadaşlık", + "add_source": "Kaynak Ekle", + "snapchat_plus": "Snapchat Plus", + "snapchat_plus_state": { + "subscribed": "Abone Olunanlar", + "not_subscribed": "Abone Olunmayanlar" + }, + "hidden_birthday": "Doğum Günü : Gizli" + }, + "chat_export": { + "dialog_negative_button": "İptal", + "dialog_positive_button": "Dışa Aktar", + "exported_to": "{path}'a aktarıldı", + "exporting_chats": "Sohbetler Dışa Aktarılıyor...", + "processing_chats": "{amount} konuşma işleniyor...", + "export_fail": "Konuşma {conversation} dışa aktarılamadı", + "writing_output": "Çıktı yazılıyor...", + "finished": "Bitti! Artık bu iletişim kutusunu kapatabilirsiniz.", + "no_messages_found": "Mesaj bulunamadı!", + "exporting_message": "{conversation} dışa aktarılıyor...", + "exporter_dialog": { + "text_field_selection_all": "Tümü", + "export_file_format_title": "Dosya Formatını Dışa Aktar", + "download_medias_title": "Medyaları İndirin", + "amount_of_messages_title": "Mesaj Miktarı (hepsi için boş bırakın)", + "message_type_filter_title": "Mesajları Türe Göre Filtreleme", + "text_field_selection": "{amount} seçildi", + "select_conversations_title": "Konuşmaları Seç" + } + }, + "button": { + "ok": "Tamam", + "positive": "Evet", + "negative": "Hayır", + "cancel": "İptal", + "open": "Aç", + "download": "İndir", + "send": "Gönder" + }, + "profile_picture_downloader": { + "button": "Profil Resmini İndir", + "title": "Profil Resmi İndirici", + "avatar_option": "Profil Resmi", + "background_option": "Arkaplan" + }, + "download_processor": { + "attachment_type": { + "snap": "Snap", + "sticker": "Çıkartma", + "external_media": "Harici Medya", + "note": "Not", + "original_story": "Orijinal Hikaye", + "gif": "GIF" + }, + "select_attachments_title": "Ekleri seçin", + "download_started_toast": "İndirme başladı", + "unsupported_content_type_toast": "Desteklenmeyen içerik türü!", + "failed_no_longer_available_toast": "Medya artık mevcut değil", + "no_attachments_toast": "Ek bulunamadı!", + "already_queued_toast": "Medya zaten sırada!", + "already_downloaded_toast": "Medya zaten indirildi!", + "download_toast": "{path} indiriliyor...", + "processing_toast": "{path} işleniyor...", + "failed_generic_toast": "İndirme başarısız oldu", + "failed_to_create_preview_toast": "Önizleme oluşturulamadı", + "failed_processing_toast": "İşlem başarısız {error}", + "failed_gallery_toast": "Galeriye kaydedilemedi {error}", + "dash_dialog": { + "title": "Dash medyasını indir", + "download_all": "Tümünü İndir", + "segment_text": "Segment {from} - {to}" + }, + "dash_no_chapter": "Bölüm bulunamadı" + }, + "streaks_reminder": { + "notification_title": "Seriler", + "notification_text": "{friend} ile olan Serini {hoursLeft} saat içinde kaybedeceksin" + }, + "content_type": { + "FAMILY_CENTER_INVITE": "Aile Merkezi Davetiyesi", + "STATUS_CONVERSATION_CAPTURE_RECORD": "Ekran Kaydı", + "STATUS_CALL_MISSED_VIDEO": "Cevapsız Görüntülü Arama", + "CREATIVE_TOOL_ITEM": "Yaratıcı Araç Öğesi", + "STICKER": "Çıkartma", + "TINY_SNAP": "Minik Snap", + "STATUS_SAVE_TO_CAMERA_ROLL": "Film Rulosuna Kaydedildi", + "EXTERNAL_MEDIA": "Harici Medya", + "SNAP": "Snap", + "LOCATION": "Konum", + "CHAT": "Sohbet", + "STATUS_PLUS_GIFT": "Durum Artı Hediyesi", + "STATUS_COUNTDOWN": "Geri Sayım", + "LIVE_LOCATION_SHARE": "Canlı Konum Paylaşımı", + "STATUS": "Durum", + "STATUS_CONVERSATION_CAPTURE_SCREENSHOT": "Ekran görüntüsü", + "FAMILY_CENTER_ACCEPT": "Aile Merkezi Kabul", + "FAMILY_CENTER_LEAVE": "Aile Merkezi Ayrıl", + "STATUS_CALL_MISSED_AUDIO": "Cevapsız Sesli Arama", + "NOTE": "Sesli Not", + "MAP_REACTION": "Harita Tepkisi", + "SHARE": "Paylaş" + }, + "better_notifications": { + "button": { + "download": "İndir", + "reply": "Yanıtla", + "mark_as_read": "Okundu olarak işaretle" + } + }, + "half_swipe_notifier": { + "notification_content_group": "{friend} {duration} saniye boyunca {group}'a yarım kaydırma yaptı", + "notification_channel_name": "Yarım Kaydırma", + "notification_content_dm": "{friend} sohbetinize {duration} saniye boyunca yarım kaydırma yaptı" + }, + "friendship_link_type": { + "mutual": "Karşılıklı", + "deleted": "Silinen", + "following": "Takip Edilen", + "incoming_follower": "Gelen Takipçi", + "incoming": "Gelen", + "blocked": "Engellenen", + "suggested": "Önerilen", + "outgoing": "Giden" + }, + "call_start_confirmation": { + "dialog_message": "Bir arama başlatmak istediğinizden emin misiniz?", + "dialog_title": "Arama Başlat" + }, + "bulk_messaging_action": { + "choose_action_title": "Bir eylem seçin", + "progress_status": "{total} öğesinin {index} öğesi işleniyor", + "actions": { + "clear_conversations": "Konuşmaları Temizle", + "remove_friends": "Arkadaşları Kaldır" + }, + "selection_dialog_continue_button": "Devam et", + "confirmation_dialog": { + "message": "Bu, seçilen tüm arkadaşları etkileyecektir. Bu eylem geri alınamaz.", + "title": "Emin misiniz?" + } + }, + "media_download_source": { + "public_story": "Herkese Açık Hikaye", + "spotlight": "Spotlight", + "pending": "Beklemede", + "merged": "Birleştirilmiş", + "story_logger": "Hikaye Kaydedici", + "none": "Hiçbiri", + "profile_picture": "Profil Fotoğrafı", + "story": "Hikaye", + "chat_media": "Sohbet Medyası", + "message_logger": "Mesaj Kaydedici", + "voice_call": "Sesli Arama" + }, + "material3_strings": { + "date_input_invalid_not_allowed": "Geçersiz tarih", + "date_range_input_invalid_range_input": "Geçersiz tarih aralığı", + "date_range_picker_scroll_to_previous_month": "Önceki ay", + "date_picker_switch_to_input_mode": "Giriş", + "date_range_picker_day_in_range": "Seçilen", + "date_input_invalid_for_pattern": "Geçersiz tarih", + "date_picker_today_description": "Bugün", + "date_picker_switch_to_calendar_mode": "Takvim", + "date_range_picker_start_headline": "Şuradan", + "date_range_picker_end_headline": "Şuraya", + "date_range_picker_scroll_to_next_month": "Sonraki ay", + "date_range_picker_title": "Tarih aralığı seçin", + "date_input_invalid_year_range": "Geçersiz yıl" + }, + "mark_as_seen": { + "unseen_toast": "Görülmedi olarak işaretlendi!", + "already_seen_toast": "Zaten görüldü olarak işaretlendi!", + "already_unseen_toast": "Zaten görülmemiş olarak işaretlendi!", + "no_unseen_snaps_toast": "Görülmeyen Snap bulunamadı!", + "seen_toast": "Görüldü olarak işaretlendi!" + }, + "actions": { + "clean_snapchat_cache": { + "name": "Snapchat Önbelleğini Temizle", + "description": "Snapchat Önbelleğini Temizler" + }, + "manage_friend_list": { + "name": "Arkadaş Listesini Yönet", + "description": "Yedekleme yaparken arkadaş listenizi içe/dışa aktarın" + }, + "export_chat_messages": { + "name": "Sohbet Mesajlarını Dışa Aktar", + "description": "Konuşma mesajlarını bir JSON/HTML/TXT dosyasına aktarır" + }, + "export_memories": { + "name": "Anıları Dışa Aktar", + "description": "Anıları bir ZIP dosyasına aktarır" + }, + "bulk_messaging_action": { + "name": "Toplu Mesajlaşma Eylemi", + "description": "Arkadaş silme veya konuşmaları toplu silme gibi işlemleri gerçekleştirir" + }, + "regen_mappings": { + "name": "Eşlemeleri Yeniden Oluştur", + "description": "Eşlemeleri manuel olarak yeniden oluşturma" + }, + "change_language": { + "name": "Dili Değiştir", + "description": "SnapEnhance'in dilini değiştir" + }, + "friend_tracker": { + "name": "Arkadaş Takibi", + "description": "Snapchat'te arkadaşlarınızı takip edin" + }, + "logger_history": { + "name": "Kaydedici Geçmişi", + "description": "Günlüğe kaydedilen mesajların geçmişini görüntüle" + }, + "file_imports": { + "name": "Dosya İçe Aktarımları", + "description": "Snapchat'te kullanmak için dosyaları içe aktarma" + }, + "security_features": { + "name": "Güvenlik Özellikleri", + "description": "Güvenlik özellikleri tercihlerini değiştir" + }, + "theming": { + "name": "Temalandırma", + "description": "Snapchat'in görünümünü ve hissini özelleştirin" + } + }, + "scopes": { + "friend": "Arkadaş", + "group": "Grup" + }, + "end_to_end_encryption": { + "toolbox": { + "initiate_exchange_button": "Anahtar Değişimini Başlat", + "no_shared_key": "Bu arkadaşınızla henüz paylaşılmış bir sırrınız yok. Yeni bir tane başlatmak için aşağıya tıklayın.", + "shared_key_fingerprint": "Parmak iziniz:\n\n{fingerprint}\n\nArkadaşınızın parmak iziyle eşleşip eşleşmediğini kontrol edin!" + }, + "confirmation_dialogs": { + "title": "Uçtan uca şifreleme", + "confirmation_2": "Devam etmek istediğinizden gerçekten emin misiniz? Bu geri çekilmek için son şansın.", + "confirmation_1": "UYARI: Bu işlem mevcut anahtarınızın üzerine yazacaktır. Bu arkadaşınızdan gelen tüm şifreli mesajlara erişiminizi kaybedeceksiniz. Devam etmek istediğinizden emin misiniz?" + }, + "unencrypted_conversation_send_failure_toast": "Hem şifrelenmiş hem de şifrelenmemiş konuşmalara şifrelenmiş içerik gönderemezsiniz!", + "no_participants_to_encrypt_toast": "Bu konuşmada mesajlarınızı şifreleyebileceğiniz hiç arkadaşınız yok!", + "accept_public_key_failure_toast": "Açık anahtar kabul edilemedi", + "accept_secret_button": "Sırrı Kabul Et", + "accept_public_key_button": "Açık Anahtarı Kabul Et", + "outgoing_pk_message": "Anahtar değişim talebi", + "outgoing_secret_message": "Anahtar değişimi yanıtı", + "incoming_secret_message": "Arkadaşınız az önce açık anahtarınızı kabul etti. Sırrı kabul etmek için aşağıya tıklayın.", + "accept_public_key_success_toast": "Açık anahtar başarıyla kabul edildi!", + "accept_secret_key_success_toast": "Tamamlandı! Artık bu arkadaşınızla şifreli mesajlar gönderip alabilirsiniz.", + "accept_secret_key_failure_toast": "Gizli anahtar kabul edilemedi", + "incoming_pk_message": "Az önce bir açık anahtar isteği aldınız. Kabul etmek için aşağıya tıklayın.", + "native_hooks_send_failure_toast": "Gönderilemedi! Lütfen ayarlardan Yerel Kancaları etkinleştirin.", + "encryption_failed_toast": "İleti şifrelenemedi! Daha fazla ayrıntı için logcat'i kontrol edin." + }, + "biometric_auth": { + "unlock_button": "Kilidi Aç", + "title": "Snapchat Kilidini Aç", + "subtitle": "Snapchat'in kilidini açmak için lütfen giriş yap" + }, + "auto_open_snaps": { + "title": "Snap'leri Otomatik Aç", + "notification_content": "{count} Snap Açıldı" + }, + "friend_mutation_observer": { + "notification_channel_name": "Arkadaş Değişim Gözlemcisi", + "birthday_removed": "{username} doğum gününü ({birthday}) kaldırdı", + "birthday_added": "{username} doğum gününü ekledi ({birthday})", + "bitmoji_avatar_changed": "{username} Bitmoji avatarını değiştirdi", + "friend_removed": "{username} sizi arkadaşlıktan çıkardı", + "birthday_changed": "{username} doğum gününü {oldBirthday} yerine {newBirthday} olarak değiştirdi", + "bitmoji_selfie_changed": "{username} Bitmoji selfie'sini değiştirdi", + "bitmoji_background_changed": "{username} Bitmoji arka planını değiştirdi", + "bitmoji_scene_changed": "{username} Bitmoji sahnesini değiştirdi" + }, + "theming_attributes": { + "sigColorTextPrimary": "Ana Metin Rengi", + "sigColorChatChat": "Ana Arkadaş Besleme Metin Rengi", + "sigColorChatPendingSending": "İkincil Arkadaş Besleme Metin Rengi", + "sigColorChatSnapWithSound": "Sesli Metin Renkli Snapler", + "sigColorChatSnapWithoutSound": "Sesli Metin Rengi Olmayan Snapler", + "actionSheetDescriptionTextColor": "Eylem Menüsü Açıklama Metin Rengi", + "sigColorBackgroundMain": "Arkaplan Rengi", + "listBackgroundDrawable": "Konuşma listesi Arkaplanı", + "sigColorChatConversationsLine": "Konuşmalar Çizgi Rengi", + "actionSheetBackgroundDrawable": "Eylem Menüsü Arkaplan Rengi", + "actionSheetRoundedBackgroundDrawable": "Eylem Menüsü Yuvarlak Arkaplan Rengi", + "sigColorBackgroundSurface": "Arkaplan Yüzey Rengi", + "sigColorIconPrimary": "Eylem Menüsü Simge Rengi", + "sigExceptionColorCameraGridLines": "Kamera Izgara Çizgileri Rengi", + "listDivider": "Liste Bölücü Rengi", + "sigColorIconSecondary": "İkincil Simge Rengi", + "itemShapeFillColor": "Ürün Şekli Dolgu Rengi", + "ringColor": "Halka Rengi", + "ringStartColor": "Halka Başlangıç Rengi", + "sigColorLayoutPlaceholder": "Düzen Yer Tutucu Rengi", + "scButtonColor": "Snapchat Buton Rengi", + "recipientPillBackgroundDrawable": "Alıcı Hap Arkaplanı", + "boxBackgroundColor": "Kutu Arkaplan Rengi", + "editTextColor": "Metin Rengini Düzenle", + "chipBackgroundColor": "Çip Arkaplan Rengi", + "recipientInputStyle": "Alıcı Giriş Stili", + "rangeFillColor": "Aralık Dolgu Rengi", + "pstsIndicatorColor": "PSTS Gösterge Rengi", + "pstsTabBackground": "PSTS Sekme Arkaplanı", + "pstsDividerColor": "PSTS Bölücü Rengi", + "tabTextColor": "Sekme Metin Rengi", + "statusBarForeground": "Durum Çubuğu Önplan Rengi", + "statusBarBackground": "Durum Çubuğu Arkaplan Rengi", + "strokeColor": "Vuruş Rengi", + "storyReplayViewRingColor": "Hikaye Tekrarı Görünüm Daire Rengi", + "sigColorButtonPrimary": "Birincil Düğme Rengi", + "sigColorBaseAppYellow": "Temel Uygulama Sarı Renk", + "sigColorBackgroundSurfaceTranslucent": "Yarı Saydam Arkaplan Yüzey Rengi", + "sigColorStoryRingDiscoverTabThumbnailStoryRing": "Hikaye Halkası Keşfet Sekmesi Küçük Resim Hikaye Halkası Rengi", + "sigColorStoryRingFriendsFeedStoryRing": "Hikaye Halkası Arkadaş Beslemesi Hikaye Halkası Rengi" + }, + "send_override_dialog": { + "title": "Medyayı {type} olarak gönder", + "duration": "Süre: {duration}", + "saveable_snap_hint": "Snap'i sohbette kaydedilebilir hale getirin", + "unlimited_duration": "Sınırsız" + } +} diff --git a/common/src/main/assets/lang/uk_UA.json b/common/src/main/assets/lang/uk_UA.json new file mode 100644 index 0000000000..0d22c49d22 --- /dev/null +++ b/common/src/main/assets/lang/uk_UA.json @@ -0,0 +1,1732 @@ +{ + "setup": { + "dialogs": { + "select_language": "Оберіть мову", + "save_folder": "SnapEnhance вимагає дозволів на зберігання для завантаження та збереження медіафайлів із Snapchat.\nБудь ласка, виберіть місце, куди потрібно завантажити медіа.", + "select_save_folder_button": "Виберіть папку" + }, + "mappings": { + "dialog": "Створення зіставлень, це може зайняти деякий час...", + "generate_failure_no_snapchat": "SnapEnhance не вдалося виявити Snapchat, спробуйте перевстановити Snapchat.", + "generate_failure": "Під час створення зіставлення сталася помилка. Повторіть спробу." + }, + "permissions": { + "dialog": "Щоб продовжити, потрібно відповідати таким вимогам:", + "notification_access": "Доступ до сповіщень", + "display_over_other_apps": "Відображення поверх інших програм", + "request_button": "Запит", + "battery_optimization": "Оптимізація батареї" + } + }, + "manager": { + "routes": { + "home_settings": "Налаштування", + "tasks": "Завдання", + "features": "Особливості", + "home": "Додому", + "logged_stories": "Зареєстровані історії", + "better_location": "Краще розташування", + "manage_rule_feature": "Керувати функцією правила", + "friend_tracker": "Відстеження друзів", + "edit_rule": "Редагувати правило", + "home_logs": "Журнали", + "logger_history": "Історія реєстратора", + "edit_theme": "Редагувати тему", + "manage_repos": "Керування репозиторіями", + "manage_scope": "Керувати обсягом", + "messaging_preview": "Попередній перегляд", + "scripts": "Сценарії", + "file_imports": "Імпорт файлів", + "social": "Соціальний", + "theming": "Тематизація" + }, + "sections": { + "features": { + "reset_option": "Скинути", + "saved_config_snackbar": "Конфігурацію збережено", + "import_option": "Імпорт", + "config_export_failure_toast": "Не вдалося експортувати конфігурацію {error}", + "config_export_success_toast": "Конфігурацію успішно експортовано", + "disabled": "Вимкнено", + "export_option": "Експорт", + "config_import_success_toast": "Конфігурацію успішно імпортовано", + "config_import_failure_toast": "Не вдалося імпортувати конфігурацію {error}", + "older_required": "Для правильної роботи цієї функції потрібен Snapchat v{version} або старішої версії", + "newer_required": "Для правильної роботи цієї функції потрібен Snapchat v{version} або новішої версії", + "search_button": "Пошук" + }, + "manage_scope": { + "rules_title": "Правила", + "streaks_length_text": "Довжина: {length}", + "streaks_expiration_text": "Термін дії закінчується через {eta}", + "streaks_expiration_text_expired": "Термін дії минув", + "reminder_button": "Встановити нагадування", + "delete_scope_confirm_dialog_title": "Ви впевнені, що бажаєте видалити {scope}?", + "e2ee_title": "Наскрізне шифрування", + "not_found": "Не знайдено", + "streaks_title": "Смуги", + "logged_stories_button": "Показати зареєстровані історії", + "notes_placeholder": "Натисніть, щоб додати примітку", + "participants_text": "{count} учасників" + }, + "home": { + "version_title": "v{versionName} · шляхом rhunk", + "debug_build_summary_title": "Ви використовуєте збірку для налагодження SnapEnhance", + "debug_build_summary_content": "Версія {versionName} ({versionCode})", + "quick_actions_title": "Швидкі дії", + "debug_build_summary_date": "Дата складання: {date} ({days} днів тому)", + "update_title": "Оновлення SnapEnhance", + "update_content": "Доступна версія {version}!", + "update_button": "Завантажити" + }, + "home_settings": { + "actions_title": "Дії", + "export_button": "Експорт", + "clear_button": "Очистити", + "success_toast": "Готово!", + "message_logger_title": "Реєстратор повідомлень", + "message_logger_summary": "{messageCount} повідомлень\n{storyCount} історій", + "view_logger_history_button": "Переглянути історію реєстратора", + "debug_title": "Налагодження" + }, + "tasks": { + "remove_selected_tasks_title": "Ви впевнені, що бажаєте видалити вибрані завдання?", + "remove_all_tasks_title": "Ви впевнені, що хочете видалити всі завдання?", + "delete_files_option": "Також видалити файли", + "no_tasks": "Завдань немає", + "failed_to_open_file": "Не вдалося відкрити файл", + "merge_files_toast": "Об’єднання {count} файлів", + "remove_selected_tasks_confirm": "Видалити {count} завдань?", + "remove_all_tasks_confirm": "Видалити всі завдання?", + "merge_button": "Об’єднати" + }, + "social": { + "friends_tab": "Друзі", + "groups_tab": "Групи", + "streaks_expiration_short": "{hours} год", + "empty_hint": "(порожній)" + }, + "messaging_preview": { + "unsave_all_option": "Скасувати збереження всіх", + "save_selection_option": "Зберегти вибране", + "bridge_connection_failed": "Не вдалося підключитися до мосту. Переконайтеся, що Snapchat працює у фоновому режимі", + "save_all_option": "Зберегти все", + "mark_selection_as_seen_option": "Позначити вибране зображення як видиме", + "unsave_selection_option": "Скасувати збереження виділеного", + "no_message_hint": "Немає повідомлення", + "bridge_init_failed": "Не вдалося ініціалізувати міст обміну повідомленнями. Переконайтеся, що Snapchat працює у фоновому режимі", + "message_fetch_failed": "Не вдалося отримати повідомлення", + "mark_all_as_seen_option": "Позначити всі знімки як видимі", + "delete_selection_option": "Видалити виділення", + "delete_all_option": "Видалити все" + }, + "file_imports": { + "file_import_failed": "Не вдалося імпортувати файл: {error}", + "file_imported": "Файл успішно імпортовано", + "file_delete_failed": "Не вдалося видалити файл", + "import_file_button": "Імпорт файлу", + "file_not_found": "Файл не знайдено", + "no_files_hint": "Тут ви можете імпортувати файли для використання в Snapchat. Натисніть кнопку нижче, щоб імпортувати файл." + }, + "better_location": { + "spoofed_coordinates_title": "Широта {latitude}, Висота {longitude}", + "choose_location_button": "Виберіть Розташування", + "teleport_to_friend_button": "Телепорт до друга", + "search_bar": "Пошук", + "latitude_dialog_hint": "Широта", + "teleport_to_friend_title": "Телепорт до друга", + "suspend_location_updates": "Призупинити оновлення місцезнаходження", + "delete_dialog_title": "Видалити збережену координату", + "no_friends_map": "На карті немає друзів", + "saved_coordinates_title": "Збережені координати", + "delete_dialog_message": "Ви впевнені, що бажаєте видалити цю збережену координату?", + "no_saved_coordinates_hint": "Немає збережених координат", + "longitude_dialog_hint": "Висота", + "save_coordinates_dialog_title": "Зберегти координати", + "saved_name_dialog_hint": "Збережена назва", + "save_dialog_button": "Зберегти", + "spoof_location_toggle": "Локація підробки", + "no_friends_found": "Друзів не знайдено" + }, + "home_logs": { + "no_logs_hint": "Журнали відсутні", + "clear_logs_button": "Очистити журнали", + "export_logs_button": "Експорт журналів", + "saving_logs_toast": "Зберігання журналів, це може зайняти деякий час...", + "saved_logs_failure_toast": "Не вдалося зберегти журнали", + "saved_logs_success_toast": "Журнали успішно збережено" + }, + "manage_rule_feature": { + "whitelist_state_option": "Ніхто, крім...", + "blacklist_state_option": "Усі, крім...", + "whitelist_state_subtext": "Це правило стосуватиметься лише {count} друзів/груп", + "whitelist_state_button": "Виберіть дозволених друзів/групи", + "blacklist_state_subtext": "Це правило стосуватиметься всіх, крім {count} друзів/груп", + "dialog_clear_confirmation_text": "Ви впевнені, що бажаєте очистити список?", + "blacklist_state_button": "Виберіть виключених друзів/групи", + "clear_list_button": "Очистити список друзів/груп", + "disable_state_option": "Вимкнено", + "disable_state_subtext": "Це не вплине на друзів/групи" + }, + "logged_stories": { + "no_stories": "Історій не знайдено", + "save_from_cache_button": "Зберегти з кешу", + "story_failed_to_load": "Не вдалося завантажити" + }, + "logger_history": { + "list_group_format": "Група {name}", + "reverse_order_checkbox": "Зворотний порядок", + "chat_attachment": "Вкладення {index}", + "empty_message": "Порожнє повідомлення чату", + "no_more_messages": "Більше жодних повідомлень", + "message_parse_failed": "Не вдалося проаналізувати повідомлення", + "download_attachment_failed_toast": "Не вдалося завантажити вкладений файл", + "list_friend_format": "Друг {name}", + "unknown_sender": "Невідомий відправник" + }, + "theming": { + "no_themes_hint": "Теми не знайдено" + } + }, + "dialogs": { + "add_friend": { + "category_friends": "Друзі", + "search_hint": "Пошук", + "title": "Додати друга або групу", + "fetch_error": "Не вдалося отримати дані", + "category_groups": "Групи", + "participants_text": "{count} учасників" + }, + "scripting_warning": { + "title": "Увага", + "content": "SnapEnhance містить інструмент створення сценаріїв, що дозволяє виконувати визначений користувачем код на вашому пристрої. Будьте вкрай обережні та встановлюйте модулі лише з відомих надійних джерел. Неавторизовані або неперевірені модулі можуть становити загрозу безпеці вашої системи." + }, + "file_imports": { + "no_files_settings_hint": "Файли не знайдено. Переконайтеся, що ви імпортували необхідні файли в розділ «Імпорт файлів»", + "settings_select_file_hint": "Виберіть імпортований файл" + }, + "reset_config": { + "content": "Ви впевнені, що бажаєте скинути конфігурацію?", + "success_toast": "Конфігурацію успішно скинуто", + "title": "Скинути конфігурацію" + }, + "export_config": { + "title": "Експортувати конфіденційні дані?", + "content": "Бажаєте експортувати конфігурацію з конфіденційними даними? (Наприклад, координати розташування тощо)" + }, + "messaging_action": { + "select_all_button": "Виберіть усі", + "title": "Виберіть типи вмісту для обробки" + } + } + }, + "scopes": { + "friend": "Друг", + "group": "Група" + }, + "rules": { + "modes": { + "blacklist": "Режим чорного списку", + "whitelist": "Режим білого списку" + }, + "properties": { + "auto_download": { + "name": "Автоматичне завантаження", + "options": { + "blacklist": "Виключити з автоматичного завантаження", + "whitelist": "Автоматичне завантаження" + }, + "description": "Автоматично завантажувати знімки під час їх перегляду" + }, + "auto_open_snaps": { + "options": { + "blacklist": "Виключити з Auto Open Snaps", + "whitelist": "Автоматичне відкриття знімків" + }, + "name": "Автоматичне відкриття знімків", + "description": "Автоматично відкриває знімки під час їх отримання" + }, + "hide_friend_feed": { + "name": "Приховати від стрічки друзів" + }, + "e2e_encryption": { + "name": "Використовуйте шифрування E2E" + }, + "pin_conversation": { + "name": "Закріпити розмову" + }, + "auto_save": { + "description": "Зберігає повідомлення чату під час їх перегляду", + "options": { + "blacklist": "Виключити з автозбереження", + "whitelist": "Автоматичне збереження" + }, + "name": "Автоматичне збереження" + }, + "stealth": { + "options": { + "blacklist": "Виключити з прихованого режиму", + "whitelist": "Стелс режим" + }, + "name": "Режим скритності", + "description": "Запобігає тому, щоб хтось дізнався, що ви відкрили їхні знімки/чати та розмови" + }, + "unsaveable_messages": { + "description": "Запобігає збереженню повідомлень у чаті іншими людьми", + "name": "Повідомлення, які не можна зберегти", + "options": { + "whitelist": "Повідомлення, які не можна зберегти", + "blacklist": "Виключити з повідомлень, які не можна зберегти" + } + }, + "exclude_message_logger": { + "name": "Виключити з реєстратора повідомлень" + } + }, + "toasts": { + "enabled": "{ruleName} включено", + "disabled": "{ruleName} вимкнено" + } + }, + "features": { + "properties": { + "downloader": { + "properties": { + "prevent_self_auto_download": { + "name": "Запобігання самостійного автоматичного завантаження", + "description": "Запобігає автоматичному завантаженню ваших власних знімків" + }, + "path_format": { + "name": "Формат шляху", + "description": "Вкажіть формат шляху до файлу" + }, + "allow_duplicate": { + "description": "Дозволяє завантажувати одне й те саме медіа кілька разів", + "name": "Дозволити дублювання" + }, + "ffmpeg_options": { + "description": "Укажіть додаткові параметри FFmpeg", + "properties": { + "audio_bitrate": { + "name": "Бітрейт аудіо", + "description": "Встановити бітрейт аудіо (кбіт/с)" + }, + "constant_rate_factor": { + "name": "Фактор постійної ставки", + "description": "Встановіть коефіцієнт постійної швидкості для відеокодера\nВід 0 до 51 для libx264" + }, + "threads": { + "name": "Нитки", + "description": "Кількість ниток для використання" + }, + "video_bitrate": { + "description": "Встановити бітрейт відео (кбіт/с)", + "name": "Бітрейт відео" + }, + "preset": { + "name": "Попереднє налаштування", + "description": "Встановіть швидкість перетворення" + }, + "custom_video_codec": { + "description": "Встановити спеціальний відеокодек (наприклад, libx264)", + "name": "Спеціальний відеокодек" + }, + "custom_audio_codec": { + "name": "Спеціальний аудіокодек", + "description": "Встановіть спеціальний аудіокодек (наприклад, AAC)" + } + }, + "name": "Параметри FFmpeg" + }, + "save_folder": { + "description": "Виберіть каталог, куди потрібно завантажити всі медіафайли", + "name": "Зберегти папку" + }, + "force_voice_note_format": { + "name": "Примусовий формат голосової нотатки", + "description": "Примушує зберігати голосові нотатки у вказаному форматі" + }, + "download_context_menu": { + "name": "Завантажити контекстне меню", + "description": "Дозволяє завантажувати/переглядати повідомлення з бесіди чи історії за допомогою контекстного меню.\nТривале натискання кнопок призведе до примусового завантаження" + }, + "auto_download_voice_notes": { + "description": "Автоматично завантажує голосові нотатки під час їх відтворення", + "name": "Автоматичне завантаження голосових нотаток" + }, + "opera_download_button": { + "description": "Додає кнопку завантаження у верхньому правому куті під час перегляду Snap.\nТривале натискання кнопок призведе до примусового завантаження", + "name": "Кнопка завантаження Opera" + }, + "merge_overlays": { + "name": "Об’єднати накладення", + "description": "Об’єднує текст і медіафайл Snap в один файл" + }, + "force_image_format": { + "name": "Примусовий формат зображення", + "description": "Примусове збереження зображень у вказаному форматі" + }, + "auto_download_sources": { + "description": "Виберіть джерела для автоматичного завантаження", + "name": "Джерела автоматичного завантаження" + }, + "download_profile_pictures": { + "description": "Дозволяє завантажувати зображення профілю зі сторінки профілю", + "name": "Завантажте зображення профілю" + }, + "logging": { + "name": "Лісозаготівля", + "description": "Показує тости під час завантаження медіа" + }, + "custom_path_format": { + "name": "Спеціальний формат шляху", + "description": "Укажіть спеціальний формат шляху для завантажених медіа\n\nДоступні змінні:\n - %ім'я користувача%\n - %джерело%\n - %хеш%\n - %date_time%" + } + }, + "name": "Завантажувач", + "description": "Завантажте Snapchat Media" + }, + "messaging": { + "properties": { + "message_logger": { + "properties": { + "auto_purge": { + "description": "Автоматично видаляє кешовані повідомлення, які старші за вказаний період часу", + "name": "Автоматичне очищення" + }, + "message_filter": { + "name": "Фільтр повідомлень", + "description": "Виберіть, які повідомлення слід реєструвати (пусто для всіх повідомлень)" + }, + "keep_my_own_messages": { + "name": "Зберігати мої власні повідомлення", + "description": "Запобігає видаленню ваших власних повідомлень" + }, + "deleted_message_color": { + "name": "Колір видаленого повідомлення", + "description": "Встановлює колір видалених повідомлень" + } + }, + "description": "Запобігає видаленню повідомлень", + "name": "Реєстратор повідомлень" + }, + "auto_mark_as_read": { + "description": "Автоматично позначає повідомлення/знімки як прочитані, навіть якщо ввімкнено режим невидимості", + "name": "Автоматичне позначення як прочитане" + }, + "disable_replay_in_ff": { + "name": "Вимкнути відтворення в FF", + "description": "Вимикає можливість повторного відтворення за допомогою тривалого натискання зі стрічки друзів" + }, + "loop_media_playback": { + "name": "Цикл відтворення медіа", + "description": "Зациклює відтворення медіа під час перегляду знімків/історій" + }, + "hide_peek_a_peek": { + "description": "Запобігає надсиланню сповіщень, коли ви наполовину проводите пальцем у чаті", + "name": "Приховати Peek-a-Peek" + }, + "hide_bitmoji_presence": { + "name": "Приховати присутність Bitmoji", + "description": "Запобігає появі Bitmoji під час чату" + }, + "bypass_screenshot_detection": { + "name": "Обхід виявлення знімка екрана", + "description": "Запобігає Snapchat виявляти, коли ви робите знімок екрана" + }, + "half_swipe_notifier": { + "properties": { + "min_duration": { + "name": "Мінімальна тривалість", + "description": "Мінімальна тривалість напівсвайпу (у секундах)" + }, + "max_duration": { + "name": "Максимальна тривалість", + "description": "Максимальна тривалість половини свайпа (у секундах)" + } + }, + "name": "Сповіщувач згортання наполовину", + "description": "Сповіщає, коли хтось наполовину переходить у розмову" + }, + "skip_when_marking_as_seen": { + "name": "Пропустити під час позначення як побаченого", + "description": "Автоматично переходить до наступного кадру, коли знімок позначається як видимий.\nВикористовуйте разом із кнопкою «Позначити прив’язку як видиме»" + }, + "better_notifications": { + "description": "Додає більше інформації в отримані сповіщення", + "name": "Кращі сповіщення", + "properties": { + "chat_preview": { + "name": "Попередній перегляд чату", + "description": "Показує попередній перегляд отриманих повідомлень у сповіщенні" + }, + "media_preview": { + "name": "Попередній перегляд медіа", + "description": "Показує попередній перегляд вибраних типів медіа в сповіщенні" + }, + "media_caption": { + "description": "Показує вкладений заголовок медіафайлу в сповіщенні", + "name": "Медіапідпис" + }, + "mark_as_read_button": { + "name": "Кнопка «Позначити як прочитане»", + "description": "Дозволяє позначити повідомлення як прочитане зі сповіщення" + }, + "smart_replies": { + "name": "Розумні відповіді", + "description": "Додає запропоновані відповіді на сповіщення (Android 10+). Використовуйте разом із кнопкою відповіді" + }, + "group_notifications": { + "name": "Групові сповіщення", + "description": "Згрупуйте сповіщення в одне" + }, + "stacked_media_messages": { + "description": "Об’єднує кілька медіа-повідомлень в одне текстове сповіщення, якщо їх неможливо попередньо переглянути. Використовуйте в поєднанні з попереднім переглядом чату", + "name": "Складені медіа-повідомлення" + }, + "friend_add_source": { + "description": "Показує джерело запиту друга в сповіщенні", + "name": "Джерело додавання друга" + }, + "mark_as_read_and_save_in_chat": { + "name": "Позначити як прочитане та зберегти в чаті", + "description": "Додає до сповіщення позначку прочитаного та кнопку збереження в чаті" + }, + "reply_button": { + "name": "Кнопка відповіді", + "description": "До сповіщення додається кнопка відповіді" + }, + "download_button": { + "name": "Кнопка завантаження", + "description": "Дозволяє завантажувати медіа зі сповіщення" + } + } + }, + "prevent_message_sending": { + "name": "Заборонити надсилання повідомлень", + "description": "Запобігає надсиланню певних типів повідомлень" + }, + "friend_mutation_notifier": { + "description": "Повідомляє, коли щось змінюється в профілі друга", + "name": "Сповіщувач про мутацію друзів" + }, + "bypass_message_action_restrictions": { + "name": "Обійти обмеження щодо дії повідомлення", + "description": "Дозволяє реагувати на знімок, не відкриваючи його, або зберегти повідомлення, яке неможливо зберегти" + }, + "auto_save_messages_in_conversations": { + "name": "Автоматичне збереження повідомлень", + "description": "Автоматично зберігає кожне повідомлення в розмовах" + }, + "strip_media_metadata": { + "description": "Видаляє метадані медіа перед надсиланням у вигляді повідомлення", + "name": "Видалення медіа-метаданих" + }, + "bypass_message_retention_policy": { + "name": "Обхід політики збереження повідомлень", + "description": "Запобігає видаленню повідомлень після їх перегляду" + }, + "unlimited_conversation_pinning": { + "description": "Дозволяє закріплювати необмежену кількість розмов локально", + "name": "Необмежене закріплення розмов" + }, + "notification_blacklist": { + "name": "Чорний список сповіщень", + "description": "Виберіть сповіщення, які потрібно заблокувати" + }, + "gallery_media_send_override": { + "name": "Заміна надсилання медіафайлів із галереї", + "description": "Підроблює медіаджерело під час надсилання з галереї" + }, + "prevent_story_rewatch_indicator": { + "description": "Запобігає тому, щоб хтось дізнався, що ви переглянули їхню історію", + "name": "Індикатор запобігання перегляду історії" + }, + "remove_groups_locked_status": { + "name": "Видалення статусу заблокованих груп", + "description": "Дозволяє переглядати інформацію про групу після того, як вас вигнали" + }, + "hide_typing_notifications": { + "name": "Приховати сповіщення про введення", + "description": "Не дозволяє нікому знати, що ви набираєте повідомлення" + }, + "unlimited_snap_view_time": { + "name": "Необмежений час перегляду знімків", + "description": "Знімає часове обмеження для перегляду знімків" + }, + "call_start_confirmation": { + "name": "Підтвердження початку виклику", + "description": "Відображає діалогове вікно підтвердження під час виклику" + }, + "anonymous_story_viewing": { + "description": "Не дозволяє нікому знати, що ви бачили їх історію", + "name": "Анонімний перегляд історії" + }, + "mark_snap_as_seen_button": { + "name": "Кнопка «Позначити прив’язку як видиму»", + "description": "Додає кнопку для позначення Snap як видимого під час його перегляду.\nЦе працюватиме, навіть якщо ввімкнено режим Stealth" + }, + "double_tap_chat_action_custom_emoji": { + "name": "Двічі торкніться чату. Реакція власних емодзі", + "description": "Встановлює спеціальну реакцію емодзі для дії чату подвійним дотиком" + }, + "double_tap_chat_action": { + "name": "Дія чату подвійним дотиком", + "description": "Виконує спеціальну дію, коли двічі торкається повідомлення в чаті" + } + }, + "description": "Змініть спосіб спілкування з друзями", + "name": "Обмін повідомленнями" + }, + "user_interface": { + "properties": { + "custom_theme": { + "name": "Спеціальна тема", + "description": "Налаштуйте кольори Snapchat\nПримітка: якщо ви виберете темну тему (наприклад, Amoled), можливо, вам доведеться ввімкнути темний режим у налаштуваннях Snapchat для кращих результатів" + }, + "hide_ui_components": { + "name": "Приховати компоненти інтерфейсу користувача", + "description": "Виберіть, які компоненти інтерфейсу користувача приховати" + }, + "old_bitmoji_selfie": { + "name": "Старе селфі Bitmoji", + "description": "Повертає селфі Bitmoji зі старих версій Snapchat" + }, + "enable_friend_feed_menu_bar": { + "description": "Вмикає нову панель меню стрічки друзів", + "name": "Рядок меню стрічки друзів" + }, + "message_indicators": { + "name": "Індикатори повідомлень", + "description": "Додає до повідомлень спеціальні значки індикаторів \nПримітка. Індикатори можуть бути неточними на 100%" + }, + "edit_text_override": { + "name": "Редагувати перевизначення тексту", + "description": "Перевизначає поведінку текстового поля" + }, + "snap_preview": { + "name": "Попередній перегляд знімків", + "description": "Відображає невеликий попередній перегляд поруч із невидимими знімками в чаті" + }, + "bootstrap_override": { + "properties": { + "app_appearance": { + "name": "Зовнішній вигляд програми", + "description": "Встановлює постійний зовнішній вигляд програми" + }, + "home_tab": { + "description": "Перевизначає вкладку запуску під час відкриття Snapchat", + "name": "Вкладка «Домашня сторінка»" + }, + "simple_snapchat": { + "name": "Простий Snapchat", + "description": "Вмикає спрощену версію Snapchat" + } + }, + "name": "Перевизначення Bootstrap", + "description": "Замінює параметри завантаження інтерфейсу користувача" + }, + "map_friend_nametags": { + "name": "Покращені теги імен на карті друзів", + "description": "Покращує теги імен друзів на Snapmap" + }, + "streak_expiration_info": { + "description": "Показує таймер завершення смуги поряд із лічильником смуг", + "name": "Показати інформацію про закінчення серії" + }, + "hide_story_suggestions": { + "description": "Видаляє пропозиції зі сторінки «Історії»", + "name": "Приховати пропозиції історії" + }, + "prevent_message_list_auto_scroll": { + "name": "Запобігання автоматичного прокручування списку повідомлень", + "description": "Запобігає прокручування списку повідомлень униз під час надсилання/отримання повідомлення" + }, + "opera_media_quick_info": { + "description": "Показує корисну інформацію про медіа, наприклад дату створення, у контекстному меню програми перегляду Opera", + "name": "Opera Media Коротка інформація" + }, + "disable_spotlight": { + "description": "Вимикає сторінку Spotlight", + "name": "Вимкнути Spotlight" + }, + "vertical_story_viewer": { + "name": "Вертикальний переглядач історій", + "description": "Вмикає вертикальний переглядач для всіх історій" + }, + "stealth_mode_indicator": { + "name": "Індикатор режиму Stealth", + "description": "Додає 👻 emoji поруч із розмовами в прихованому режимі" + }, + "hide_friend_feed_entry": { + "description": "Приховує певного друга зі стрічки друзів\nВикористовуйте вкладку соціальних мереж, щоб керувати цією функцією", + "name": "Приховати запис стрічки друзів" + }, + "enable_app_appearance": { + "name": "Увімкніть налаштування зовнішнього вигляду програми", + "description": "Вмикає приховане налаштування зовнішнього вигляду програми\nМоже не потрібний для новіших версій Snapchat" + }, + "friend_feed_message_preview": { + "name": "Попередній перегляд повідомлень стрічки друзів", + "description": "Показує попередній перегляд останніх повідомлень у стрічці друзів", + "properties": { + "amount": { + "name": "Сума", + "description": "Кількість повідомлень для попереднього перегляду" + } + } + }, + "friend_feed_menu_buttons": { + "description": "Виберіть, які кнопки відображати в меню «Стрічка друзів»", + "name": "Кнопки меню стрічки друзів" + }, + "auto_close_friend_feed_menu": { + "name": "Автоматичне закриття меню стрічки друзів", + "description": "Автоматично закриває меню Friend Feed після натискання кнопки налаштування" + }, + "hide_quick_add_suggestions": { + "name": "Приховати пропозиції швидкого додавання", + "description": "Видаляє пропозиції щодо швидкого додавання друзів" + }, + "hide_streak_restore": { + "description": "Приховує кнопку «Відновити» у стрічці друзів", + "name": "Приховати відновлення смуги" + } + }, + "description": "Змініть зовнішній вигляд Snapchat", + "name": "Інтерфейс користувача" + }, + "global": { + "properties": { + "media_upload_quality": { + "properties": { + "custom_image_upload_format": { + "description": "Встановлює спеціальний формат завантаження зображень\nДля найкращої якості виберіть формат без втрат (наприклад, PNG)", + "name": "Спеціальний формат завантаження зображень" + }, + "force_video_upload_source_quality": { + "description": "Примушує Snapchat використовувати вихідну якість під час завантаження відео\nЗауважте, що це може не видалити метадані з медіа", + "name": "Примусове завантаження вихідної якості відео" + }, + "disable_image_compression": { + "name": "Вимкнути стиснення зображень", + "description": "Вимикає стиснення зображень під час завантаження медіа" + } + }, + "description": "Перекриває якість завантаження медіа", + "name": "Якість завантаження медіа" + }, + "disable_confirmation_dialogs": { + "description": "Автоматично підтверджує вибрані дії", + "name": "Вимкнути діалогові вікна підтвердження" + }, + "snapchat_plus": { + "name": "Snapchat Plus", + "description": "Вмикає функції Snapchat Plus\nДеякі серверні функції можуть не працювати" + }, + "better_location": { + "properties": { + "coordinates": { + "name": "Координати", + "description": "Встановіть координати підробленого місця" + }, + "walk_radius": { + "name": "Радіус ходьби", + "description": "Навмання ходити в межах цього радіусу (футів)" + }, + "suspend_location_updates": { + "name": "Призупинити оновлення місцезнаходження", + "description": "Запобігає оновленню вашого місцезнаходження" + }, + "spoof_location": { + "description": "Підмінює ваше місцезнаходження за вказане", + "name": "Локація підробки" + }, + "always_update_location": { + "name": "Завжди оновлювати місцезнаходження", + "description": "Примусово оновлювати місцезнаходження Snapchat, навіть якщо дані GPS не отримано" + }, + "spoof_battery_level": { + "description": "Підроблює рівень заряду акумулятора вашого пристрою на карті\nЗначення має бути від 0 до 100", + "name": "Підробка рівня заряду батареї" + }, + "spoof_headphones": { + "name": "Підроблені навушники", + "description": "Підробляє статус прослуховування музики на карті" + }, + "show_battery_level": { + "name": "Показати рівень заряду батареї", + "description": "Показує рівень заряду акумулятора ваших друзів на карті" + } + }, + "description": "Покращує розташування Snapchat", + "name": "Краще розташування" + }, + "block_ads": { + "name": "Блокувати рекламу", + "description": "Запобігає показу реклами" + }, + "hide_active_music": { + "name": "Приховати активну музику", + "description": "Не дозволяє Snapchat знати, що ви слухаєте музику\nЦе дозволить вам робити знімки за допомогою кнопок керування гучністю під час прослуховування музики" + }, + "auto_updater": { + "name": "Автоматичне оновлення", + "description": "Автоматично перевіряє наявність нових оновлень" + }, + "disable_custom_tabs": { + "description": "Відкриває посилання у підтримуваних програмах, а не у веб-браузері", + "name": "Вимкнути спеціальні вкладки" + }, + "disable_permission_requests": { + "name": "Вимкнути запити на дозвіл", + "description": "Запобігає запиту Snapchat конкретних дозволів" + }, + "bypass_video_length_restriction": { + "description": "Одиночне: надсилає одне відео\nРозділити: розділити відео після редагування", + "name": "Обійти обмеження щодо тривалості відео" + }, + "default_video_playback_rate": { + "description": "Встановлює стандартну швидкість для відтворення відео\nЗначення має бути від 0,1 до 4,0", + "name": "Стандартна швидкість відтворення відео" + }, + "video_playback_rate_slider": { + "name": "Повзунок швидкості відтворення відео", + "description": "Додає повзунок у контекстне меню Opera для зміни швидкості відтворення відео\nПримітка. Зміни стосуються лише наступних відео" + }, + "default_volume_controls": { + "name": "Регулятори гучності за замовчуванням", + "description": "Змушує Snapchat використовувати системні елементи керування гучністю" + }, + "disable_metrics": { + "name": "Вимкнути показники", + "description": "Блокує надсилання певних аналітичних даних до Snapchat" + }, + "disable_snap_splitting": { + "description": "Запобігає розділенню знімків на кілька частин\nНадіслані вами фотографії перетворюються на відео", + "name": "Вимкнути Snap Splitting" + }, + "disable_story_sections": { + "name": "Вимкнути розділи історії", + "description": "Видаляє розділи зі сторінки «Історії».\nДля належної роботи може знадобитися оновлення" + }, + "disable_telecom_framework": { + "name": "Вимкнути Telecom Framework", + "description": "Запобігає Snapchat використовувати структуру Android Telecom\nЦе дозволяє слухати музику під час розмови" + }, + "spotlight_comments_username": { + "name": "Ім'я користувача Spotlight Comments", + "description": "Показує ім’я користувача автора в коментарях Spotlight" + }, + "disable_google_play_dialogs": { + "name": "Вимкнути діалогові вікна служб Google Play", + "description": "Заборонити показ діалогових вікон доступності служб Google Play" + }, + "disable_memories_snap_feed": { + "description": "Запобігає Snapchat показувати останні спогади, коли ви проводите пальцем угору в камері", + "name": "Вимкнути спогади Snap Feed" + } + }, + "description": "Налаштуйте глобальні налаштування Snapchat", + "name": "Глобальний" + }, + "streaks_reminder": { + "properties": { + "interval": { + "name": "Інтервал", + "description": "Інтервал між кожним нагадуванням (годин)" + }, + "group_notifications": { + "description": "Згрупуйте сповіщення в одне", + "name": "Групові сповіщення" + }, + "remaining_hours": { + "name": "Час, що залишився", + "description": "Час, що залишився до відображення сповіщення (годин)" + } + }, + "name": "Нагадування про смуги", + "description": "Періодично сповіщає вас про ваші смуги" + }, + "camera": { + "properties": { + "override_front_resolution": { + "name": "Перевизначити передню роздільну здатність", + "description": "Замінює роздільну здатність передньої камери" + }, + "back_custom_frame_rate": { + "name": "Назад Користувацька частота кадрів", + "description": "Перевизначає частоту кадрів задньої камери" + }, + "disable_cameras": { + "description": "Забороняє Snapchat використовувати вибрані камери", + "name": "Вимкнути камери" + }, + "override_back_resolution": { + "name": "Перевизначити зворотну роздільну здатність", + "description": "Замінює роздільну здатність камери для задньої камери" + }, + "force_camera_source_encoding": { + "name": "Примусове кодування джерела камери", + "description": "Примусове кодування джерела камери" + }, + "immersive_camera_preview": { + "name": "Імерсивний попередній перегляд", + "description": "Не дозволяє Snapchat обрізати попередній перегляд камери\nЦе може призвести до мерехтіння камери на деяких пристроях" + }, + "black_photos": { + "name": "Чорні фотографії", + "description": "Замінює зроблені фотографії чорним фоном\nЦе не впливає на відео" + }, + "front_custom_frame_rate": { + "description": "Перевизначає частоту кадрів передньої камери", + "name": "Фронтальна частота кадрів" + }, + "hevc_recording": { + "name": "Запис HEVC", + "description": "Використовує кодек HEVC (H.265) для запису відео" + }, + "custom_resolution": { + "description": "Встановлює спеціальну роздільну здатність камери, ширину x висоту (наприклад, 1920x1080).\nКористувацька роздільна здатність має підтримуватися вашим пристроєм", + "name": "Спеціальна роздільна здатність" + }, + "startup_default_camera": { + "name": "Запуск камери за замовчуванням", + "description": "Встановлює камеру за умовчанням під час відкриття Snapchat" + } + }, + "name": "Камера", + "description": "Налаштуйте правильні параметри для ідеального знімка" + }, + "rules": { + "name": "Правила", + "description": "Керуйте автоматичними функціями для окремих людей" + }, + "experimental": { + "properties": { + "native_hooks": { + "description": "Небезпечні функції, які підключаються до рідного коду Snapchat", + "properties": { + "composer_hooks": { + "properties": { + "composer_logs": { + "name": "Журнали композитора", + "description": "Переспрямовує журнали консолі Composer до SnapEnhance" + }, + "show_first_created_username": { + "description": "Показує перше створене ім’я користувача поруч із поточним іменем користувача на сторінці профілю", + "name": "Показати перше створене ім'я користувача" + }, + "bypass_camera_roll_limit": { + "description": "Збільшує максимальну кількість медіафайлів, які можна надіслати з папки камери", + "name": "Обійти ліміт камери" + }, + "composer_console": { + "name": "Консоль композитора", + "description": "Дозволяє виконувати код JavaScript у Composer (лише arm64)" + } + }, + "description": "Впроваджує код у кросплатформну структуру інтерфейсу користувача Composer", + "name": "Композиторські гачки" + }, + "custom_emoji_font": { + "name": "Спеціальний шрифт Emoji", + "description": "Дозволяє використовувати спеціальний шрифт emoji. Працює лише зі шрифтами .ttf" + }, + "disable_bitmoji": { + "name": "Вимкнути Bitmoji", + "description": "Вимикає Bitmoji профілю друзів" + }, + "custom_shared_library": { + "description": "Завантажує спеціальну спільну бібліотеку в Snapchat. Ця функція призначена лише для тестування", + "name": "Спеціальна спільна бібліотека" + } + }, + "name": "Рідні гачки" + }, + "convert_message_locally": { + "description": "Перетворює знімки на локальний зовнішній носій чату. Це відображається в контекстному меню завантаження чату", + "name": "Конвертувати повідомлення локально" + }, + "edit_message": { + "description": "Дозволяє редагувати повідомлення в бесідах", + "name": "Редагувати повідомлення" + }, + "add_friend_source_spoof": { + "description": "Підроблює джерело запиту друга", + "name": "Додати друга Source Spoof" + }, + "app_lock": { + "name": "Блокування програми", + "properties": { + "lock_on_resume": { + "name": "Заблокувати резюме", + "description": "Блокує програму, коли її повторно відкривають" + } + }, + "description": "Запобігає доступу до Snapchat без пароля" + }, + "custom_streaks_expiration_format": { + "description": "Налаштовує формат терміну дії смуг\n\nДоступні змінні:\n - %c: кількість смуг\n - %e: Емодзі «Пісочний годинник».\n - %d: днів\n - %h: години\n - %m: хвилини\n - %s: Секунди\n - %w: час, що залишився", + "name": "Спеціальний формат терміну дії смуг" + }, + "spoof": { + "properties": { + "remove_vpn_transport_flag": { + "name": "Видаліть прапор транспортування VPN", + "description": "Запобігає Snapchat виявляти VPN" + }, + "fingerprint": { + "name": "Відбиток пальця пристрою", + "description": "Підробка відбитків пальців вашого пристрою" + }, + "play_store_installer_package_name": { + "name": "Назва пакета інсталятора Play Store", + "description": "Замінює назву пакета інсталятора на com.android.vending" + }, + "android_id": { + "name": "Android ID", + "description": "Підмінює ваш Android ID на вказане значення" + }, + "remove_mock_location_flag": { + "name": "Видаліть позначку фіктивного розташування", + "description": "Запобігає Snapchat виявляти фальшиве місцезнаходження" + }, + "randomize_persistent_device_token": { + "description": "Генерує випадковий маркер пристрою після кожного входу", + "name": "Рандомізуйте постійний маркер пристрою" + } + }, + "description": "Підробка різної інформації про вас", + "name": "Обман" + }, + "best_friend_pinning": { + "description": "Дозволяє закріпити друга як найкращого друга номер один. Примітка: лише ви можете бачити свого закріпленого найкращого друга", + "name": "Закріплення найкращого друга" + }, + "e2ee": { + "properties": { + "encrypted_message_indicator": { + "name": "Індикатор зашифрованого повідомлення", + "description": "Додає 🔒 emoji поруч із зашифрованими повідомленнями" + }, + "force_message_encryption": { + "description": "Запобігає надсиланню зашифрованих повідомлень людям, у яких не ввімкнено шифрування E2E, лише якщо вибрано кілька розмов", + "name": "Примусове шифрування повідомлень" + } + }, + "description": "Шифрує ваші повідомлення за допомогою AES за допомогою спільного секретного ключа\nОбов’язково зберігайте ключ у безпечному місці!", + "name": "Наскрізне шифрування" + }, + "better_transcript": { + "properties": { + "enhanced_transcript_in_notifications": { + "name": "Розширена транскрипція в сповіщеннях", + "description": "Транскрибує голосові нотатки в сповіщеннях за допомогою DeepL. Для цього в Better Notifications потрібно ввімкнути функцію попереднього перегляду чату" + }, + "force_transcription": { + "name": "Примусова транскрипція голосової нотатки", + "description": "Дозволяє транскрибувати всі голосові нотатки" + }, + "preferred_transcription_lang": { + "name": "Бажана мова транскрипції", + "description": "Бажана мова для розшифровки голосової нотатки (наприклад, EN, ES, FR)" + }, + "enhanced_transcript": { + "name": "Покращена транскрипція", + "description": "Покращує розшифровку голосових нотаток за допомогою DeepL.\nПеред використанням цієї функції переконайтеся, що ви прочитали їхню політику конфіденційності." + } + }, + "name": "Краща транскрипція", + "description": "Покращує розшифровку голосових нотаток" + }, + "infinite_story_boost": { + "name": "Нескінченна Story Boost", + "description": "Обійти затримку Story Boost Limit" + }, + "media_file_picker": { + "name": "Вибір мультимедійних файлів", + "description": "Дозволяє вибрати будь-який відео/аудіофайл із галереї" + }, + "account_switcher": { + "description": "Дозволяє перемикатися між обліковими записами без виходу з системи \nНатисніть і утримуйте значок пошуку поруч із вашим профілем Bitmoji, щоб відкрити меню \nПримітка. Ця функція є експериментальною та, ймовірно, буде змінена в майбутньому", + "properties": { + "auto_backup_current_account": { + "name": "Автоматичне резервне копіювання поточного облікового запису", + "description": "Автоматично створює резервну копію поточного облікового запису під час виходу з системи або зміни облікового запису" + } + }, + "name": "Перемикач облікових записів" + }, + "context_menu_fix": { + "name": "Виправлення контекстного меню", + "description": "Спробуйте відновити меню Friend Feed, оскільки коли пристрій не в мережі, воно не відображається належним чином" + }, + "cof_experiments": { + "name": "Експерименти COF", + "description": "Вмикає невипущені/бета-функції Snapchat" + }, + "voice_note_auto_play": { + "description": "Автоматично відтворює наступну голосову нотатку після закінчення поточної", + "name": "Автовідтворення голосової нотатки" + }, + "no_friend_score_delay": { + "name": "Немає затримки оцінки друзів", + "description": "Усуває затримку під час перегляду результатів друзів" + }, + "story_logger": { + "name": "Реєстратор історій", + "description": "Надає історію історій друзів" + }, + "call_recorder": { + "name": "Запис дзвінків", + "description": "Автоматично записує аудіодзвінки" + }, + "prevent_forced_logout": { + "description": "Запобігає виходу Snapchat під час входу на іншому пристрої", + "name": "Запобігання примусового виходу з системи" + }, + "hidden_snapchat_plus_features": { + "name": "Приховані функції Snapchat Plus", + "description": "Вмикає невипущені/бета-функції Snapchat Plus\nМоже не працювати на старіших версіях Snapchat" + }, + "friend_notes": { + "description": "Дозволяє додавати нотатки до профілів друзів", + "name": "Примітки друзів" + }, + "meo_passcode_bypass": { + "description": "Обійти пароль лише для моїх очей\nЦе спрацює, лише якщо код доступу було введено правильно раніше", + "name": "Обхід пароля лише для моїх очей" + }, + "snapscore_changes": { + "name": "Зміни Snapscore", + "description": "Відстежує зміни в Snapscore друзів\nВикористовуйте цю функцію лише в новіших версіях Snapchat" + } + }, + "description": "Експериментальні особливості", + "name": "Експериментальний" + }, + "scripting": { + "properties": { + "developer_mode": { + "name": "Режим розробника", + "description": "Показує інформацію про налагодження в інтерфейсі користувача Snapchat" + }, + "auto_reload": { + "name": "Автоматичне перезавантаження", + "description": "Автоматично перезавантажує сценарії, коли вони змінюються" + }, + "module_folder": { + "name": "Папка модуля", + "description": "Папка, в якій знаходяться сценарії" + }, + "integrated_ui": { + "name": "Інтегрований інтерфейс користувача", + "description": "Дозволяє сценаріям додавати спеціальні компоненти інтерфейсу до Snapchat" + }, + "disable_log_anonymization": { + "description": "Вимикає анонімізацію журналів", + "name": "Вимкнути анонімізацію журналу" + } + }, + "name": "Сценарії", + "description": "Запустіть власні сценарії, щоб розширити SnapEnhance" + }, + "friend_tracker": { + "properties": { + "record_messaging_events": { + "description": "Записує події обміну повідомленнями, такі як відкриття знімка, читання повідомлення тощо.", + "name": "Записуйте події обміну повідомленнями" + }, + "auto_purge": { + "description": "Автоматично видаляє кешовані події, давніші за вказаний проміжок часу", + "name": "Автоматичне очищення" + }, + "allow_running_in_background": { + "name": "Дозволити роботу у фоновому режимі", + "description": "Дозволяє трекеру працювати у фоновому режимі. Примітка. Це значно розрядить акумулятор" + } + }, + "name": "Відстеження друзів", + "description": "Записує активність друга в Snapchat" + } + }, + "notices": { + "unstable": "⚠ Нестабільний", + "ban_risk": "⚠ Ця функція може спричинити бан", + "internal_behavior": "⚠ Це може порушити внутрішню роботу Snapchat" + }, + "options": { + "notifications": { + "typing": "Введення", + "stories": "Оповідання", + "snap": "Знімок", + "speaking": "Говорячи", + "chat_reaction": "Реакція DM", + "group_chat_reaction": "Групова реакція", + "chat_screenshot": "Скріншот", + "chat_screen_record": "Запис екрана", + "snap_replay": "Повторити Знімок", + "camera_roll_save": "Зберегти фотоплівку", + "chat": "Чат", + "initiate_audio": "Вхідний аудіодзвінок", + "initiate_video": "Вхідний відеодзвінок", + "abandon_audio": "Пропущений аудіодзвінок", + "chat_reply": "Чат Відповідь", + "abandon_video": "Пропущений відеодзвінок" + }, + "auto_purge": { + "3_months": "3 Місяці", + "6_months": "6 Місяці", + "never": "Ніколи", + "3_hours": "3 години", + "1_day": "1 День", + "3_days": "3 Днів", + "1_week": "1 Тиждень", + "1_month": "1 Місяць", + "1_hour": "1 годину", + "6_hours": "6 годин", + "12_hours": "12 годин", + "2_weeks": "2 Тижнів" + }, + "path_format": { + "append_hash": "Додайте унікальний хеш до імені файлу", + "append_source": "До назви файлу додайте джерело медіа", + "append_date_time": "Додайте дату й час до імені файлу", + "create_source_folder": "Створіть папку для кожного типу медіаджерела", + "append_username": "До назви файлу додайте ім’я користувача", + "create_author_folder": "Створіть папку для кожного автора" + }, + "strip_media_metadata": { + "remove_audio_note_duration": "Видалити тривалість звукової нотатки", + "hide_extras": "Приховати додаткові елементи (наприклад, згадки)", + "hide_snap_filters": "Сховати фільтри прив’язки", + "remove_audio_note_transcript_capability": "Видалення можливості розшифровки звукової нотатки", + "hide_caption_text": "Приховати текст підпису" + }, + "hide_ui_components": { + "hide_map_reactions": "Видалити реакції карти", + "hide_billboard_prompt": "Видалити підказку Billboard у стрічці друзів", + "hide_voice_record_button": "Видалити кнопку запису голосу", + "hide_chat_call_buttons": "Видаліть кнопки виклику чату", + "hide_live_location_share_button": "Видалити кнопку прямого доступу до місцезнаходження", + "hide_unread_chat_hint": "Видалити непрочитану підказку чату", + "hide_profile_call_buttons": "Видалити кнопки виклику профілю", + "hide_post_to_story_buttons": "Видаліть кнопки «Опублікувати в історії», перш ніж надсилати знімок", + "hide_stickers_button": "Кнопка видалення наклейок", + "hide_snapchat_plus_gift_reminders": "Видаліть нагадування про подарунки Snapchat Plus у розмовах" + }, + "disable_story_sections": { + "discover": "Відкрийте для себе", + "following": "Слідую", + "friends": "Друзі", + "suggested_stories": "Запропоновані історії" + }, + "double_tap_chat_action": { + "delete_message": "Видалити повідомлення", + "copy_text": "Копіювати текст у буфер обміну", + "mark_as_read": "Позначити як прочитане", + "like_message": "Як повідомлення", + "custom_emoji_reaction": "Спеціальна реакція Emoji" + }, + "old_bitmoji_selfie": { + "2d": "2D Bitmoji", + "3d": "3D Bitmoji" + }, + "hide_story_suggestions": { + "hide_my_stories": "Сховати мої історії", + "hide_suggested_friend_stories": "Сховати запропоновані історії друзів" + }, + "friend_feed_menu_buttons": { + "unsaveable_messages": "⬇️ Повідомлення, які не можна зберегти", + "e2e_encryption": "🔒 Використовуйте шифрування E2E", + "stealth": "👻 Режим скритності", + "mark_snaps_as_seen": "👀 Позначте знімки як видимі", + "auto_save": "💬 Автоматичне збереження повідомлень", + "mark_stories_as_seen_locally": "👀 Позначити історії як видимі локально", + "auto_download": "⬇️ Автоматичне завантаження", + "conversation_info": "👤 Інформація про розмову", + "auto_open_snaps": "📷 Автоматичне відкриття знімків" + }, + "app_appearance": { + "always_light": "Завжди Світло", + "always_dark": "Завжди темно" + }, + "auto_download_sources": { + "friend_snaps": "Друг Snaps", + "friend_stories": "Історії друзів", + "public_stories": "Публічні історії", + "spotlight": "Прожектор" + }, + "add_friend_source_spoof": { + "added_by_group_chat": "Через груповий чат", + "added_by_username": "За іменем користувача", + "added_by_mention": "За згадкою", + "added_by_qr_code": "За QR-кодом", + "added_by_community": "Від спільноти", + "added_by_quick_add": "Швидким додаванням (високий ризик бути заблокованим)" + }, + "disable_permission_requests": { + "notifications": "Сповіщення", + "read_media_video": "Читайте медіа-відео", + "location": "Розташування", + "read_media_images": "Читайте медіа-зображення", + "microphone": "Мікрофон", + "camera": "Камера", + "read_contacts": "Читати контакти", + "nearby_devices": "Пристрої поблизу", + "phone_calls": "Телефонні дзвінки" + }, + "friend_mutation_notifier": { + "bitmoji_selfie_changes": "Сповіщати, коли хтось змінює своє селфі Bitmoji", + "bitmoji_scene_changes": "Сповіщати, коли хтось змінює свою сцену Bitmoji", + "birthday_changes": "Сповіщати, коли хтось змінює свій день народження", + "bitmoji_background_changes": "Сповіщати, коли хтось змінює свій фон Bitmoji", + "bitmoji_avatar_changes": "Сповіщати, коли хтось змінює свій аватар Bitmoji", + "remove_friend": "Сповіщати, коли хтось видаляє вас зі списку друзів" + }, + "custom_theme": { + "amoled_dark_mode": "Темний режим Amoled", + "custom": "Спеціальні теми (використовуйте швидкі дії для керування темами)", + "material_you_light": "Material You Світло (Android 12+)", + "material_you_dark": "Material You Темний (Android 12+)" + }, + "gallery_media_send_override": { + "ORIGINAL": "Оригінальний носій", + "SNAP": "Знімок", + "SAVEABLE_SNAP": "Знімок, який можна зберегти", + "always_ask": "Завжди запитуйте", + "NOTE": "Аудіозаписка" + }, + "disable_confirmation_dialogs": { + "ignore_friend": "Ігнорувати друга", + "hide_friend": "Приховати друга", + "block_friend": "Заблокувати друга", + "hide_conversation": "Приховати розмову", + "clear_conversation": "Очистити розмову зі стрічки друзів", + "erase_message": "Видалити повідомлення", + "remove_friend": "Видалити друга" + }, + "disable_cameras": { + "front": "Фронтальна камера", + "back": "Задня камера" + }, + "message_indicators": { + "location_indicator": "Додає піктограму 📍 до знімків, якщо їх було надіслано з увімкненим місцеположенням", + "platform_indicator": "Додає піктограму платформи, з якої було надіслано медіафайл (наприклад, Android, iOS, Web)", + "encryption_indicator": "Додає значок 🔒 поруч із повідомленнями, надісланими лише вам", + "ovf_editor_indicator": "Вказує, чи було надіслано знімок за допомогою редактора OVF", + "director_mode_indicator": "Додає піктограму ✏️ до знімків, коли їх було надіслано в режимі режисера, який можна використовувати для надсилання зображень із галереї як знімків" + }, + "edit_text_override": { + "bypass_text_input_limit": "Обійти ліміт введення тексту", + "multi_line_chat_input": "Багаторядковий ввід чату" + }, + "auto_mark_as_read": { + "snap_reply": "Відповідаючи на них, позначайте знімки як прочитані", + "conversation_read": "Позначайте бесіду як прочитану під час надсилання повідомлення" + }, + "logging": { + "success": "Успіх", + "failure": "Провал", + "started": "Розпочато", + "progress": "Прогрес" + }, + "bypass_video_length_restriction": { + "single": "Один носій", + "split": "Розділення медіа" + }, + "snapchat_plus": { + "not_subscribed": "Не підписаний", + "basic": "Базовий", + "ad_free": "Без реклами" + }, + "home_tab": { + "chat": "Чат", + "map": "Карта", + "camera": "Камера", + "discover": "Відкрийте для себе", + "spotlight": "Прожектор" + }, + "auto_reload": { + "all": "Усі (Snapchat + SnapEnhance)", + "snapchat_only": "Лише Snapchat" + }, + "simple_snapchat": { + "always_disabled": "Завжди вимкнено", + "always_enabled": "Завжди ввімкнено" + }, + "startup_default_camera": { + "front": "Фронтальна камера", + "back": "Задня камера" + } + } + }, + "actions": { + "clean_snapchat_cache": { + "name": "Очистити кеш Snapchat", + "description": "Очищає кеш Snapchat" + }, + "change_language": { + "name": "Змінити мову", + "description": "Змініть мову SnapEnhance" + }, + "manage_friend_list": { + "name": "Керувати списком друзів", + "description": "Імпорт/експорт списку друзів під час резервного копіювання" + }, + "export_chat_messages": { + "name": "Експортувати повідомлення чату", + "description": "Експортує повідомлення розмов у файл JSON/HTML/TXT" + }, + "export_memories": { + "name": "Експорт спогадів", + "description": "Експорт спогадів у файл ZIP" + }, + "file_imports": { + "name": "Імпорт файлів", + "description": "Імпорт файлів для використання в Snapchat" + }, + "friend_tracker": { + "description": "Відстежуйте своїх друзів у Snapchat", + "name": "Відстеження друзів" + }, + "logger_history": { + "name": "Історія реєстратора", + "description": "Переглянути історію зареєстрованих повідомлень" + }, + "bulk_messaging_action": { + "description": "Виконує такі операції, як видалення друзів або масове видалення розмов", + "name": "Дія масового обміну повідомленнями" + }, + "regen_mappings": { + "name": "Відновити зіставлення", + "description": "Відновити зіставлення вручну" + }, + "security_features": { + "name": "Функції безпеки", + "description": "Змінити налаштування функцій безпеки" + }, + "theming": { + "name": "Тематизація", + "description": "Налаштуйте зовнішній вигляд Snapchat" + } + }, + "half_swipe_notifier": { + "notification_content_group": "{friend} щойно наполовину перейшов до {group} на {duration} с", + "notification_channel_name": "Проведіть наполовину", + "notification_content_dm": "{friend} щойно наполовину перейшов у ваш чат на {duration} с" + }, + "download_processor": { + "failed_no_longer_available_toast": "Медіафайли більше недоступні", + "download_started_toast": "Завантаження розпочато", + "select_attachments_title": "Виберіть вкладення", + "failed_processing_toast": "Помилка обробки {error}", + "attachment_type": { + "snap": "Знімок", + "sticker": "Стікери", + "external_media": "Зовнішній носій", + "note": "Примітка", + "original_story": "Оригінальна історія", + "gif": "GIF" + }, + "dash_dialog": { + "download_all": "Завантажити всі", + "segment_text": "Відрізок {from} - {to}", + "title": "Завантажити медіадаш" + }, + "unsupported_content_type_toast": "Непідтримуваний тип вмісту!", + "dash_no_chapter": "Розділ не знайдено", + "no_attachments_toast": "Вкладень не знайдено!", + "already_downloaded_toast": "Медіа вже завантажено!", + "failed_to_create_preview_toast": "Не вдалося створити попередній перегляд", + "failed_gallery_toast": "Не вдалося зберегти в галерею {error}", + "processing_toast": "Обробка {path}...", + "failed_generic_toast": "Не вдалося завантажити", + "already_queued_toast": "ЗМІ вже в черзі!", + "content_saved_toast": "Збережено!", + "download_toast": "Завантаження {path}..." + }, + "friendship_link_type": { + "incoming_follower": "Вхідний послідовник", + "deleted": "Видалено", + "suggested": "Запропоновано", + "outgoing": "Вихідний", + "following": "Слідую", + "blocked": "Заблоковано", + "incoming": "Вхідні", + "mutual": "Взаємний" + }, + "end_to_end_encryption": { + "outgoing_secret_message": "Відповідь обміну ключами", + "incoming_pk_message": "Ви щойно отримали запит на відкритий ключ. Натисніть нижче, щоб прийняти його.", + "incoming_secret_message": "Ваш друг щойно прийняв ваш відкритий ключ. Натисніть нижче, щоб прийняти секрет.", + "encryption_failed_toast": "Не вдалося зашифрувати повідомлення! Щоб дізнатися більше, перевірте logcat.", + "toolbox": { + "no_shared_key": "У вас ще немає спільного секрету з цим другом. Натисніть нижче, щоб розпочати новий.", + "shared_key_fingerprint": "Ваш відбиток пальця:\n\n{fingerprint}\n\nПеревірте, чи збігається він з відбитком пальця вашого друга!", + "initiate_exchange_button": "Ініціювати обмін ключами" + }, + "outgoing_pk_message": "Запит на обмін ключами", + "confirmation_dialogs": { + "title": "Наскрізне шифрування", + "confirmation_1": "ПОПЕРЕДЖЕННЯ: це перезапише наявний ключ. Ви втратите доступ до всіх зашифрованих повідомлень від цього друга. Ви впевнені, що бажаєте продовжити?", + "confirmation_2": "Ви ДІЙСНО впевнені, що бажаєте продовжити? Це ваш останній шанс відступити." + }, + "native_hooks_send_failure_toast": "Не вдалося надіслати! Увімкніть Native Hooks у налаштуваннях.", + "accept_secret_key_success_toast": "Готово! Тепер ви можете надсилати та отримувати зашифровані повідомлення з цим другом.", + "accept_secret_button": "Прийняти секрет", + "unencrypted_conversation_send_failure_toast": "Ви не можете надсилати зашифрований вміст одночасно в зашифровані та незашифровані розмови!", + "no_participants_to_encrypt_toast": "У вас немає друзів у цій розмові, з якими можна шифрувати повідомлення!", + "accept_public_key_success_toast": "Відкритий ключ успішно прийнято!", + "accept_public_key_failure_toast": "Не вдалося прийняти відкритий ключ", + "accept_secret_key_failure_toast": "Не вдалося прийняти секретний ключ", + "accept_public_key_button": "Прийняти відкритий ключ" + }, + "theming_attributes": { + "sigColorTextPrimary": "Основний колір тексту", + "sigColorChatChat": "Колір тексту основної стрічки друзів", + "sigColorChatSnapWithSound": "Знімки зі звуковим кольором тексту", + "ringStartColor": "Початковий колір дзвінка", + "sigColorLayoutPlaceholder": "Колір заповнювача макета", + "recipientPillBackgroundDrawable": "Одержувач таблетки фону", + "scButtonColor": "Колір кнопки Snapchat", + "rangeFillColor": "Колір заливки діапазону", + "pstsDividerColor": "Колір розділювача PSTS", + "statusBarBackground": "Колір тла рядка стану", + "editTextColor": "Редагувати колір тексту", + "chipBackgroundColor": "Колір фону мікросхеми", + "sigColorBackgroundSurface": "Колір фонової поверхні", + "sigColorChatPendingSending": "Колір тексту каналу вторинних друзів", + "sigColorBackgroundMain": "Колір фону", + "listBackgroundDrawable": "Тло списку розмов", + "sigColorChatConversationsLine": "Колір лінії розмов", + "sigColorStoryRingDiscoverTabThumbnailStoryRing": "Історійне кільце Відкрийте вкладку Мініатюра Колір сюжетного кільця", + "boxBackgroundColor": "Колір фону коробки", + "recipientInputStyle": "Стиль введення одержувача", + "actionSheetRoundedBackgroundDrawable": "Круглий фоновий колір меню дій", + "strokeColor": "Колір обведення", + "listDivider": "Колір роздільника списку", + "sigColorIconSecondary": "Додатковий колір значка", + "pstsIndicatorColor": "Колір індикатора PSTS", + "pstsTabBackground": "Фон вкладки PSTS", + "sigColorChatSnapWithoutSound": "Знімки без звукового кольору тексту", + "actionSheetDescriptionTextColor": "Дія Меню Опис Колір тексту", + "actionSheetBackgroundDrawable": "Колір фону меню дій", + "sigColorIconPrimary": "Колір значка меню дій", + "sigExceptionColorCameraGridLines": "Колір сітки камери", + "itemShapeFillColor": "Колір заливки форми елемента", + "ringColor": "Колір кільця", + "tabTextColor": "Колір тексту вкладки", + "statusBarForeground": "Колір переднього плану рядка стану", + "storyReplayViewRingColor": "Колір кільця перегляду історії", + "sigColorButtonPrimary": "Колір основної кнопки", + "sigColorBaseAppYellow": "Базовий додаток жовтого кольору", + "sigColorBackgroundSurfaceTranslucent": "Напівпрозорий фоновий колір поверхні", + "sigColorStoryRingFriendsFeedStoryRing": "Колір кільця Story Ring Friends Feed Story Ring" + }, + "conversation_preview": { + "no_messages": "Повідомлень не знайдено!", + "streak_expiration": "закінчується через {day} днів {hour} годин {minute} хвилин", + "total_messages": "Усього надісланих/отриманих повідомлень: {count}", + "title": "Попередній перегляд", + "unknown_user": "Невідомий користувач" + }, + "bulk_messaging_action": { + "choose_action_title": "Виберіть дію", + "progress_status": "Обробка {index} із {total}", + "selection_dialog_continue_button": "Продовжити", + "confirmation_dialog": { + "message": "Це вплине на всіх вибраних друзів. Цю дію не можна скасувати.", + "title": "Ви впевнені?" + }, + "actions": { + "remove_friends": "Видалити друзів", + "clear_conversations": "Очистити розмови" + } + }, + "content_type": { + "STATUS_COUNTDOWN": "Зворотний відлік", + "FAMILY_CENTER_LEAVE": "Сімейний центр Відпустка", + "STATUS_CALL_MISSED_AUDIO": "Пропущений аудіодзвінок", + "STATUS_CALL_MISSED_VIDEO": "Пропущений відеодзвінок", + "SNAP": "Змінок", + "EXTERNAL_MEDIA": "Зовнішній носій", + "FAMILY_CENTER_ACCEPT": "Сімейний центр Прийняти", + "FAMILY_CENTER_INVITE": "Сімейний центр запрошення", + "STATUS": "Статус", + "TINY_SNAP": "Крихітний знімок", + "NOTE": "Аудіозаписка", + "STICKER": "Стікери", + "LIVE_LOCATION_SHARE": "Обмін геоданими в реальному часі", + "CHAT": "Чат", + "SHARE": "Поділіться", + "LOCATION": "Розташування", + "STATUS_SAVE_TO_CAMERA_ROLL": "Збережено в фотоплівку", + "STATUS_CONVERSATION_CAPTURE_SCREENSHOT": "Скріншот", + "STATUS_CONVERSATION_CAPTURE_RECORD": "Запис екрана", + "CREATIVE_TOOL_ITEM": "Творчий інструмент", + "STATUS_PLUS_GIFT": "Статус плюс подарунок", + "MAP_REACTION": "Реакція карти" + }, + "friend_menu_option": { + "auto_download_blacklist": "Чорний список автоматичного завантаження", + "stealth_mode": "Режим скритності", + "mark_snaps_as_seen": "Позначте знімки як видимі", + "mark_stories_as_seen_locally": "Позначити історії як видимі локально", + "anti_auto_save": "Антиавтозбереження", + "preview": "Попередній перегляд" + }, + "mark_as_seen": { + "unseen_toast": "Позначено як непереглянуте!", + "no_unseen_snaps_toast": "Невидимі знімки не знайдено!", + "seen_toast": "Позначено як бачене!", + "already_seen_toast": "Вже позначено як бачене!", + "already_unseen_toast": "Вже позначено як непереглянуте!" + }, + "opera_context_menu": { + "sent_at": "Надіслано {date}", + "media_size": "Розмір носія: {size}", + "media_duration": "Тривалість медіа: {duration} мс", + "show_debug_info": "Показати інформацію про налагодження", + "expires_at": "Термін дії закінчується {date}", + "download": "Завантажити медіа", + "created_at": "Створено {date}" + }, + "material3_strings": { + "date_input_invalid_for_pattern": "Недійсна дата", + "date_picker_today_description": "Сьогодні", + "date_range_picker_title": "Виберіть діапазон дат", + "date_range_input_invalid_range_input": "Недійсний діапазон дат", + "date_picker_switch_to_input_mode": "Введення", + "date_range_picker_scroll_to_next_month": "Наступного місяця", + "date_input_invalid_year_range": "Недійсний рік", + "date_picker_switch_to_calendar_mode": "Календар", + "date_range_picker_start_headline": "Від", + "date_range_picker_end_headline": "До", + "date_range_picker_scroll_to_previous_month": "Попередній місяць", + "date_range_picker_day_in_range": "Вибране", + "date_input_invalid_not_allowed": "Недійсна дата" + }, + "auto_open_snaps": { + "title": "Автоматичне відкриття знімків", + "notification_content": "Відкрито знімків: {count}" + }, + "friend_mutation_observer": { + "birthday_changed": "{username} змінив день народження з {oldBirthday} на {newBirthday}", + "bitmoji_background_changed": "{username} змінив фон Bitmoji", + "bitmoji_selfie_changed": "{username} змінив своє селфі Bitmoji", + "bitmoji_scene_changed": "{username} змінив сцену Bitmoji", + "birthday_removed": "{username} видалив свою дату народження ({birthday})", + "birthday_added": "{username} додав свій день народження ({birthday})", + "bitmoji_avatar_changed": "{username} змінив свій аватар Bitmoji", + "notification_channel_name": "Друг спостерігач мутації", + "friend_removed": "{username} видалив вас зі списку друзів" + }, + "send_override_dialog": { + "duration": "Тривалість: {duration}", + "unlimited_duration": "Необмежений", + "saveable_snap_hint": "Зробіть Snap доступним для збереження в чаті", + "title": "Надіслати медіа як {type}" + }, + "profile_info": { + "add_source": "Додати джерело", + "mutable_username": "Змінне ім'я користувача", + "display_name": "Відображуване ім'я", + "birthday": "День народження : {month} {day}", + "friendship": "Дружба", + "snapchat_plus_state": { + "subscribed": "Підписався", + "not_subscribed": "Не підписаний" + }, + "added_date": "Дата додавання", + "hidden_birthday": "День народження: приховано", + "snapchat_plus": "Snapchat Plus", + "title": "Інформація про профіль", + "first_created_username": "Перше створене ім'я користувача" + }, + "button": { + "ok": "OK", + "negative": "Ні", + "cancel": "Скасувати", + "send": "Надіслати", + "download": "Завантажити", + "positive": "Так", + "open": "Відкрити" + }, + "chat_export": { + "exporter_dialog": { + "amount_of_messages_title": "Кількість повідомлень (залиште поле порожнім для всіх)", + "export_file_format_title": "Формат файлу експорту", + "download_medias_title": "Завантажити медіа", + "text_field_selection": "Вибрано {amount}", + "message_type_filter_title": "Фільтрувати повідомлення за типом", + "select_conversations_title": "Виберіть Розмови", + "text_field_selection_all": "Всі" + }, + "exported_to": "Експортовано в {path}", + "no_messages_found": "Повідомлень не знайдено!", + "dialog_positive_button": "Експорт", + "export_fail": "Не вдалося експортувати бесіду {conversation}", + "finished": "Готово! Тепер ви можете закрити це діалогове вікно.", + "writing_output": "Запис вихідних даних...", + "exporting_chats": "Експорт чатів...", + "exporting_message": "Експорт {conversation}...", + "processing_chats": "Обробка {amount} розмов...", + "dialog_negative_button": "Скасувати" + }, + "chat_action_menu": { + "delete_logged_message_button": "Видалити зареєстроване повідомлення", + "show_chat_edit_history": "Показати історію редагування чату", + "preview_button": "Попередній перегляд", + "download_button": "Завантажити", + "convert_message": "Перетворити повідомлення", + "edit_message": "Редагувати повідомлення" + }, + "profile_picture_downloader": { + "button": "Завантажити зображення профілю", + "title": "Завантажувач зображень профілю", + "avatar_option": "Аватар", + "background_option": "Фон" + }, + "call_start_confirmation": { + "dialog_title": "Розпочати виклик", + "dialog_message": "Ви впевнені, що хочете почати дзвінок?" + }, + "media_download_source": { + "story_logger": "Реєстратор історій", + "spotlight": "Прожектор", + "merged": "Об'єднано", + "none": "Жодного", + "public_story": "Публічна історія", + "voice_call": "Голосовий виклик", + "chat_media": "Медіа-чат", + "pending": "В очікуванні", + "story": "Розповідь", + "profile_picture": "Фото профілю", + "message_logger": "Реєстратор повідомлень" + }, + "streaks_reminder": { + "notification_text": "Ви втратите серію з {friend} через {hoursLeft} год", + "notification_title": "Смуги" + }, + "modal_option": { + "close": "Закрити", + "profile_info": "Інформація про профіль" + }, + "gallery_media_send_override": { + "multiple_media_toast": "Ви можете надсилати лише одне медіа за раз" + }, + "better_notifications": { + "button": { + "mark_as_read": "Позначити як прочитане", + "download": "Завантажити", + "reply": "Відповісти" + } + }, + "biometric_auth": { + "unlock_button": "Розблокувати", + "subtitle": "Щоб розблокувати Snapchat, пройдіть авторизацію", + "title": "Розблокуйте Snapchat" + } +} diff --git a/common/src/main/assets/lang/wep.json b/common/src/main/assets/lang/wep.json new file mode 100644 index 0000000000..c49f6a3f31 --- /dev/null +++ b/common/src/main/assets/lang/wep.json @@ -0,0 +1,1389 @@ +{ + "setup": { + "dialogs": { + "select_language": "Sprach uswähle", + "save_folder": "SnapEnhance benötigt Speicherberächtigunge zum Abelade und Speicher vo Medie vo Snapchat.\nBitte wähled Sie de Ort us wo d Medie sölled abeglade werde.", + "select_save_folder_button": "Ordner wählen" + }, + "mappings": { + "dialog": "Mappings werden generiert, dies könnte ein bisschen dauern ...", + "generate_failure_no_snapchat": "SnapEnhance konnte Snapchat nicht finden, bitte versuchen Sie Snapchat neu zu installieren.", + "generate_failure": "Beim Generieren der Zuordnungen ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut." + }, + "permissions": { + "battery_optimization": "Batterieoptimierung", + "dialog": "Um fortfahren zu können, müssen Sie diese Anforderungen erfüllen:", + "notification_access": "Zugriff auf Benachrichtigungen", + "display_over_other_apps": "Über anderen Apps einblenden", + "request_button": "Anfordern" + } + }, + "manager": { + "sections": { + "social": { + "friends_tab": "Freunde", + "groups_tab": "Gruppen", + "empty_hint": "(leer)", + "streaks_expiration_short": "{hours}h" + }, + "manage_scope": { + "reminder_button": "Erinnerung erstellen", + "logged_stories_button": "Aufgezeichnete Stories anschauen", + "e2ee_title": "Ende-zu-Ende Verschlüsselung", + "rules_title": "Regeln", + "participants_text": "{count} Teilnehmer", + "not_found": "Nicht gefunden", + "streaks_title": "Streaks", + "streaks_length_text": "Länge: {length}", + "streaks_expiration_text": "Läuft ab in {eta}", + "streaks_expiration_text_expired": "Abgelaufen", + "delete_scope_confirm_dialog_title": "Sind Sie sicher, dass Sie ein {scope} löschen wollen?" + }, + "home": { + "update_title": "SnapEnhance Update", + "update_content": "Version {version} ist verfügbar!", + "update_button": "Herunterladen" + }, + "home_logs": { + "no_logs_hint": "Keine Protokolle verfügbar", + "clear_logs_button": "Protokolle löschen", + "export_logs_button": "Protokolle exportieren", + "saving_logs_toast": "Speichern von Protokollen, das kann eine Weile dauern ...", + "saved_logs_success_toast": "Protokolle erfolgreich gespeichert", + "saved_logs_failure_toast": "Speichern von Protokollen fehlgeschlagen" + }, + "home_settings": { + "actions_title": "Aktionen", + "message_logger_title": "Nachrichtenaufzeichner", + "debug_title": "Debuggen", + "success_toast": "Erledigt!", + "message_logger_summary": "{messageCount} Nachrichten\n{storyCount} Stories", + "export_button": "Exportieren", + "clear_button": "Löschen", + "view_logger_history_button": "Historie des Nachrichtenaufzeichners ansehen" + }, + "tasks": { + "no_tasks": "Keine Aufgaben", + "merge_files_toast": "Zusammenführen von {count} Dateien", + "remove_selected_tasks_title": "Sind Sie sicher, dass Sie die ausgewählten Aufgaben entfernen möchten?", + "remove_all_tasks_title": "Sind Sie sicher, dass Sie alle Aufgaben entfernen möchten?", + "delete_files_option": "Auch Dateien löschen", + "remove_selected_tasks_confirm": "{count} Aufgaben entfernen?", + "remove_all_tasks_confirm": "Alle Aufgaben entfernen?" + }, + "features": { + "disabled": "Deaktiviert", + "export_option": "Exportieren", + "import_option": "Importieren", + "reset_option": "Zurücksetzen", + "config_export_success_toast": "Einstellungen erfolgreich exportiert", + "config_import_success_toast": "Einstellungen erfolgreich importiert", + "config_import_failure_toast": "Einstellungen konnten nicht importiert werden. {error}", + "saved_config_snackbar": "Einstellungen gespeichert" + }, + "logged_stories": { + "story_failed_to_load": "Laden fehlgeschlagen", + "no_stories": "Keine Stories gefunden", + "save_from_cache_button": "Aus Zwischenspeicher speichern" + }, + "messaging_preview": { + "bridge_connection_failed": "Verbindung zu Snapchat über den Brückendienst fehlgeschlagen", + "bridge_init_failed": "Initialisierung der Nachrichtenbrücke fehlgeschlagen", + "message_fetch_failed": "Fehler beim Abrufen der Nachrichten", + "no_message_hint": "Keine Nachricht", + "save_selection_option": "Auswahl speichern", + "save_all_option": "Alles speichern", + "unsave_selection_option": "Auswahl nicht mehr speichern", + "unsave_all_option": "Alles nicht mehr speichern", + "mark_selection_as_seen_option": "Ausgewählten Snap als gesehen markieren", + "mark_all_as_seen_option": "Alle Snaps als gesehen markieren", + "delete_selection_option": "Auswahl löschen", + "delete_all_option": "Alles löschen" + }, + "logger_history": { + "list_friend_format": "Freund {name}", + "list_group_format": "Gruppe {name}", + "no_more_messages": "Keine weiteren Nachrichten", + "reverse_order_checkbox": "Umgekehrte Reihenfolge", + "chat_attachment": "Anhang {index}", + "empty_message": "Leere Chatnachricht", + "message_parse_failed": "Nachricht konnte nicht verarbeitet werden", + "unknown_sender": "Unbekannter Absender", + "download_attachment_failed_toast": "Fehler beim Herunterladen des Anhangs" + } + }, + "dialogs": { + "add_friend": { + "search_hint": "Suchen", + "title": "Freund oder Gruppe hinzufügen", + "fetch_error": "Fehler beim Abrufen der Daten", + "category_groups": "Gruppen", + "category_friends": "Freunde" + }, + "reset_config": { + "title": "Einstellungen zurücksetzen", + "content": "Bist Du sicher, dass Du die Einstellungen zurücksetzen möchtest?", + "success_toast": "Einstellungen erfolgreich zurückgesetzt" + }, + "scripting_warning": { + "title": "Warnung", + "content": "SnapEnhance enthält ein Skripting-Tool, das die Ausführung von benutzerdefinierten Code auf Ihrem Gerät ermöglicht. Seien Sie äußerst vorsichtig und installieren Sie nur Module aus bekannten, zuverlässigen Quellen. Unautorisierte oder ungeprüfte Module können Sicherheitsrisiken für Ihr System darstellen." + }, + "messaging_action": { + "title": "Wählen Sie die zu verarbeitenden Inhaltstypen um fortzufahren", + "select_all_button": "Alle auswählen" + } + }, + "routes": { + "tasks": "Aufgaben", + "features": "Funktionen", + "home": "Startseite", + "home_settings": "Einstellungen", + "home_logs": "Logs", + "logger_history": "Logs Verlauf", + "logged_stories": "Geloggte Storys", + "friend_tracker": "Freundtracker:in", + "edit_rule": "Regel bearbeiten", + "social": "Sozial", + "manage_scope": "Verwalte Scope", + "messaging_preview": "Vorschau", + "scripts": "Skripte" + } + }, + "features": { + "properties": { + "scripting": { + "properties": { + "integrated_ui": { + "name": "Integrierte Benutzeroberfläche", + "description": "Erlaubt Skripten, benutzerdefinierte UI-Komponenten zu Snapchat hinzuzufügen" + }, + "developer_mode": { + "name": "Entwickler:innenmodus", + "description": "Zeigt Debug-Informationen auf Snapchat's UI" + }, + "module_folder": { + "name": "Modulordner", + "description": "Der Ordner, in dem sich die Skripte befinden" + }, + "auto_reload": { + "name": "Automatisches Neuladen", + "description": "Automatisches Neuladen von Skripten, wenn diese sich ändern" + }, + "disable_log_anonymization": { + "name": "Log-Anonymisierung deaktivieren", + "description": "Deaktiviert die Anonymisierung von Logs" + } + }, + "name": "Scripting", + "description": "Benutzerdefinierte Skripte ausführen, um SnapEnhance zu erweitern" + }, + "downloader": { + "name": "Downloader", + "description": "Snapchat Medien herunterladen", + "properties": { + "save_folder": { + "name": "Speicherverzeichnis", + "description": "Wähle das Verzeichnis, in das alle Medien heruntergeladen werden sollen" + }, + "auto_download_sources": { + "name": "Quellen automatisch herunterladen", + "description": "Wähle die Quellen, von denen automatisch herunterzuladen ist" + }, + "prevent_self_auto_download": { + "name": "Selbst-Auto-Download verhindern", + "description": "Verhindert, dass eigene Snaps automatisch heruntergeladen werden" + }, + "path_format": { + "name": "Pfadformat", + "description": "Gib das Dateiformat an" + }, + "allow_duplicate": { + "name": "Duplikate erlauben", + "description": "Ermöglicht es, dass dieselben Medien mehrmals heruntergeladen werden" + }, + "merge_overlays": { + "name": "Overlays zusammenführen", + "description": "Kombiniert den Text und die Medien eines Snaps in eine Datei" + }, + "force_image_format": { + "name": "Bildformat erzwingen", + "description": "Erzwingt das Speichern von Bildern in einem bestimmten Format" + }, + "force_voice_note_format": { + "name": "Sprachnotiz Format erzwingen", + "description": "Erzwingt das Speichern von Sprachnotizen in einem bestimmten Format" + }, + "download_profile_pictures": { + "name": "Profilbilder herunterladen", + "description": "Ermöglicht das Herunterladen von Profilbildern von einer Profilseite" + }, + "opera_download_button": { + "name": "Schwebender Download Button", + "description": "Fügt einen Download-Button in der oberen rechten Ecke hinzu, wenn ein Snap angezeigt wird.\nGedrückt halten um einen Download zu starten" + }, + "download_context_menu": { + "name": "Download Kontext Menü", + "description": "Ermöglicht es, Nachrichten oder eine Story herunterzuladen/vorher anzuschauen mithilfe des Kontext Menüs.\nLanges Drücken des Knopfes erzwingt den Download" + }, + "ffmpeg_options": { + "name": "FFmpeg-Optionen", + "description": "Zusätzliche FFmpeg-Optionen angeben", + "properties": { + "threads": { + "name": "Threads", + "description": "Die Anzahl Threads, welche zu gebrauchen ist" + }, + "preset": { + "name": "Voreinstellungen", + "description": "Geschwindigkeit der Konvertierung festlegen" + }, + "constant_rate_factor": { + "name": "Konstanter Rate-Faktor", + "description": "Setze den Constant Rate Factor für den Video-Encoder\nvon 0 bis 51 für libx264" + }, + "video_bitrate": { + "name": "Videobitrate", + "description": "Video-Bitrate (kbps) festlegen" + }, + "audio_bitrate": { + "name": "Audiobitrate", + "description": "Audio-Bitrate (kbps) festlegen" + }, + "custom_video_codec": { + "name": "Benutzerdefinierter Video-Codec", + "description": "Wähle einen benutzerdefinierten Video-Codec (z.B. libx264)" + }, + "custom_audio_codec": { + "name": "Benutzerdefinierter Audio-Codec", + "description": "Wähle einen benutzerdefinierten Audio-Codec (z.B. AAC)" + } + } + }, + "logging": { + "name": "Logging", + "description": "Zeigt Toasts, wenn Medien heruntergeladen werden" + }, + "custom_path_format": { + "name": "Benutzerdefiniertes Pfadformat", + "description": "Legen Sie ein benutzerdefiniertes Pfadformat für heruntergeladene Medien fest\n\nVerfügbare Variablen:\n - %username%\n - %source%\n - %hash%\n - %date_time%" + } + } + }, + "user_interface": { + "name": "Benutzeroberfläche", + "description": "Ändere das Aussehen von Snapchat", + "properties": { + "enable_app_appearance": { + "name": "Aktiviert die App Darstellungseinstellungen", + "description": "Aktiviert die versteckte App-Erscheinungsbild Einstellung,\nbei neueren Snapchat-Versionen möglicherweise nicht erforderlich" + }, + "friend_feed_message_preview": { + "name": "Freund Feed Nachrichten Vorschau", + "description": "Zeigt eine Vorschau der letzten Nachrichten im Freundes-Feed", + "properties": { + "amount": { + "name": "Anzahl", + "description": "Die Anzahl der Nachrichten, die in der Vorschau angezeigt werden" + } + } + }, + "snap_preview": { + "name": "Snap-Vorschau", + "description": "Zeigt eine kleine Vorschau neben ungesehenen Snaps im Chat an" + }, + "bootstrap_override": { + "name": "Bootstrap Überschreibung", + "description": "Bootstrap-Einstellungen der Benutzeroberfläche überschreiben", + "properties": { + "app_appearance": { + "name": "App-Erscheinungsbild", + "description": "Legt eine dauerhafte App-Darstellung fest" + }, + "home_tab": { + "name": "Home Registerkarte", + "description": "Überschreibt den Start-Tab beim Öffnen von Snapchat" + } + } + }, + "map_friend_nametags": { + "name": "Verbesserte Karten-Namensschilder von Freunden", + "description": "Verbessert die Namensschilder von Freunden auf der Snapmap" + }, + "prevent_message_list_auto_scroll": { + "name": "Automatisches Scrollen der Nachrichtenliste verhindern", + "description": "Verhindert, dass die Nachrichtenliste beim Senden/Empfangen einer Nachricht nach unten scrollt" + }, + "streak_expiration_info": { + "name": "Informationen zum Flammen-Ablauf anzeigen", + "description": "Zeigt einen Flammen-Ablauf-Timer neben dem Flammen-Zähler" + }, + "hide_friend_feed_entry": { + "name": "Freund Feed Eintrag ausblenden", + "description": "Versteckt einen bestimmten Freund aus dem Freundes-Feed,\nBenutze den sozialen Tab um diese Funktion zu verwalten" + }, + "hide_streak_restore": { + "name": "Flammen-Wiederherstellung verstecken", + "description": "Versteckt den Wiederherstellen-Button im Freundesfeed" + }, + "hide_story_suggestions": { + "name": "Story Vorschläge ausblenden", + "description": "Entfernt Empfehlungen von der Story‐Seite" + }, + "hide_ui_components": { + "name": "UI-Komponenten ausblenden", + "description": "Wähle aus welche UI-Elemente ausgeblendet werden sollen" + }, + "opera_media_quick_info": { + "name": "Medien Schnellinfo", + "description": "Zeigt nützliche Informationen zu Medien wie das Erstellungsdatum im Kontextmenü der Snap-Ansicht an" + }, + "old_bitmoji_selfie": { + "name": "Altes Bitmoji-Selfie", + "description": "Bringt die Bitmoji-Selfies aus früheren Snapchat-Versionen zurück" + }, + "disable_spotlight": { + "name": "Spotlight deaktivieren", + "description": "Deaktiviert die Spotlight Seite" + }, + "friend_feed_menu_buttons": { + "name": "Schaltflächen für das Freunde-Feed Menü", + "description": "Wähle aus welche Schaltflächen in der Freunde Feed Menüleiste angezeigt werden sollen" + }, + "vertical_story_viewer": { + "name": "Vertikale Story Ansicht", + "description": "Aktiviert die vertikale Story Ansicht für alle Storys" + }, + "enable_friend_feed_menu_bar": { + "name": "Freunde Feed Menüleiste", + "description": "Aktiviert die neue Freunde Feed Menüleiste" + }, + "message_indicators": { + "name": "Nachrichtenindikatoren", + "description": "Fügt Nachrichten spezifische Anzeigesymbole hinzu\nHinweis: Die Symbole sind möglicherweise nicht 100 % genau" + }, + "stealth_mode_indicator": { + "name": "Diebstahl Modus Indikator", + "description": "Fügt den Konversationen im Stealth-Modus ein 👻-Emoji hinzu" + }, + "edit_text_override": { + "name": "Textfeld-Verhalten überschreiben", + "description": "Überschreibt das Verhalten von Textfeldern" + } + } + }, + "messaging": { + "name": "Mitteilungen", + "description": "Ändern wie mit Freunden interagiert wird", + "properties": { + "bypass_screenshot_detection": { + "name": "Umgehen der Screenshot-Erkennung", + "description": "Verhindert, dass Snapchat erkennt, wenn du einen Screenshot machst" + }, + "anonymous_story_viewing": { + "name": "Anonyme Story Ansicht", + "description": "Verhindert, dass jemand erfährt, dass du seine Story gesehen hast" + }, + "prevent_story_rewatch_indicator": { + "name": "Wiederholungs-Indikator bei Stories verhindern", + "description": "Verhindert, dass andere wissen, dass Sie ihre Story noch einmal angeschaut haben" + }, + "hide_peek_a_peek": { + "name": "Vorschau Benachrichtigung verhindern", + "description": "Verhindert, dass eine Benachrichtigung gesendet wird, wenn Sie halb in einen Chat swipen" + }, + "hide_bitmoji_presence": { + "name": "Bitmoji Präsenz verstecken", + "description": "Verhindert, dass dein Bitmoji im Chat auftaucht" + }, + "hide_typing_notifications": { + "name": "Tippen-Benachrichtigungen verbergen", + "description": "Verhindert, dass jemand erfährt, dass du eine Nachricht tippst" + }, + "unlimited_snap_view_time": { + "name": "Unbegrenzte Zeit zum Ansehen von Snaps", + "description": "Entfernt das Zeitlimit für die Anzeige von Snaps" + }, + "auto_mark_as_read": { + "name": "Automatisch als gelesen markieren", + "description": "Markiert Nachrichten bzw. Snaps automatisch als gelesen wenn der Stealth Mode aktiviert ist" + }, + "loop_media_playback": { + "name": "Medien Wiedergabe Wiederholen", + "description": "Wiederholt Snaps & Stories beim ansehen in einer Schleife" + }, + "disable_replay_in_ff": { + "name": "Replay in FF deaktivieren", + "description": "Deaktiviert die Möglichkeit, mit einem langen Drücken vom Freundes-Feed zu wiederholen" + }, + "half_swipe_notifier": { + "name": "Über Half-Swipes informieren", + "description": "Benachrichtigt Sie, wenn jemand halb in ihren Chat swiped", + "properties": { + "min_duration": { + "name": "Mindestdauer", + "description": "Die Mindestdauer der halben Swipes (in Sekunden)" + }, + "max_duration": { + "name": "Maximale Dauer", + "description": "Die maximale Dauer des halben Swipes (in Sekunden)" + } + } + }, + "call_start_confirmation": { + "name": "Bestätigung des Starts eines Anrufs", + "description": "Zeigt einen Bestätigungsdialog beim Starten eines Anrufs an" + }, + "unlimited_conversation_pinning": { + "name": "Unlimitiertes Anpinnen von Konversationen", + "description": "Erlaubt dir eine unlimitierte Anzahl von Konversationen lokal anzupinnen" + }, + "prevent_message_sending": { + "name": "Nachrichtenversand verhindern", + "description": "Verhindert das Versenden bestimmter Nachrichten" + }, + "friend_mutation_notifier": { + "name": "Freund-Mutationsbenachrichtigung", + "description": "Benachrichtigt Sie, wenn sich etwas im Profil eines Freundes ändert" + }, + "better_notifications": { + "name": "Bessere Benachrichtigungen", + "description": "Zeige weitere Informationen in Benachrichtigungen an" + }, + "notification_blacklist": { + "name": "Benachrichtigungs Blacklist", + "description": "Wählen Sie Benachrichtigungen aus, die blockiert werden sollen" + }, + "message_logger": { + "name": "Nachrichten Logger", + "description": "Verhindert, dass Nachrichten gelöscht werden", + "properties": { + "keep_my_own_messages": { + "name": "Eigene Nachrichten behalten", + "description": "Verhindert, dass Ihre eigenen Nachrichten gelöscht werden" + }, + "auto_purge": { + "name": "Automatische Bereinigung", + "description": "Löscht automatisch zwischengespeicherte Nachrichten, die älter als die angegebene Zeit sind" + }, + "message_filter": { + "name": "Nachrichtenfilter", + "description": "Wählen Sie aus, welche Nachrichten behalten werden sollen (leer für alle Nachrichten)" + } + } + }, + "auto_save_messages_in_conversations": { + "name": "Automatisches Speichern von Nachrichten", + "description": "Speichert automatisch jede Nachricht in Unterhaltungen" + }, + "gallery_media_send_override": { + "name": "Galerie-Medien senden Überschreiben", + "description": "Fälscht die Medienquelle, wenn etwas von der Galerie gesendet wird" + }, + "strip_media_metadata": { + "name": "Medien-Metadaten entfernen", + "description": "Entfernt Metadaten von Medien vor dem Versand als Nachricht" + }, + "bypass_message_retention_policy": { + "name": "Umgehung der Richtlinie zur Aufbewahrung von Nachrichten", + "description": "Verhindert, dass Nachrichten nach dem Anzeigen gelöscht werden" + }, + "bypass_message_action_restrictions": { + "name": "Umgehung von Nachrichtenaktionsbeschränkungen", + "description": "Ermöglicht es Ihnen, auf einen Snap zu reagieren, ohne ihn geöffnet zu haben, oder eine nicht speicherbare Nachricht zu speichern" + }, + "remove_groups_locked_status": { + "name": "Entfernen des Gruppen-Sperrstatus", + "description": "Ermöglicht es Ihnen, nach dem Rauswurf Gruppeninformationen anzuzeigen" + } + } + }, + "global": { + "name": "Global", + "description": "Globale Snapchat-Einstellungen anpassen", + "properties": { + "better_location": { + "name": "Besserer Ort", + "description": "Erweitert den Snapchat-Ort", + "properties": { + "spoof_location": { + "name": "Täusche den Ort", + "description": "Täuscht Ihren Standort auf einen bestimmten Ort vor" + }, + "coordinates": { + "name": "Koordinaten", + "description": "Wähle die Koordinaten des getäuschten Standorts" + }, + "walk_radius": { + "name": "Radius", + "description": "Laufe zufälligerweise in diesem Radium (ft) herum" + }, + "always_update_location": { + "name": "Aktualisiere den Ort immer", + "description": "Zwingt Snapchat dazu, die den Standort zu aktualisieren, auch wenn es kein GPS Signal erhält" + }, + "suspend_location_updates": { + "name": "Stoppe die Aktualisierung des Standortes", + "description": "Fügt in den Karteneinstellungen eine Schaltfläche hinzu, um Standortaktualisierungen auszusetzen" + }, + "spoof_battery_level": { + "name": "­Täusche den Akkustand vor", + "description": "Verfälscht den Akkustand Ihres Geräts auf der Karte\nDer Wert muss zwischen 0 und 100 liegen" + }, + "spoof_headphones": { + "name": "Simuliere Kopfhörer", + "description": "Verfälscht den Status des Musikhörens auf der Karte" + } + } + }, + "snapchat_plus": { + "name": "Snapchat Plus", + "description": "Aktiviert Snapchat Plus Funktionen\nEinige serverseitige Funktionen funktionieren möglicherweise nicht" + }, + "media_upload_quality": { + "name": "Qualität", + "description": "Überschreibt die Medienuploadqualität", + "properties": { + "force_video_upload_source_quality": { + "name": "Erzwingen Sie die Qualität der Video-Upload-Quelle", + "description": "Erzwing Snapchat die Quellenqualität zu nutzen, wenn Videos hochgelden werden\nBitte merkte, dass dies eventuell die Metadaten von Medien nicht entfernt" + }, + "disable_image_compression": { + "name": "Deaktiviert Bildkompression", + "description": "Deaktiviert Bildkompression, wenn Medien hochgeladen werden" + }, + "custom_image_upload_format": { + "name": "Benutzerdefiniertes Bild-Upload-Format", + "description": "Setzt ein benutzerdefiniertes Bildhochladformat\nWähle ein verlustfreies Format (wie PNG) für die beste Qualität" + } + } + }, + "disable_confirmation_dialogs": { + "name": "Bestätigungsdialoge deaktivieren", + "description": "Bestätigt automatisch ausgewählte Aktionen" + }, + "auto_updater": { + "name": "Auto Updater", + "description": "Automatisch auf Updates prüfen" + }, + "disable_metrics": { + "name": "Metriken deaktivieren", + "description": "Verhindert das Senden von analytischen Daten an Snapchat" + }, + "disable_story_sections": { + "name": "Story Sektion deaktivieren", + "description": "Entfernt Sektionen von der Story-Seite\nErfordert möglicherweise eine Aktualisierung, um richtig zu funktionieren" + }, + "disable_custom_tabs": { + "description": "Öffnet Links in unterstützen Applikationen, anstatt dem Webbrowser", + "name": "Deaktivieren Sie benutzerdefinierte Registerkarten" + }, + "block_ads": { + "name": "Werbung blockieren", + "description": "Verhindert die Anzeige von Werbung" + }, + "disable_permission_requests": { + "name": "Berechtigungsanfragen deaktivieren", + "description": "Verhindert, dass Snapchat nach bestimmten Berechtigungen fragt" + }, + "bypass_video_length_restriction": { + "name": "Umgeht Videolängenbeschränkungen", + "description": "Einzel: sendet ein einzelnes Video\nSplitt: Videos nach Bearbeitung aufteilen" + }, + "disable_memories_snap_feed": { + "name": "Memories Snap Feed deaktivieren", + "description": "Verhindert, dass Snapchat aktuelle Erinnerungen anzeigt, wenn Sie in der Kamera nach oben wischen" + }, + "spotlight_comments_username": { + "name": "Spotlight Kommentare Benutzername", + "description": "Zeigt den Benutzernamen des Autors in Spotlight-Kommentaren an" + }, + "default_video_playback_rate": { + "name": "Standardmäßige Videowiedergaberate", + "description": "Legt die Standardgeschwindigkeit für die Wiedergabe von Videos fest\n\tDer Wert muss zwischen 0,1 und 4,0 liegen" + }, + "video_playback_rate_slider": { + "name": "Schieberegler für die Videowiedergaberate", + "description": "Fügt einen Schieberegler im Opera-Kontextmenü hinzu, um die Videowiedergabegeschwindigkeit zu ändern\nHinweis: Änderungen gelten nur für nachfolgende Videos" + }, + "disable_google_play_dialogs": { + "name": "Google Play-Service Dialog deaktivieren", + "description": "Verfügbarkeitsdialog für Google Play Services nicht anzeigen" + }, + "default_volume_controls": { + "name": "Standard Lautstärkekontrolle", + "description": "Zwinge Snapchat die Systemlautstärke zu nutzen" + }, + "hide_active_music": { + "name": "Aktive Musik ausblenden", + "description": "Verhindert, dass Snapchat erkennt, dass Sie Musik hören.\nSo können Sie mithilfe der Lautstärketasten Snaps aufnehmen, während Sie Musik hören" + }, + "disable_snap_splitting": { + "name": "Snap-Aufteilung deaktivieren", + "description": "Verhindert, dass Snaps in mehrere Teile aufgeteilt werden\nBilder werden in Videos umgewandelt" + } + } + }, + "rules": { + "name": "Regeln", + "description": "Automatische Funktionen für einzelne Personen verwalten" + }, + "camera": { + "name": "Kamera", + "description": "Pass die richtigen Einstellungen für den perfekten Snap an", + "properties": { + "disable_cameras": { + "name": "Kameras deaktivieren", + "description": "Verhindert, dass Snapchat die gewählten Kameras nutzt" + }, + "black_photos": { + "name": "Schwarze Fotos", + "description": "Ersetzt die aufgenommenen Fotos durch einen schwarzen Hintergrund\nVideos sind davon nicht betroffen" + }, + "immersive_camera_preview": { + "name": "Immersive Vorschau", + "description": "Verhindert das Beschneiden der Kameravorschau\nDas kann dazu führen, dass die Kamera auf einigen Geräten flickert" + }, + "override_front_resolution": { + "name": "Überschreiben der Frontauflösung", + "description": "Überschreibt die Kameraauflösung für die Selfie-Kamera" + }, + "override_back_resolution": { + "name": "Hauptkamera Auflösung überschreiben", + "description": "Überschreibt die Kameraauflösung der Hauptkamera" + }, + "custom_resolution": { + "name": "Benutzerdefinierte Auflösung", + "description": "Legt eine benutzerdefinierte Kameraauflösung (Breite x Höhe) fest (z. B. 1920x1080).\nDie benutzerdefinierte Auflösung muss von Ihrem Gerät unterstützt werden" + }, + "front_custom_frame_rate": { + "name": "Benutzerdefinierte Frame-Rate Selfie-Kamera", + "description": "Überschreibt die Frame-Rate der Selfie-Kamera" + }, + "back_custom_frame_rate": { + "name": "Benutzerdefinierte Frame-Rate Haupt-Kamera", + "description": "Überschreibt die Frame-Rate der Haupt-Kamera" + }, + "force_camera_source_encoding": { + "name": "Kodierung der Kameraquelle erzwingen", + "description": "Erzwingt die Kodierung der Kameraquelle" + }, + "hevc_recording": { + "name": "HEVC Aufnahme", + "description": "Verwendet HEVC (H.265) Codec für die Videoaufzeichnung" + } + } + }, + "streaks_reminder": { + "name": "Flammen-Erinnerung", + "description": "Benachrichtigt dich regelmäßig über deine Flammen", + "properties": { + "interval": { + "name": "Intervall", + "description": "Das Intervall zwischen jeder Erinnerung (Stunden)" + }, + "remaining_hours": { + "name": "Verbleibende Zeit", + "description": "Die verbleibende Zeit, bevor die Benachrichtigung angezeigt wird (in Stunden)" + }, + "group_notifications": { + "name": "Gruppierte Benachrichtigungen", + "description": "Benachrichtigungen in eine einzelne gruppieren" + } + } + }, + "experimental": { + "name": "Experimentell", + "description": "Experimentelle Funktionen", + "properties": { + "native_hooks": { + "name": "Native Hooks", + "description": "Unsichere Funktionen, welche sich in Snapchats nativen Code einhängen", + "properties": { + "composer_hooks": { + "name": "Composer Haken", + "description": "Injiziert Code in das Composer UI-Framework (nur arm64)", + "properties": { + "show_first_created_username": { + "name": "Zeige erstgewählten Benutzernamen", + "description": "Zeige den erstgewählten Benutzernamen neben dem jetzigen Benutzernamen in der Profileseite" + }, + "bypass_camera_roll_limit": { + "name": "Umgeht das Kamerarollenlimit", + "description": "Erhöht die maximale Anzahl von Medien, die Sie aus der Kamerarolle senden können" + }, + "composer_console": { + "name": "Composer-Konsole", + "description": "Ermöglicht das Ausführen von JavaScript-Code in Composer (nur arm64)" + }, + "composer_logs": { + "name": "Composer-Protokolle", + "description": "Leitet Konsolenprotokolle von Composer zu SnapEnhance um" + } + } + }, + "disable_bitmoji": { + "name": "Bitmojis deaktivieren", + "description": "Deaktiviert das Bitmoji des Freund:innenprofil" + } + } + }, + "spoof": { + "name": "Simulieren", + "description": "Verschiedene Informationen über dich vortäuschen", + "properties": { + "play_store_installer_package_name": { + "name": "Goolge Play Installationsname des Paketes", + "description": "Überschreibt den Namen des Installationspakets auf com.android.vending_machine" + }, + "remove_vpn_transport_flag": { + "name": "VPN-Transport-Flagge entfernen", + "description": "Hindert Snapchat daran, VPNs zu erkennen" + }, + "remove_mock_location_flag": { + "name": "Kennzeichnung für den gefälschten Standort entfernen", + "description": "Verhindert, dass Snapchat gefälschte Standorte erkennt" + } + } + }, + "convert_message_locally": { + "name": "Nachricht lokal umwandeln", + "description": "Konvertiert Snaps lokal in externe Chat-Medien. Dies erscheint im Kontextmenü für den Chat-Download" + }, + "media_file_picker": { + "name": "Mediendatei-Auswahl", + "description": "Ermöglicht es beliebige Video und Audio Dateien von der Gallerie auszuwählen" + }, + "story_logger": { + "name": "Geschichtenprotokollierer", + "description": "Liefert eine Historie der Stories von Freund:innen" + }, + "call_recorder": { + "name": "Anrufaufzeichner", + "description": "Zeichnet automatisch Audioanrufe auf" + }, + "account_switcher": { + "name": "­Accountwechsler", + "properties": { + "auto_backup_current_account": { + "name": "Automatisches Backup des aktuellen Accounts", + "description": "Automatisch wird das aktuelle Konto gesichert, wenn Sie sich ausloggen oder zwischen Konten wechseln" + } + }, + "description": "Ermöglicht es Ihnen, zwischen Konten zu wechseln, ohne sich auszuloggen\nHalten Sie lange auf das Suchsymbol neben Ihrem Bitmoji-Profil, um das Menü zu öffnen\nHinweis: Diese Funktion ist experimentell und wird wahrscheinlich in Zukunft geändert" + }, + "edit_message": { + "name": "Nachrichten bearbeiten", + "description": "Ermöglicht es Nachrichten in Konversationen zu bearbeiten" + }, + "app_lock": { + "name": "App-Sperre", + "description": "Verhindert den Zugriff auf Snapchat ohne einen passcode", + "properties": { + "lock_on_resume": { + "name": "Sperren beim fortsetzen", + "description": "Sperrt die App wenn sie wieder geöffnet wird" + } + } + }, + "infinite_story_boost": { + "name": "Unendlicher Story Boost", + "description": "Story Boost Limit Verzögerung umgehen" + }, + "meo_passcode_bypass": { + "name": "Passwortumgehung für privaten Bereich", + "description": "Umgeht das Passwort für den privaten Bereich\nFunktioniert nur, wenn das korrekte Passwort schon einmal eingegeben wurde" + }, + "no_friend_score_delay": { + "name": "Keine Friend Score Verzögerung", + "description": "Entfernt die Verzögerung beim Betrachten einer Friends Score" + }, + "best_friend_pinning": { + "name": "Bester Freund Pinning", + "description": "Erlaubt einen Freund als besten Freund Nummer 1 anzupinnen. Notiz: Nur du kannst deinen gepinnten besten Freund sehen" + }, + "e2ee": { + "name": "Ende-zu-Ende-Verschlüsselung", + "description": "Verschlüsselt deine Nachrichten mit AES unter Verwendung eines freigegebenen geheimen Schlüssels\nAchte darauf, dass du deinen Schlüssel an einem sicheren Ort aufbewahrst!", + "properties": { + "encrypted_message_indicator": { + "name": "Anzeige für verschlüsselte Nachrichten", + "description": "Fügt einen 🔒 Emoji neben verschlüsselten Nachrichten hinzu" + }, + "force_message_encryption": { + "name": "Nachrichtenverschlüsselung erzwingen", + "description": "Verhindert das Senden von verschlüsselten Nachrichten an Personen, die keine E2E-Verschlüsselung aktiviert haben, wenn mehrere Unterhaltungen ausgewählt sind" + } + } + }, + "add_friend_source_spoof": { + "name": "Freundesquellen-Änderung hinzufügen", + "description": "Verfälscht die Quelle einer Freundschaftsanfrage" + }, + "hidden_snapchat_plus_features": { + "name": "Verborgene Snapchat Plus-Funktionen", + "description": "Aktiviert unveröffentlichte/beta Snapchat Plus Funktionen\nKönnte auf älteren Snapchat-Versionen nicht funktionieren" + }, + "custom_streaks_expiration_format": { + "name": "Benutzerdefiniertes Ablaufdatum für Serien", + "description": "Passt das Ablaufdatumformat für Serien an\n\nVerfügbare Variablen:\n- %c: Anzahl der Serien\n- %e: Sanduhr-Emoji\n- %d: Tage\n- %h: Stunden\n- %m: Minuten\n- %s: Sekunden\n- %w: Verbleibende Zeit" + }, + "prevent_forced_logout": { + "name": "Erzwungenen Logout verhindern", + "description": "Verhindert, dass Snapchat dich abmeldet, wenn du dich auf einem anderen Gerät anmeldest" + } + } + }, + "friend_tracker": { + "name": "Freundestracker", + "description": "Die Aktivität der Freunde aufzeichnen", + "properties": { + "record_messaging_events": { + "name": "Nachrichtenevents aufzeichnen", + "description": "Zeichnet Nachrichtenereignisse wie das Öffnen eines Snaps, das Lesen einer Nachricht usw. auf." + }, + "allow_running_in_background": { + "name": "Hintergrundnutzung erlauben", + "description": "Ermöglicht die Ausführung des Trackers im Hintergrund. Hinweis: Dadurch wird Ihr Akku erheblich entladen" + } + } + } + }, + "options": { + "auto_reload": { + "snapchat_only": "Nur Snapchat", + "all": "Alles (Snapchat + SnapEnhance)" + }, + "auto_purge": { + "12_hours": "12 Stunden", + "1_day": "1 Tag", + "3_days": "3 Tage", + "3_months": "3 Monate", + "6_months": "6 Monate", + "never": "Nie", + "1_hour": "1 Stunde", + "3_hours": "3 Stunden", + "6_hours": "6 Stunden", + "1_week": "1 Woche", + "2_weeks": "2 Wochen", + "1_month": "1 Monat" + }, + "disable_story_sections": { + "friends": "Freunde", + "following": "Folge Ich", + "discover": "Entdecken" + }, + "disable_cameras": { + "front": "Selfie Kamera", + "back": "Hauptkamera" + }, + "disable_permission_requests": { + "read_contacts": "Kontakte lesen", + "nearby_devices": "In der Nähe befindliche Geräte", + "notifications": "Benachrichtigungen", + "read_media_images": "Medienbilder lesen", + "read_media_video": "Medienvideos lesen", + "camera": "Kamera", + "microphone": "Mikrofon", + "location": "Standort", + "phone_calls": "Telefonanrufe" + }, + "auto_mark_as_read": { + "snap_reply": "Markiert Snaps als gelesen sobald auf sie geantwortet wird", + "conversation_read": "Markiert Konversation als gelesen sobald eine Narchicht gesendet wird" + }, + "gallery_media_send_override": { + "NOTE": "Audio-Notiz", + "SNAP": "Snap", + "always_ask": "Immer Fragen", + "ORIGINAL": "Original" + }, + "strip_media_metadata": { + "hide_caption_text": "Bildunterschriftstext ausblenden", + "hide_snap_filters": "Snap Filter ausblenden", + "hide_extras": "Extras ausblenden (z. B. Erwähnungen)", + "remove_audio_note_duration": "Dauer der Sprachnachricht entfernen", + "remove_audio_note_transcript_capability": "Sprachnachricht-Transkriptionsfunktion entfernen" + }, + "app_appearance": { + "always_light": "Immer hell", + "always_dark": "Immer dunkel" + }, + "friend_feed_menu_buttons": { + "auto_download": "⬇️ Auto-Download", + "auto_save": "💬 Auto-Nachricht-Speichern", + "unsaveable_messages": "⬇️ Nicht speicherbare Nachrichten", + "mark_snaps_as_seen": "👀 Snaps als gesehen markieren", + "mark_stories_as_seen_locally": "👀 Stories als lokal gesehen markieren", + "auto_open_snaps": "📷 Automatisches Öffnen von Snaps", + "stealth": "👻 Heimlicher Modus", + "conversation_info": "👤 Gesprächsinformationen", + "e2e_encryption": "🔒 E2E-Verschlüsselung verwenden" + }, + "path_format": { + "create_author_folder": "Erzeuge ein Verzeichnis für jeden Benutzer", + "create_source_folder": "Ordner für jeden Medienquellentyp erstellen", + "append_hash": "Fügt jedem Dateinamen einen einzigartigen Hash hinzu", + "append_source": "Füge die Medienquelle zum Dateinamen hinzu", + "append_username": "Füge den Benutzernamen zum Dateinamen hinzu", + "append_date_time": "Füge Datum und Uhrzeit zum Dateinamen hinzu" + }, + "auto_download_sources": { + "friend_snaps": "Freund-Snaps", + "friend_stories": "Freund-Stories", + "public_stories": "Öffentliche Stories", + "spotlight": "Spotlight" + }, + "logging": { + "started": "Gestartet", + "success": "Erfolgreich", + "progress": "Fortschritt", + "failure": "Fehler" + }, + "notifications": { + "chat_screenshot": "Screenshot", + "chat_screen_record": "Bildschirmaufnahme", + "snap_replay": "Snap Wiederholung", + "camera_roll_save": "In Aufnahmen gespeichert", + "chat": "Chat", + "chat_reply": "Chat Antwort", + "snap": "Snap", + "typing": "Schreiben", + "stories": "Stories", + "speaking": "Sprache", + "chat_reaction": "DM-Reaktion", + "group_chat_reaction": "Gruppenreaktion", + "initiate_audio": "Eingehender Audioanruf", + "abandon_audio": "Verpasster Audioanruf", + "initiate_video": "Eingehender Videoanruf", + "abandon_video": "Verpasster Videoanruf" + }, + "hide_ui_components": { + "hide_profile_call_buttons": "Entferne Anruf Tasten", + "hide_chat_call_buttons": "Entferne Anruf Tasten im Chat", + "hide_live_location_share_button": "Schaltfläche Live-Standortfreigabe entfernen", + "hide_stickers_button": "Entferne Stickers Taste", + "hide_voice_record_button": "Knopf für Sprachaufzeichnung entfernen", + "hide_unread_chat_hint": "Hinweis auf ungelesene Chats entfernen" + }, + "hide_story_suggestions": { + "hide_suggested_friend_stories": "Empfohlene Stories von Freunden ausblenden", + "hide_my_stories": "Meine Stories verbergen" + }, + "home_tab": { + "map": "Karte", + "chat": "Chat", + "camera": "Kamera", + "discover": "Entdecken", + "spotlight": "Spotlight" + }, + "add_friend_source_spoof": { + "added_by_username": "Nach Benutzername", + "added_by_mention": "Durch Erwähnung", + "added_by_group_chat": "Per Gruppenchat", + "added_by_qr_code": "Per QR-Code", + "added_by_community": "Per Community" + }, + "bypass_video_length_restriction": { + "single": "Einzelnes Medium", + "split": "Medien aufteilen" + }, + "old_bitmoji_selfie": { + "2d": "2D Bitmoji", + "3d": "3D Bitmoji" + }, + "disable_confirmation_dialogs": { + "erase_message": "Nachricht löschen", + "remove_friend": "Freund entfernen", + "block_friend": "Freund blockieren", + "ignore_friend": "Freund ignorieren", + "hide_friend": "Freund ausblenden", + "hide_conversation": "Konversation ausblenden", + "clear_conversation": "Konversation aus dem Freundes-Feed löschen" + }, + "edit_text_override": { + "multi_line_chat_input": "Mehrzeiliges Chat-Eingabefeld", + "bypass_text_input_limit": "Umgehen des Limits für die Texteingabe" + }, + "message_indicators": { + "encryption_indicator": "Fügt neben Nachrichten, die nur an Sie gesendet wurden, ein 🔒-Symbol hinzu", + "platform_indicator": "Fügt das Plattformsymbol hinzu, von der aus ein Medium gesendet wurde (z. B. Android, iOS, Web)", + "location_indicator": "Fügt Snaps ein 📍-Symbol hinzu, wenn sie mit aktivierter Standortfunktion gesendet wurden", + "ovf_editor_indicator": "Kennzeichnet, ob ein Snap mit dem OVF-Editor gesendet wurde", + "director_mode_indicator": "Fügt Snaps ein ✏️-Symbol hinzu, wenn sie mit dem Director-Modus gesendet wurden, der verwendet werden kann, um Galeriebilder als Snaps zu senden" + }, + "friend_mutation_notifier": { + "remove_friend": "Benachrichtige, wenn dich jemand als Freund entfernt", + "birthday_changes": "Benachrichtige, wenn jemand sein Geburtsdatum ändert", + "bitmoji_selfie_changes": "Benachrichtigen, wenn jemand sein Bitmoji-Selfie ändert", + "bitmoji_avatar_changes": "Benachrichtige, wenn jemand sein Bitmoji-Avatar ändert", + "bitmoji_background_changes": "Benachrichtige, wenn jemand den Hintergrund seines Bitmoji ändert", + "bitmoji_scene_changes": "Benachrichtige, wenn jemand seine Bitmoji-Szene ändert" + } + }, + "notices": { + "unstable": "⚠ Instabil", + "ban_risk": "⚠ Dieses Feature kann zu Bans führen", + "internal_behavior": "⚠ Dies kann das interne Verhalten von Snapchat stören" + } + }, + "content_type": { + "CHAT": "Chat", + "STATUS_CALL_MISSED_VIDEO": "Verpasster Videoanruf", + "STATUS_CALL_MISSED_AUDIO": "Verpasster Sprachanruf", + "LIVE_LOCATION_SHARE": "Live-Standort teilen", + "CREATIVE_TOOL_ITEM": "Kreativ-Werkzeug Element", + "STATUS_COUNTDOWN": "Countdown", + "MAP_REACTION": "Kartenreaktion", + "SNAP": "Snap", + "EXTERNAL_MEDIA": "Externe Medien", + "NOTE": "Sprachnachricht", + "STICKER": "Sticker", + "STATUS": "Status", + "LOCATION": "Standort", + "STATUS_SAVE_TO_CAMERA_ROLL": "In Camera Roll gespeichert", + "STATUS_CONVERSATION_CAPTURE_SCREENSHOT": "Screenshot", + "STATUS_CONVERSATION_CAPTURE_RECORD": "Bildschirmaufnahme", + "FAMILY_CENTER_INVITE": "Family Center einladen", + "FAMILY_CENTER_ACCEPT": "Family Center akzeptieren", + "FAMILY_CENTER_LEAVE": "Family Center verlassen", + "STATUS_PLUS_GIFT": "Status Plus Geschenk", + "TINY_SNAP": "Winziger Snap" + }, + "media_download_source": { + "public_story": "Öffentliche Story", + "merged": "Zusammengeführt", + "none": "Keine", + "pending": "Ausstehend", + "chat_media": "Chat-Medium", + "story": "Story", + "spotlight": "Spotlight", + "profile_picture": "Profilbild", + "story_logger": "Story Logger", + "message_logger": "Nachrichtenprotokollierung", + "voice_call": "Sprachanruf" + }, + "profile_info": { + "added_date": "Datum hinzugefügt", + "title": "Profil Info", + "first_created_username": "Erster Benutzername", + "display_name": "Anzeigename", + "mutable_username": "Änderbarer Benutzername", + "birthday": "Geburtstag: {month} {day}", + "hidden_birthday": "Geburtstag : Versteckt", + "friendship": "Freundschaft", + "add_source": "Quelle hinzufügen", + "snapchat_plus": "Snapchat Plus", + "snapchat_plus_state": { + "subscribed": "Abonniert", + "not_subscribed": "Nicht abonniert" + } + }, + "chat_export": { + "exported_to": "Exportiert zu {path}", + "exporting_chats": "Chats exportieren...", + "exporter_dialog": { + "download_medias_title": "Medien herunterladen", + "select_conversations_title": "Konversationen auswählen", + "text_field_selection": "{amount} ausgewählt", + "text_field_selection_all": "Alle", + "export_file_format_title": "Dateiformat für den Export", + "message_type_filter_title": "Nachrichten nach Typ filtern", + "amount_of_messages_title": "Anzahl der Nachrichten (für alle leer lassen)" + }, + "writing_output": "Ausgabe schreiben...", + "finished": "Fertig! Du kannst diesen Dialog jetzt schließen.", + "no_messages_found": "Keine Nachrichten gefunden!", + "dialog_negative_button": "Abbrechen", + "dialog_positive_button": "Exportieren", + "processing_chats": "{amount} Konversationen werden verarbeitet...", + "export_fail": "Konversation {conversation} konnte nicht exportiert werden", + "exporting_message": "{conversation} wird exportiert..." + }, + "download_processor": { + "attachment_type": { + "original_story": "Originale Story", + "snap": "Snap", + "sticker": "Sticker", + "gif": "GIF", + "external_media": "Externe Medien", + "note": "Notiz" + }, + "select_attachments_title": "Anhänge auswählen", + "download_started_toast": "Download gestartet", + "no_attachments_toast": "Keine Anhänge gefunden!", + "download_toast": "{path} wird heruntergeladen...", + "processing_toast": "Verarbeite {path}...", + "failed_generic_toast": "Download fehlgeschlagen", + "failed_to_create_preview_toast": "Fehler beim Erstellen der Vorschau", + "failed_processing_toast": "Fehler beim Verarbeiten {error}", + "failed_gallery_toast": "Speichern in der Galerie fehlgeschlagen {error}", + "dash_no_chapter": "Kein Abschnitt gefunden", + "unsupported_content_type_toast": "Nicht unterstützter Content-Typ!", + "failed_no_longer_available_toast": "Datei ist nicht mehr verfügbar", + "already_queued_toast": "Datei wird bereits bearbeitet!", + "already_downloaded_toast": "Datei wurde bereits heruntergeladen!", + "dash_dialog": { + "title": "Dash-Medium herunterladen", + "download_all": "Alle herunterladen", + "segment_text": "Segment {from} - {to}" + } + }, + "friend_mutation_observer": { + "bitmoji_background_changed": "{username} hat den Hintergrund seines/ ihres Bitmojis geändert", + "bitmoji_scene_changed": "{username} hat seine/ ihre Bitmoji-Szene geändert", + "notification_channel_name": "Freund-Mutationsbeobachter", + "friend_removed": "{username} hat dich als Freund:in entfernt", + "birthday_removed": "{username} hat sein/ ihr Geburtsdatum ({birthday}) entfernt", + "birthday_added": "{username} hat sein/ ihr Geburtsdatum ({birthday}) hinzugefügt", + "birthday_changed": "{username} hat sein/ ihr Geburtsdatum von {oldBirthday} auf {newBirthday} geändert", + "bitmoji_selfie_changed": "{username} hat sein/ ihr Bitmoji-Selfie geändert", + "bitmoji_avatar_changed": "{username} hat sein/ ihr Bitmoji-Avatar geändert" + }, + "material3_strings": { + "date_range_picker_start_headline": "Von", + "date_range_picker_end_headline": "Bis", + "date_range_picker_title": "Wähle einen Zeitraum", + "date_picker_switch_to_calendar_mode": "Kalender", + "date_picker_switch_to_input_mode": "Eingabe", + "date_range_picker_scroll_to_previous_month": "Vorheriger Monat", + "date_range_picker_scroll_to_next_month": "Nächster Monat", + "date_picker_today_description": "Heute", + "date_range_picker_day_in_range": "Gewählt", + "date_input_invalid_for_pattern": "Invalides Datum", + "date_input_invalid_year_range": "Invalides Jahr", + "date_input_invalid_not_allowed": "Invalides Datum", + "date_range_input_invalid_range_input": "Ungültiger Zeitraum" + }, + "friend_menu_option": { + "mark_snaps_as_seen": "Snaps als gesehen markieren", + "mark_stories_as_seen_locally": "Stories als lokal gesehen markieren", + "preview": "Vorschau", + "stealth_mode": "Inkognitomodus", + "auto_download_blacklist": "Blacklist für automatische Downloads", + "anti_auto_save": "Anti-Auto-Speichern" + }, + "opera_context_menu": { + "media_size": "Mediengröße: {size}", + "media_duration": "Mediendauer: {duration} ms", + "show_debug_info": "Debug-Informationen anzeigen", + "download": "Medien herunterladen", + "sent_at": "Gesendet am {date}", + "created_at": "Erstellt am {date}", + "expires_at": "Läuft am {date} ab" + }, + "modal_option": { + "profile_info": "Profil Info", + "close": "Schließen" + }, + "conversation_preview": { + "unknown_user": "Unbekannter Benutzer", + "streak_expiration": "läuft in {day} Tagen, {hour} Stunden, {minute} Minuten ab", + "total_messages": "Insgesamt gesendete/empfangene Nachrichten: {count}", + "title": "Vorschau" + }, + "scopes": { + "friend": "Freund:in", + "group": "Gruppe" + }, + "rules": { + "toasts": { + "enabled": "{ruleName} aktiviert", + "disabled": "{ruleName} deaktiviert" + }, + "modes": { + "blacklist": "Blacklist Modus", + "whitelist": "Whitlist Modus" + }, + "properties": { + "auto_download": { + "name": "Auto-Download", + "description": "Snaps beim Ansehen automatisch herunterladen", + "options": { + "blacklist": "Vom Auto-Download ausschließen", + "whitelist": "Auto-Download" + } + }, + "stealth": { + "name": "Heimlicher Modus", + "description": "Verhindert, dass jemand weiß, dass du seine Snaps/Chats oder Konversationen geöffnet hast", + "options": { + "blacklist": "Vom Heimlichen Modus ausschließen", + "whitelist": "Heimlicher Modus" + } + }, + "auto_save": { + "name": "Automatisches speichern", + "description": "Speichert Chat-Nachrichten beim Ansehen", + "options": { + "blacklist": "Vom automatischen Speichern ausschließen", + "whitelist": "Automatisch speichern" + } + }, + "unsaveable_messages": { + "name": "Nicht speicherbare Nachrichten", + "description": "Verhindert, dass Nachrichten im Chat von anderen Personen gespeichert werden können", + "options": { + "blacklist": "Von nicht speicherbaren Nachrichten ausschließen", + "whitelist": "Nicht speicherbare Nachrichten" + } + }, + "auto_open_snaps": { + "name": "Automatisches Öffnen von Snaps", + "description": "Öffnet Snaps automatisch beim Empfang", + "options": { + "blacklist": "Von der automatischen Snap-Öffnung ausschließen", + "whitelist": "Auto-Öffnen von Snaps" + } + }, + "hide_friend_feed": { + "name": "Vom Freundes-Feed ausblenden" + }, + "e2e_encryption": { + "name": "E2E-Verschlüsselung verwenden" + }, + "pin_conversation": { + "name": "Unterhaltung anheften" + } + } + }, + "actions": { + "clean_snapchat_cache": { + "name": "Leere den Snapchat Cache", + "description": "Leert den Snapchat Cache" + }, + "manage_friend_list": { + "name": "Freundesliste verwalten", + "description": "Im-/ex- portiere deine Freundesliste beim Backup" + }, + "export_chat_messages": { + "name": "Exportiere die Chatnachrichten", + "description": "Exportiert Chat-Nachrichten in eine JSON/HTML/TXT Datei" + }, + "export_memories": { + "name": "Exportiere die Memories", + "description": "Exportiere die Memories in eine ZIP-Datei" + }, + "bulk_messaging_action": { + "name": "Massen Nachrichten Aktion", + "description": "Führt Operationen wie das Löschen von Freunden oder amassenlöschung von Konversationen durch" + }, + "regen_mappings": { + "name": "Erneuere die Mappings", + "description": "Erneuere die Mappings manuell" + }, + "change_language": { + "name": "Sprache ändern", + "description": "Ändere die Sprache von SnapEnhance" + } + }, + "chat_action_menu": { + "preview_button": "Vorschau", + "download_button": "Download", + "delete_logged_message_button": "Gespeicherte Nachrichten löschen", + "convert_message": "Nachricht konvertieren", + "edit_message": "Nachricht bearbeiten" + }, + "gallery_media_send_override": { + "multiple_media_toast": "Du kannst nur eine Datei auf einmal senden" + }, + "mark_as_seen": { + "no_unseen_snaps_toast": "Es wurden keine ungesehenen Snaps gefunden!", + "seen_toast": "Markiert als gesehen!", + "unseen_toast": "Markiert als ungesehen!", + "already_seen_toast": "Bereits markiert als gesehen!", + "already_unseen_toast": "Schon als ungelesen markiert!" + }, + "friendship_link_type": { + "mutual": "Gegenseitig", + "outgoing": "Ausgehend", + "blocked": "Blockiert", + "deleted": "Gelöscht", + "following": "Folge Ich", + "suggested": "Empfohlen", + "incoming": "Eingehend", + "incoming_follower": "Eingehender Follower" + }, + "bulk_messaging_action": { + "choose_action_title": "Wähle eine Aktion", + "progress_status": "Verarbeite {index} von {total}", + "selection_dialog_continue_button": "Weiter", + "confirmation_dialog": { + "title": "Sind Sie sicher?", + "message": "Das betrifft alle ausgewählten Freunde. Diese Aktion kann nicht rückgängig gemacht werden." + }, + "actions": { + "remove_friends": "Freunde entfernen", + "clear_conversations": "Lösche Konversationen" + } + }, + "button": { + "ok": "OK", + "positive": "Ja", + "negative": "Nein", + "cancel": "Abbrechen", + "open": "Öffnen", + "download": "Download" + }, + "better_notifications": { + "button": { + "reply": "Antwort", + "download": "Herunterladen", + "mark_as_read": "Als gelesen markieren" + } + }, + "profile_picture_downloader": { + "button": "Profilbilder herunterladen", + "title": "Profilbild-Downloader", + "avatar_option": "Avatar", + "background_option": "Hintergrund" + }, + "call_start_confirmation": { + "dialog_title": "Anruf starten", + "dialog_message": "Sind Sie sicher, dass Sie einen Anruf starten wollen?" + }, + "half_swipe_notifier": { + "notification_channel_name": "Halb-Swipe", + "notification_content_dm": "{friend} hat gerade für {duration} Sekunden halb in deinen Chat geswiped", + "notification_content_group": "{friend} hat gerade halb in {group} für {duration} Sekunden geswiped" + }, + "streaks_reminder": { + "notification_title": "Flammen", + "notification_text": "Du wirst deine Flammen mit {friend} in {hoursLeft} Stunden verlieren" + }, + "biometric_auth": { + "unlock_button": "Entsperren", + "title": "Entsperre Snapchat", + "subtitle": "Bitte authentifizieren Sie sich um Snapchat zu entsperren" + }, + "end_to_end_encryption": { + "toolbox": { + "no_shared_key": "Sie haben noch kein gemeinsames Geheimnis mit diesem Freund. Klicken Sie unten, um ein neues zu erstellen.", + "shared_key_fingerprint": "Dein Fingerabdruck ist:\n\n{fingerprint}\n\nSieh nach, ob er mit dem Fingerabdruck deines Freundes übereinstimmt!", + "initiate_exchange_button": "Schlüsselaustausch einleiten" + }, + "confirmation_dialogs": { + "title": "Ende-zu-Ende Verschlüsselung", + "confirmation_1": "WARNUNG: Dadurch wird Ihr vorhandener Schlüssel überschrieben. Sie werden den Zugang zu allen verschlüsselten Nachrichten dieses Freundes verlieren. Sind Sie sicher, dass Sie fortfahren möchten?", + "confirmation_2": "Sind Sie WIRKLICH sicher, dass Sie weitermachen wollen? Dies ist Ihre letzte Chance, einen Rückzieher zu machen." + }, + "unencrypted_conversation_send_failure_toast": "Sie können keine verschlüsselten Inhalte zu verschlüsselten und unverschlüsselten Gesprächen gleichzeitig senden!", + "native_hooks_send_failure_toast": "Senden fehlgeschlagen! Bitte aktivieren Sie die nativen Hooks in den Einstellungen.", + "no_participants_to_encrypt_toast": "Sie haben in diesem Gespräch keine Freunde, mit denen Sie Nachrichten verschlüsseln können!", + "encryption_failed_toast": "Nachricht konnte nicht verschlüsselt werden! Prüfen Sie logcat für weitere Details.", + "accept_public_key_success_toast": "Öffentlicher Schlüssel erfolgreich akzeptiert!", + "accept_secret_key_success_toast": "Geschafft! Sie können nun verschlüsselte Nachrichten an diesen Freund senden und empfangen.", + "accept_public_key_failure_toast": "Akzeptieren des öffentlichen Schlüssels fehlgeschlagen", + "accept_secret_key_failure_toast": "Akzeptieren des geheimen Schlüssels fehlgeschlagen", + "accept_secret_button": "Geheimnis akzeptieren", + "accept_public_key_button": "Öffentlichen Schlüssel akzeptieren", + "outgoing_pk_message": "Schlüsselaustausch-Anfrage", + "outgoing_secret_message": "Schlüsselaustausch-Antwort", + "incoming_pk_message": "Sie haben gerade eine Anfrage für einen öffentlichen Schlüssel erhalten. Klicken Sie unten, um sie anzunehmen.", + "incoming_secret_message": "Ihr Freund hat gerade Ihren öffentlichen Schlüssel akzeptiert. Klicken Sie unten, um das Passwort zu akzeptieren." + }, + "auto_open_snaps": { + "title": "Auto-Öffnen von Snaps", + "notification_content": "{count} Snaps geöffnet" + } +} diff --git a/common/src/main/assets/lang/zh_SIMPLIFIED.json b/common/src/main/assets/lang/zh_SIMPLIFIED.json new file mode 100644 index 0000000000..321bbeb7bd --- /dev/null +++ b/common/src/main/assets/lang/zh_SIMPLIFIED.json @@ -0,0 +1,416 @@ +{ + "setup": { + "dialogs": { + "select_language": "选择语言", + "save_folder": "SnapEnhance 需要存储权限才能从 Snapchat 下载和保存媒体。\n请选择媒体下载的位置。", + "select_save_folder_button": "选择文件夹" + }, + "mappings": { + "dialog": "生成映射,这可能需要一段时间......", + "generate_failure_no_snapchat": "SnapEnhance 无法检测到 Snapchat,请尝试重新安装 Snapchat", + "generate_failure": "尝试生成映射时出错,请重试。" + }, + "permissions": { + "dialog": "要继续,您需要满足以下要求:", + "notification_access": "通知访问", + "battery_optimization": "电池优化", + "display_over_other_apps": "显示在其他应用程序之上", + "request_button": "要求" + } + }, + "friend_menu_option": { + "preview": "预览", + "stealth_mode": "隐身模式", + "auto_download_blacklist": "自动下载黑名单", + "anti_auto_save": "自动保存" + }, + "chat_action_menu": { + "preview_button": "预览", + "download_button": "下载", + "delete_logged_message_button": "删除已记录的消息" + }, + "opera_context_menu": { + "download": "下载媒体" + }, + "modal_option": { + "profile_info": "配置信息", + "close": "关闭" + }, + "conversation_preview": { + "streak_expiration": "在 {day} 天 {hour} 小时 {minute} 分钟", + "title": "预览", + "unknown_user": "未知用户" + }, + "profile_info": { + "title": "配置信息", + "display_name": "显示姓名 ", + "added_date": "添加日期", + "birthday": "生日: {month} {day}" + }, + "chat_export": { + "dialog_negative_button": "取消", + "dialog_positive_button": "导出", + "exported_to": "导出到 {path}", + "exporting_chats": "正在导出聊天...", + "processing_chats": "正在处理 {amount} 个对话...", + "export_fail": "导出对话 {conversation} 失败", + "writing_output": "正在写入输出...", + "finished": "完成了!您现在可以关闭此对话框。", + "no_messages_found": "未找到消息!", + "exporting_message": "正在导出 {conversation}..." + }, + "button": { + "ok": "确定", + "positive": "是", + "negative": "否", + "cancel": "取消", + "open": "打开" + }, + "scopes": { + "friend": "朋友", + "group": "团体" + }, + "manager": { + "routes": { + "tasks": "任务", + "features": "特征", + "home_logs": "日志", + "logger_history": "记录器历史记录", + "logged_stories": "记录的故事", + "manage_scope": "管理范围", + "social": "社会的", + "messaging_preview": "预览", + "scripts": "脚本", + "home": "家", + "home_settings": "设置", + "friend_tracker": "好友追踪器", + "edit_rule": "编辑规则", + "better_location": "更好的位置", + "manage_rule_feature": "管理规则功能", + "manage_repos": "管理资源库", + "file_imports": "导入文件", + "theming": "外观", + "edit_theme": "自定义外观" + }, + "sections": { + "home": { + "update_button": "下载", + "update_content": "版本 {version} 可用!", + "update_title": "SnapEnhance 更新", + "version_title": "v{versionName} 作者:rhunk", + "debug_build_summary_title": "你正在使用SnapEnhance的一个测试版", + "debug_build_summary_content": "版本 {versionName} ({versionCode})", + "debug_build_summary_date": "版本发布时间: {date} ({days} days ago)", + "quick_actions_title": "快捷操作" + }, + "home_logs": { + "no_logs_hint": "没有可用的日志", + "export_logs_button": "导出日志", + "clear_logs_button": "清除日志", + "saving_logs_toast": "保存日志,这可能需要一段时间...", + "saved_logs_success_toast": "日志保存成功", + "saved_logs_failure_toast": "保存日志失败" + }, + "home_settings": { + "debug_title": "调试", + "message_logger_title": "消息记录器", + "success_toast": "完毕", + "clear_button": "清除", + "view_logger_history_button": "查看记录器历史记录", + "actions_title": "行动", + "message_logger_summary": "{messageCount} 条消息\n{storyCount} 个故事", + "export_button": "出口" + }, + "features": { + "config_export_success_toast": "配置导出成功", + "export_option": "出口", + "import_option": "进口", + "saved_config_snackbar": "配置已保存", + "config_import_failure_toast": "导入配置失败 {error}", + "disabled": "残疾人", + "config_import_success_toast": "配置导入成功", + "reset_option": "重置", + "config_export_failure_toast": "导出设置失败 {error}", + "older_required": "这个功能需要 Snapchat v{version} 或更老版本的 Snapchat", + "newer_required": "这个功能需要 Snapchat v{version} 或更新版本的 Snapchat", + "search_button": "搜索" + }, + "tasks": { + "no_tasks": "没有任务", + "merge_files_toast": "合并 {count} 个文件", + "remove_all_tasks_title": "您确定要删除所有任务吗?", + "remove_selected_tasks_title": "您确定要删除选定的任务吗?", + "remove_selected_tasks_confirm": "删除 {count} 个任务?", + "remove_all_tasks_confirm": "删除所有任务?", + "delete_files_option": "也删除文件", + "failed_to_open_file": "打开文件失败", + "merge_button": "合并" + }, + "social": { + "streaks_expiration_short": "{hours}小时", + "friends_tab": "朋友们", + "groups_tab": "团体", + "empty_hint": "(空的)" + }, + "manage_scope": { + "streaks_expiration_text": "过期日期在 {eta}", + "streaks_expiration_text_expired": "已到期", + "reminder_button": "设置提醒", + "participants_text": "{count} 名参与者", + "logged_stories_button": "显示记录的故事", + "e2ee_title": "端到端加密", + "rules_title": "规则", + "not_found": "未找到", + "streaks_title": "条纹", + "streaks_length_text": "长度: {length}", + "delete_scope_confirm_dialog_title": "您确定要删除 {scope}?", + "notes_placeholder": "点击以添加笔记" + }, + "messaging_preview": { + "no_message_hint": "没有消息", + "bridge_init_failed": "消息网络初始化失败 请确保Snapchat正在后台运行", + "save_selection_option": "保存选择", + "save_all_option": "保存全部", + "mark_selection_as_seen_option": "将选定的快照标记为可见", + "mark_all_as_seen_option": "将所有快照标记为已看到", + "unsave_all_option": "全部取消保存", + "bridge_connection_failed": "连接网桥失败。请确保 Snapchat 正在后台运行", + "unsave_selection_option": "取消保存选择", + "delete_selection_option": "删除选择", + "delete_all_option": "删除所有", + "message_fetch_failed": "获取消息失败" + }, + "logger_history": { + "chat_attachment": "依恋 {index}", + "empty_message": "空聊天消息", + "list_group_format": "团体 {name}", + "no_more_messages": "没有更多消息了", + "reverse_order_checkbox": "相反的顺序", + "list_friend_format": "朋友 {name}", + "unknown_sender": "未知发件人", + "download_attachment_failed_toast": "附件下载失败", + "message_parse_failed": "解析消息失败" + }, + "logged_stories": { + "story_failed_to_load": "加载失败", + "no_stories": "没有找到故事", + "save_from_cache_button": "从缓存中保存" + }, + "better_location": { + "saved_name_dialog_hint": "保存的名称", + "save_coordinates_dialog_title": "保存坐标", + "teleport_to_friend_title": "传送至朋友", + "search_bar": "搜索", + "latitude_dialog_hint": "纬度", + "delete_dialog_title": "删除已保存的坐标", + "save_dialog_button": "保存", + "saved_coordinates_title": "已保存的坐标", + "no_saved_coordinates_hint": "没有已保存的坐标", + "suspend_location_updates": "停止位置更新", + "no_friends_found": "没有找到好友", + "no_friends_map": "没有朋友在地图上", + "longitude_dialog_hint": "经度", + "choose_location_button": "选择位置", + "teleport_to_friend_button": "传送至好友", + "spoof_location_toggle": "虚拟位置", + "delete_dialog_message": "你确定要删除这个坐标吗?", + "spoofed_coordinates_title": "纬度 {latitude}, 经度 {longitude}" + }, + "file_imports": { + "file_import_failed": "文件导入失败: {error}", + "file_not_found": "文件不存在", + "import_file_button": "导入文件", + "file_imported": "文件导入成功", + "file_delete_failed": "删除文件失败", + "no_files_hint": "你可以在这里导入文件供 Snapchat 使用。按下方按钮以导入文件。" + }, + "manage_rule_feature": { + "whitelist_state_subtext": "只有 {count} 个好友或群组会被此规则影响", + "disable_state_option": "关闭", + "disable_state_subtext": "没有好友或群组会被影响", + "whitelist_state_option": "除了...", + "whitelist_state_button": "选择好友或群组", + "blacklist_state_option": "所有人除了...", + "blacklist_state_subtext": "除 {count} 个好友和群组以外的所有人都会被此规则影响", + "blacklist_state_button": "选择要排除的好友或群组", + "clear_list_button": "清空好友/群组列表", + "dialog_clear_confirmation_text": "你确定要清空此列表吗?" + }, + "theming": { + "no_themes_hint": "没有任何外观" + } + }, + "dialogs": { + "add_friend": { + "search_hint": "搜索", + "fetch_error": "获取数据失败", + "category_groups": "群组", + "category_friends": "好友", + "title": "添加好友或群组", + "participants_text": "{count} 个成员" + }, + "reset_config": { + "content": "您确定要重置配置吗?", + "success_toast": "配置重置成功", + "title": "重置配置" + }, + "messaging_action": { + "title": "选择要处理的内容类型", + "select_all_button": "全选" + }, + "scripting_warning": { + "content": "SnapEnhance 包含一个脚本工具,允许在您的设备上执行用户自定义的代码。请格外小心,仅安装来自已知且可靠来源的模块。未经授权或未经验证的模块可能会给您的系统带来安全风险。", + "title": "警告" + }, + "file_imports": { + "no_files_settings_hint": "未找到文件。请确保已在文件导入部分导入所需文件", + "settings_select_file_hint": "选择已导入的文件" + }, + "export_config": { + "content": "您想导出包含敏感数据的配置吗?(如位置坐标等)", + "title": "导出敏感信息?" + } + } + }, + "rules": { + "toasts": { + "enabled": "{ruleName} 已启用", + "disabled": "{ruleName} 已禁用" + }, + "properties": { + "auto_download": { + "name": "自动下载", + "description": "当查看时自动下载快照", + "options": { + "blacklist": "从自动下载中排除", + "whitelist": "自动下载" + } + }, + "stealth": { + "name": "隐身模式", + "description": "不让任何人知道您已打开他们的 Snap, 聊天框或对话", + "options": { + "blacklist": "从隐身模式中排除", + "whitelist": "隐身模式" + } + }, + "unsaveable_messages": { + "name": "无法保存的消息", + "description": "防止您的消息在聊天中被其他人保存", + "options": { + "whitelist": "无法保存的消息", + "blacklist": "从无法保存的消息中排除" + } + }, + "auto_open_snaps": { + "options": { + "whitelist": "自动打开地图", + "blacklist": "从自动打开地图中排除" + }, + "description": "在收到Snap消息时自动打开", + "name": "自动打开Snap消息" + }, + "auto_save": { + "name": "自动保存", + "description": "当查看时自动保存消息", + "options": { + "blacklist": "从自动保存中排除", + "whitelist": "自动保存" + } + }, + "e2e_encryption": { + "name": "使用E2E加密" + }, + "pin_conversation": { + "name": "置顶聊天" + }, + "hide_friend_feed": { + "name": "从朋友圈中隐藏" + } + }, + "modes": { + "blacklist": "黑名单模式", + "whitelist": "白名单模式" + } + }, + "actions": { + "export_chat_messages": { + "name": "导出聊天记录", + "description": "用JSON/HTML/TXT格式导出聊天记录" + }, + "export_memories": { + "description": "使用ZIP格式的压缩包导出回忆", + "name": "导出回忆" + }, + "change_language": { + "description": "切换 SnapEnhance 使用的语言", + "name": "切换语言" + }, + "regen_mappings": { + "name": "重新生成映射", + "description": "手动重新生成映射" + }, + "file_imports": { + "description": "导入给 Snapchat 使用的文件", + "name": "文件导入" + }, + "manage_friend_list": { + "name": "管理好友列表", + "description": "在备份时导入/导出你的好友列表" + }, + "security_features": { + "description": "修改安全设置", + "name": "安全功能" + }, + "clean_snapchat_cache": { + "name": "清理 Snapchat 临时文件", + "description": "清理 Snapchat 临时文件" + }, + "bulk_messaging_action": { + "description": "执行删除好友或批量删除对话等操作", + "name": "批量发送" + }, + "friend_tracker": { + "name": "好友追踪", + "description": "追踪你的 Snapchat 好友" + }, + "logger_history": { + "description": "查看信息历史记录", + "name": "历史日志" + }, + "theming": { + "name": "外观", + "description": "自定义 Snapchat" + } + }, + "features": { + "properties": { + "downloader": { + "properties": { + "path_format": { + "name": "路径格式" + }, + "prevent_self_auto_download": { + "description": "防止自动下载你自己的Snaps", + "name": "防止自动下载" + }, + "save_folder": { + "name": "保存文件夹", + "description": "选择所有媒体保存的位置" + }, + "auto_download_sources": { + "description": "选择启用自动下载的源", + "name": "自动下载源" + } + }, + "description": "下载 Snapchat 媒体文件", + "name": "下载器" + } + }, + "notices": { + "unstable": "⚠ 不稳定", + "internal_behavior": "⚠ 这个功能可能破坏 Snapchat 的正常功能", + "ban_risk": "⚠ 这个功能可能会导致账号封禁" + } + } +} diff --git a/common/src/main/java/javax/lang/model/SourceVersion.java b/common/src/main/java/javax/lang/model/SourceVersion.java new file mode 100644 index 0000000000..0adde3e303 --- /dev/null +++ b/common/src/main/java/javax/lang/model/SourceVersion.java @@ -0,0 +1,23 @@ +package javax.lang.model; + +// Rhino misses this class in the android platform +@SuppressWarnings("unused") +public enum SourceVersion { + RELEASE_0, + RELEASE_1, + RELEASE_2, + RELEASE_3, + RELEASE_4, + RELEASE_5, + RELEASE_6, + RELEASE_7, + RELEASE_8, + RELEASE_9, + RELEASE_10, + RELEASE_11; + + @SuppressWarnings("unused") + public static SourceVersion latestSupported() { + return RELEASE_8; + } +} diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/Constants.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/Constants.kt new file mode 100644 index 0000000000..9f0afefba3 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/Constants.kt @@ -0,0 +1,7 @@ +package me.rhunk.snapenhance.common + +object Constants { + val SNAPCHAT_PACKAGE_NAME get() = "com.snapchat.android" + val SE_PACKAGE_NAME get() = BuildConfig.APPLICATION_ID + const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.3" +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/ReceiversConfig.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/ReceiversConfig.kt new file mode 100644 index 0000000000..db81a6c157 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/ReceiversConfig.kt @@ -0,0 +1,8 @@ +package me.rhunk.snapenhance.common + +object ReceiversConfig { + const val BRIDGE_SYNC_ACTION = "me.rhunk.snapenhance.core.bridge.SYNC" + const val DOWNLOAD_REQUEST_EXTRA = "request" + const val DOWNLOAD_METADATA_EXTRA = "metadata" + const val MESSAGING_PREVIEW_EXTRA = "messaging_preview" +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/action/EnumAction.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/action/EnumAction.kt new file mode 100644 index 0000000000..079ac2be66 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/action/EnumAction.kt @@ -0,0 +1,26 @@ +package me.rhunk.snapenhance.common.action + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Chat +import androidx.compose.material.icons.filled.CleaningServices +import androidx.compose.material.icons.filled.DeleteOutline +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.PersonOutline +import androidx.compose.ui.graphics.vector.ImageVector + + +enum class EnumAction( + val key: String, + val icon: ImageVector, + val exitOnFinish: Boolean = false, +) { + EXPORT_CHAT_MESSAGES("export_chat_messages", Icons.AutoMirrored.Default.Chat), + EXPORT_MEMORIES("export_memories", Icons.Default.Image), + BULK_MESSAGING_ACTION("bulk_messaging_action", Icons.Default.DeleteOutline), + CLEAN_CACHE("clean_snapchat_cache", Icons.Default.CleaningServices, exitOnFinish = true), + MANAGE_FRIEND_LIST("manage_friend_list", Icons.Default.PersonOutline); + + companion object { + const val ACTION_PARAMETER = "se_action" + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/BridgeFiles.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/BridgeFiles.kt new file mode 100644 index 0000000000..cef545577c --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/BridgeFiles.kt @@ -0,0 +1,99 @@ +package me.rhunk.snapenhance.common.bridge + +import android.content.Context +import android.os.ParcelFileDescriptor +import android.os.ParcelFileDescriptor.AutoCloseInputStream +import android.os.ParcelFileDescriptor.AutoCloseOutputStream +import me.rhunk.snapenhance.bridge.storage.FileHandle +import me.rhunk.snapenhance.common.bridge.FileHandleScope.entries +import me.rhunk.snapenhance.common.bridge.InternalFileHandleType.entries +import me.rhunk.snapenhance.common.util.LazyBridgeValue +import me.rhunk.snapenhance.common.util.lazyBridge +import java.io.File + + +enum class FileHandleScope( + val key: String +) { + INTERNAL("internal"), + LOCALE("locale"), + USER_IMPORT("user_import"), + COMPOSER("composer"); + + companion object { + fun fromValue(name: String): FileHandleScope? = entries.find { it.key == name } + } +} + +enum class InternalFileHandleType( + val key: String, + val fileName: String, + val isDatabase: Boolean = false +) { + CONFIG("config", "config.json"), + MAPPINGS("mappings", "mappings.json"), + MESSAGE_LOGGER("message_logger", "message_logger.db", isDatabase = true), + PINNED_BEST_FRIEND("pinned_best_friend", "pinned_best_friend.txt"), + NATIVE_SIG_CACHE("native_sig_cache", "native_sig_cache.txt"); + + fun resolve(context: Context): File = if (isDatabase) { + context.getDatabasePath(fileName) + } else { + File(context.filesDir, fileName) + } + + companion object { + fun fromValue(name: String): InternalFileHandleType? = entries.find { it.key == name } + } +} + +fun FileHandle.toWrapper() = FileHandleWrapper(lazyBridge { this }) + +open class FileHandleWrapper( + private val fileHandle: LazyBridgeValue<FileHandle> +) { + fun exists() = fileHandle.value.exists() + fun create() = fileHandle.value.create() + fun delete() = fileHandle.value.delete() + + fun writeBytes(data: ByteArray) = fileHandle.value.open( + ParcelFileDescriptor.MODE_WRITE_ONLY or + ParcelFileDescriptor.MODE_CREATE or + ParcelFileDescriptor.MODE_TRUNCATE + ).use { pfd -> + AutoCloseOutputStream(pfd).use { + it.write(data) + } + } + + open fun readBytes(): ByteArray = fileHandle.value.open( + ParcelFileDescriptor.MODE_READ_ONLY or + ParcelFileDescriptor.MODE_CREATE + ).use { pfd -> + AutoCloseInputStream(pfd).use { + it.readBytes() + } + } + + fun inputStream(block: (AutoCloseInputStream) -> Unit) = fileHandle.value.open( + ParcelFileDescriptor.MODE_READ_ONLY or + ParcelFileDescriptor.MODE_CREATE + ).use { pfd -> + AutoCloseInputStream(pfd).use { + block(it) + } + } + + fun outputStream(block: (AutoCloseOutputStream) -> Unit) = fileHandle.value.open( + ParcelFileDescriptor.MODE_WRITE_ONLY or + ParcelFileDescriptor.MODE_CREATE or + ParcelFileDescriptor.MODE_TRUNCATE + ).use { pfd -> + AutoCloseOutputStream(pfd).use { + block(it) + } + } +} + + + diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/InternalFileWrapper.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/InternalFileWrapper.kt new file mode 100644 index 0000000000..4690470057 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/InternalFileWrapper.kt @@ -0,0 +1,21 @@ +package me.rhunk.snapenhance.common.bridge + +import me.rhunk.snapenhance.bridge.storage.FileHandleManager +import me.rhunk.snapenhance.common.util.LazyBridgeValue +import me.rhunk.snapenhance.common.util.lazyBridge + +open class InternalFileWrapper( + fileHandleManager: LazyBridgeValue<FileHandleManager>, + private val fileType: InternalFileHandleType, + val defaultValue: String? = null +): FileHandleWrapper(lazyBridge { fileHandleManager.value.getFileHandle(FileHandleScope.INTERNAL.key, fileType.key)!! }) { + override fun readBytes(): ByteArray { + if (!exists()) { + defaultValue?.toByteArray(Charsets.UTF_8)?.let { + writeBytes(it) + return it + } + } + return super.readBytes() + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/LocaleWrapper.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/LocaleWrapper.kt new file mode 100644 index 0000000000..c34d39e7f7 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/LocaleWrapper.kt @@ -0,0 +1,103 @@ +package me.rhunk.snapenhance.common.bridge.wrapper + +import android.content.Context +import android.os.ParcelFileDescriptor +import android.os.ParcelFileDescriptor.AutoCloseInputStream +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import me.rhunk.snapenhance.bridge.storage.FileHandleManager +import me.rhunk.snapenhance.common.bridge.FileHandleScope +import me.rhunk.snapenhance.common.logger.AbstractLogger +import me.rhunk.snapenhance.common.util.LazyBridgeValue +import java.util.Locale + + +class LocaleWrapper( + private val fileHandleManager: LazyBridgeValue<FileHandleManager> +) { + companion object { + const val DEFAULT_LOCALE = "en_US" + + fun fetchAvailableLocales(context: Context): List<String> { + return context.resources.assets.list("lang")?.map { it.substringBefore(".") }?.sorted() ?: listOf(DEFAULT_LOCALE) + } + } + + var userLocale = DEFAULT_LOCALE + + private val translationMap = linkedMapOf<String, String>() + + lateinit var loadedLocale: Locale + + private fun load(locale: String, pfd: ParcelFileDescriptor) { + loadedLocale = if (locale.contains("_")) { + val split = locale.split("_") + Locale(split[0], split[1]) + } else { + Locale(locale) + } + + val translations = AutoCloseInputStream(pfd).use { + runCatching { + JsonParser.parseReader(it.reader()).asJsonObject + }.onFailure { + AbstractLogger.directError("Failed to parse locale file: ${it.message}", it) + }.getOrNull() + } + if (translations == null || translations.isJsonNull) { + throw IllegalStateException("Failed to parse $locale.json") + } + + fun scanObject(jsonObject: JsonObject, prefix: String = "") { + jsonObject.entrySet().forEach { + if (it.value.isJsonPrimitive) { + val key = "$prefix${it.key}" + translationMap[key] = it.value.asString + } + if (!it.value.isJsonObject) return@forEach + scanObject(it.value.asJsonObject, "$prefix${it.key}.") + } + } + + scanObject(translations) + } + + fun load() { + fileHandleManager.value.getFileHandle(FileHandleScope.LOCALE.key, "$DEFAULT_LOCALE.json")?.open(ParcelFileDescriptor.MODE_READ_ONLY)?.use { + load(DEFAULT_LOCALE, it) + } ?: run { + throw IllegalStateException("Failed to load default locale") + } + + if (userLocale != DEFAULT_LOCALE) { + fileHandleManager.value.getFileHandle(FileHandleScope.LOCALE.key, "$userLocale.json")?.open(ParcelFileDescriptor.MODE_READ_ONLY)?.use { + load(userLocale, it) + } + } + } + + fun reload(locale: String) { + userLocale = locale + translationMap.clear() + load() + } + + operator fun get(key: String) = translationMap[key] ?: key.also { AbstractLogger.directDebug("Missing translation for $key") } + fun getOrNull(key: String) = translationMap[key] + + fun format(key: String, vararg args: Pair<String, String>): String { + return args.fold(get(key)) { acc, pair -> + acc.replace("{${pair.first}}", pair.second) + } + } + + fun getCategory(key: String): LocaleWrapper { + return LocaleWrapper(fileHandleManager).apply { + translationMap.putAll( + this@LocaleWrapper.translationMap + .filterKeys { it.startsWith("$key.") } + .mapKeys { it.key.substring(key.length + 1) } + ) + } + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/LoggerWrapper.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/LoggerWrapper.kt new file mode 100644 index 0000000000..cd8764ef5b --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/LoggerWrapper.kt @@ -0,0 +1,494 @@ +package me.rhunk.snapenhance.common.bridge.wrapper + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import com.google.gson.GsonBuilder +import com.google.gson.JsonObject +import kotlinx.coroutines.* +import me.rhunk.snapenhance.bridge.logger.BridgeLoggedMessage +import me.rhunk.snapenhance.bridge.logger.LoggedChatEdit +import me.rhunk.snapenhance.bridge.logger.LoggerInterface +import me.rhunk.snapenhance.common.bridge.InternalFileHandleType +import me.rhunk.snapenhance.common.data.StoryData +import me.rhunk.snapenhance.common.logger.AbstractLogger +import me.rhunk.snapenhance.common.util.SQLiteDatabaseHelper +import me.rhunk.snapenhance.common.util.ktx.getBlobOrNull +import me.rhunk.snapenhance.common.util.ktx.getIntOrNull +import me.rhunk.snapenhance.common.util.ktx.getLongOrNull +import me.rhunk.snapenhance.common.util.ktx.getStringOrNull +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import java.io.File +import java.util.UUID + +class LoggedMessage( + val messageId: Long, + val conversationId: String, + val userId: String, + val username: String, + val sendTimestamp: Long, + val addedTimestamp: Long, + val groupTitle: String?, + val messageData: ByteArray, +) + +class ConversationInfo( + val conversationId: String, + val participantSize: Int, + val groupTitle: String?, + val usernames: List<String> +) + +data class TrackerLog( + val id: Int, + val timestamp: Long, + val conversationId: String, + val conversationTitle: String?, + val isGroup: Boolean, + val username: String, + val userId: String, + val eventType: String, + val data: String +) { + fun toJson(): JsonObject { + return JsonObject().apply { + addProperty("id", id) + addProperty("timestamp", timestamp) + addProperty("conversationId", conversationId) + addProperty("conversationTitle", conversationTitle) + addProperty("isGroup", isGroup) + addProperty("username", username) + addProperty("userId", userId) + addProperty("eventType", eventType) + addProperty("data", data) + } + } + + fun toCsv(): String { + return "$id,$timestamp,$conversationId,$conversationTitle,$isGroup,$username,$userId,$eventType,$data" + } +} + +class LoggerWrapper( + val databaseFile: File +): LoggerInterface.Stub() { + constructor(context: Context): this(File(context.getDatabasePath(InternalFileHandleType.MESSAGE_LOGGER.fileName).absolutePath)) + + private var _database: SQLiteDatabase? = null + @OptIn(ExperimentalCoroutinesApi::class) + private val coroutineScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1)) + private val gson by lazy { GsonBuilder().create() } + + private val database get() = synchronized(this) { + _database?.takeIf { it.isOpen } ?: run { + _database?.close() + val openedDatabase = SQLiteDatabase.openDatabase(databaseFile.absolutePath, null, SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.OPEN_READWRITE) + SQLiteDatabaseHelper.createTablesFromSchema(openedDatabase, mapOf( + "messages" to listOf( + "id INTEGER PRIMARY KEY", + "message_id BIGINT", + "conversation_id VARCHAR", + "user_id CHAR(36)", + "username VARCHAR", + "send_timestamp BIGINT", + "added_timestamp BIGINT", + "group_title VARCHAR", + "message_data BLOB" + ), + "chat_edits" to listOf( + "id INTEGER PRIMARY KEY", + "edit_number INTEGER", + "added_timestamp BIGINT", + "conversation_id VARCHAR", + "message_id BIGINT", + "message_text BLOB" + ), + "stories" to listOf( + "id INTEGER PRIMARY KEY", + "added_timestamp BIGINT", + "user_id VARCHAR", + "posted_timestamp BIGINT", + "created_timestamp BIGINT", + "url VARCHAR", + "encryption_key BLOB", + "encryption_iv BLOB" + ), + "tracker_events" to listOf( + "id INTEGER PRIMARY KEY", + "timestamp BIGINT", + "conversation_id CHAR(36)", + "conversation_title VARCHAR", + "is_group BOOLEAN", + "username VARCHAR", + "user_id VARCHAR", + "event_type VARCHAR", + "data VARCHAR" + ) + )) + _database = openedDatabase + openedDatabase + } + } + + protected fun finalize() { + _database?.close() + } + + fun init() { + + } + + override fun getLoggedIds(conversationId: Array<String>, limit: Int): LongArray { + if (conversationId.any { + runCatching { UUID.fromString(it) }.isFailure + }) return longArrayOf() + + return database.rawQuery("SELECT message_id FROM messages WHERE conversation_id IN (${ + conversationId.joinToString(",") { "'$it'" } + }) ORDER BY message_id DESC LIMIT $limit", null).use { + val ids = mutableListOf<Long>() + while (it.moveToNext()) { + ids.add(it.getLong(0)) + } + ids.toLongArray() + } + } + + override fun getMessage(conversationId: String?, id: Long): ByteArray? { + return database.rawQuery( + "SELECT message_data FROM messages WHERE conversation_id = ? AND message_id = ?", + arrayOf(conversationId, id.toString()) + ).use { + if (it.moveToFirst()) it.getBlob(0) else null + } + } + + override fun addMessage(bridgeLoggedMessage: BridgeLoggedMessage) { + val hasMessage = database.rawQuery("SELECT message_id FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(bridgeLoggedMessage.conversationId, bridgeLoggedMessage.messageId.toString())).use { + it.moveToFirst() + it.count > 0 + } + + if (!hasMessage) { + runBlocking(coroutineScope.coroutineContext) { + database.insert("messages", null, ContentValues().apply { + put("message_id", bridgeLoggedMessage.messageId) + put("conversation_id", bridgeLoggedMessage.conversationId) + put("user_id", bridgeLoggedMessage.userId) + put("username", bridgeLoggedMessage.username) + put("send_timestamp", bridgeLoggedMessage.sendTimestamp) + put("added_timestamp", System.currentTimeMillis()) + put("group_title", bridgeLoggedMessage.groupTitle) + put("message_data", bridgeLoggedMessage.messageData) + }) + } + } + + // handle message edits + runBlocking(coroutineScope.coroutineContext) { + runCatching { + val messageObject = gson.fromJson( + bridgeLoggedMessage.messageData.toString(Charsets.UTF_8), + JsonObject::class.java + ) + if (messageObject.getAsJsonObject("mMessageContent") + ?.getAsJsonPrimitive("mContentType")?.asString != "CHAT" + ) return@runBlocking + + val metadata = messageObject.getAsJsonObject("mMetadata") + if (metadata.get("mIsEdited")?.asBoolean != true) return@runBlocking + + val messageTextContent = + messageObject.getAsJsonObject("mMessageContent")?.getAsJsonArray("mContent") + ?.map { it.asByte }?.toByteArray()?.let { + ProtoReader(it).getString(2, 1) + } ?: return@runBlocking + + database.rawQuery( + "SELECT MAX(edit_number), message_text FROM chat_edits WHERE conversation_id = ? AND message_id = ?", + arrayOf(bridgeLoggedMessage.conversationId, bridgeLoggedMessage.messageId.toString()) + ).use { + it.moveToFirst() + val editNumber = it.getInt(0) + val lastEditedMessage = it.getString(1) + + if (lastEditedMessage == messageTextContent) return@runBlocking + + database.insert("chat_edits", null, ContentValues().apply { + put("edit_number", editNumber + 1) + put("added_timestamp", System.currentTimeMillis()) + put("conversation_id", bridgeLoggedMessage.conversationId) + put("message_id", bridgeLoggedMessage.messageId) + put("message_text", messageTextContent) + }) + } + }.onFailure { + AbstractLogger.directDebug("Failed to handle message edit: ${it.message}") + } + } + } + + fun purgeAll(maxAge: Long? = null) { + coroutineScope.launch { + maxAge?.let { + val maxTime = System.currentTimeMillis() - it + database.execSQL("DELETE FROM messages WHERE added_timestamp < ?", arrayOf(maxTime.toString())) + database.execSQL("DELETE FROM chat_edits WHERE added_timestamp < ?", arrayOf(maxTime.toString())) + database.execSQL("DELETE FROM stories WHERE added_timestamp < ?", arrayOf(maxTime.toString())) + } ?: run { + database.execSQL("DELETE FROM messages") + database.execSQL("DELETE FROM chat_edits") + database.execSQL("DELETE FROM stories") + } + } + } + + fun getStoredMessageCount(): Int { + return database.rawQuery("SELECT COUNT(*) FROM messages", null).use { + it.moveToFirst() + it.getInt(0) + } + } + + fun getStoredStoriesCount(): Int { + return database.rawQuery("SELECT COUNT(*) FROM stories", null).use { + it.moveToFirst() + it.getInt(0) + } + } + + override fun deleteMessage(conversationId: String, messageId: Long) { + coroutineScope.launch { + database.execSQL("DELETE FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())) + database.execSQL("DELETE FROM chat_edits WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())) + } + } + + override fun addStory(userId: String, url: String, postedAt: Long, createdAt: Long, key: ByteArray?, iv: ByteArray?): Boolean { + if (database.rawQuery("SELECT id FROM stories WHERE user_id = ? AND url = ?", arrayOf(userId, url)).use { + it.moveToFirst() + }) { + return false + } + runBlocking(coroutineScope.coroutineContext) { + database.insert("stories", null, ContentValues().apply { + put("user_id", userId) + put("added_timestamp", System.currentTimeMillis()) + put("url", url) + put("posted_timestamp", postedAt) + put("created_timestamp", createdAt) + put("encryption_key", key) + put("encryption_iv", iv) + }) + } + return true + } + + override fun logTrackerEvent( + conversationId: String, + conversationTitle: String?, + isGroup: Boolean, + username: String, + userId: String, + eventType: String, + data: String + ) { + runBlocking(coroutineScope.coroutineContext) { + database.insert("tracker_events", null, ContentValues().apply { + put("timestamp", System.currentTimeMillis()) + put("conversation_id", conversationId) + put("conversation_title", conversationTitle) + put("is_group", isGroup) + put("username", username) + put("user_id", userId) + put("event_type", eventType) + put("data", data) + }) + } + } + + fun deleteTrackerLog(id: Int) { + coroutineScope.launch { + database.execSQL("DELETE FROM tracker_events WHERE id = ?", arrayOf(id.toString())) + } + } + + fun getLogs( + pageIndex: Int, + pageSize: Int, + reverseOrder: Boolean = true, + timestamp: Long? = null, + filter: ((TrackerLog) -> Boolean)? = null + ): List<TrackerLog> { + return database.rawQuery("SELECT * FROM tracker_events " + + "WHERE timestamp ${if (reverseOrder) "<" else ">"} ? " + + "ORDER BY timestamp ${if (reverseOrder) "DESC" else ""} " + + "LIMIT $pageSize OFFSET ${pageIndex * pageSize}", arrayOf((timestamp ?: if (reverseOrder) Long.MAX_VALUE else 0).toString())).use { + val logs = mutableListOf<TrackerLog>() + while (it.moveToNext()) { + val log = TrackerLog( + id = it.getIntOrNull("id") ?: continue, + timestamp = it.getLongOrNull("timestamp") ?: continue, + conversationId = it.getStringOrNull("conversation_id") ?: continue, + conversationTitle = it.getStringOrNull("conversation_title"), + isGroup = it.getIntOrNull("is_group") == 1, + username = it.getStringOrNull("username") ?: continue, + userId = it.getStringOrNull("user_id") ?: continue, + eventType = it.getStringOrNull("event_type") ?: continue, + data = it.getStringOrNull("data") ?: continue + ) + if (filter != null && !filter(log)) continue + logs.add(log) + } + logs + } + } + + fun purgeTrackerLogs(maxAge: Long) { + coroutineScope.launch { + val maxTime = System.currentTimeMillis() - maxAge + database.execSQL("DELETE FROM tracker_events WHERE timestamp < ?", arrayOf(maxTime.toString())) + } + } + + fun findConversation(search: String): List<String> { + return database.rawQuery("SELECT DISTINCT conversation_id FROM tracker_events WHERE is_group = 1 AND conversation_id LIKE ?", arrayOf("%$search%")).use { + val conversations = mutableListOf<String>() + while (it.moveToNext()) { + conversations.add(it.getString(0)) + } + conversations + } + } + + fun findUsername(search: String): List<String> { + return database.rawQuery("SELECT DISTINCT username FROM tracker_events WHERE username LIKE ?", arrayOf("%$search%")).use { + val usernames = mutableListOf<String>() + while (it.moveToNext()) { + usernames.add(it.getString(0)) + } + usernames + } + } + + + fun getStories(userId: String, from: Long, limit: Int = Int.MAX_VALUE): Map<Long, StoryData> { + val stories = sortedMapOf<Long, StoryData>() + database.rawQuery("SELECT * FROM stories WHERE user_id = ? AND posted_timestamp < ? ORDER BY posted_timestamp DESC LIMIT $limit", arrayOf(userId, from.toString())).use { + while (it.moveToNext()) { + stories[it.getLongOrNull("posted_timestamp") ?: continue] = StoryData( + url = it.getStringOrNull("url") ?: continue, + postedAt = it.getLongOrNull("posted_timestamp") ?: continue, + createdAt = it.getLongOrNull("created_timestamp") ?: continue, + key = it.getBlobOrNull("encryption_key"), + iv = it.getBlobOrNull("encryption_iv") + ) + } + } + return stories + } + + fun getAllConversations(): List<String> { + return database.rawQuery("SELECT DISTINCT conversation_id FROM messages", null).use { + val conversations = mutableListOf<String>() + while (it.moveToNext()) { + conversations.add(it.getString(0)) + } + conversations + } + } + + fun getConversationInfo(conversationId: String): ConversationInfo? { + val usernames = database.rawQuery("SELECT DISTINCT username FROM messages WHERE conversation_id = ?", arrayOf(conversationId)).use { + val usernames = mutableListOf<String>() + while (it.moveToNext()) { + usernames.add(it.getString(0)) + } + usernames + } + + if (usernames.size > 2) { usernames.remove("myai") } + + val groupTitle = if (usernames.size > 2) database.rawQuery("SELECT group_title FROM messages WHERE conversation_id = ? AND group_title IS NOT NULL LIMIT 1", arrayOf(conversationId)).use { + if (!it.moveToFirst()) return@use null + it.getStringOrNull("group_title") + } else null + + return ConversationInfo(conversationId, usernames.size, groupTitle, usernames) + } + + override fun getChatEdits(conversationId: String, messageId: Long): List<LoggedChatEdit> { + val edits = mutableListOf<LoggedChatEdit>() + database.rawQuery( + "SELECT added_timestamp, message_text FROM chat_edits WHERE conversation_id = ? AND message_id = ? ORDER BY added_timestamp ASC", + arrayOf(conversationId, messageId.toString()) + ).use { cursor -> + while (cursor.moveToNext()) { + edits.add(LoggedChatEdit().apply { + timestamp = cursor.getLongOrNull("added_timestamp") ?: return@apply + message = cursor.getStringOrNull("message_text") + }.takeIf { it.timestamp > 0L } ?: continue) + } + } + + if (edits.isNotEmpty()) { + // append original message + database.rawQuery("SELECT added_timestamp, message_data FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())).use { cursor -> + if (!cursor.moveToFirst()) return@use + + val originalMessage = cursor.getBlobOrNull("message_data") ?: return@use + val addedTimestamp = cursor.getLongOrNull("added_timestamp") ?: return@use + + val messageObject = gson.fromJson( + originalMessage.toString(Charsets.UTF_8), + JsonObject::class.java + ) + + val messageTextContent = + messageObject.getAsJsonObject("mMessageContent")?.getAsJsonArray("mContent") + ?.map { it.asByte }?.toByteArray()?.let { + ProtoReader(it).getString(2, 1) + } ?: return@use + + if (edits.firstOrNull()?.message != messageTextContent) { + edits.add(0, LoggedChatEdit().apply { + timestamp = addedTimestamp + message = messageTextContent + }) + } + } + } + + return edits + } + + fun fetchMessages( + conversationId: String, + fromTimestamp: Long, + limit: Int, + reverseOrder: Boolean = true, + filter: ((LoggedMessage) -> Boolean)? = null + ): List<LoggedMessage> { + val messages = mutableListOf<LoggedMessage>() + database.rawQuery( + "SELECT * FROM messages WHERE conversation_id = ? AND send_timestamp ${if (reverseOrder) "<" else ">"} ? ORDER BY send_timestamp ${if (reverseOrder) "DESC" else "ASC"}", + arrayOf(conversationId, fromTimestamp.toString()) + ).use { + while (it.moveToNext() && messages.size < limit) { + val message = LoggedMessage( + messageId = it.getLongOrNull("message_id") ?: continue, + conversationId = it.getStringOrNull("conversation_id") ?: continue, + userId = it.getStringOrNull("user_id") ?: continue, + username = it.getStringOrNull("username") ?: continue, + sendTimestamp = it.getLongOrNull("send_timestamp") ?: continue, + addedTimestamp = it.getLongOrNull("added_timestamp") ?: continue, + groupTitle = it.getStringOrNull("group_title"), + messageData = it.getBlobOrNull("message_data") ?: continue + ) + if (filter != null && !filter(message)) continue + messages.add(message) + } + } + return messages + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/MappingsWrapper.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/MappingsWrapper.kt new file mode 100644 index 0000000000..427024a04b --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/MappingsWrapper.kt @@ -0,0 +1,102 @@ +package me.rhunk.snapenhance.common.bridge.wrapper + +import android.content.Context +import com.google.gson.JsonParser +import kotlinx.coroutines.runBlocking +import me.rhunk.snapenhance.bridge.storage.FileHandleManager +import me.rhunk.snapenhance.common.BuildConfig +import me.rhunk.snapenhance.common.Constants +import me.rhunk.snapenhance.common.bridge.FileHandleScope +import me.rhunk.snapenhance.common.bridge.InternalFileHandleType +import me.rhunk.snapenhance.common.bridge.InternalFileWrapper +import me.rhunk.snapenhance.common.logger.AbstractLogger +import me.rhunk.snapenhance.common.util.LazyBridgeValue +import me.rhunk.snapenhance.mapper.AbstractClassMapper +import me.rhunk.snapenhance.mapper.ClassMapper +import kotlin.reflect.KClass + +class MappingsWrapper( + private val fileHandleManager: LazyBridgeValue<FileHandleManager> +): InternalFileWrapper(fileHandleManager, InternalFileHandleType.MAPPINGS, defaultValue = "{}") { + private lateinit var context: Context + private var mappingUniqueHash: Long = 0 + var isMappingsLoaded = false + private set + + private val mappers = ClassMapper.DEFAULT_MAPPERS.associateBy { it::class } + + private fun getUniqueBuildId() = (getSnapchatPackageInfo()?.longVersionCode ?: -1) xor BuildConfig.BUILD_HASH.hashCode().toLong() + + fun init(context: Context) { + this.context = context + mappingUniqueHash = getUniqueBuildId() + + if (exists()) { + runCatching { + loadCached() + }.onFailure { + delete() + } + } + } + + fun getSnapchatPackageInfo() = runCatching { + context.packageManager.getPackageInfo( + Constants.SNAPCHAT_PACKAGE_NAME, + 0 + ) + }.getOrNull() + + fun getGeneratedBuildNumber() = mappingUniqueHash + fun isMappingsOutdated() = mappingUniqueHash != getUniqueBuildId() || isMappingsLoaded.not() + + private fun loadCached() { + if (!exists()) { + throw Exception("Mappings file does not exist") + } + val mappingsObject = JsonParser.parseString(readBytes().toString(Charsets.UTF_8)).asJsonObject.also { + mappingUniqueHash = it["unique_hash"].asLong + } + + mappingsObject.entrySet().forEach { (key, value) -> + mappers.values.firstOrNull { it.mapperName == key }?.let { mapper -> + mapper.readFromJson(value.asJsonObject) + mapper.classLoader = context.classLoader + } + } + isMappingsLoaded = true + } + + fun refresh(): List<String> { + mappingUniqueHash = getUniqueBuildId() + + // reset native signature cache + fileHandleManager.value.getFileHandle(FileHandleScope.INTERNAL.key, InternalFileHandleType.NATIVE_SIG_CACHE.key).delete() + + val classMapper = ClassMapper(*mappers.values.toTypedArray()) + + runCatching { + classMapper.loadApk(getSnapchatPackageInfo()?.applicationInfo?.sourceDir ?: throw Exception("Failed to get APK")) + }.onFailure { + throw Exception("Failed to load APK", it) + } + + runBlocking { + val result = classMapper.run().apply { + addProperty("unique_hash", mappingUniqueHash) + } + writeBytes(result.toString().toByteArray()) + } + + return classMapper.getWarns() + } + + @Suppress("UNCHECKED_CAST") + fun <T : AbstractClassMapper> useMapper(type: KClass<T>, callback: T.() -> Unit) { + mappers[type]?.let { + callback(it as? T ?: return) + } ?: run { + AbstractLogger.directError("Mapper ${type.simpleName} is not registered", Throwable()) + } + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigConstants.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigConstants.kt new file mode 100644 index 0000000000..fa9d0aa454 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigConstants.kt @@ -0,0 +1,11 @@ +package me.rhunk.snapenhance.common.config + +/* + Due to recent resource obfuscation, some UI features will no longer work because it depends on non obfuscated resources +*/ +val RES_OBF_VERSION_CHECK = VersionCheck(maxVersion = ("13.7.0.42" to 157172)) + +/* + After this version, Snapchat will start detecting modifications to their app (to be confirmed) +*/ +val MOD_DETECTION_VERSION_CHECK = VersionCheck(maxVersion = ("12.33.1.19 (84704)" to 84704)) \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigContainer.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigContainer.kt new file mode 100644 index 0000000000..ce446b9682 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigContainer.kt @@ -0,0 +1,112 @@ +package me.rhunk.snapenhance.common.config + +import android.content.Context +import com.google.gson.JsonObject +import me.rhunk.snapenhance.common.logger.AbstractLogger +import kotlin.reflect.KProperty + +typealias ConfigParamsBuilder = ConfigParams.() -> Unit + +open class ConfigContainer( + val hasGlobalState: Boolean = false +) { + var parentContainerKey: PropertyKey<*>? = null + val properties = mutableMapOf<PropertyKey<*>, PropertyValue<*>>() + var globalState: Boolean? = null + + private inline fun <T> registerProperty( + key: String, + type: DataProcessors.PropertyDataProcessor<*>, + defaultValue: PropertyValue<T>, + params: ConfigParams.() -> Unit = {}, + propertyKeyCallback: (PropertyKey<*>) -> Unit = {} + ): PropertyValue<T> { + val propertyKey = PropertyKey({ parentContainerKey }, key, type, ConfigParams().also { it.params() }) + properties[propertyKey] = defaultValue + propertyKeyCallback(propertyKey) + return defaultValue + } + + protected fun boolean(key: String, defaultValue: Boolean = false, params: ConfigParamsBuilder = {}) = + registerProperty(key, DataProcessors.BOOLEAN, PropertyValue(defaultValue), params) + + protected fun integer(key: String, defaultValue: Int = 0, params: ConfigParamsBuilder = {}) = + registerProperty(key, DataProcessors.INTEGER, PropertyValue(defaultValue), params) + + protected fun float(key: String, defaultValue: Float = 0f, params: ConfigParamsBuilder = {}) = + registerProperty(key, DataProcessors.FLOAT, PropertyValue(defaultValue), params) + + protected fun string(key: String, defaultValue: String = "", params: ConfigParamsBuilder = {}) = + registerProperty(key, DataProcessors.STRING, PropertyValue(defaultValue), params) + + protected fun multiple( + key: String, + vararg values: String = emptyArray(), + params: ConfigParamsBuilder = {} + ) = registerProperty(key, + DataProcessors.STRING_MULTIPLE_SELECTION, PropertyValue(mutableListOf<String>(), defaultValues = values.toList()), params) + + //null value is considered as Off/Disabled + protected fun unique( + key: String, + vararg values: String = emptyArray(), + params: ConfigParamsBuilder = {} + ) = registerProperty(key, + DataProcessors.STRING_UNIQUE_SELECTION, PropertyValue("null", defaultValues = values.toList()), params) + + protected fun <T : ConfigContainer> container( + key: String, + container: T, + params: ConfigParamsBuilder = {} + ) = registerProperty(key, DataProcessors.container(container), PropertyValue(container), params) { + container.parentContainerKey = it + }.get() + + protected fun mapCoordinates( + key: String, + defaultValue: Pair<Double, Double> = 0.0 to 0.0, + params: ConfigParamsBuilder = {} + ) = registerProperty(key, DataProcessors.MAP_COORDINATES, PropertyValue(defaultValue), params) + + protected fun color( + key: String, + defaultValue: Int? = null, + params: ConfigParamsBuilder = {} + ) = registerProperty(key, DataProcessors.INT_COLOR, PropertyValue(defaultValue, defaultValues = defaultValue?.let { listOf(it) }), params) + + fun toJson(exportSensitiveData: Boolean = true): JsonObject { + val json = JsonObject() + properties.forEach { (propertyKey, propertyValue) -> + if (!exportSensitiveData && propertyKey.params.flags.contains(ConfigFlag.SENSITIVE)) return@forEach + val serializedValue = propertyValue.getNullable()?.let { propertyKey.dataType.serializeAny(it, exportSensitiveData) } + json.add(propertyKey.name, serializedValue) + } + return json + } + + fun fromJson(json: JsonObject) { + properties.forEach { (key, _) -> + runCatching { + val jsonElement = json.get(key.name) ?: return@forEach + properties[key]?.setAny(key.dataType.deserializeAny(jsonElement)) + }.onFailure { + AbstractLogger.directError("Failed to deserialize property ${key.name}", it) + } + } + } + + open fun lateInit(context: Context) { + properties.values.filter { it.getNullable() is ConfigContainer }.forEach { + (it.get() as ConfigContainer).lateInit(context) + } + } + + fun getPropertyPair(key: String): PropertyPair<*> { + val propertyKey = properties.keys.firstOrNull { it.name == key } + ?: throw IllegalArgumentException("Property $key not found") + return PropertyPair(propertyKey, properties[propertyKey]!!) + } + + operator fun getValue(t: Any?, property: KProperty<*>) = this.globalState + operator fun setValue(t: Any?, property: KProperty<*>, t1: Boolean?) { this.globalState = t1 } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigObjects.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigObjects.kt new file mode 100644 index 0000000000..6feae66ee0 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigObjects.kt @@ -0,0 +1,160 @@ +package me.rhunk.snapenhance.common.config + +import androidx.compose.ui.graphics.vector.ImageVector +import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper +import kotlin.reflect.KProperty + +data class PropertyPair<T>( + val key: PropertyKey<T>, + val value: PropertyValue<*> +) { + val name get() = key.name +} + +enum class FeatureNotice( + val key: String +) { + UNSTABLE("unstable"), + BAN_RISK("ban_risk"), + INTERNAL_BEHAVIOR("internal_behavior"); + + val id get() = 1 shl ordinal +} + +enum class ConfigFlag { + NO_TRANSLATE, + HIDDEN, + FOLDER, + USER_IMPORT, + NO_DISABLE_KEY, + REQUIRE_RESTART, + REQUIRE_CLEAN_CACHE, + SENSITIVE; + + val id = 1 shl ordinal +} + +data class VersionCheck( + // Pair<versionString, versionCode> + val minVersion: Pair<String, Long>? = null, + val maxVersion: Pair<String, Long>? = null, + val isDisabled: Boolean = false, +) { + fun checkVersion(versionCode: Long): Pair<Pair<String, Long>, VersionRequirement>? { + minVersion?.let { + if (versionCode <= it.second) { + return minVersion to VersionRequirement.NEWER_REQUIRED + } + } + + maxVersion?.let { + if (versionCode >= it.second) { + return maxVersion to VersionRequirement.OLDER_REQUIRED + } + } + + return null + } +} + +enum class VersionRequirement( + val key: String +) { + OLDER_REQUIRED("older_required"), + NEWER_REQUIRED("newer_required"); + + val id = 1 shl ordinal +} + +class ConfigParams( + private var _flags: Int? = null, + private var _notices: Int? = null, + + var icon: ImageVector? = null, + var disabledKey: String? = null, + var customTranslationPath: String? = null, + var customOptionTranslationPath: String? = null, + var inputCheck: ((String) -> Boolean)? = { true }, + var filenameFilter: ((String) -> Boolean)? = null, + var versionCheck: VersionCheck? = null, +) { + val notices get() = _notices?.let { FeatureNotice.entries.filter { flag -> it and flag.id != 0 } } ?: emptyList() + val flags get() = _flags?.let { ConfigFlag.entries.filter { flag -> it and flag.id != 0 } } ?: emptyList() + + fun addNotices(vararg values: FeatureNotice) { + this._notices = (this._notices ?: 0) or values.fold(0) { acc, featureNotice -> acc or featureNotice.id } + } + + fun addFlags(vararg values: ConfigFlag) { + this._flags = (this._flags ?: 0) or values.fold(0) { acc, flag -> acc or flag.id } + } + + fun requireRestart() { + addFlags(ConfigFlag.REQUIRE_RESTART) + } + fun requireCleanCache() { + addFlags(ConfigFlag.REQUIRE_CLEAN_CACHE) + } +} + +class PropertyValue<T>( + private var value: T? = null, + val defaultValues: List<*>? = null +) { + inner class PropertyValueNullable { + fun get() = value + operator fun getValue(t: Any?, property: KProperty<*>): T? = getNullable() + operator fun setValue(t: Any?, property: KProperty<*>, t1: T?) = set(t1) + } + + fun nullable() = PropertyValueNullable() + + fun isSet() = value != null + fun getNullable() = value?.takeIf { it != "null" } + fun isEmpty() = value == null || value == "null" || value.toString().isEmpty() + fun get() = getNullable() ?: throw IllegalStateException("Property is not set") + fun set(value: T?) { setAny(value) } + @Suppress("UNCHECKED_CAST") + fun setAny(value: Any?) { this.value = value as T? } + + operator fun getValue(t: Any?, property: KProperty<*>): T = get() + operator fun setValue(t: Any?, property: KProperty<*>, t1: T?) = set(t1) +} + +data class PropertyKey<T>( + private val _parent: () -> PropertyKey<*>?, + val name: String, + val dataType: DataProcessors.PropertyDataProcessor<T>, + val params: ConfigParams = ConfigParams(), +) { + private val parentKey by lazy { _parent() } + + fun propertyOption(translation: LocaleWrapper, key: String): String { + if (key == "null") { + return translation[params.disabledKey?.let { disabledKey -> + params.customOptionTranslationPath?.let { + "$it.$disabledKey" + } ?: key + } ?: "manager.sections.features.disabled"] + } + + return if (!params.flags.contains(ConfigFlag.NO_TRANSLATE)) + translation[params.customOptionTranslationPath?.let { + "$it.$key" + } ?: "features.options.${name}.$key"] + else key + } + + fun propertyName() = propertyTranslationPath() + ".name" + fun propertyDescription() = propertyTranslationPath() + ".description" + + fun propertyTranslationPath(): String { + params.customTranslationPath?.let { + return it + } + return parentKey?.let { + "${it.propertyTranslationPath()}.properties.$name" + } ?: "features.properties.$name" + } +} + diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/DataProcessors.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/DataProcessors.kt new file mode 100644 index 0000000000..9b6f747ef6 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/DataProcessors.kt @@ -0,0 +1,119 @@ +package me.rhunk.snapenhance.common.config + +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonNull +import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive + +object DataProcessors { + enum class Type { + STRING, + BOOLEAN, + INTEGER, + FLOAT, + STRING_MULTIPLE_SELECTION, + STRING_UNIQUE_SELECTION, + MAP_COORDINATES, + INT_COLOR, + CONTAINER, + } + + class PropertyDataProcessor<T> + internal constructor( + val type: Type, + private val serialize: (T, exportSensitiveData: Boolean) -> JsonElement, + private val deserialize: (JsonElement) -> T + ) { + @Suppress("UNCHECKED_CAST") + fun serializeAny(value: Any, exportSensitiveData: Boolean) = serialize(value as T, exportSensitiveData) + fun deserializeAny(value: JsonElement) = deserialize(value) + } + + val STRING = PropertyDataProcessor( + type = Type.STRING, + serialize = { it, _ -> + if (it != null) JsonPrimitive(it) + else JsonNull.INSTANCE + }, + deserialize = { + if (it.isJsonNull) null + else it.asString + }, + ) + + val BOOLEAN = PropertyDataProcessor( + type = Type.BOOLEAN, + serialize = { it, _ -> + if (it) JsonPrimitive(true) + else JsonPrimitive(false) + }, + deserialize = { it.asBoolean }, + ) + + val INTEGER = PropertyDataProcessor( + type = Type.INTEGER, + serialize = { it, _ -> JsonPrimitive(it) }, + deserialize = { it.asInt }, + ) + + val FLOAT = PropertyDataProcessor( + type = Type.FLOAT, + serialize = { it, _ -> JsonPrimitive(it) }, + deserialize = { it.asFloat }, + ) + + val STRING_MULTIPLE_SELECTION = PropertyDataProcessor( + type = Type.STRING_MULTIPLE_SELECTION, + serialize = { it, _ -> JsonArray().apply { it.forEach { add(it) } } }, + deserialize = { obj -> + obj.asJsonArray.map { it.asString }.toMutableList() + }, + ) + + val STRING_UNIQUE_SELECTION = PropertyDataProcessor( + type = Type.STRING_UNIQUE_SELECTION, + serialize = { it, _ -> JsonPrimitive(it) }, + deserialize = { obj -> obj.takeIf { !it.isJsonNull }?.asString?.takeIf { it != "false" && it != "true" } } + ) + + val MAP_COORDINATES = PropertyDataProcessor( + type = Type.MAP_COORDINATES, + serialize = { it, _ -> + JsonObject().apply { + addProperty("lat", it.first.takeIf { it in -90.0..90.0 } ?: 0.0) + addProperty("lng", it.second.takeIf { it in -180.0..180.0 } ?: 0.0) + } + }, + deserialize = { obj -> + val jsonObject = obj.asJsonObject + (jsonObject["lat"].asDouble.takeIf { it in -90.0..90.0 } ?: 0.0) to + (jsonObject["lng"].asDouble.takeIf { it in -180.0..180.0 } ?: 0.0) + }, + ) + + val INT_COLOR = PropertyDataProcessor( + type = Type.INT_COLOR, + serialize = { it, _ -> + it?.let { JsonPrimitive(it) } ?: JsonNull.INSTANCE + }, + deserialize = { if (it.isJsonNull) null else it.asString.toIntOrNull() }, + ) + + fun <T : ConfigContainer> container(container: T) = PropertyDataProcessor( + type = Type.CONTAINER, + serialize = { it, exportSensitiveData -> + JsonObject().apply { + addProperty("state", it.globalState) + add("properties", it.toJson(exportSensitiveData)) + } + }, + deserialize = { obj -> + val jsonObject = obj.asJsonObject + container.apply { + globalState = jsonObject["state"]?.takeIf { !it.isJsonNull }?.asBoolean + jsonObject["properties"]?.asJsonObject?.let { fromJson(it) } + } + }, + ) +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ModConfig.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ModConfig.kt new file mode 100644 index 0000000000..0f7c395143 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ModConfig.kt @@ -0,0 +1,139 @@ +package me.rhunk.snapenhance.common.config + +import android.content.Context +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.JsonObject +import me.rhunk.snapenhance.bridge.ConfigStateListener +import me.rhunk.snapenhance.bridge.storage.FileHandleManager +import me.rhunk.snapenhance.common.bridge.InternalFileHandleType +import me.rhunk.snapenhance.common.bridge.InternalFileWrapper +import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper +import me.rhunk.snapenhance.common.config.impl.RootConfig +import me.rhunk.snapenhance.common.logger.AbstractLogger +import me.rhunk.snapenhance.common.util.LazyBridgeValue +import kotlin.properties.Delegates + +class ModConfig( + private val context: Context, + fileHandleManager: LazyBridgeValue<FileHandleManager> +) { + private val fileWrapper = InternalFileWrapper(fileHandleManager, InternalFileHandleType.CONFIG, "{}") + var locale: String = LocaleWrapper.DEFAULT_LOCALE + + private val gson: Gson = GsonBuilder().setPrettyPrinting().create() + var wasPresent by Delegates.notNull<Boolean>() + + /* Used to notify the bridge client about config changes */ + var configStateListener: ConfigStateListener? = null + lateinit var root: RootConfig + private set + + fun isInitialized() = ::root.isInitialized + + private fun createRootConfig() = RootConfig().apply { lateInit(context) } + + fun load() { + wasPresent = fileWrapper.exists() + root = createRootConfig().apply { + if (!wasPresent) { + writeConfigObject(this) + return@apply + } + runCatching { + loadConfig(this) + }.onFailure { + writeConfigObject(this) + } + } + } + + private fun loadConfig(config: RootConfig) { + val configFileContent = fileWrapper.readBytes() + val configObject = gson.fromJson(configFileContent.toString(Charsets.UTF_8), JsonObject::class.java) + locale = configObject.get("_locale")?.asString ?: LocaleWrapper.DEFAULT_LOCALE + config.fromJson(configObject) + } + + fun exportToString( + exportSensitiveData: Boolean = true, + config: RootConfig = root, + ): String { + return gson.toJson(config.toJson(exportSensitiveData).apply { + addProperty("_locale", locale) + }) + } + + fun reset() { + root = RootConfig().apply { + writeConfigObject(this) + } + } + + fun writeConfig(dispatchConfigListener: Boolean = true) { + writeConfigObject(root, dispatchConfigListener) + } + + private fun writeConfigObject(config: RootConfig, dispatchConfigListener: Boolean = true) { + var shouldRestart = false + var shouldCleanCache = false + var configChanged = false + + fun compareDiff(originalContainer: ConfigContainer, modifiedContainer: ConfigContainer) { + val parentContainerFlags = modifiedContainer.parentContainerKey?.params?.flags ?: emptySet() + + parentContainerFlags.takeIf { originalContainer.hasGlobalState }?.apply { + if (modifiedContainer.globalState != originalContainer.globalState) { + configChanged = true + if (contains(ConfigFlag.REQUIRE_RESTART)) shouldRestart = true + if (contains(ConfigFlag.REQUIRE_CLEAN_CACHE)) shouldCleanCache = true + } + } + + for (property in modifiedContainer.properties) { + val modifiedValue = property.value.getNullable() + val originalValue = originalContainer.properties.entries.firstOrNull { + it.key.name == property.key.name + }?.value?.getNullable() + + if (originalValue is ConfigContainer && modifiedValue is ConfigContainer) { + compareDiff(originalValue, modifiedValue) + continue + } + + if (modifiedValue != originalValue) { + val flags = property.key.params.flags + parentContainerFlags + configChanged = true + if (flags.contains(ConfigFlag.REQUIRE_RESTART)) shouldRestart = true + if (flags.contains(ConfigFlag.REQUIRE_CLEAN_CACHE)) shouldCleanCache = true + } + } + } + + val oldConfig = runCatching { fileWrapper.readBytes().toString(Charsets.UTF_8) }.getOrNull() + fileWrapper.writeBytes(exportToString(config = config).toByteArray(Charsets.UTF_8)) + + configStateListener?.takeIf { dispatchConfigListener && it.asBinder().pingBinder() }?.also { + runCatching { + compareDiff(createRootConfig().apply { + fromJson(gson.fromJson(oldConfig ?: return@runCatching, JsonObject::class.java)) + }, config) + + if (configChanged) { + it.onConfigChanged() + if (shouldCleanCache) it.onCleanCacheRequired() + else if (shouldRestart) it.onRestartRequired() + } + }.onFailure { + AbstractLogger.directError("Error while calling config state listener", it, "ConfigStateListener") + } + } + } + + fun loadFromString(string: String) { + val configObject = gson.fromJson(string, JsonObject::class.java) + locale = configObject.get("_locale")?.asString ?: LocaleWrapper.DEFAULT_LOCALE + root.fromJson(configObject) + writeConfig() + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Camera.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Camera.kt new file mode 100644 index 0000000000..52d920e7f3 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Camera.kt @@ -0,0 +1,62 @@ +package me.rhunk.snapenhance.common.config.impl + +import android.content.Context +import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.CameraManager +import me.rhunk.snapenhance.common.Constants +import me.rhunk.snapenhance.common.config.* +import me.rhunk.snapenhance.common.logger.AbstractLogger + +class Camera : ConfigContainer() { + companion object { + private val defaultResolutions = listOf("3264x2448", "3264x1840", "3264x1504", "2688x1512", "2560x1920", "2448x2448", "2340x1080", "2160x1080", "1920x1440", "1920x1080", "1600x1200", "1600x960", "1600x900", "1600x736", "1600x720", "1560x720", "1520x720", "1440x1080", "1440x720", "1280x720", "1080x1080", "1080x720", "960x720", "720x720", "720x480", "640x480", "352x288", "320x240", "176x144").toTypedArray() + private val customFrameRates = arrayOf("5", "10", "20", "25", "30", "48", "60", "90", "120") + } + + private lateinit var _overrideFrontResolution: PropertyValue<String> + private lateinit var _overrideBackResolution: PropertyValue<String> + + override fun lateInit(context: Context) { + val backResolutions = mutableListOf<String>() + val frontResolutions = mutableListOf<String>() + + context.getSystemService(CameraManager::class.java).apply { + if (context.packageName == Constants.SNAPCHAT_PACKAGE_NAME) return@apply // prevent snapchat from crashing + + runCatching { + cameraIdList.forEach { cameraId -> + val characteristics = getCameraCharacteristics(cameraId) + val isSelfie = characteristics.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT + + (frontResolutions.takeIf { isSelfie } ?: backResolutions).addAll( + characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)?.let { + it.outputFormats.flatMap { format -> it.getOutputSizes(format).toList() } + }?.sortedByDescending { it.width * it.height }?.map { "${it.width}x${it.height}" }?.distinct() ?: emptyList() + ) + } + }.onFailure { + AbstractLogger.directError("Failed to get camera resolutions", it) + backResolutions.addAll(defaultResolutions) + frontResolutions.addAll(defaultResolutions) + } + } + + _overrideFrontResolution = unique("override_front_resolution", *frontResolutions.toTypedArray()) + { addFlags(ConfigFlag.NO_TRANSLATE) } + _overrideBackResolution = unique("override_back_resolution", *backResolutions.toTypedArray()) + { addFlags(ConfigFlag.NO_TRANSLATE) } + } + + val disableCameras = multiple("disable_cameras", "front", "back") { addNotices(FeatureNotice.INTERNAL_BEHAVIOR); requireRestart() } + val immersiveCameraPreview = boolean("immersive_camera_preview") { addNotices(FeatureNotice.UNSTABLE); versionCheck = RES_OBF_VERSION_CHECK.copy(isDisabled = true) } + val blackPhotos = boolean("black_photos") + val frontCustomFrameRate = unique("front_custom_frame_rate", *customFrameRates) { requireRestart(); addFlags(ConfigFlag.NO_TRANSLATE) } + val backCustomFrameRate = unique("back_custom_frame_rate", *customFrameRates) { requireRestart(); addFlags(ConfigFlag.NO_TRANSLATE) } + val hevcRecording = boolean("hevc_recording") { requireRestart() } + val forceCameraSourceEncoding = boolean("force_camera_source_encoding") + val startupDefaultCamera = unique("startup_default_camera", "front", "back") { requireRestart() } + val overrideFrontResolution get() = _overrideFrontResolution + val overrideBackResolution get() = _overrideBackResolution + + val customResolution = string("custom_resolution") { addNotices(FeatureNotice.UNSTABLE); inputCheck = { it.matches(Regex("\\d+x\\d+")) } } +} diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/DownloaderConfig.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/DownloaderConfig.kt new file mode 100644 index 0000000000..15b8e0c91c --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/DownloaderConfig.kt @@ -0,0 +1,53 @@ +package me.rhunk.snapenhance.common.config.impl + +import me.rhunk.snapenhance.common.config.ConfigContainer +import me.rhunk.snapenhance.common.config.ConfigFlag +import me.rhunk.snapenhance.common.config.FeatureNotice + +class DownloaderConfig : ConfigContainer() { + inner class FFMpegOptions : ConfigContainer() { + val threads = integer("threads", 4) // Bump Default Value to 4 Tested on Pixel 5 (Qualcomm Snapdragon 765G) Had no lag + val preset = unique("preset", "ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow") { + addFlags(ConfigFlag.NO_TRANSLATE) + } + val constantRateFactor = integer("constant_rate_factor", 30) + val videoBitrate = integer("video_bitrate", 5000) + val audioBitrate = integer("audio_bitrate", 128) + val customVideoCodec = string("custom_video_codec") { addFlags(ConfigFlag.NO_TRANSLATE) } + val customAudioCodec = string("custom_audio_codec") { addFlags(ConfigFlag.NO_TRANSLATE) } + } + + val saveFolder = string("save_folder") { addFlags(ConfigFlag.FOLDER, ConfigFlag.SENSITIVE); requireRestart() } + val autoDownloadSources = multiple("auto_download_sources", + "friend_snaps", + "friend_stories", + "public_stories", + "spotlight" + ) + val preventSelfAutoDownload = boolean("prevent_self_auto_download") + val pathFormat = multiple("path_format", + "create_author_folder", + "create_source_folder", + "append_hash", + "append_source", + "append_username", + "append_date_time", + ).apply { set(mutableListOf("append_hash", "append_date_time", "append_type", "append_username")) } + val allowDuplicate = boolean("allow_duplicate") + val mergeOverlays = boolean("merge_overlays") { addNotices(FeatureNotice.UNSTABLE) } + val forceImageFormat = unique("force_image_format", "jpg", "png", "webp") { + addFlags(ConfigFlag.NO_TRANSLATE) + } + val forceVoiceNoteFormat = unique("force_voice_note_format", "aac", "mp3", "opus") { + addFlags(ConfigFlag.NO_TRANSLATE) + } + val autoDownloadVoiceNotes = boolean("auto_download_voice_notes") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) } + val downloadProfilePictures = boolean("download_profile_pictures") { requireRestart() } + val operaDownloadButton = boolean("opera_download_button") { requireRestart() } + val downloadContextMenu = boolean("download_context_menu") + val ffmpegOptions = container("ffmpeg_options", FFMpegOptions()) { addNotices(FeatureNotice.UNSTABLE) } + val logging = multiple("logging", "started", "success", "progress", "failure").apply { + set(mutableListOf("success", "progress", "failure")) + } + val customPathFormat = string("custom_path_format") { addNotices(FeatureNotice.UNSTABLE) } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt new file mode 100644 index 0000000000..16aba133a3 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt @@ -0,0 +1,97 @@ +package me.rhunk.snapenhance.common.config.impl + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Fingerprint +import androidx.compose.material.icons.filled.Memory +import me.rhunk.snapenhance.common.config.ConfigContainer +import me.rhunk.snapenhance.common.config.ConfigFlag +import me.rhunk.snapenhance.common.config.FeatureNotice + +class Experimental : ConfigContainer() { + companion object { + val cofExperimentList = listOf( + "android_action_menu_v2", + "android_action_menu_adjust_message_position", + "chat_emoji_reactions_sending_enabled", + "chat_text_message_plugin", + ) + } + + class BetterTranscriptConfig: ConfigContainer(hasGlobalState = true) { + val forceTranscription = boolean("force_transcription") { requireRestart() } + val preferredTranscriptionLang = string("preferred_transcription_lang") { requireRestart() } + val notificationTranscript = boolean("notification_transcript") { requireRestart() } + } + + class ComposerHooksConfig: ConfigContainer(hasGlobalState = true) { + val showFirstCreatedUsername = boolean("show_first_created_username") + val bypassCameraRollLimit = boolean("bypass_camera_roll_limit") + val customSelfDestructSnapDelay = boolean("custom_self_destruct_snap_delay") + val composerConsole = boolean("composer_console") + val composerLogs = boolean("composer_logs") + } + + class NativeHooks : ConfigContainer() { + val composerHooks = container("composer_hooks", ComposerHooksConfig()) { requireRestart() } + val disableBitmoji = boolean("disable_bitmoji") + val customEmojiFont = string("custom_emoji_font") { + requireRestart() + addNotices(FeatureNotice.UNSTABLE) + addFlags(ConfigFlag.USER_IMPORT) + filenameFilter = { it.endsWith(".ttf") } + } + val customSharedLibrary = string("custom_shared_library") { + requireRestart() + addNotices(FeatureNotice.INTERNAL_BEHAVIOR) + addFlags(ConfigFlag.USER_IMPORT) + filenameFilter = { it.endsWith(".so") } + } + } + + class E2EEConfig : ConfigContainer(hasGlobalState = true) { + val encryptedMessageIndicator = boolean("encrypted_message_indicator") + val forceMessageEncryption = boolean("force_message_encryption") + } + + class AccountSwitcherConfig : ConfigContainer(hasGlobalState = true) { + val autoBackupCurrentAccount = boolean("auto_backup_current_account", defaultValue = true) + } + + class AppLockConfig: ConfigContainer(hasGlobalState = true) { + val lockOnResume = boolean("lock_on_resume", defaultValue = true) + } + + val nativeHooks = container("native_hooks", NativeHooks()) { icon = Icons.Default.Memory; requireRestart() } + val spoof = container("spoof", Spoof()) { icon = Icons.Default.Fingerprint ; addNotices(FeatureNotice.BAN_RISK); requireRestart() } + val convertMessageLocally = boolean("convert_message_locally") { requireRestart() } + val mediaFilePicker = boolean("media_file_picker") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) } + val storyLogger = boolean("story_logger") { requireRestart(); addNotices(FeatureNotice.UNSTABLE); } + val callRecorder = boolean("call_recorder") { requireRestart(); addNotices(FeatureNotice.UNSTABLE); } + val accountSwitcher = container("account_switcher", AccountSwitcherConfig()) { requireRestart(); addNotices(FeatureNotice.UNSTABLE) } + val betterTranscript = container("better_transcript", BetterTranscriptConfig()) { requireRestart() } + val voiceNoteAutoPlay = boolean("voice_note_auto_play") { requireRestart() } + val friendNotes = boolean("friend_notes") { requireRestart() } + val contextMenuFix = boolean("context_menu_fix") { requireRestart() } + val cofExperiments = multiple("cof_experiments", *cofExperimentList.toTypedArray()) { requireRestart(); addFlags(ConfigFlag.NO_TRANSLATE); addNotices(FeatureNotice.UNSTABLE) } + val appLock = container("app_lock", AppLockConfig()) { requireRestart(); addNotices(FeatureNotice.UNSTABLE) } + val infiniteStoryBoost = boolean("infinite_story_boost") + val meoPasscodeBypass = boolean("meo_passcode_bypass") + val noFriendScoreDelay = boolean("no_friend_score_delay") { requireRestart()} + val bestFriendPinning = boolean("best_friend_pinning") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) } + val e2eEncryption = container("e2ee", E2EEConfig()) { requireRestart() } + val hiddenSnapchatPlusFeatures = boolean("hidden_snapchat_plus_features") { + addNotices(FeatureNotice.BAN_RISK, FeatureNotice.UNSTABLE) + requireRestart() + } + val customStreaksExpirationFormat = string("custom_streaks_expiration_format") { requireRestart() } + val addFriendSourceSpoof = unique("add_friend_source_spoof", + "added_by_username", + "added_by_mention", + "added_by_group_chat", + "added_by_qr_code", + "added_by_community", + "added_by_quick_add", + ) { addNotices(FeatureNotice.BAN_RISK) } + val preventForcedLogout = boolean("prevent_forced_logout") { requireRestart(); addNotices(FeatureNotice.BAN_RISK, FeatureNotice.INTERNAL_BEHAVIOR); } + val snapScoreChanges = boolean("snapscore_changes") { requireRestart() } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/FriendTrackerConfig.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/FriendTrackerConfig.kt new file mode 100644 index 0000000000..b7af216c85 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/FriendTrackerConfig.kt @@ -0,0 +1,15 @@ +package me.rhunk.snapenhance.common.config.impl + +import me.rhunk.snapenhance.common.config.ConfigContainer +import me.rhunk.snapenhance.common.util.PURGE_DISABLED_KEY +import me.rhunk.snapenhance.common.util.PURGE_VALUES +import me.rhunk.snapenhance.common.util.PURGE_TRANSLATION_KEY + +class FriendTrackerConfig: ConfigContainer(hasGlobalState = true) { + val recordMessagingEvents = boolean("record_messaging_events", false) + val allowRunningInBackground = boolean("allow_running_in_background", false) + val autoPurge = unique("auto_purge", *PURGE_VALUES) { + customOptionTranslationPath = PURGE_TRANSLATION_KEY + disabledKey = PURGE_DISABLED_KEY + }.apply { set(PURGE_DISABLED_KEY) } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Global.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Global.kt new file mode 100644 index 0000000000..26c90c6519 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Global.kt @@ -0,0 +1,59 @@ +package me.rhunk.snapenhance.common.config.impl + +import me.rhunk.snapenhance.common.config.ConfigContainer +import me.rhunk.snapenhance.common.config.ConfigFlag +import me.rhunk.snapenhance.common.config.FeatureNotice + +class Global : ConfigContainer() { + companion object { + val permissionMap = mapOf( + "android.permission.POST_NOTIFICATIONS" to "notifications", + "android.permission.READ_MEDIA_IMAGES" to "read_media_images", + "android.permission.READ_MEDIA_VIDEO" to "read_media_video", + "android.permission.CAMERA" to "camera", + "android.permission.ACCESS_FINE_LOCATION" to "location", + "android.permission.RECORD_AUDIO" to "microphone", + "android.permission.READ_CONTACTS" to "read_contacts", + "android.permission.BLUETOOTH_CONNECT" to "nearby_devices", + "android.permission.READ_PHONE_STATE" to "phone_calls", + ) + } + + inner class BetterLocationConfig : ConfigContainer(hasGlobalState = true) { + val spoofLocation = boolean("spoof_location") + val coordinates = mapCoordinates("coordinates", 0.0 to 0.0) { addFlags(ConfigFlag.SENSITIVE) } // lat, long + val walkRadius = string("walk_radius") { requireRestart(); inputCheck = { it.toDoubleOrNull()?.isFinite() == true && it.toDouble() >= 0.0 } } + val alwaysUpdateLocation = boolean("always_update_location") { requireRestart() } + val suspendLocationUpdates = boolean("suspend_location_updates") + val spoofBatteryLevel = string("spoof_battery_level") { requireRestart(); inputCheck = { it.isEmpty() || it.toIntOrNull() in 0..100 } } + val spoofHeadphones = boolean("spoof_headphones") { requireRestart() } + val showBatteryLevel = boolean("show_battery_level") { requireRestart() } + } + + inner class MediaUploadQualityConfig : ConfigContainer() { + val forceVideoUploadSourceQuality = boolean("force_video_upload_source_quality") { requireRestart() } + val disableImageCompression = boolean("disable_image_compression") { requireRestart() } + val customUploadImageFormat = unique("custom_image_upload_format", "jpeg", "png", "webp") { requireRestart(); addFlags(ConfigFlag.NO_TRANSLATE) } + } + + val betterLocation = container("better_location", BetterLocationConfig()) + val snapchatPlus = unique("snapchat_plus", "not_subscribed", "basic", "ad_free") { requireRestart() } + val mediaUploadQualityConfig = container("media_upload_quality", MediaUploadQualityConfig()) + val disableConfirmationDialogs = multiple("disable_confirmation_dialogs", "erase_message", "remove_friend", "block_friend", "ignore_friend", "hide_friend", "hide_conversation", "clear_conversation") { requireRestart() } + val disableMetrics = boolean("disable_metrics") { requireRestart() } + val disableStorySections = multiple("disable_story_sections", "friends", "suggested_stories", "following", "discover") { requireRestart(); requireCleanCache() } + val blockAds = boolean("block_ads") + val disableCustomTabs = boolean("disable_custom_tabs") { requireRestart() } + val disablePermissionRequests = multiple("disable_permission_requests", *permissionMap.values.toTypedArray()) { requireRestart(); addNotices(FeatureNotice.UNSTABLE) } + val disableMemoriesSnapFeed = boolean("disable_memories_snap_feed") + val spotlightCommentsUsername = boolean("spotlight_comments_username") { requireRestart() } + val bypassVideoLengthRestriction = unique("bypass_video_length_restriction", "split", "single") { addNotices( + FeatureNotice.BAN_RISK); requireRestart() } + val defaultVideoPlaybackRate = float("default_video_playback_rate", 1.0F) { requireRestart(); inputCheck = { (it.toFloatOrNull() ?: 1.0F) in 0.1F..4.0F} } + val videoPlaybackRateSlider = boolean("video_playback_rate_slider") { requireRestart() } + val disableGooglePlayDialogs = boolean("disable_google_play_dialogs") { requireRestart() } + val defaultVolumeControls = boolean("default_volume_controls") { requireRestart() } + val disableTelecomFramework = boolean("disable_telecom_framework") { requireRestart() } + val hideActiveMusic = boolean("hide_active_music") { requireRestart() } + val disableSnapSplitting = boolean("disable_snap_splitting") { addNotices(FeatureNotice.UNSTABLE) } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt new file mode 100644 index 0000000000..24b3848dcf --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt @@ -0,0 +1,105 @@ +package me.rhunk.snapenhance.common.config.impl + +import me.rhunk.snapenhance.common.config.ConfigContainer +import me.rhunk.snapenhance.common.config.FeatureNotice +import me.rhunk.snapenhance.common.config.PropertyValue +import me.rhunk.snapenhance.common.data.NotificationType +import me.rhunk.snapenhance.common.util.PURGE_DISABLED_KEY +import me.rhunk.snapenhance.common.util.PURGE_TRANSLATION_KEY +import me.rhunk.snapenhance.common.util.PURGE_VALUES + +class MessagingTweaks : ConfigContainer() { + companion object { + const val DELETED_MESSAGE_COLOR = 0x6Eb71c1c; + } + + inner class HalfSwipeNotifierConfig : ConfigContainer(hasGlobalState = true) { + val minDuration: PropertyValue<Int> = integer("min_duration", defaultValue = 0) { + inputCheck = { it.toIntOrNull()?.coerceAtLeast(0) != null && maxDuration.get() >= it.toInt() } + } + val maxDuration: PropertyValue<Int> = integer("max_duration", defaultValue = 20) { + inputCheck = { it.toIntOrNull()?.coerceAtLeast(0) != null && minDuration.get() <= it.toInt() } + } + } + + inner class MessageLoggerConfig : ConfigContainer(hasGlobalState = true) { + val keepMyOwnMessages = boolean("keep_my_own_messages") + val autoPurge = unique("auto_purge", *PURGE_VALUES) { + customOptionTranslationPath = PURGE_TRANSLATION_KEY + disabledKey = PURGE_DISABLED_KEY + }.apply { set("3_days") } + val messageFilter = multiple("message_filter", "CHAT", + "SNAP", + "NOTE", + "EXTERNAL_MEDIA", + "STICKER" + ) { + customOptionTranslationPath = "content_type" + } + val deletedMessageColor = color("deleted_message_color", DELETED_MESSAGE_COLOR) + } + + class BetterNotifications: ConfigContainer() { + val groupNotifications = boolean("group_notifications") + val chatPreview = boolean("chat_preview") + val mediaPreview = multiple("media_preview", "SNAP", "EXTERNAL_MEDIA", "STICKER", "SHARE", "TINY_SNAP", "MAP_REACTION") { + customOptionTranslationPath = "content_type" + } + val mediaCaption = boolean("media_caption") + val stackedMediaMessages = boolean("stacked_media_messages") + val friendAddSource = boolean("friend_add_source") + val replyButton = boolean("reply_button") { addNotices(FeatureNotice.UNSTABLE) } + val smartReplies = boolean("smart_replies") + val downloadButton = boolean("download_button") + val markAsReadButton = boolean("mark_as_read_button") { addNotices(FeatureNotice.UNSTABLE) } + val markAsReadAndSaveInChat = boolean("mark_as_read_and_save_in_chat") { addNotices(FeatureNotice.UNSTABLE) } + } + + val bypassScreenshotDetection = boolean("bypass_screenshot_detection") { requireRestart() } + val anonymousStoryViewing = boolean("anonymous_story_viewing") + val preventStoryRewatchIndicator = boolean("prevent_story_rewatch_indicator") { requireRestart() } + val hidePeekAPeek = boolean("hide_peek_a_peek") + val hideBitmojiPresence = boolean("hide_bitmoji_presence") + val hideTypingNotifications = boolean("hide_typing_notifications") + val unlimitedSnapViewTime = boolean("unlimited_snap_view_time") + val autoMarkAsRead = multiple("auto_mark_as_read", "snap_reply", "conversation_read", "save_snap_in_chat") { requireRestart() } + val markSnapAsSeenButton = boolean("mark_snap_as_seen_button") { requireRestart() } + val skipWhenMarkingAsSeen = boolean("skip_when_marking_as_seen") { requireRestart() } + val loopMediaPlayback = boolean("loop_media_playback") { requireRestart() } + val disableReplayInFF = boolean("disable_replay_in_ff") + val halfSwipeNotifier = container("half_swipe_notifier", HalfSwipeNotifierConfig()) { requireRestart()} + val callStartConfirmation = boolean("call_start_confirmation") { requireRestart() } + val unlimitedConversationPinning = boolean("unlimited_conversation_pinning") { requireRestart() } + val disableSnapModeRestrictions = boolean("disable_snap_mode_restrictions") { requireRestart() } + val autoSaveMessagesInConversations = multiple("auto_save_messages_in_conversations", + "CHAT", + "SNAP", + "NOTE", + "EXTERNAL_MEDIA", + "STICKER" + ) { requireRestart(); customOptionTranslationPath = "content_type" } + val preventMessageSending = multiple("prevent_message_sending", *NotificationType.getOutgoingValues().map { it.key }.toTypedArray()) { + customOptionTranslationPath = "features.options.notifications" + } + val friendMutationNotifier = multiple("friend_mutation_notifier", + "remove_friend", + "birthday_changes", + "bitmoji_selfie_changes", + "bitmoji_avatar_changes", + "bitmoji_background_changes", + "bitmoji_scene_changes", + ) { requireRestart() } + val betterNotifications = container("better_notifications", BetterNotifications()) { requireRestart() } + val notificationBlacklist = multiple("notification_blacklist", *NotificationType.getIncomingValues().map { it.key }.toTypedArray()) { + customOptionTranslationPath = "features.options.notifications" + } + val messageLogger = container("message_logger", MessageLoggerConfig()) { requireRestart() } + val galleryMediaSendOverride = unique("gallery_media_send_override", "always_ask", "SNAP", "NOTE", "SAVEABLE_SNAP") { requireRestart() } + val stripMediaMetadata = multiple("strip_media_metadata", "hide_caption_text", "hide_snap_filters", "hide_extras", "remove_audio_note_duration", "remove_audio_note_transcript_capability") { requireRestart() } + val bypassMessageRetentionPolicy = boolean("bypass_message_retention_policy") { addNotices(FeatureNotice.UNSTABLE); requireRestart() } + val bypassMessageActionRestrictions = boolean("bypass_message_action_restrictions") { requireRestart() } + val removeGroupsLockedStatus = boolean("remove_groups_locked_status") { requireRestart() } + val doubleTapChatAction = unique("double_tap_chat_action", "like_message", "copy_text", "delete_message", "mark_as_read", "custom_emoji_reaction") { requireRestart() } + val doubleTapChatActionCustomEmoji = string("double_tap_chat_action_custom_emoji") { + inputCheck = { it.length == 2 && it.toByteArray(Charsets.UTF_8).size >= 4 } } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/RootConfig.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/RootConfig.kt new file mode 100644 index 0000000000..d258af9af7 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/RootConfig.kt @@ -0,0 +1,22 @@ +package me.rhunk.snapenhance.common.config.impl + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Rule +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.* +import me.rhunk.snapenhance.common.config.ConfigContainer +import me.rhunk.snapenhance.common.config.FeatureNotice + +class RootConfig : ConfigContainer() { + val downloader = container("downloader", DownloaderConfig()) { icon = Icons.Default.Download } + val userInterface = container("user_interface", UserInterfaceTweaks()) { icon = Icons.Default.RemoveRedEye } + val messaging = container("messaging", MessagingTweaks()) { icon = Icons.AutoMirrored.Default.Send } + val global = container("global", Global()) { icon = Icons.Default.MiscellaneousServices } + val rules = container("rules", Rules()) { icon = Icons.AutoMirrored.Default.Rule } + val camera = container("camera", Camera()) { icon = Icons.Default.Camera; requireRestart() } + val streaksReminder = container("streaks_reminder", StreaksReminderConfig()) { icon = Icons.Default.Alarm } + val experimental = container("experimental", Experimental()) { icon = Icons.Default.Science; addNotices( + FeatureNotice.UNSTABLE) } + val scripting = container("scripting", Scripting()) { icon = Icons.Default.DataObject } + val friendTracker = container("friend_tracker", FriendTrackerConfig()) { icon = Icons.Default.PersonSearch } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Rules.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Rules.kt new file mode 100644 index 0000000000..3cf65f407f --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Rules.kt @@ -0,0 +1,28 @@ +package me.rhunk.snapenhance.common.config.impl + +import me.rhunk.snapenhance.common.config.ConfigContainer +import me.rhunk.snapenhance.common.config.PropertyValue +import me.rhunk.snapenhance.common.data.MessagingRuleType +import me.rhunk.snapenhance.common.data.RuleState + + +class Rules : ConfigContainer() { + private val rules = mutableMapOf<MessagingRuleType, PropertyValue<String>>() + + fun getRuleState(ruleType: MessagingRuleType): RuleState? { + return rules[ruleType]?.getNullable()?.let { RuleState.getByName(it) } + } + + init { + MessagingRuleType.entries.filter { it.listMode }.forEach { ruleType -> + rules[ruleType] = unique(ruleType.key,"whitelist", "blacklist") { + customTranslationPath = "rules.properties.${ruleType.key}" + customOptionTranslationPath = "rules.modes" + addNotices(*ruleType.configNotices) + requireRestart() + }.apply { + set(ruleType.defaultValue) + } + } + } +} diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Scripting.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Scripting.kt new file mode 100644 index 0000000000..793c3653d8 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Scripting.kt @@ -0,0 +1,13 @@ +package me.rhunk.snapenhance.common.config.impl + +import me.rhunk.snapenhance.common.config.ConfigContainer +import me.rhunk.snapenhance.common.config.ConfigFlag + +class Scripting : ConfigContainer() { + val developerMode = boolean("developer_mode", false) { requireRestart() } + val moduleFolder = string("module_folder", "modules") { addFlags(ConfigFlag.FOLDER, ConfigFlag.SENSITIVE); requireRestart() } + val autoReload = unique("auto_reload", "snapchat_only", "all") + val integratedUI = boolean("integrated_ui", false) { requireRestart() } + val disableLogAnonymization = boolean("disable_log_anonymization", false) { requireRestart() } + val disableOptimization = boolean("disable_optimization", false) { requireRestart() } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Spoof.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Spoof.kt new file mode 100644 index 0000000000..e75e2ae994 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Spoof.kt @@ -0,0 +1,10 @@ +package me.rhunk.snapenhance.common.config.impl + +import me.rhunk.snapenhance.common.config.ConfigContainer +import me.rhunk.snapenhance.common.config.ConfigFlag + +class Spoof : ConfigContainer(hasGlobalState = true) { + val overridePlayStoreInstallerPackageName = boolean("play_store_installer_package_name") { requireRestart() } + val removeVpnTransportFlag = boolean("remove_vpn_transport_flag") { requireRestart() } + val removeMockLocationFlag = boolean("remove_mock_location_flag") { requireRestart() } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/StreaksReminderConfig.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/StreaksReminderConfig.kt new file mode 100644 index 0000000000..9c9c0dce16 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/StreaksReminderConfig.kt @@ -0,0 +1,9 @@ +package me.rhunk.snapenhance.common.config.impl + +import me.rhunk.snapenhance.common.config.ConfigContainer + +class StreaksReminderConfig : ConfigContainer(hasGlobalState = true) { + val interval = integer("interval", 1) + val remainingHours = integer("remaining_hours", 13) + val groupNotifications = boolean("group_notifications", true) +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt new file mode 100644 index 0000000000..8db3f4ba56 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt @@ -0,0 +1,61 @@ +package me.rhunk.snapenhance.common.config.impl + +import me.rhunk.snapenhance.common.config.ConfigContainer +import me.rhunk.snapenhance.common.config.FeatureNotice +import me.rhunk.snapenhance.common.config.RES_OBF_VERSION_CHECK +import me.rhunk.snapenhance.common.data.MessagingRuleType + +class UserInterfaceTweaks : ConfigContainer() { + class BootstrapOverride : ConfigContainer() { + companion object { + val tabs = arrayOf("map", "chat", "camera", "discover", "spotlight") + } + + val appAppearance = unique("app_appearance", "always_light", "always_dark") { requireRestart() } + val homeTab = unique("home_tab", *tabs) { addNotices(FeatureNotice.UNSTABLE); requireRestart() } + } + + inner class FriendFeedMessagePreview : ConfigContainer(hasGlobalState = true) { + val amount = integer("amount", defaultValue = 1) + } + + + val friendFeedMenuButtons = multiple( + "friend_feed_menu_buttons","conversation_info", "mark_snaps_as_seen", "mark_stories_as_seen_locally", *MessagingRuleType.entries.filter { it.showInFriendMenu }.map { it.key }.toTypedArray() + ).apply { + set(mutableListOf("conversation_info", MessagingRuleType.STEALTH.key)) + } + val autoCloseFriendFeedMenu = boolean("auto_close_friend_feed_menu") + val friendFeedMessagePreview = container("friend_feed_message_preview", FriendFeedMessagePreview()) { requireRestart() } + val snapPreview = boolean("snap_preview") { addNotices(FeatureNotice.UNSTABLE); requireRestart() } + val bootstrapOverride = container("bootstrap_override", BootstrapOverride()) { requireRestart() } + val mapFriendNameTags = boolean("map_friend_nametags") { requireRestart() } + val preventMessageListAutoScroll = boolean("prevent_message_list_auto_scroll") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) } + val streakExpirationInfo = boolean("streak_expiration_info") { requireRestart() } + val hideFriendFeedEntry = boolean("hide_friend_feed_entry") { requireRestart() } + val hideStreakRestore = boolean("hide_streak_restore") { requireRestart() } + val hideQuickAddSuggestions = boolean("hide_quick_add_suggestions") { requireRestart() } + val hideStorySuggestions = multiple("hide_story_suggestions", "hide_suggested_friend_stories", "hide_my_stories") { requireRestart() } + val hideUiComponents = multiple("hide_ui_components", + "hide_voice_record_button", + "hide_stickers_button", + "hide_live_location_share_button", + "hide_chat_call_buttons", + "hide_profile_call_buttons", + "hide_unread_chat_hint", + "hide_post_to_story_buttons", + "hide_billboard_prompt", + "hide_snapchat_plus_gift_reminders", + "hide_map_reactions", + ) { requireRestart(); versionCheck = RES_OBF_VERSION_CHECK } + val operaMediaQuickInfo = boolean("opera_media_quick_info") { requireRestart() } + val oldBitmojiSelfie = unique("old_bitmoji_selfie", "2d", "3d") { requireCleanCache() } + val disableSpotlight = boolean("disable_spotlight") { requireRestart() } + val verticalStoryViewer = boolean("vertical_story_viewer") { requireRestart() } + val messageIndicators = multiple("message_indicators", "encryption_indicator", "platform_indicator", "location_indicator", "ovf_editor_indicator", "director_mode_indicator") { requireRestart() } + val stealthModeIndicator = boolean("stealth_mode_indicator") { requireRestart() } + val editTextOverride = multiple("edit_text_override", "multi_line_chat_input", "bypass_text_input_limit") { + requireRestart(); addNotices(FeatureNotice.BAN_RISK, FeatureNotice.INTERNAL_BEHAVIOR) + } + val preventForcedKeyboard = boolean("prevent_forced_keyboard") { requireRestart() } +} diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/FileType.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/FileType.kt new file mode 100644 index 0000000000..805cc465f8 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/FileType.kt @@ -0,0 +1,76 @@ +package me.rhunk.snapenhance.common.data + +import java.io.File +import java.io.InputStream + +enum class FileType( + val fileExtension: String? = null, + val mimeType: String, + val isVideo: Boolean = false, + val isImage: Boolean = false, + val isAudio: Boolean = false +) { + GIF("gif", "image/gif", false, false, false), + PNG("png", "image/png", false, true, false), + MP4("mp4", "video/mp4", true, false, false), + MKV("mkv", "video/mkv", true, false, false), + AVI("avi", "video/avi", true, false, false), + MP3("mp3", "audio/mp3",false, false, true), + OPUS("opus", "audio/opus", false, false, true), + AAC("aac", "audio/aac", false, false, true), + JPG("jpg", "image/jpg",false, true, false), + ZIP("zip", "application/zip", false, false, false), + WEBP("webp", "image/webp", false, true, false), + MPD("mpd", "text/xml", false, false, false), + UNKNOWN("dat", "application/octet-stream", false, false, false); + + companion object { + private val fileSignatures = mapOf( + "52494646" to WEBP, + "504b0304" to ZIP, + "89504e47" to PNG, + "00000020" to MP4, + "00000018" to MP4, + "0000001c" to MP4, + "494433" to MP3, + "4f676753" to OPUS, + "fff15" to AAC, + "ffd8ff" to JPG, + "47494638" to GIF, + "1a45dfa3" to MKV, + ) + + fun fromString(string: String?): FileType { + return entries.firstOrNull { it.fileExtension.equals(string, ignoreCase = true) } ?: UNKNOWN + } + + private fun bytesToHex(bytes: ByteArray): String { + val result = StringBuilder() + for (b in bytes) { + result.append(String.format("%02x", b)) + } + return result.toString() + } + + fun fromFile(file: File): FileType { + file.inputStream().use { inputStream -> + val buffer = ByteArray(16) + inputStream.read(buffer) + return fromByteArray(buffer) + } + } + + fun fromByteArray(array: ByteArray): FileType { + val headerBytes = ByteArray(16) + System.arraycopy(array, 0, headerBytes, 0, 16) + val hex = bytesToHex(headerBytes) + return fileSignatures.entries.firstOrNull { hex.startsWith(it.key) }?.value ?: UNKNOWN + } + + fun fromInputStream(inputStream: InputStream): FileType { + val buffer = ByteArray(16) + inputStream.read(buffer) + return fromByteArray(buffer) + } + } +} diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/MessagingCoreObjects.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/MessagingCoreObjects.kt new file mode 100644 index 0000000000..a5597e2af7 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/MessagingCoreObjects.kt @@ -0,0 +1,138 @@ +package me.rhunk.snapenhance.common.data + +import android.database.Cursor +import android.os.Parcelable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Message +import androidx.compose.material.icons.outlined.* +import androidx.compose.ui.graphics.vector.ImageVector +import kotlinx.parcelize.Parcelize +import me.rhunk.snapenhance.common.config.FeatureNotice +import me.rhunk.snapenhance.common.data.download.toKeyPair +import me.rhunk.snapenhance.common.util.ktx.getIntOrNull +import me.rhunk.snapenhance.common.util.ktx.getInteger +import me.rhunk.snapenhance.common.util.ktx.getLongOrNull +import me.rhunk.snapenhance.common.util.ktx.getStringOrNull +import kotlin.time.Duration.Companion.hours + + +enum class RuleState( + val key: String +) { + BLACKLIST("blacklist"), + WHITELIST("whitelist"); + + companion object { + fun getByName(name: String) = entries.first { it.key == name } + } +} + +enum class SocialScope( + val key: String, + val tabRoute: String, +) { + FRIEND("friend", "friend_info/{id}"), + GROUP("group", "group_info/{id}"); + + companion object { + fun getByName(name: String) = entries.first { it.key == name } + } +} + +enum class MessagingRuleType( + val key: String, + val listMode: Boolean, + val icon: ImageVector, + val showInFriendMenu: Boolean = true, + val defaultValue: String? = "whitelist", + val configNotices: Array<FeatureNotice> = emptyArray() +) { + STEALTH("stealth", true, Icons.Outlined.TrackChanges), + AUTO_DOWNLOAD("auto_download", true, Icons.Outlined.DownloadForOffline), + AUTO_SAVE("auto_save", true, Icons.Outlined.Save, defaultValue = "blacklist"), + AUTO_OPEN_SNAPS("auto_open_snaps", true, Icons.Outlined.OpenInFull, configNotices = arrayOf(FeatureNotice.BAN_RISK, FeatureNotice.UNSTABLE), defaultValue = null), + UNSAVEABLE_MESSAGES("unsaveable_messages", true, Icons.Outlined.FolderOff, defaultValue = null), + HIDE_FRIEND_FEED("hide_friend_feed", false, Icons.Outlined.VisibilityOff, showInFriendMenu = false), + E2E_ENCRYPTION("e2e_encryption", false, Icons.Outlined.Lock), + PIN_CONVERSATION("pin_conversation", false, Icons.Outlined.PushPin, showInFriendMenu = false), + EXCLUDE_MESSAGE_LOGGER("exclude_message_logger", false, Icons.AutoMirrored.Filled.Message, showInFriendMenu = false); + + fun translateOptionKey(optionKey: String): String { + return if (listMode) "rules.properties.$key.options.$optionKey" else "rules.properties.$key.name" + } + + companion object { + fun getByName(name: String) = entries.firstOrNull { it.key == name } + } +} + +@Parcelize +data class FriendStreaks( + val notify: Boolean = true, + val expirationTimestamp: Long, + val length: Int +): Parcelable { + fun hoursLeft() = (expirationTimestamp - System.currentTimeMillis()) / 1000 / 60 / 60 + + fun isAboutToExpire(expireHours: Int) = (expirationTimestamp - System.currentTimeMillis()).let { + it > 0 && it < expireHours.hours.inWholeMilliseconds + } +} + +@Parcelize +data class MessagingGroupInfo( + val conversationId: String, + val name: String, + val participantsCount: Int +): Parcelable { + companion object { + fun fromCursor(cursor: Cursor): MessagingGroupInfo { + return MessagingGroupInfo( + conversationId = cursor.getStringOrNull("conversationId")!!, + name = cursor.getStringOrNull("name")!!, + participantsCount = cursor.getInteger("participantsCount") + ) + } + } +} + +@Parcelize +data class MessagingFriendInfo( + val userId: String, + val dmConversationId: String?, + val displayName: String?, + val mutableUsername: String, + val bitmojiId: String?, + val selfieId: String?, + var streaks: FriendStreaks?, +): Parcelable { + companion object { + fun fromCursor(cursor: Cursor): MessagingFriendInfo { + return MessagingFriendInfo( + userId = cursor.getStringOrNull("userId")!!, + dmConversationId = cursor.getStringOrNull("dmConversationId"), + displayName = cursor.getStringOrNull("displayName"), + mutableUsername = cursor.getStringOrNull("mutableUsername")!!, + bitmojiId = cursor.getStringOrNull("bitmojiId"), + selfieId = cursor.getStringOrNull("selfieId"), + streaks = cursor.getLongOrNull("expirationTimestamp")?.let { + FriendStreaks( + notify = cursor.getIntOrNull("notify") == 1, + expirationTimestamp = it, + length = cursor.getIntOrNull("length") ?: 0 + ) + } + ) + } + } +} + +class StoryData( + val url: String, + val postedAt: Long, + val createdAt: Long, + val key: ByteArray?, + val iv: ByteArray? +) { + fun getEncryptionKeyPair() = key?.let { (it to (iv ?: return@let null)) }?.toKeyPair() +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/SessionEventsData.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/SessionEventsData.kt new file mode 100644 index 0000000000..a1da60b068 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/SessionEventsData.kt @@ -0,0 +1,175 @@ +package me.rhunk.snapenhance.common.data + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + + +data class FriendPresenceState( + val bitmojiPresent: Boolean, + val typing: Boolean, + val wasTyping: Boolean, + val speaking: Boolean, + val peeking: Boolean +) + +open class SessionEvent( + val type: SessionEventType, + val conversationId: String, + val authorUserId: String, +) + +class SessionMessageEvent( + type: SessionEventType, + conversationId: String, + authorUserId: String, + val serverMessageId: Long, + val messageData: ByteArray? = null, + val reactionId: Int? = null, +) : SessionEvent(type, conversationId, authorUserId) + + +enum class SessionEventType( + val key: String +) { + MESSAGE_READ_RECEIPTS("message_read_receipts"), + MESSAGE_DELETED("message_deleted"), + MESSAGE_SAVED("message_saved"), + MESSAGE_UNSAVED("message_unsaved"), + MESSAGE_EDITED("message_edited"), + MESSAGE_REACTION_ADD("message_reaction_add"), + MESSAGE_REACTION_REMOVE("message_reaction_remove"), + SNAP_OPENED("snap_opened"), + SNAP_REPLAYED("snap_replayed"), + SNAP_REPLAYED_TWICE("snap_replayed_twice"), + SNAP_SCREENSHOT("snap_screenshot"), + SNAP_SCREEN_RECORD("snap_screen_record"), +} + +enum class TrackerEventType( + val key: String +) { + // pcs events + CONVERSATION_ENTER("conversation_enter"), + CONVERSATION_EXIT("conversation_exit"), + STARTED_TYPING("started_typing"), + STOPPED_TYPING("stopped_typing"), + STARTED_SPEAKING("started_speaking"), + STOPPED_SPEAKING("stopped_speaking"), + STARTED_PEEKING("started_peeking"), + STOPPED_PEEKING("stopped_peeking"), + + // mcs events + MESSAGE_READ("message_read"), + MESSAGE_DELETED("message_deleted"), + MESSAGE_SAVED("message_saved"), + MESSAGE_UNSAVED("message_unsaved"), + MESSAGE_EDITED("message_edited"), + MESSAGE_REACTION_ADD("message_reaction_add"), + MESSAGE_REACTION_REMOVE("message_reaction_remove"), + SNAP_OPENED("snap_opened"), + SNAP_REPLAYED("snap_replayed"), + SNAP_REPLAYED_TWICE("snap_replayed_twice"), + SNAP_SCREENSHOT("snap_screenshot"), + SNAP_SCREEN_RECORD("snap_screen_record"), +} + + +@Parcelize +class TrackerEventsResult( + val rules: Map<ScopedTrackerRule, List<TrackerRuleEvent>>, +): Parcelable { + fun getActions(): Map<TrackerRuleAction, TrackerRuleActionParams> { + return rules.flatMap { + it.value + }.fold(mutableMapOf()) { acc, ruleEvent -> + ruleEvent.actions.forEach { action -> + acc[action] = acc[action]?.merge(ruleEvent.params) ?: ruleEvent.params + } + acc + } + } + + fun canTrackOn(conversationId: String?, userId: String?): Boolean { + return rules.any { (scopedRule, events) -> + if (!events.any { it.enabled }) return@any false + val scopes = scopedRule.scopes + + when (scopes[userId]) { + TrackerScopeType.WHITELIST -> return@any true + TrackerScopeType.BLACKLIST -> return@any false + else -> {} + } + + when (scopes[conversationId]) { + TrackerScopeType.WHITELIST -> return@any true + TrackerScopeType.BLACKLIST -> return@any false + else -> {} + } + + return@any scopes.isEmpty() || scopes.any { it.value == TrackerScopeType.BLACKLIST } + } + } +} + +enum class TrackerRuleAction( + val key: String +) { + LOG("log"), + IN_APP_NOTIFICATION("in_app_notification"), + PUSH_NOTIFICATION("push_notification"), + CUSTOM("custom"); + + companion object { + fun fromString(value: String): TrackerRuleAction? { + return entries.find { it.key == value } + } + } +} + +@Parcelize +data class TrackerRuleActionParams( + var onlyInsideConversation: Boolean = false, + var onlyOutsideConversation: Boolean = false, + var onlyWhenAppActive: Boolean = false, + var onlyWhenAppInactive: Boolean = false, + var noPushNotificationWhenAppActive: Boolean = false, +): Parcelable { + fun merge(other: TrackerRuleActionParams): TrackerRuleActionParams { + return TrackerRuleActionParams( + onlyInsideConversation = onlyInsideConversation || other.onlyInsideConversation, + onlyOutsideConversation = onlyOutsideConversation || other.onlyOutsideConversation, + onlyWhenAppActive = onlyWhenAppActive || other.onlyWhenAppActive, + onlyWhenAppInactive = onlyWhenAppInactive || other.onlyWhenAppInactive, + noPushNotificationWhenAppActive = noPushNotificationWhenAppActive || other.noPushNotificationWhenAppActive, + ) + } +} + +@Parcelize +data class TrackerRule( + val id: Int, + val enabled: Boolean, + val name: String, +): Parcelable + +@Parcelize +data class ScopedTrackerRule( + val rule: TrackerRule, + val scopes: Map<String, TrackerScopeType> +): Parcelable + +enum class TrackerScopeType( + val key: String +) { + WHITELIST("whitelist"), + BLACKLIST("blacklist"); +} + +@Parcelize +data class TrackerRuleEvent( + val id: Int, + val enabled: Boolean, + val eventType: String, + val params: TrackerRuleActionParams, + val actions: List<TrackerRuleAction> +): Parcelable diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/SnapEnums.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/SnapEnums.kt new file mode 100644 index 0000000000..5294fd44b8 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/SnapEnums.kt @@ -0,0 +1,229 @@ +package me.rhunk.snapenhance.common.data + +import me.rhunk.snapenhance.common.data.ContentType.entries +import me.rhunk.snapenhance.common.data.FriendAddSource.entries +import me.rhunk.snapenhance.common.data.FriendLinkType.entries +import me.rhunk.snapenhance.common.data.MixerStoryType.entries +import me.rhunk.snapenhance.common.data.NotificationType.entries +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader + +enum class MessageState { + PREPARING, SENDING, COMMITTED, FAILED, CANCELING +} + +enum class NotificationType ( + val key: String, + val isIncoming: Boolean = false, + val associatedOutgoingContentType: ContentType? = null, + private vararg val aliases: String +) { + SCREENSHOT("chat_screenshot", true, ContentType.STATUS_CONVERSATION_CAPTURE_SCREENSHOT), + SCREEN_RECORD("chat_screen_record", true, ContentType.STATUS_CONVERSATION_CAPTURE_RECORD), + CAMERA_ROLL_SAVE("camera_roll_save", true, ContentType.STATUS_SAVE_TO_CAMERA_ROLL), + SNAP_REPLAY("snap_replay", true, ContentType.STATUS), + SNAP("snap", true), + CHAT("chat", true), + CHAT_REPLY("chat_reply", true), + TYPING("typing", true), + MAP_LIVE_LOCATION("map_live_location", true), + STORIES("stories", true), + SPEAKING("speaking", true), + DM_REACTION("chat_reaction", true, null,"snap_reaction", "voicenote_reaction"), + GROUP_REACTION("group_chat_reaction", true, null,"group_snap_reaction", "group_voicenote_reaction"), + INITIATE_AUDIO("initiate_audio", true), + ABANDON_AUDIO("abandon_audio", false, ContentType.STATUS_CALL_MISSED_AUDIO), + INITIATE_VIDEO("initiate_video", true), + ABANDON_VIDEO("abandon_video", false, ContentType.STATUS_CALL_MISSED_VIDEO); + + fun isMatch(key: String): Boolean { + return this.key == key || aliases.contains(key) + } + + companion object { + fun getByKey(key: String): NotificationType? { + return entries.firstOrNull { it.key == key } + } + + fun getIncomingValues(): List<NotificationType> { + return entries.filter { it.isIncoming }.toList() + } + + fun getOutgoingValues(): List<NotificationType> { + return entries.filter { it.associatedOutgoingContentType != null }.toList() + } + + fun fromContentType(contentType: ContentType): NotificationType? { + return entries.firstOrNull { it.associatedOutgoingContentType == contentType } + } + } +} + +enum class ContentType(val id: Int) { + UNKNOWN(-1), + SNAP(0), + CHAT(1), + EXTERNAL_MEDIA(2), + SHARE(3), + NOTE(4), + STICKER(5), + STATUS(6), + LOCATION(8), + STATUS_SAVE_TO_CAMERA_ROLL(9), + STATUS_CONVERSATION_CAPTURE_SCREENSHOT(10), + STATUS_CONVERSATION_CAPTURE_RECORD(11), + STATUS_CALL_MISSED_VIDEO(12), + STATUS_CALL_MISSED_AUDIO(13), + STATUS_INVITE_LINK_CHANGE(14), + CANVAS_APP(15), + LIVE_LOCATION_SHARE(16), + CREATIVE_TOOL_ITEM(17), + FAMILY_CENTER_INVITE(18), + FAMILY_CENTER_ACCEPT(19), + FAMILY_CENTER_LEAVE(20), + SNAP_NOT_VIEWABLE(21), + STATUS_PLUS_GIFT(22), + NON_PARTICIPANT_BOT_RESPONSE(23), + EEL_UPGRADE_PROMPT(24), + PROMPT_LENS_RESPONSE(25), + TINY_SNAP(26), + STATUS_COUNTDOWN(27), + MAP_REACTION(28); + + companion object { + fun fromId(i: Int): ContentType { + return entries.firstOrNull { it.id == i } ?: UNKNOWN + } + + fun fromMessageContainer(protoReader: ProtoReader?): ContentType? { + if (protoReader == null) return null + return protoReader.run { + when { + contains(8) -> STATUS + contains(2) -> CHAT + contains(11) -> SNAP + contains(6) -> NOTE + contains(3) -> EXTERNAL_MEDIA + contains(4) -> STICKER + contains(5) -> SHARE + contains(7) -> EXTERNAL_MEDIA // story replies + contains(20) -> MAP_REACTION + else -> null + } + } + } + } +} + +enum class PlayableSnapState { + NOTDOWNLOADED, DOWNLOADING, DOWNLOADFAILED, PLAYABLE, VIEWEDREPLAYABLE, PLAYING, VIEWEDNOTREPLAYABLE +} + +enum class MediaReferenceType { + UNASSIGNED, OVERLAY, IMAGE, VIDEO, ASSET_BUNDLE, AUDIO, ANIMATED_IMAGE, FONT, WEB_VIEW_CONTENT, VIDEO_NO_AUDIO +} + + +enum class MessageUpdate( + val key: String, +) { + UNKNOWN("unknown"), + READ("read"), + RELEASE("release"), + SAVE("save"), + UNSAVE("unsave"), + ERASE("erase"), + SCREENSHOT("screenshot"), + SCREEN_RECORD("screen_record"), + REPLAY("replay"), + REACTION("reaction"), + REMOVEREACTION("remove_reaction"), + REVOKETRANSCRIPTION("revoke_transcription"), + ALLOWTRANSCRIPTION("allow_transcription"), + ERASESAVEDSTORYMEDIA("erase_saved_story_media"), +} + +enum class FriendLinkType(val value: Int, val shortName: String) { + MUTUAL(0, "mutual"), + OUTGOING(1, "outgoing"), + BLOCKED(2, "blocked"), + DELETED(3, "deleted"), + FOLLOWING(4, "following"), + SUGGESTED(5, "suggested"), + INCOMING(6, "incoming"), + INCOMING_FOLLOWER(7, "incoming_follower"); + + companion object { + fun fromValue(value: Int): FriendLinkType { + return entries.firstOrNull { it.value == value } ?: SUGGESTED + } + } +} + +enum class MixerStoryType( + val index: Int, +) { + UNKNOWN(-1), + SUBSCRIPTIONS(2), + DISCOVER(3), + FRIENDS(5), + MY_STORIES(6); + + companion object { + fun fromIndex(index: Int): MixerStoryType { + return entries.firstOrNull { it.index == index } ?: UNKNOWN + } + } +} + +enum class QuotedMessageContentStatus { + UNKNOWN, + AVAILABLE, + DELETED, + JOINEDAFTERORIGINALMESSAGESENT, + UNAVAILABLE, + STORYMEDIADELETEDBYPOSTER +} + +enum class FriendAddSource( + val id: Int +) { + UNKNOWN(0), + PHONE(1), + USERNAME(2), + QR_CODE(3), + ADDED_ME_BACK(4), + NEARBY(5), + SUGGESTED(6), + OFFICIAL_STORY_SEARCH(7), + DEEP_LINK(8), + INVITE(9), + STORY_CHROME(10), + SHARED_USERNAME(11), + SHARED_STORY(12), + GROUP_CHAT(13), + SHAZAM(14), + MOB(15), + FEATURED_OFFICIAL_STORY(16), + OUR_STORY(17), + INFLUENCER_RECOMMENDATION(18), + DISPLAY_NAME(198), + TEST(20), + MENTION(21), + SUBSCRIPTION(22), + MENTION_STICKER(23), + SNAPCODE_STICKER(24), + SPOTLIGHT(25), + PUBLIC_PROFILE(26), + LENS(27), + CHAT(28), + SNAP_ANYONE(29), + COMMUNITY(30), + NEARBY_FRIENDS(31), + SEARCH(32); + + companion object { + fun fromId(id: Int): FriendAddSource { + return entries.firstOrNull { it.id == id } ?: UNKNOWN + } + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/ThemingObjects.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/ThemingObjects.kt new file mode 100644 index 0000000000..c95d4a61cd --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/ThemingObjects.kt @@ -0,0 +1,120 @@ +package me.rhunk.snapenhance.common.data + +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize + + +@Parcelize +data class ThemeColorEntry( + @SerializedName("key") + val key: String, + @SerializedName("value") + var value: Int, +): Parcelable + +@Parcelize +data class DatabaseThemeContent( + @SerializedName("colors") + val colors: List<ThemeColorEntry> = emptyList(), +): Parcelable + +data class DatabaseTheme( + val id: Int, + val enabled: Boolean, + val name: String, + val description: String?, + val version: String?, + val author: String?, + val updateUrl: String?, +) { + fun toExportedTheme(content: DatabaseThemeContent): ExportedTheme { + return ExportedTheme( + name = name, + description = description, + version = version, + author = author, + content = content, + ) + } +} + +data class ExportedTheme( + val name: String, + val description: String?, + val version: String?, + val author: String?, + val content: DatabaseThemeContent, +) { + fun toDatabaseTheme(id: Int = -1, updateUrl: String? = null, enabled: Boolean = false): DatabaseTheme { + return DatabaseTheme( + id = id, + enabled = enabled, + name = name, + description = description, + version = version, + author = author, + updateUrl = updateUrl, + ) + } +} + +data class RepositoryThemeManifest( + val name: String, + val author: String?, + val description: String?, + val version: String?, + val filepath: String, +) + +data class RepositoryIndex( + val themes: List<RepositoryThemeManifest> = emptyList(), +) + +enum class ThemingAttributeType { + COLOR +} + +val AvailableThemingAttributes = mapOf( + ThemingAttributeType.COLOR to listOf( + "sigColorTextPrimary", + "sigColorBackgroundSurface", + "sigColorBackgroundMain", + "actionSheetBackgroundDrawable", + "actionSheetRoundedBackgroundDrawable", + "sigColorChatChat", + "sigColorChatPendingSending", + "sigColorChatSnapWithSound", + "sigColorChatSnapWithoutSound", + "sigExceptionColorCameraGridLines", + "listDivider", + "listBackgroundDrawable", + "sigColorIconPrimary", + "actionSheetDescriptionTextColor", + "ringColor", + "sigColorIconSecondary", + "itemShapeFillColor", + "ringStartColor", + "sigColorLayoutPlaceholder", + "scButtonColor", + "recipientPillBackgroundDrawable", + "boxBackgroundColor", + "editTextColor", + "chipBackgroundColor", + "recipientInputStyle", + "rangeFillColor", + "pstsIndicatorColor", + "pstsTabBackground", + "pstsDividerColor", + "tabTextColor", + "statusBarForeground", + "statusBarBackground", + "strokeColor", + "storyReplayViewRingColor", + "sigColorButtonPrimary", + "sigColorBaseAppYellow", + "sigColorBackgroundSurfaceTranslucent", + "sigColorStoryRingFriendsFeedStoryRing", + "sigColorStoryRingDiscoverTabThumbnailStoryRing", + ) +) diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/DownloadMediaType.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/DownloadMediaType.kt new file mode 100644 index 0000000000..ede7c0e82b --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/DownloadMediaType.kt @@ -0,0 +1,22 @@ +package me.rhunk.snapenhance.common.data.download + +import android.net.Uri + +enum class DownloadMediaType { + PROTO_MEDIA, + DIRECT_MEDIA, + REMOTE_MEDIA, + LOCAL_MEDIA; + + companion object { + fun fromUri(uri: Uri): DownloadMediaType { + return when (uri.scheme) { + "proto" -> PROTO_MEDIA + "direct" -> DIRECT_MEDIA + "http", "https" -> REMOTE_MEDIA + "file" -> LOCAL_MEDIA + else -> throw IllegalArgumentException("Unknown uri scheme: ${uri.scheme}") + } + } + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/DownloadMetadata.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/DownloadMetadata.kt new file mode 100644 index 0000000000..29a97c58fb --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/DownloadMetadata.kt @@ -0,0 +1,9 @@ +package me.rhunk.snapenhance.common.data.download + +data class DownloadMetadata( + val mediaIdentifier: String, + val outputPath: String, + val mediaAuthor: String?, + val downloadSource: String, + val iconUrl: String? +) \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/DownloadRequest.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/DownloadRequest.kt new file mode 100644 index 0000000000..3dec97b7b7 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/DownloadRequest.kt @@ -0,0 +1,99 @@ +package me.rhunk.snapenhance.common.data.download + +import me.rhunk.snapenhance.common.config.impl.RootConfig +import java.text.SimpleDateFormat +import java.util.Locale + + +data class DashOptions(val offsetTime: Long, val duration: Long?) +data class AudioStreamFormat(val channels: Int, val sampleRate: Int, val encoding: Int) + +data class InputMedia( + val content: String, + val type: DownloadMediaType, + val encryption: MediaEncryptionKeyPair? = null, + val attachmentType: String? = null, + val isOverlay: Boolean = false, +) + +data class DownloadRequest( + val inputMedias: Array<InputMedia>, + val dashOptions: DashOptions? = null, + val audioStreamFormat: AudioStreamFormat? = null, + private val flags: Int = 0, +) { + object Flags { + const val MERGE_OVERLAY = 1 + const val DASH_PLAYLIST = 2 + const val AUDIO_STREAM = 4 + } + + val isDashPlaylist: Boolean + get() = flags and Flags.DASH_PLAYLIST != 0 + + val shouldMergeOverlay: Boolean + get() = flags and Flags.MERGE_OVERLAY != 0 + + val isAudioStream: Boolean + get() = flags and Flags.AUDIO_STREAM != 0 +} + +fun String.sanitizeForPath(): String { + return this.replace(" ", "_") + .replace(Regex("\\p{Cntrl}"), "") +} + +fun createNewFilePath( + config: RootConfig, + hexHash: String, + downloadSource: MediaDownloadSource, + mediaAuthor: String?, + creationTimestamp: Long? +): String { + val pathFormat by config.downloader.pathFormat + val customPathFormat by config.downloader.customPathFormat + val sanitizedMediaAuthor = mediaAuthor?.sanitizeForPath() ?: hexHash + val currentDateTime = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.ENGLISH).format(creationTimestamp ?: System.currentTimeMillis()) + + val finalPath = StringBuilder() + + fun appendFileName(string: String) { + if (finalPath.isEmpty() || finalPath.endsWith("/")) { + finalPath.append(string) + } else { + finalPath.append("_").append(string) + } + } + + if (customPathFormat.isNotEmpty()) { + finalPath.append(customPathFormat + .replace("%username%", sanitizedMediaAuthor) + .replace("%source%", downloadSource.pathName) + .replace("%hash%", hexHash) + .replace("%date_time%", currentDateTime) + ) + } else { + if (pathFormat.contains("create_author_folder")) { + finalPath.append(sanitizedMediaAuthor).append("/") + } + if (pathFormat.contains("create_source_folder")) { + finalPath.append(downloadSource.pathName).append("/") + } + if (pathFormat.contains("append_hash")) { + appendFileName(hexHash) + } + if (pathFormat.contains("append_source")) { + appendFileName(downloadSource.pathName) + } + if (pathFormat.contains("append_username")) { + appendFileName(sanitizedMediaAuthor) + } + if (pathFormat.contains("append_date_time")) { + appendFileName(currentDateTime) + } + } + + if (finalPath.isEmpty() || finalPath.isBlank()) finalPath.append(hexHash) + + return finalPath.toString() +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/DownloadStage.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/DownloadStage.kt new file mode 100644 index 0000000000..674e7a3742 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/DownloadStage.kt @@ -0,0 +1,14 @@ +package me.rhunk.snapenhance.common.data.download + +enum class DownloadStage( + val isFinalStage: Boolean = false, +) { + PENDING(false), + DOWNLOADING(false), + MERGING(false), + DOWNLOADED(true), + SAVED(true), + MERGE_FAILED(true), + FAILED(true), + CANCELLED(true) +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/MediaDownloadSource.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/MediaDownloadSource.kt new file mode 100644 index 0000000000..e95252939e --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/MediaDownloadSource.kt @@ -0,0 +1,37 @@ +package me.rhunk.snapenhance.common.data.download + +import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper + +enum class MediaDownloadSource( + val key: String, + val pathName: String = key, + val ignoreFilter: Boolean = false +) { + NONE("none", ignoreFilter = true), + PENDING("pending", ignoreFilter = true), + CHAT_MEDIA("chat_media", "chat_media"), + STORY("story", "story"), + PUBLIC_STORY("public_story", "public_story"), + SPOTLIGHT("spotlight", "spotlight"), + PROFILE_PICTURE("profile_picture", "profile_picture"), + STORY_LOGGER("story_logger", "story_logger"), + MESSAGE_LOGGER("message_logger", "message_logger"), + MERGED("merged", "merged"), + VOICE_CALL("voice_call", "voice_call"); + + fun matches(source: String?): Boolean { + if (source == null) return false + return source.contains(key, ignoreCase = true) + } + + fun translate(translation: LocaleWrapper): String { + return translation["media_download_source.$key"] + } + + companion object { + fun fromKey(key: String?): MediaDownloadSource { + if (key == null) return NONE + return entries.find { it.key == key } ?: NONE + } + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/MediaEncryptionKeyPair.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/MediaEncryptionKeyPair.kt new file mode 100644 index 0000000000..5adbdb6de8 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/MediaEncryptionKeyPair.kt @@ -0,0 +1,32 @@ + +package me.rhunk.snapenhance.common.data.download + +import java.io.InputStream +import javax.crypto.Cipher +import javax.crypto.CipherInputStream +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +// key and iv are base64 encoded into url safe strings +data class MediaEncryptionKeyPair( + val key: String, + val iv: String, + val urlSafe: Boolean = true +) { + @OptIn(ExperimentalEncodingApi::class) + fun decryptInputStream(inputStream: InputStream): InputStream { + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init( + Cipher.DECRYPT_MODE, + SecretKeySpec(if (urlSafe) Base64.UrlSafe.decode(key) else Base64.Default.decode(key), "AES"), + IvParameterSpec(if (urlSafe) Base64.UrlSafe.decode(iv) else Base64.Default.decode(iv)) + ) + return CipherInputStream(inputStream, cipher) + } +} + +@OptIn(ExperimentalEncodingApi::class) +fun Pair<ByteArray, ByteArray>.toKeyPair() + = MediaEncryptionKeyPair(Base64.UrlSafe.encode(this.first), Base64.UrlSafe.encode(this.second)) diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/SplitMediaAssetType.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/SplitMediaAssetType.kt new file mode 100644 index 0000000000..8c6d33e3b4 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/SplitMediaAssetType.kt @@ -0,0 +1,5 @@ +package me.rhunk.snapenhance.common.data.download + +enum class SplitMediaAssetType { + ORIGINAL, OVERLAY +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/database/DatabaseObject.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/database/DatabaseObject.kt similarity index 67% rename from app/src/main/kotlin/me/rhunk/snapenhance/database/DatabaseObject.kt rename to common/src/main/kotlin/me/rhunk/snapenhance/common/database/DatabaseObject.kt index d54f2553f2..dcd70f9b6e 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/database/DatabaseObject.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/database/DatabaseObject.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.database +package me.rhunk.snapenhance.common.database import android.database.Cursor diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/ConversationMessage.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/ConversationMessage.kt new file mode 100644 index 0000000000..6284428296 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/ConversationMessage.kt @@ -0,0 +1,43 @@ +package me.rhunk.snapenhance.common.database.impl + +import android.annotation.SuppressLint +import android.database.Cursor +import me.rhunk.snapenhance.common.database.DatabaseObject +import me.rhunk.snapenhance.common.util.ktx.getBlobOrNull +import me.rhunk.snapenhance.common.util.ktx.getInteger +import me.rhunk.snapenhance.common.util.ktx.getLong +import me.rhunk.snapenhance.common.util.ktx.getLongOrNull +import me.rhunk.snapenhance.common.util.ktx.getStringOrNull + +@Suppress("ArrayInDataClass") +data class ConversationMessage( + var clientConversationId: String? = null, + var clientMessageId: Int = 0, + var serverMessageId: Int = 0, + var messageContent: ByteArray? = null, + var isSaved: Int = 0, + var isViewedByUser: Int = 0, + var contentType: Int = 0, + var creationTimestamp: Long = 0, + var readTimestamp: Long = 0, + var quotedServerMessageId: Long? = null, + var senderId: String? = null +) : DatabaseObject { + + @SuppressLint("Range") + override fun write(cursor: Cursor) { + with(cursor) { + clientConversationId = getStringOrNull("client_conversation_id") + clientMessageId = getInteger("client_message_id") + serverMessageId = getInteger("server_message_id") + messageContent = getBlobOrNull("message_content") + isSaved = getInteger("is_saved") + isViewedByUser = getInteger("is_viewed_by_user") + contentType = getInteger("content_type") + creationTimestamp = getLong("creation_timestamp") + readTimestamp = getLong("read_timestamp") + quotedServerMessageId = getLongOrNull("quoted_server_message_id") + senderId = getStringOrNull("sender_id") + } + } +} diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/FriendFeedEntry.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/FriendFeedEntry.kt new file mode 100644 index 0000000000..d17e38672e --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/FriendFeedEntry.kt @@ -0,0 +1,57 @@ +package me.rhunk.snapenhance.common.database.impl + +import android.annotation.SuppressLint +import android.database.Cursor +import me.rhunk.snapenhance.common.database.DatabaseObject +import me.rhunk.snapenhance.common.util.ktx.getBlobOrNull +import me.rhunk.snapenhance.common.util.ktx.getIntOrNull +import me.rhunk.snapenhance.common.util.ktx.getLongOrNull +import me.rhunk.snapenhance.common.util.ktx.getStringOrNull +import java.nio.ByteBuffer +import java.util.UUID + +data class FriendFeedEntry( + var feedDisplayName: String? = null, + var participantsSize: Int = 0, + var lastInteractionTimestamp: Long = 0, + var displayTimestamp: Long = 0, + var displayInteractionType: String? = null, + var lastInteractionUserId: Int? = null, + var key: String? = null, + var friendUserId: String? = null, + var participants: List<String>? = null, + var conversationType: Int? = null, + var friendDisplayName: String? = null, + var friendDisplayUsername: String? = null, + var friendLinkType: Int? = null, + var bitmojiAvatarId: String? = null, + var bitmojiSelfieId: String? = null, + var streakCount: Int? = null, + var streakExpirationTimestampMs: Long? = null, +) : DatabaseObject { + @SuppressLint("Range") + override fun write(cursor: Cursor) { + with(cursor) { + key = getStringOrNull("client_conversation_id") ?: getStringOrNull("key") + feedDisplayName = (getStringOrNull("conversation_title") ?: getStringOrNull("feedDisplayName"))?.takeIf { it.isNotBlank() } + lastInteractionTimestamp = getLongOrNull("last_updated_timestamp") ?: getLongOrNull("lastInteractionTimestamp") ?: 0L + + participants = getBlobOrNull("participants")?.toList()?.chunked(16)?.map { ByteBuffer.wrap(it.toByteArray()).run { UUID(long, long) }.toString() } ?: emptyList() + participantsSize = getIntOrNull("participantsSize") ?: participants?.size ?: 0 + conversationType = getIntOrNull("conversation_type") ?: getIntOrNull("kind") + + displayTimestamp = getLongOrNull("displayTimestamp") ?: 0L + displayInteractionType = getStringOrNull("displayInteractionType") + lastInteractionUserId = getIntOrNull("lastInteractionUserId") + friendUserId = getStringOrNull("friendUserId") + friendDisplayName = getStringOrNull("friendDisplayName") + friendDisplayUsername = getStringOrNull("friendDisplayUsername") + friendLinkType = getIntOrNull("friendLinkType") + bitmojiAvatarId = getStringOrNull("bitmojiAvatarId") + bitmojiSelfieId = getStringOrNull("bitmojiSelfieId") + + streakCount = getIntOrNull("streak_count") + streakExpirationTimestampMs = getLongOrNull("streak_expiration_timestamp_ms") + } + } +} diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/FriendInfo.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/FriendInfo.kt new file mode 100644 index 0000000000..9a1a0bd8c6 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/FriendInfo.kt @@ -0,0 +1,71 @@ +package me.rhunk.snapenhance.common.database.impl + +import android.annotation.SuppressLint +import android.database.Cursor +import me.rhunk.snapenhance.common.database.DatabaseObject +import me.rhunk.snapenhance.common.util.ktx.getIntOrNull +import me.rhunk.snapenhance.common.util.ktx.getInteger +import me.rhunk.snapenhance.common.util.ktx.getLong +import me.rhunk.snapenhance.common.util.ktx.getStringOrNull + +data class FriendInfo( + var id: Int = 0, + var lastModifiedTimestamp: Long = 0, + var username: String? = null, + var userId: String? = null, + var displayName: String? = null, + var bitmojiAvatarId: String? = null, + var bitmojiSelfieId: String? = null, + var bitmojiSceneId: String? = null, + var bitmojiBackgroundId: String? = null, + var friendmojis: String? = null, + var friendmojiCategories: String? = null, + var snapScore: Int = 0, + var birthday: Long = 0, + var addedTimestamp: Long = -1, + var reverseAddedTimestamp: Long = -1, + var serverDisplayName: String? = null, + var streakLength: Int = 0, + var streakExpirationTimestamp: Long = 0, + var reverseBestFriendRanking: Int = 0, + var isPinnedBestFriend: Int = 0, + var plusBadgeVisibility: Int = 0, + var usernameForSorting: String? = null, + var friendLinkType: Int = 0, + var postViewEmoji: String? = null, + var businessCategory: Int = 0, +) : DatabaseObject { + val mutableUsername get() = username?.split("|")?.last() + val firstCreatedUsername get() = username?.split("|")?.first() + + @SuppressLint("Range") + override fun write(cursor: Cursor) { + with(cursor) { + id = getInteger("_id") + lastModifiedTimestamp = getLong("_lastModifiedTimestamp") + username = getStringOrNull("username") + userId = getStringOrNull("userId") + displayName = getStringOrNull("displayName") + bitmojiAvatarId = getStringOrNull("bitmojiAvatarId") + bitmojiSelfieId = getStringOrNull("bitmojiSelfieId") + bitmojiSceneId = getStringOrNull("bitmojiSceneId") + bitmojiBackgroundId = getStringOrNull("bitmojiBackgroundId") + friendmojis = getStringOrNull("friendmojis") + friendmojiCategories = getStringOrNull("friendmojiCategories") + snapScore = getInteger("score") + birthday = getLong("birthday") + addedTimestamp = getLong("addedTimestamp") + reverseAddedTimestamp = getLong("reverseAddedTimestamp") + serverDisplayName = getStringOrNull("serverDisplayName") + streakLength = getInteger("streakLength") + streakExpirationTimestamp = getLong("streakExpiration") + reverseBestFriendRanking = getIntOrNull("reverseBestFriendRanking") ?: 0 + usernameForSorting = getStringOrNull("usernameForSorting") + friendLinkType = getInteger("friendLinkType") + postViewEmoji = getStringOrNull("postViewEmoji") + businessCategory = getIntOrNull("businessCategory") ?: 0 + isPinnedBestFriend = getIntOrNull("isPinnedBestFriend") ?: 0 + plusBadgeVisibility = getIntOrNull("plusBadgeVisibility") ?: 0 + } + } +} diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/StoryEntry.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/StoryEntry.kt new file mode 100644 index 0000000000..7f90fca2ac --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/StoryEntry.kt @@ -0,0 +1,27 @@ +package me.rhunk.snapenhance.common.database.impl + +import android.annotation.SuppressLint +import android.database.Cursor +import me.rhunk.snapenhance.common.database.DatabaseObject +import me.rhunk.snapenhance.common.util.ktx.getInteger +import me.rhunk.snapenhance.common.util.ktx.getStringOrNull + +data class StoryEntry( + var id: Int = 0, + var storyId: String? = null, + var displayName: String? = null, + var isLocal: Boolean? = null, + var userId: String? = null +) : DatabaseObject { + + @SuppressLint("Range") + override fun write(cursor: Cursor) { + with(cursor) { + id = getInteger("_id") + storyId = getStringOrNull("storyId") + displayName = getStringOrNull("displayName") + isLocal = getInteger("isLocal") == 1 + userId = getStringOrNull("userId") + } + } +} diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/StorySnapEntry.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/StorySnapEntry.kt new file mode 100644 index 0000000000..0bc4d89dcf --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/StorySnapEntry.kt @@ -0,0 +1,21 @@ +package me.rhunk.snapenhance.common.database.impl + +import android.database.Cursor +import me.rhunk.snapenhance.common.database.DatabaseObject +import me.rhunk.snapenhance.common.util.ktx.getStringOrNull + +data class StorySnapEntry( + var rawSnapId: String? = null, + var mediaUrl: String? = null, + var mediaKey: String? = null, + var mediaIv: String? = null, +) : DatabaseObject { + override fun write(cursor: Cursor) { + with(cursor) { + rawSnapId = getStringOrNull("rawSnapId")!! + mediaUrl = getStringOrNull("mediaUrl") + mediaKey = getStringOrNull("mediaKey")?.takeIf { it.isNotEmpty() } + mediaIv = getStringOrNull("mediaIv")?.takeIf { it.isNotEmpty() } + } + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/UserConversationLink.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/UserConversationLink.kt new file mode 100644 index 0000000000..9865c657fc --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/UserConversationLink.kt @@ -0,0 +1,23 @@ +package me.rhunk.snapenhance.common.database.impl + +import android.annotation.SuppressLint +import android.database.Cursor +import me.rhunk.snapenhance.common.database.DatabaseObject +import me.rhunk.snapenhance.common.util.ktx.getInteger +import me.rhunk.snapenhance.common.util.ktx.getStringOrNull + +class UserConversationLink( + var userId: String? = null, + var clientConversationId: String? = null, + var conversationType: Int = 0 +) : DatabaseObject { + + @SuppressLint("Range") + override fun write(cursor: Cursor) { + with(cursor) { + userId = getStringOrNull("user_id") + clientConversationId = getStringOrNull("client_conversation_id") + conversationType = getInteger("conversation_type") + } + } +} diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/logger/AbstractLogger.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/logger/AbstractLogger.kt new file mode 100644 index 0000000000..1de33266e4 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/logger/AbstractLogger.kt @@ -0,0 +1,60 @@ +package me.rhunk.snapenhance.common.logger + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.util.Log +import kotlin.system.exitProcess + +abstract class AbstractLogger( + logChannel: LogChannel, +) { + private val TAG = logChannel.shortName + + companion object { + + private const val TAG = "SnapEnhanceCommon" + + fun directDebug(message: Any?, tag: String = TAG) { + Log.println(Log.DEBUG, tag, message.toString()) + } + + fun directError(message: Any?, throwable: Throwable, tag: String = TAG) { + Log.println(Log.ERROR, tag, message.toString()) + Log.println(Log.ERROR, tag, throwable.stackTraceToString()) + } + + } + + open fun debug(message: Any?, tag: String = TAG) {} + + open fun error(message: Any?, tag: String = TAG) {} + + open fun error(message: Any?, throwable: Throwable, tag: String = TAG) {} + + open fun info(message: Any?, tag: String = TAG) {} + + open fun verbose(message: Any?, tag: String = TAG) {} + + open fun warn(message: Any?, tag: String = TAG) {} + + open fun assert(message: Any?, tag: String = TAG) {} +} + +fun Context.fatalCrash(throwable: Throwable) { + getSystemService(NotificationManager::class.java).apply { + createNotificationChannel( + NotificationChannel("default", "Default", NotificationManager.IMPORTANCE_HIGH) + ) + notify( + 0, + Notification.Builder(this@fatalCrash, "default") + .setContentTitle("Failed to load SnapEnhance") + .setStyle(Notification.BigTextStyle().bigText(throwable.message + "\n" + throwable.stackTraceToString())) + .setSmallIcon(android.R.drawable.stat_notify_error) + .build() + ) + } + exitProcess(1) +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/logger/LogChannel.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/logger/LogChannel.kt new file mode 100644 index 0000000000..0d9696e5a8 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/logger/LogChannel.kt @@ -0,0 +1,19 @@ +package me.rhunk.snapenhance.common.logger + +enum class LogChannel( + val channel: String, + val shortName: String +) { + CORE("SnapEnhanceCore", "core"), + COMMON("SnapEnhanceCommon", "common"), + SCRIPTING("Scripting", "scripting"), + NATIVE("SnapEnhanceNative", "native"), + MANAGER("SnapEnhanceManager", "manager"), + XPOSED("LSPosed-Bridge", "xposed"); + + companion object { + fun fromChannel(channel: String): LogChannel? { + return entries.find { it.channel == channel } + } + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/logger/LogLevel.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/logger/LogLevel.kt new file mode 100644 index 0000000000..b20c78a292 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/logger/LogLevel.kt @@ -0,0 +1,30 @@ +package me.rhunk.snapenhance.common.logger + +import android.util.Log + +enum class LogLevel( + val letter: String, + val shortName: String, + val priority: Int = Log.INFO +) { + VERBOSE("V", "verbose", Log.VERBOSE), + DEBUG("D", "debug", Log.DEBUG), + INFO("I", "info", Log.INFO), + WARN("W", "warn", Log.WARN), + ERROR("E", "error", Log.ERROR), + ASSERT("A", "assert", Log.ASSERT); + + companion object { + fun fromLetter(letter: String): LogLevel? { + return entries.find { it.letter == letter } + } + + fun fromShortName(shortName: String): LogLevel? { + return entries.find { it.shortName == shortName } + } + + fun fromPriority(priority: Int): LogLevel? { + return entries.find { it.priority == priority } + } + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/messaging/MessagingTask.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/messaging/MessagingTask.kt new file mode 100644 index 0000000000..7b019a17f2 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/messaging/MessagingTask.kt @@ -0,0 +1,126 @@ +package me.rhunk.snapenhance.common.messaging + +import androidx.compose.runtime.MutableIntState +import kotlinx.coroutines.delay +import me.rhunk.snapenhance.bridge.snapclient.MessagingBridge +import me.rhunk.snapenhance.bridge.snapclient.types.Message +import me.rhunk.snapenhance.common.data.ContentType +import kotlin.random.Random + + +enum class MessagingTaskType( + val key: String +) { + SAVE("SAVE"), + UNSAVE("UNSAVE"), + DELETE("ERASE"), + READ("READ"), +} + +typealias MessagingTaskConstraint = Message.() -> Boolean + +object MessagingConstraints { + val USER_ID: (String) -> MessagingTaskConstraint = { userId: String -> + { + this.senderId == userId + } + } + val NO_USER_ID: (String) -> MessagingTaskConstraint = { userId: String -> + { + this.senderId != userId + } + } + val MY_USER_ID: (messagingBridge: MessagingBridge) -> MessagingTaskConstraint = { + val myUserId = it.myUserId + { + this.senderId == myUserId + } + } + val CONTENT_TYPE: (Array<ContentType>) -> MessagingTaskConstraint = { + val contentTypes = it.map { type -> type.id }; + { + contentTypes.contains(this.contentType) + } + } +} + +class MessagingTask( + private val messagingBridge: MessagingBridge, + private val conversationId: String, + val taskType: MessagingTaskType, + val constraints: List<MessagingTaskConstraint>, + private val processedMessageCount: MutableIntState, + val onSuccess: (message: Message) -> Unit = {}, + private val onFailure: (message: Message, reason: String) -> Unit = { _, _ -> }, + private val overrideClientMessageIds: List<Long>? = null, + private val amountToProcess: Int? = null, +) { + private suspend fun processMessages( + messages: List<Message> + ) { + messages.forEach { message -> + if (constraints.any { !it(message) }) { + return@forEach + } + + val error = messagingBridge.updateMessage(conversationId, message.clientMessageId, taskType.key) + error?.takeIf { error != "DUPLICATE_REQUEST" }?.let { + onFailure(message, error) + } + processedMessageCount.intValue++ + onSuccess(message) + delay(Random.nextLong(50, 80)) + } + } + + fun hasFixedGoal() = overrideClientMessageIds?.takeIf { it.isNotEmpty() } != null || amountToProcess?.takeIf { it > 0 } != null + + suspend fun run() { + var processedOverrideMessages = 0 + var lastMessageId = Long.MAX_VALUE + + do { + val fetchedMessages = messagingBridge.fetchConversationWithMessagesPaginated( + conversationId, + 100, + lastMessageId + ) ?: return + + if (fetchedMessages.isEmpty()) { + break + } + + lastMessageId = fetchedMessages.first().clientMessageId + + overrideClientMessageIds?.let { ids -> + fetchedMessages.retainAll { message -> + ids.contains(message.clientMessageId) + } + } + + amountToProcess?.let { amount -> + while (processedMessageCount.intValue + fetchedMessages.size > amount) { + fetchedMessages.removeLastOrNull() + } + } + + processMessages(fetchedMessages.reversed()) + + overrideClientMessageIds?.let { ids -> + processedOverrideMessages += fetchedMessages.count { message -> + ids.contains(message.clientMessageId) + } + + if (processedOverrideMessages >= ids.size) { + return + } + } + + amountToProcess?.let { amount -> + if (processedMessageCount.intValue >= amount) { + return + } + } + } while (true) + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/JSModule.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/JSModule.kt new file mode 100644 index 0000000000..f0e7c0cdf6 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/JSModule.kt @@ -0,0 +1,314 @@ +package me.rhunk.snapenhance.common.scripting + +import android.os.Handler +import android.widget.Toast +import kotlinx.coroutines.* +import me.rhunk.snapenhance.common.scripting.bindings.AbstractBinding +import me.rhunk.snapenhance.common.scripting.bindings.BindingsContext +import me.rhunk.snapenhance.common.scripting.impl.JavaInterfaces +import me.rhunk.snapenhance.common.scripting.impl.Networking +import me.rhunk.snapenhance.common.scripting.impl.Protobuf +import me.rhunk.snapenhance.common.scripting.ktx.contextScope +import me.rhunk.snapenhance.common.scripting.ktx.putFunction +import me.rhunk.snapenhance.common.scripting.ktx.scriptable +import me.rhunk.snapenhance.common.scripting.ktx.scriptableObject +import me.rhunk.snapenhance.common.scripting.type.ModuleInfo +import me.rhunk.snapenhance.common.scripting.type.Permissions +import me.rhunk.snapenhance.common.scripting.ui.InterfaceManager +import org.mozilla.javascript.Function +import org.mozilla.javascript.ScriptableObject +import org.mozilla.javascript.Undefined +import org.mozilla.javascript.Wrapper +import java.io.Reader +import java.lang.reflect.Modifier +import kotlin.reflect.KClass + +class JSModule( + private val scriptRuntime: ScriptRuntime, + val moduleInfo: ModuleInfo, + private val reader: Reader, +) { + val coroutineScope = CoroutineScope(Dispatchers.IO) + private val moduleBindings = mutableMapOf<String, AbstractBinding>() + private lateinit var moduleObject: ScriptableObject + + private val moduleBindingContext by lazy { + BindingsContext( + moduleInfo = moduleInfo, + runtime = scriptRuntime, + module = this, + ) + } + + fun load(block: ScriptableObject.() -> Unit) { + contextScope { + val classLoader = scriptRuntime.androidContext.classLoader + moduleObject = initSafeStandardObjects() + moduleObject.putConst("module", moduleObject, scriptableObject { + putConst("info", this, scriptableObject { + putConst("name", this, moduleInfo.name) + putConst("version", this, moduleInfo.version) + putConst("displayName", this, moduleInfo.displayName) + putConst("description", this, moduleInfo.description) + putConst("author", this, moduleInfo.author) + putConst("minSnapchatVersion", this, moduleInfo.minSnapchatVersion) + putConst("minSEVersion", this, moduleInfo.minSEVersion) + putConst("grantedPermissions", this, moduleInfo.grantedPermissions) + }) + }) + + scriptRuntime.logger.apply { + moduleObject.putConst("console", moduleObject, scriptableObject { + putFunction("log") { info(argsToString(it)) } + putFunction("warn") { warn(argsToString(it)) } + putFunction("error") { error(argsToString(it)) } + putFunction("debug") { debug(argsToString(it)) } + putFunction("info") { info(argsToString(it)) } + putFunction("trace") { verbose(argsToString(it)) } + putFunction("verbose") { verbose(argsToString(it)) } + }) + } + + registerBindings( + JavaInterfaces(), + InterfaceManager(), + Networking(), + Protobuf() + ) + + moduleObject.putFunction("setField") { args -> + val obj = args?.get(0) ?: return@putFunction Undefined.instance + val name = args[1].toString() + val value = args[2] + val field = obj.javaClass.declaredFields.find { it.name == name } ?: return@putFunction Undefined.instance + field.isAccessible = true + field.set(obj, value.toPrimitiveValue(lazy { field.type.name })) + Undefined.instance + } + + moduleObject.putFunction("getField") { args -> + val obj = args?.get(0) ?: return@putFunction Undefined.instance + val name = args[1].toString() + val field = obj.javaClass.declaredFields.find { it.name == name } ?: return@putFunction Undefined.instance + field.isAccessible = true + field.get(obj) + } + + moduleObject.putFunction("sleep") { args -> + val time = args?.get(0) as? Number ?: return@putFunction Undefined.instance + Thread.sleep(time.toLong()) + Undefined.instance + } + + moduleObject.putFunction("findClass") { + val className = it?.get(0).toString() + val useModClassLoader = it?.getOrNull(1) as? Boolean ?: false + if (useModClassLoader) moduleInfo.ensurePermissionGranted(Permissions.UNSAFE_CLASSLOADER) + + runCatching { + if (useModClassLoader) this::class.java.classLoader?.loadClass(className) + else classLoader.loadClass(className) + }.onFailure { throwable -> + scriptRuntime.logger.error("Failed to load class $className", throwable) + }.getOrNull() + } + + moduleObject.putFunction("type") { args -> + val className = args?.get(0).toString() + val useModClassLoader = args?.getOrNull(1) as? Boolean ?: false + if (useModClassLoader) moduleInfo.ensurePermissionGranted(Permissions.UNSAFE_CLASSLOADER) + + val clazz = runCatching { + if (useModClassLoader) this::class.java.classLoader?.loadClass(className) else classLoader.loadClass(className) + }.getOrNull() ?: return@putFunction Undefined.instance + + scriptableObject("JavaClassWrapper") { + val newInstance: (Array<out Any?>?) -> Any? = { args -> + val constructor = clazz.declaredConstructors.find { + (args ?: emptyArray()).isSameParameters(it.parameterTypes) + }?.also { it.isAccessible = true } ?: throw IllegalArgumentException("Constructor not found with args ${argsToString(args)}") + constructor.newInstance(*args ?: emptyArray()) + } + putFunction("__new__") { newInstance(it) } + + clazz.declaredMethods.filter { Modifier.isStatic(it.modifiers) }.forEach { method -> + putFunction(method.name) { args -> + val declaredMethod = clazz.declaredMethods.find { + it.name == method.name && (args ?: emptyArray()).isSameParameters(it.parameterTypes) + }?.also { it.isAccessible = true } ?: throw IllegalArgumentException("Method ${method.name} not found with args ${argsToString(args)}") + declaredMethod.invoke(null, *args ?: emptyArray()) + } + } + + clazz.declaredFields.filter { Modifier.isStatic(it.modifiers) }.forEach { field -> + field.isAccessible = true + defineProperty(field.name, { field.get(null) }, { value -> field.set(null, value) }, 0) + } + + if (get("newInstance") == null) { + putFunction("newInstance") { newInstance(it) } + } + } + } + + moduleObject.putFunction("logInfo") { args -> + scriptRuntime.logger.info(argsToString(args)) + Undefined.instance + } + + moduleObject.putFunction("logError") { args -> + scriptRuntime.logger.error(argsToString(arrayOf(args?.get(0))), args?.getOrNull(1) as? Throwable ?: Throwable()) + Undefined.instance + } + + moduleObject.putFunction("setTimeout") { + val function = it?.get(0) as? Function ?: return@putFunction Undefined.instance + val time = it[1] as? Number ?: 0 + + return@putFunction coroutineScope.launch { + delay(time.toLong()) + contextScope { + function.call(this, this@putFunction, this@putFunction, emptyArray()) + } + } + } + + moduleObject.putFunction("setInterval") { + val function = it?.get(0) as? Function ?: return@putFunction Undefined.instance + val time = it[1] as? Number ?: 0 + + return@putFunction coroutineScope.launch { + while (true) { + delay(time.toLong()) + contextScope { + function.call(this, this@putFunction, this@putFunction, emptyArray()) + } + } + } + } + + arrayOf("clearInterval", "clearTimeout").forEach { + moduleObject.putFunction(it) { args -> + val job = args?.get(0) as? Job ?: return@putFunction Undefined.instance + runCatching { + job.cancel() + } + Undefined.instance + } + } + + for (toastFunc in listOf("longToast", "shortToast")) { + moduleObject.putFunction(toastFunc) { args -> + Handler(scriptRuntime.androidContext.mainLooper).post { + Toast.makeText( + scriptRuntime.androidContext, + args?.joinToString(" ") ?: "", + if (toastFunc == "longToast") Toast.LENGTH_LONG else Toast.LENGTH_SHORT + ).show() + } + Undefined.instance + } + } + + block(moduleObject) + + moduleBindings.forEach { (_, instance) -> + instance.context = moduleBindingContext + + runCatching { + instance.onInit() + }.onFailure { + scriptRuntime.logger.error("Failed to init binding ${instance.name}", it) + } + } + + moduleObject.putFunction("require") { args -> + val bindingName = args?.get(0).toString() + val (namespace, path) = bindingName.takeIf { + it.startsWith("@") && it.contains("/") + }?.let { + it.substring(1).substringBefore("/") to it.substringAfter("/") + } ?: (null to "") + + when (namespace) { + "modules" -> scriptRuntime.getModuleByName(path)?.moduleObject?.scriptable("module")?.scriptable("exports") + else -> moduleBindings[bindingName]?.getObject() + } + } + } + + contextScope(shouldOptimize = scriptRuntime.config().scripting.disableOptimization.getNullable() != true) { + evaluateReader(moduleObject, reader, moduleInfo.name, 1, null) + } + } + + fun unload() { + callFunction("module.onUnload") + runCatching { + coroutineScope.cancel("Module unloaded") + } + moduleBindings.entries.removeIf { (name, binding) -> + runCatching { + binding.onDispose() + }.onFailure { + scriptRuntime.logger.error("Failed to dispose binding $name", it) + } + true + } + } + + fun callFunction(name: String, vararg args: Any?) { + contextScope { + name.split(".").also { split -> + val function = split.dropLast(1).fold(moduleObject) { obj, key -> + obj.get(key, obj) as? ScriptableObject ?: return@contextScope Unit + }.get(split.last(), moduleObject) as? Function ?: return@contextScope Unit + + runCatching { + function.call(this, moduleObject, moduleObject, args) + }.onFailure { + scriptRuntime.logger.error("Error while calling function $name", it) + } + } + } + } + + fun registerBindings(vararg bindings: AbstractBinding) { + bindings.forEach { + moduleBindings[it.name] = it.apply { + context = moduleBindingContext + } + } + } + + fun onBridgeConnected(reloaded: Boolean = false) { + if (reloaded) { + moduleBindings.values.forEach { binding -> + runCatching { + binding.onBridgeReloaded() + }.onFailure { + scriptRuntime.logger.error("Failed to call onBridgeConnected for binding ${binding.name}", it) + } + } + } + + callFunction("module.onBridgeConnected", reloaded) + } + + @Suppress("UNCHECKED_CAST") + fun <T : Any> getBinding(clazz: KClass<T>): T? { + return moduleBindings.values.find { clazz.isInstance(it) } as? T + } + + private fun argsToString(args: Array<out Any?>?): String { + return args?.joinToString(" ") { + when (it) { + is Wrapper -> it.unwrap().let { value -> + if (value is Throwable) value.message + "\n" + value.stackTraceToString() + else value.toString() + } + else -> it.toString() + } + } ?: "null" + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/PrimitiveUtil.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/PrimitiveUtil.kt new file mode 100644 index 0000000000..366d246788 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/PrimitiveUtil.kt @@ -0,0 +1,39 @@ +package me.rhunk.snapenhance.common.scripting + +fun Any?.toPrimitiveValue(type: Lazy<String>) = when (this) { + is Number -> when (type.value) { + "byte" -> this.toByte() + "short" -> this.toShort() + "int" -> this.toInt() + "long" -> this.toLong() + "float" -> this.toFloat() + "double" -> this.toDouble() + "boolean" -> this.toByte() != 0.toByte() + "char" -> this.toInt().toChar() + else -> this + } + is Boolean -> if (type.value == "boolean") this.toString().toBoolean() else this + else -> this +} + +fun Array<out Any?>.isSameParameters(parameters: Array<Class<*>>): Boolean { + if (this.size != parameters.size) return false + for (i in this.indices) { + val type = parameters[i] + val value = this[i]?.toPrimitiveValue(lazy { type.name }) ?: continue + if (type.isPrimitive) { + when (type.name) { + "byte" -> if (value !is Byte) return false + "short" -> if (value !is Short) return false + "int" -> if (value !is Int) return false + "long" -> if (value !is Long) return false + "float" -> if (value !is Float) return false + "double" -> if (value !is Double) return false + "boolean" -> if (value !is Boolean) return false + "char" -> if (value !is Char) return false + else -> return false + } + } else if (!type.isAssignableFrom(value.javaClass)) return false + } + return true +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ScriptRuntime.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ScriptRuntime.kt new file mode 100644 index 0000000000..035f6cefb7 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ScriptRuntime.kt @@ -0,0 +1,76 @@ +package me.rhunk.snapenhance.common.scripting + +import android.content.Context +import android.os.ParcelFileDescriptor +import me.rhunk.snapenhance.bridge.scripting.IScripting +import me.rhunk.snapenhance.common.BuildConfig +import me.rhunk.snapenhance.common.config.impl.RootConfig +import me.rhunk.snapenhance.common.logger.AbstractLogger +import me.rhunk.snapenhance.common.scripting.type.readModuleInfo +import org.mozilla.javascript.ScriptableObject +import java.io.InputStream + +open class ScriptRuntime( + val config: () -> RootConfig, + val androidContext: Context, + logger: AbstractLogger, +) { + val logger = ScriptingLogger(logger) + + lateinit var scripting: IScripting + var buildModuleObject: ScriptableObject.(JSModule) -> Unit = {} + + private val modules = mutableMapOf<String, JSModule>() + + fun eachModule(f: JSModule.() -> Unit) { + modules.values.forEach { module -> + runCatching { + module.f() + }.onFailure { + logger.error("Failed to run module function in ${module.moduleInfo.name}", it) + } + } + } + + fun getModuleByName(name: String): JSModule? { + return modules.values.find { it.moduleInfo.name == name } + } + + fun removeModule(scriptPath: String) { + modules.remove(scriptPath) + } + + fun unload(scriptPath: String) { + val module = modules[scriptPath] ?: return + logger.info("Unloading module $scriptPath") + module.unload() + modules.remove(scriptPath) + } + + fun load(scriptPath: String, pfd: ParcelFileDescriptor): JSModule { + return ParcelFileDescriptor.AutoCloseInputStream(pfd).use { + load(scriptPath, it) + } + } + + fun load(scriptPath: String, content: InputStream): JSModule { + logger.info("Loading module $scriptPath") + val bufferedReader = content.bufferedReader() + val moduleInfo = bufferedReader.readModuleInfo() + + if (moduleInfo.minSEVersion != null && moduleInfo.minSEVersion > BuildConfig.VERSION_CODE) { + throw Exception("Module requires a newer version of SnapEnhance (min version: ${moduleInfo.minSEVersion})") + } + + return JSModule( + scriptRuntime = this, + moduleInfo = moduleInfo, + reader = bufferedReader, + ).apply { + load { + buildModuleObject(this, this@apply) + } + modules[scriptPath] = this + } + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ScriptingLogger.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ScriptingLogger.kt new file mode 100644 index 0000000000..f940609552 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ScriptingLogger.kt @@ -0,0 +1,40 @@ +package me.rhunk.snapenhance.common.scripting + +import me.rhunk.snapenhance.common.logger.AbstractLogger +import me.rhunk.snapenhance.common.logger.LogChannel + +class ScriptingLogger( + private val logger: AbstractLogger +) { + companion object { + private val TAG = LogChannel.SCRIPTING.channel + } + + fun debug(message: Any?, tag: String = TAG) { + logger.debug(message, tag) + } + + fun error(message: Any?, tag: String = TAG) { + logger.error(message, tag) + } + + fun error(message: Any?, throwable: Throwable, tag: String = TAG) { + logger.error(message, throwable, tag) + } + + fun info(message: Any?, tag: String = TAG) { + logger.info(message, tag) + } + + fun verbose(message: Any?, tag: String = TAG) { + logger.verbose(message, tag) + } + + fun warn(message: Any?, tag: String = TAG) { + logger.warn(message, tag) + } + + fun assert(message: Any?, tag: String = TAG) { + logger.assert(message, tag) + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/bindings/AbstractBinding.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/bindings/AbstractBinding.kt new file mode 100644 index 0000000000..2558fa393b --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/bindings/AbstractBinding.kt @@ -0,0 +1,25 @@ +package me.rhunk.snapenhance.common.scripting.bindings + +abstract class AbstractBinding( + val name: String, + val side: BindingSide +) { + lateinit var context: BindingsContext + + private val bridgeReloadList = mutableListOf<() -> Unit>() + + fun bridgeAutoReload(block: () -> Unit) { + bridgeReloadList += block + block() + } + + open fun onInit() {} + + open fun onBridgeReloaded() { + bridgeReloadList.forEach { it() } + } + + open fun onDispose() {} + + abstract fun getObject(): Any +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/bindings/BindingSide.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/bindings/BindingSide.kt new file mode 100644 index 0000000000..2ab8c9710b --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/bindings/BindingSide.kt @@ -0,0 +1,15 @@ +package me.rhunk.snapenhance.common.scripting.bindings + +enum class BindingSide( + val key: String +) { + COMMON("common"), + CORE("core"), + MANAGER("manager"); + + companion object { + fun fromKey(key: String): BindingSide { + return entries.firstOrNull { it.key == key } ?: COMMON + } + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/bindings/BindingsContext.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/bindings/BindingsContext.kt new file mode 100644 index 0000000000..a9308ecf61 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/bindings/BindingsContext.kt @@ -0,0 +1,11 @@ +package me.rhunk.snapenhance.common.scripting.bindings + +import me.rhunk.snapenhance.common.scripting.JSModule +import me.rhunk.snapenhance.common.scripting.ScriptRuntime +import me.rhunk.snapenhance.common.scripting.type.ModuleInfo + +class BindingsContext( + val moduleInfo: ModuleInfo, + val runtime: ScriptRuntime, + val module: JSModule +) \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/impl/ConfigInterface.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/impl/ConfigInterface.kt new file mode 100644 index 0000000000..a0069c1b23 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/impl/ConfigInterface.kt @@ -0,0 +1,79 @@ +package me.rhunk.snapenhance.common.scripting.impl + +import me.rhunk.snapenhance.common.scripting.bindings.AbstractBinding +import me.rhunk.snapenhance.common.scripting.bindings.BindingSide +import org.mozilla.javascript.annotations.JSFunction + + +enum class ConfigTransactionType( + val key: String +) { + GET("get"), + SET("set"), + SAVE("save"), + LOAD("load"), + DELETE("delete"); + + companion object { + fun fromKey(key: String) = entries.find { it.key == key } + } +} + + +@Suppress("unused") +abstract class ConfigInterface : AbstractBinding("config", BindingSide.COMMON) { + @JSFunction fun get(key: String): String? = get(key, null) + @JSFunction abstract fun get(key: String, defaultValue: Any?): String? + + @JSFunction fun getInteger(key: String): Int? = getInteger(key, null) + @JSFunction fun getInteger(key: String, defaultValue: Int?): Int? = get(key, defaultValue.toString())?.toIntOrNull() ?: defaultValue + + @JSFunction fun getDouble(key: String): Double? = getDouble(key, null) + @JSFunction fun getDouble(key: String, defaultValue: Double?): Double? = get(key, defaultValue.toString())?.toDoubleOrNull() ?: defaultValue + + @JSFunction fun getBoolean(key: String): Boolean = getBoolean(key, false) + @JSFunction fun getBoolean(key: String, defaultValue: Boolean): Boolean = get(key, defaultValue.toString())?.toBoolean() ?: defaultValue + + @JSFunction fun getLong(key: String): Long? = getLong(key, null) + @JSFunction fun getLong(key: String, defaultValue: Long?): Long? = get(key, defaultValue.toString())?.toLongOrNull() ?: defaultValue + + @JSFunction fun getFloat(key: String): Float? = getFloat(key, null) + @JSFunction fun getFloat(key: String, defaultValue: Float?): Float? = get(key, defaultValue.toString())?.toFloatOrNull() ?: defaultValue + + @JSFunction fun getByte(key: String): Byte? = getByte(key, null) + @JSFunction fun getByte(key: String, defaultValue: Byte?): Byte? = get(key, defaultValue.toString())?.toByteOrNull() ?: defaultValue + + @JSFunction fun getShort(key: String): Short? = getShort(key, null) + @JSFunction fun getShort(key: String, defaultValue: Short?): Short? = get(key, defaultValue.toString())?.toShortOrNull() ?: defaultValue + + + @JSFunction fun set(key: String, value: Any?) = set(key, value, false) + @JSFunction abstract fun set(key: String, value: Any?, save: Boolean) + + @JSFunction fun setInteger(key: String, value: Int?) = setInteger(key, value, false) + @JSFunction fun setInteger(key: String, value: Int?, save: Boolean) = set(key, value, save) + + @JSFunction fun setDouble(key: String, value: Double?) = setDouble(key, value, false) + @JSFunction fun setDouble(key: String, value: Double?, save: Boolean) = set(key, value, save) + + @JSFunction fun setBoolean(key: String, value: Boolean?) = setBoolean(key, value, false) + @JSFunction fun setBoolean(key: String, value: Boolean?, save: Boolean) = set(key, value, save) + + @JSFunction fun setLong(key: String, value: Long?) = setLong(key, value, false) + @JSFunction fun setLong(key: String, value: Long?, save: Boolean) = set(key, value, save) + + @JSFunction fun setFloat(key: String, value: Float?) = setFloat(key, value, false) + @JSFunction fun setFloat(key: String, value: Float?, save: Boolean) = set(key, value, save) + + @JSFunction fun setByte(key: String, value: Byte?) = setByte(key, value, false) + @JSFunction fun setByte(key: String, value: Byte?, save: Boolean) = set(key, value, save) + + @JSFunction fun setShort(key: String, value: Short?) = setShort(key, value, false) + @JSFunction fun setShort(key: String, value: Short?, save: Boolean) = set(key, value, save) + + @JSFunction abstract fun save() + @JSFunction abstract fun load() + @JSFunction abstract fun deleteConfig() + + override fun getObject() = this +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/impl/IPCInterface.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/impl/IPCInterface.kt new file mode 100644 index 0000000000..4be46454e8 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/impl/IPCInterface.kt @@ -0,0 +1,27 @@ +package me.rhunk.snapenhance.common.scripting.impl + +import me.rhunk.snapenhance.common.scripting.bindings.AbstractBinding +import me.rhunk.snapenhance.common.scripting.bindings.BindingSide +import org.mozilla.javascript.annotations.JSFunction + +typealias Listener = (List<String?>) -> Unit + +abstract class IPCInterface : AbstractBinding("ipc", BindingSide.COMMON) { + abstract fun on(eventName: String, listener: Listener) + + abstract fun onBroadcast(channel: String, eventName: String, listener: Listener) + + abstract fun emit(eventName: String, vararg args: String?): Int + abstract fun broadcast(channel: String, eventName: String, vararg args: String?): Int + + @Suppress("unused") + fun emit(eventName: String) = emit(eventName, *emptyArray()) + @Suppress("unused") + fun broadcast(channel: String, eventName: String) = + broadcast(channel, eventName, *emptyArray()) + + override fun getObject() = this + + @JSFunction("isBridgeAlive") + fun isBridgeAlive() = context.runtime.scripting.asBinder().pingBinder() +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/impl/JavaInterfaces.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/impl/JavaInterfaces.kt new file mode 100644 index 0000000000..457d8f5d3a --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/impl/JavaInterfaces.kt @@ -0,0 +1,61 @@ +package me.rhunk.snapenhance.common.scripting.impl + +import me.rhunk.snapenhance.common.scripting.bindings.AbstractBinding +import me.rhunk.snapenhance.common.scripting.bindings.BindingSide +import me.rhunk.snapenhance.common.scripting.ktx.contextScope +import me.rhunk.snapenhance.common.scripting.ktx.putFunction +import me.rhunk.snapenhance.common.scripting.ktx.scriptableObject +import java.lang.reflect.Proxy +import kotlin.concurrent.thread + +class JavaInterfaces : AbstractBinding("java-interfaces", BindingSide.COMMON) { + override fun getObject() = scriptableObject { + putFunction("runnable") { + val function = it?.get(0) as? org.mozilla.javascript.Function ?: return@putFunction null + Runnable { + contextScope { + function.call( + this, + this@scriptableObject, + this@scriptableObject, + emptyArray() + ) + } + } + } + + putFunction("newProxy") { arguments -> + val javaInterface = arguments?.get(0) as? Class<*> ?: return@putFunction null + val function = arguments[1] as? org.mozilla.javascript.Function ?: return@putFunction null + + Proxy.newProxyInstance( + javaInterface.classLoader, + arrayOf(javaInterface) + ) { instance, method, args -> + contextScope { + function.call( + this, + this@scriptableObject, + this@scriptableObject, + arrayOf(instance, method.name, (args ?: emptyArray<Any>()).toList()) + ) + } + } + } + + putFunction("thread") { arguments -> + val function = arguments?.get(0) as? org.mozilla.javascript.Function ?: return@putFunction null + + thread(start = false) { + contextScope { + function.call( + this, + this@scriptableObject, + this@scriptableObject, + emptyArray() + ) + } + } + } + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/impl/Networking.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/impl/Networking.kt new file mode 100644 index 0000000000..8a497bf8c0 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/impl/Networking.kt @@ -0,0 +1,166 @@ +package me.rhunk.snapenhance.common.scripting.impl + +import me.rhunk.snapenhance.common.scripting.bindings.AbstractBinding +import me.rhunk.snapenhance.common.scripting.bindings.BindingSide +import me.rhunk.snapenhance.common.scripting.ktx.contextScope +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.ByteString +import okio.ByteString.Companion.toByteString +import org.mozilla.javascript.Function +import org.mozilla.javascript.Scriptable +import org.mozilla.javascript.annotations.JSFunction +import org.mozilla.javascript.annotations.JSGetter + + +class Networking : AbstractBinding("networking", BindingSide.COMMON) { + private val defaultHttpClient = OkHttpClient() + + inner class RequestBuilderWrapper( + val requestBuilder: Request.Builder + ) { + @JSFunction + fun url(url: String) = requestBuilder.url(url).let { this } + + @JSFunction + fun addHeader(name: String, value: String) = requestBuilder.addHeader(name, value).let { this } + + @JSFunction + fun removeHeader(name: String) = requestBuilder.removeHeader(name).let { this } + + @JSFunction + fun method(method: String) = requestBuilder.method(method.uppercase(), null).let { this } + + @JSFunction + fun method(method: String, body: String) = requestBuilder.method(method.uppercase(), body.toRequestBody(null)).let { this } + + @JSFunction + fun method(method: String, body: java.io.InputStream) = requestBuilder.method(method.uppercase(), body.readBytes().toRequestBody(null)).let { this } + + @JSFunction + fun method(method: String, body: ByteArray) = requestBuilder.method(method.uppercase(), body.toRequestBody(null)).let { this } + } + + inner class ResponseWrapper( + private val response: Response + ) { + @get:JSGetter + val statusCode get() = response.code + @get:JSGetter + val statusMessage get() = response.message + @get:JSGetter + val headers get() = response.headers.toMultimap().mapValues { it.value.joinToString(", ") } + @get:JSGetter + val bodyAsString get() = response.body.string() + @get:JSGetter + val bodyAsStream get() = response.body.byteStream() + @get:JSGetter + val bodyAsByteArray get() = response.body.bytes() + @get:JSGetter + val contentLength get() = response.body.contentLength() + @JSFunction fun getHeader(name: String) = response.header(name) + @JSFunction fun close() = response.close() + } + + inner class WebsocketWrapper( + private val websocket: WebSocket + ) { + @JSFunction fun cancel() = websocket.cancel() + @JSFunction fun close(code: Int, reason: String) = websocket.close(code, reason) + @JSFunction fun queueSize() = websocket.queueSize() + @JSFunction fun send(bytes: ByteArray) = websocket.send(bytes.toByteString()) + @JSFunction fun send(text: String) = websocket.send(text) + } + + @JSFunction + fun getUrl(url: String, callback: (error: String?, response: String) -> Unit) { + defaultHttpClient.newCall(Request.Builder().url(url).build()).enqueue(object : okhttp3.Callback { + override fun onFailure(call: okhttp3.Call, e: java.io.IOException) { + callback(e.message, "") + } + + override fun onResponse(call: okhttp3.Call, response: Response) { + response.use { + callback(null, it.body.string()) + } + } + }) + } + + @JSFunction + fun getUrlAsStream(url: String, callback: (error: String?, response: java.io.InputStream) -> Unit) { + defaultHttpClient.newCall(Request.Builder().url(url).build()).enqueue(object : okhttp3.Callback { + override fun onFailure(call: okhttp3.Call, e: java.io.IOException) { + callback(e.message, java.io.ByteArrayInputStream(byteArrayOf())) + } + + override fun onResponse(call: okhttp3.Call, response: Response) { + response.use { + callback(null, it.body.byteStream()) + } + } + }) + } + + @JSFunction + fun newRequest() = RequestBuilderWrapper(Request.Builder()) + + @JSFunction + fun newWebSocket(requestBuilder: RequestBuilderWrapper, listener: Scriptable): WebsocketWrapper { + return defaultHttpClient.newWebSocket(requestBuilder.requestBuilder.build(), object: WebSocketListener() { + private fun callListener(name: String, websocket: WebSocket, vararg args: Any?) { + contextScope { + (listener.get(name, listener) as? Function)?.call(this, listener, listener, arrayOf(WebsocketWrapper(websocket), *args)) + } + } + + override fun onOpen(webSocket: WebSocket, response: Response) { + callListener("onOpen", webSocket, ResponseWrapper(response)) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + callListener("onClosed", webSocket, code, reason) + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + callListener("onClosing", webSocket, code, reason) + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + callListener("onFailure", webSocket, t.message, response?.let { ResponseWrapper(it) }) + } + + override fun onMessage(webSocket: WebSocket, bytes: ByteString) { + callListener("onMessageBytes", webSocket, bytes.toByteArray()) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + callListener("onMessageText", webSocket, text) + } + }).let { WebsocketWrapper(it) } + } + + @JSFunction + fun enqueue(requestBuilder: RequestBuilderWrapper, callback: (error: String?, response: ResponseWrapper?) -> Unit) { + defaultHttpClient.newCall(requestBuilder.requestBuilder.build()).enqueue(object : okhttp3.Callback { + override fun onFailure(call: okhttp3.Call, e: java.io.IOException) { + callback(e.message, null) + } + + override fun onResponse(call: okhttp3.Call, response: Response) { + response.use { + callback(null, ResponseWrapper(it)) + } + } + }) + } + + @JSFunction + fun execute(requestBuilder: RequestBuilderWrapper) = ResponseWrapper(defaultHttpClient.newCall(requestBuilder.requestBuilder.build()).execute()) + + override fun getObject() = this +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/impl/Protobuf.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/impl/Protobuf.kt new file mode 100644 index 0000000000..da8aa67896 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/impl/Protobuf.kt @@ -0,0 +1,69 @@ +package me.rhunk.snapenhance.common.scripting.impl + +import me.rhunk.snapenhance.common.scripting.bindings.AbstractBinding +import me.rhunk.snapenhance.common.scripting.bindings.BindingSide +import me.rhunk.snapenhance.common.scripting.ktx.putFunction +import me.rhunk.snapenhance.common.scripting.ktx.scriptableObject +import me.rhunk.snapenhance.common.util.protobuf.* +import org.mozilla.javascript.NativeArray +import java.io.InputStream + + +class Protobuf : AbstractBinding("protobuf", BindingSide.COMMON) { + private fun parseInput(input: Any?): ByteArray? { + return when (input) { + is ByteArray -> input + is InputStream -> input.readBytes() + is NativeArray -> input.toArray().map { it as Byte }.toByteArray() + else -> { + context.runtime.logger.error("Invalid input type for buffer: $input") + null + } + } + } + + override fun getObject(): Any { + return scriptableObject { + putFunction("reader") { args -> + val input = args?.get(0) ?: return@putFunction null + + val buffer = parseInput(input) ?: run { + return@putFunction null + } + + ProtoReader(buffer) + } + putFunction("writer") { + ProtoWriter() + } + putFunction("editor") { args -> + val input = args?.get(0) ?: return@putFunction null + + val buffer = parseInput(input) ?: run { + return@putFunction null + } + ProtoEditor(buffer) + } + + putFunction("grpcWriter") { args -> + val messages = args?.mapNotNull { + parseInput(it) + }?.toTypedArray() ?: run { + return@putFunction null + } + + GrpcWriter(*messages) + } + + putFunction("grpcReader") { args -> + val input = args?.get(0) ?: return@putFunction null + + val buffer = parseInput(input) ?: run { + return@putFunction null + } + + GrpcReader(buffer) + } + } + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ktx/RhinoKtx.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ktx/RhinoKtx.kt new file mode 100644 index 0000000000..eaf717cb5f --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ktx/RhinoKtx.kt @@ -0,0 +1,55 @@ +package me.rhunk.snapenhance.common.scripting.ktx + +import com.faendir.rhino_android.RhinoAndroidHelper +import org.mozilla.javascript.* +import org.mozilla.javascript.Function +import java.io.File + +private val rhinoAndroidHelper = RhinoAndroidHelper(null as File?) + +fun contextScope(shouldOptimize: Boolean = false, f: Context.() -> Any?): Any? { + val context = rhinoAndroidHelper.enterContext().apply { + languageVersion = Context.VERSION_ES6 + optimizationLevel = if (!shouldOptimize) -1 else 0 + } + try { + return context.f().let { + if (it is Wrapper) { + it.unwrap() + } else it + } + } finally { + Context.exit() + } +} + +fun Scriptable.scriptable(name: String): Scriptable? { + return this.get(name, this) as? Scriptable +} + +fun Scriptable.function(name: String): Function? { + return this.get(name, this) as? Function +} + +fun ScriptableObject.putFunction(name: String, proxy: Scriptable.(Array<out Any?>?) -> Any?) { + this.putConst(name, this, object: org.mozilla.javascript.BaseFunction() { + override fun call( + cx: Context?, + scope: Scriptable, + thisObj: Scriptable, + args: Array<out Any>? + ): Any? { + return thisObj.proxy(args?.map { + if (it is Wrapper) { + it.unwrap() + } else it + }?.toTypedArray()) + } + }) +} + +fun scriptableObject(name: String? = "ScriptableObject", f: ScriptableObject.() -> Unit): ScriptableObject { + return object: ScriptableObject() { + override fun getClassName() = name + }.apply(f) +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/type/ModuleInfo.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/type/ModuleInfo.kt new file mode 100644 index 0000000000..8bb4e4561e --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/type/ModuleInfo.kt @@ -0,0 +1,59 @@ +package me.rhunk.snapenhance.common.scripting.type + +import java.io.BufferedReader + +data class ModuleInfo( + val name: String, + val version: String, + val displayName: String? = null, + val description: String? = null, + val updateUrl: String? = null, + val author: String? = null, + val minSnapchatVersion: Long? = null, + val minSEVersion: Long? = null, + val grantedPermissions: List<String>, + val executionSides: List<String>? = null, +) { + fun ensurePermissionGranted(permission: Permissions) { + if (!grantedPermissions.contains(permission.key)) { + throw AssertionError("Permission $permission is not granted") + } + } +} + +fun BufferedReader.readModuleInfo(): ModuleInfo { + val header = readLine() + if (!header.startsWith("// ==SE_module==")) { + throw Exception("Invalid module header") + } + + val properties = mutableMapOf<String, String>() + while (true) { + val line = readLine() + if (line.startsWith("// ==/SE_module==")) { + break + } + val split = line.replaceFirst("//", "").split(":", limit = 2) + if (split.size != 2) { + throw Exception("Invalid module property") + } + properties[split[0].trim()] = split[1].trim() + } + + return ModuleInfo( + name = properties["name"]?.also { + if (!it.matches(Regex("[a-z_]+"))) { + throw Exception("Invalid module name : Only lowercase letters and underscores are allowed") + } + } ?: throw Exception("Missing module name"), + version = properties["version"] ?: throw Exception("Missing module version"), + displayName = properties["displayName"], + description = properties["description"], + updateUrl = properties["updateUrl"], + author = properties["author"], + minSnapchatVersion = properties["minSnapchatVersion"]?.toLongOrNull(), + minSEVersion = properties["minSEVersion"]?.toLongOrNull(), + grantedPermissions = properties["permissions"]?.split(",")?.map { it.trim() } ?: emptyList(), + executionSides = properties["executionSides"]?.lowercase()?.split(",")?.map { it.trim() }, + ) +} diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/type/Permissions.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/type/Permissions.kt new file mode 100644 index 0000000000..811b832b43 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/type/Permissions.kt @@ -0,0 +1,7 @@ +package me.rhunk.snapenhance.common.scripting.type + +enum class Permissions( + val key: String, +) { + UNSAFE_CLASSLOADER("unsafe-classloader"), +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ui/EnumScriptInterface.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ui/EnumScriptInterface.kt new file mode 100644 index 0000000000..6a10f02d30 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ui/EnumScriptInterface.kt @@ -0,0 +1,12 @@ +package me.rhunk.snapenhance.common.scripting.ui + +import me.rhunk.snapenhance.common.scripting.bindings.BindingSide + +enum class EnumScriptInterface( + val key: String, + val side: BindingSide +) { + SETTINGS("settings", BindingSide.MANAGER), + FRIEND_FEED_CONTEXT_MENU("friendFeedContextMenu", BindingSide.CORE), + CONVERSATION_TOOLBOX("conversationToolbox", BindingSide.CORE), +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ui/InterfaceManager.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ui/InterfaceManager.kt new file mode 100644 index 0000000000..977ac253e9 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ui/InterfaceManager.kt @@ -0,0 +1,143 @@ +package me.rhunk.snapenhance.common.scripting.ui + +import android.app.Activity +import android.app.AlertDialog +import androidx.compose.runtime.remember +import me.rhunk.snapenhance.common.scripting.bindings.AbstractBinding +import me.rhunk.snapenhance.common.scripting.bindings.BindingSide +import me.rhunk.snapenhance.common.scripting.ktx.contextScope +import me.rhunk.snapenhance.common.scripting.ktx.scriptableObject +import me.rhunk.snapenhance.common.scripting.ui.components.Node +import me.rhunk.snapenhance.common.scripting.ui.components.NodeType +import me.rhunk.snapenhance.common.scripting.ui.components.impl.ActionNode +import me.rhunk.snapenhance.common.scripting.ui.components.impl.ActionType +import me.rhunk.snapenhance.common.scripting.ui.components.impl.RowColumnNode +import me.rhunk.snapenhance.common.scripting.ui.components.impl.TextInputNode +import me.rhunk.snapenhance.common.ui.createComposeAlertDialog +import org.mozilla.javascript.Function +import org.mozilla.javascript.annotations.JSFunction + + +class InterfaceBuilder { + val nodes = mutableListOf<Node>() + var onDisposeCallback: (() -> Unit)? = null + + private fun createNode(type: NodeType, block: Node.() -> Unit): Node { + return Node(type).apply(block).also { nodes.add(it) } + } + + fun onDispose(block: () -> Unit) { + nodes.add(ActionNode(ActionType.DISPOSE, callback = block)) + } + + fun onLaunched(block: () -> Unit) { + onLaunched(Unit, block) + } + + fun onLaunched(key: Any, block: () -> Unit) { + nodes.add(ActionNode(ActionType.LAUNCHED, key, block)) + } + + fun row(block: (InterfaceBuilder) -> Unit) = RowColumnNode(NodeType.ROW).apply { + children.addAll(InterfaceBuilder().apply(block).nodes) + }.also { nodes.add(it) } + + fun column(block: (InterfaceBuilder) -> Unit) = RowColumnNode(NodeType.COLUMN).apply { + children.addAll(InterfaceBuilder().apply(block).nodes) + }.also { nodes.add(it) } + + fun text(text: String) = createNode(NodeType.TEXT) { + label(text) + } + + fun switch(state: Boolean?, callback: (Boolean) -> Unit) = createNode(NodeType.SWITCH) { + attributes["state"] = state + attributes["callback"] = callback + } + + fun button(label: String, callback: () -> Unit) = createNode(NodeType.BUTTON) { + label(label) + attributes["callback"] = callback + } + + fun slider(min: Int, max: Int, step: Int, value: Int, callback: (Int) -> Unit) = createNode( + NodeType.SLIDER + ) { + attributes["value"] = value + attributes["min"] = min + attributes["max"] = max + attributes["step"] = step + attributes["callback"] = callback + } + + fun list(label: String, items: List<String>, callback: (String) -> Unit) = createNode(NodeType.LIST) { + label(label) + attributes["items"] = items + attributes["callback"] = callback + } + + fun textInput(placeholder: String, value: String, callback: (String) -> Unit) = TextInputNode().apply { + placeholder(placeholder) + value(value) + callback(callback) + }.also { nodes.add(it) } +} + + + +@Suppress("unused") +class InterfaceManager : AbstractBinding("interface-manager", BindingSide.COMMON) { + private val interfaces = mutableMapOf<String, (args: Map<String, Any?>) -> InterfaceBuilder?>() + + fun buildInterface(scriptInterface: EnumScriptInterface, args: Map<String, Any?> = emptyMap()): InterfaceBuilder? { + return runCatching { + interfaces[scriptInterface.key]?.invoke(args) + }.onFailure { + context.runtime.logger.error("Failed to build interface ${scriptInterface.key} for ${context.moduleInfo.name}", it) + }.getOrNull() + } + + override fun onDispose() { + interfaces.clear() + } + + fun hasInterface(scriptInterfaces: EnumScriptInterface): Boolean { + return interfaces.containsKey(scriptInterfaces.key) + } + + @JSFunction fun create(name: String, callback: Function) { + interfaces[name] = { args -> + val interfaceBuilder = InterfaceBuilder() + runCatching { + contextScope { + callback.call(this, callback, callback, arrayOf(interfaceBuilder, scriptableObject { + args.forEach { (key, value) -> + putConst(key,this, value) + } + })) + } + interfaceBuilder + }.onFailure { + context.runtime.logger.error("Failed to create interface $name for ${context.moduleInfo.name}", it) + }.getOrNull() + } + } + + @JSFunction fun createAlertDialog(activity: Activity, builder: (AlertDialog.Builder) -> Unit, callback: (interfaceBuilder: InterfaceBuilder, alertDialog: AlertDialog) -> Unit): AlertDialog { + return createComposeAlertDialog(activity, builder = builder) { alertDialog -> + ScriptInterface(interfaceBuilder = remember { + InterfaceBuilder().also { + contextScope { + callback(it, alertDialog) + } + } + }) + } + } + + @JSFunction fun createAlertDialog(activity: Activity, callback: (interfaceBuilder: InterfaceBuilder, alertDialog: AlertDialog) -> Unit): AlertDialog { + return createAlertDialog(activity, {}, callback) + } + + override fun getObject() = this +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ui/ScriptInterface.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ui/ScriptInterface.kt new file mode 100644 index 0000000000..27745b43bd --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ui/ScriptInterface.kt @@ -0,0 +1,208 @@ +package me.rhunk.snapenhance.common.scripting.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.* +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.common.logger.AbstractLogger +import me.rhunk.snapenhance.common.scripting.ui.components.Node +import me.rhunk.snapenhance.common.scripting.ui.components.NodeType +import me.rhunk.snapenhance.common.scripting.ui.components.impl.ActionNode +import me.rhunk.snapenhance.common.scripting.ui.components.impl.ActionType +import kotlin.math.abs + + +@Composable +@Suppress("UNCHECKED_CAST") +private fun DrawNode(node: Node) { + val coroutineScope = rememberCoroutineScope() + val cachedAttributes = remember { mutableStateMapOf(*node.attributes.toList().toTypedArray()) } + + node.uiChangeDetection = { key, value -> + coroutineScope.launch { + cachedAttributes[key] = value + } + } + + DisposableEffect(Unit) { + onDispose { + node.uiChangeDetection = { _, _ -> } + } + } + + val arrangement = cachedAttributes["arrangement"] + val alignment = cachedAttributes["alignment"] + val spacing = cachedAttributes["spacing"]?.toString()?.toInt()?.let { abs(it) } + + val rowColumnModifier = Modifier + .then(if (cachedAttributes["fillMaxWidth"] as? Boolean == true) Modifier.fillMaxWidth() else Modifier) + .then(if (cachedAttributes["fillMaxHeight"] as? Boolean == true) Modifier.fillMaxHeight() else Modifier) + .padding( + (cachedAttributes["padding"] + ?.toString() + ?.toInt() + ?.let { abs(it) } ?: 2).dp) + + fun runCallbackSafe(callback: () -> Unit) { + runCatching { + callback() + }.onFailure { + AbstractLogger.directError("Error running callback", it) + } + } + + @Composable + fun NodeLabel() { + Text( + text = cachedAttributes["label"] as String, + fontSize = (cachedAttributes["fontSize"]?.toString()?.toInt() ?: 14).sp, + color = (cachedAttributes["color"] as? Long)?.let { Color(it) } ?: Color.Unspecified + ) + } + + if (cachedAttributes["visibility"] != "gone") { + AnimatedVisibility( + visible = cachedAttributes["visibility"] != "invisible", + ) { + when (node.type) { + NodeType.ACTION -> { + when ((node as ActionNode).actionType) { + ActionType.LAUNCHED -> { + LaunchedEffect(node.key) { + runCallbackSafe { + node.callback() + } + } + } + ActionType.DISPOSE -> { + DisposableEffect(Unit) { + onDispose { + runCallbackSafe { + node.callback() + } + } + } + } + } + } + NodeType.COLUMN -> { + Column( + verticalArrangement = arrangement as? Arrangement.Vertical ?: spacing?.let { Arrangement.spacedBy(it.dp) } ?: Arrangement.Top, + horizontalAlignment = alignment as? Alignment.Horizontal ?: Alignment.Start, + modifier = rowColumnModifier + ) { + node.children.forEach { child -> + DrawNode(child) + } + } + } + NodeType.ROW -> { + Row( + horizontalArrangement = arrangement as? Arrangement.Horizontal ?: spacing?.let { Arrangement.spacedBy(it.dp) } ?: Arrangement.SpaceBetween, + verticalAlignment = alignment as? Alignment.Vertical ?: Alignment.CenterVertically, + modifier = rowColumnModifier + ) { + node.children.forEach { child -> + DrawNode(child) + } + } + } + NodeType.TEXT -> NodeLabel() + NodeType.SWITCH -> { + var switchState by remember { + mutableStateOf(cachedAttributes["state"] as Boolean) + } + Switch( + checked = switchState, + onCheckedChange = { state -> + runCallbackSafe { + switchState = state + node.setAttribute("state", state) + (cachedAttributes["callback"] as? (Boolean) -> Unit)?.let { it(state) } + } + } + ) + } + NodeType.SLIDER -> { + var sliderValue by remember { + mutableFloatStateOf((cachedAttributes["value"] as Int).toFloat()) + } + Slider( + value = sliderValue, + onValueChange = { value -> + runCallbackSafe { + sliderValue = value + node.setAttribute("value", value.toInt()) + (cachedAttributes["callback"] as? (Int) -> Unit)?.let { it(value.toInt()) } + } + }, + valueRange = (cachedAttributes["min"] as Int).toFloat()..(cachedAttributes["max"] as Int).toFloat(), + steps = cachedAttributes["step"] as Int, + ) + } + NodeType.BUTTON -> { + OutlinedButton(onClick = { + runCallbackSafe { + (cachedAttributes["callback"] as? () -> Unit)?.let { it() } + } + }) { + NodeLabel() + } + } + NodeType.TEXT_INPUT -> { + var textInputValue by remember { + mutableStateOf(cachedAttributes["value"].toString()) + } + TextField( + value = textInputValue, + readOnly = cachedAttributes["readonly"] as? Boolean ?: false, + singleLine = cachedAttributes["singleLine"] as? Boolean ?: true, + maxLines = cachedAttributes["maxLines"] as? Int ?: 1, + onValueChange = { value -> + runCallbackSafe { + textInputValue = value + node.setAttribute("value", value) + (cachedAttributes["callback"] as? (String) -> Unit)?.let { it(value) } + } + }, + placeholder = { Text(cachedAttributes["placeholder"].toString()) } + ) + } + else -> {} + } + } + } +} + +@Composable +fun ScriptInterface(interfaceBuilder: InterfaceBuilder) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + interfaceBuilder.nodes.forEach { node -> + DrawNode(node) + } + + DisposableEffect(Unit) { + onDispose { + runCatching { + interfaceBuilder.onDisposeCallback?.invoke() + }.onFailure { + AbstractLogger.directError("Error running onDisposed callback", it) + } + } + } + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ui/components/Node.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ui/components/Node.kt new file mode 100644 index 0000000000..c7a01c7821 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ui/components/Node.kt @@ -0,0 +1,62 @@ +package me.rhunk.snapenhance.common.scripting.ui.components + +@Suppress("MemberVisibilityCanBePrivate") +open class Node( + val type: NodeType, +) { + lateinit var uiChangeDetection: (key: String, value: Any?) -> Unit + + val children = mutableListOf<Node>() + val attributes = object: HashMap<String, Any?>() { + override fun put(key: String, value: Any?): Any? { + return super.put(key, value).also { + if (::uiChangeDetection.isInitialized) { + uiChangeDetection(key, value) + } + } + } + } + + init { + visibility("visible") + } + + fun setAttribute(key: String, value: Any?) { + attributes[key] = value + } + + fun fillMaxWidth(): Node { + attributes["fillMaxWidth"] = true + return this + } + + fun fillMaxHeight(): Node { + attributes["fillMaxHeight"] = true + return this + } + + fun label(text: String): Node { + attributes["label"] = text + return this + } + + fun padding(padding: Int): Node { + attributes["padding"] = padding + return this + } + + fun fontSize(size: Int): Node { + attributes["fontSize"] = size + return this + } + + fun color(color: Long): Node { + attributes["color"] = color + return this + } + + fun visibility(state: String) { + assert(state == "visible" || state == "invisible" || state == "gone") { "Invalid visibility state. Must be one of: visible, invisible, gone" } + attributes["visibility"] = state + } +} diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ui/components/NodeType.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ui/components/NodeType.kt new file mode 100644 index 0000000000..21b9713685 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ui/components/NodeType.kt @@ -0,0 +1,13 @@ +package me.rhunk.snapenhance.common.scripting.ui.components + +enum class NodeType { + ROW, + COLUMN, + TEXT, + SWITCH, + BUTTON, + SLIDER, + LIST, + ACTION, + TEXT_INPUT, +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ui/components/impl/ActionNode.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ui/components/impl/ActionNode.kt new file mode 100644 index 0000000000..6a02ea8bab --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ui/components/impl/ActionNode.kt @@ -0,0 +1,15 @@ +package me.rhunk.snapenhance.common.scripting.ui.components.impl + +import me.rhunk.snapenhance.common.scripting.ui.components.Node +import me.rhunk.snapenhance.common.scripting.ui.components.NodeType + +enum class ActionType { + LAUNCHED, + DISPOSE +} + +class ActionNode( + val actionType: ActionType, + val key: Any = Unit, + val callback: () -> Unit +): Node(NodeType.ACTION) \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ui/components/impl/RowColumnNode.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ui/components/impl/RowColumnNode.kt new file mode 100644 index 0000000000..131d8ecce8 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ui/components/impl/RowColumnNode.kt @@ -0,0 +1,47 @@ +package me.rhunk.snapenhance.common.scripting.ui.components.impl + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.ui.Alignment +import me.rhunk.snapenhance.common.scripting.ui.components.Node +import me.rhunk.snapenhance.common.scripting.ui.components.NodeType + + +class RowColumnNode( + type: NodeType, +) : Node(type) { + companion object { + private val arrangements = mapOf( + "start" to Arrangement.Start, + "end" to Arrangement.End, + "top" to Arrangement.Top, + "bottom" to Arrangement.Bottom, + "center" to Arrangement.Center, + "spaceBetween" to Arrangement.SpaceBetween, + "spaceAround" to Arrangement.SpaceAround, + "spaceEvenly" to Arrangement.SpaceEvenly, + ) + private val alignments = mapOf( + "start" to Alignment.Start, + "end" to Alignment.End, + "top" to Alignment.Top, + "bottom" to Alignment.Bottom, + "centerVertically" to Alignment.CenterVertically, + "centerHorizontally" to Alignment.CenterHorizontally, + ) + } + + fun arrangement(arrangement: String): RowColumnNode { + attributes["arrangement"] = arrangements[arrangement] ?: throw IllegalArgumentException("Invalid arrangement") + return this + } + + fun alignment(alignment: String): RowColumnNode { + attributes["alignment"] = alignments[alignment] ?: throw IllegalArgumentException("Invalid alignment") + return this + } + + fun spacedBy(spacing: Int): RowColumnNode { + attributes["spacing"] = spacing + return this + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ui/components/impl/TextInputNode.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ui/components/impl/TextInputNode.kt new file mode 100644 index 0000000000..300f173e5f --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ui/components/impl/TextInputNode.kt @@ -0,0 +1,36 @@ +package me.rhunk.snapenhance.common.scripting.ui.components.impl + +import me.rhunk.snapenhance.common.scripting.ui.components.Node +import me.rhunk.snapenhance.common.scripting.ui.components.NodeType + +class TextInputNode : Node(NodeType.TEXT_INPUT) { + fun placeholder(text: String): TextInputNode { + attributes["placeholder"] = text + return this + } + + fun value(text: String): TextInputNode { + attributes["value"] = text + return this + } + + fun callback(callback: (String) -> Unit): TextInputNode { + attributes["callback"] = callback + return this + } + + fun readonly(state: Boolean): TextInputNode { + attributes["readonly"] = state + return this + } + + fun singleLine(state: Boolean): TextInputNode { + attributes["singleLine"] = state + return this + } + + fun maxLines(maxLines: Int): TextInputNode { + attributes["maxLines"] = maxLines + return this + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/AsyncMutableState.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/AsyncMutableState.kt new file mode 100644 index 0000000000..6fd21488f4 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/AsyncMutableState.kt @@ -0,0 +1,103 @@ +package me.rhunk.snapenhance.common.ui + +import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.concurrent.CopyOnWriteArrayList + +class AsyncUpdateDispatcher( + val updateOnFirstComposition: Boolean = true +) { + private val callbacks = CopyOnWriteArrayList<suspend () -> Unit>() + + suspend fun dispatch() { + callbacks.forEach { it() } + } + + fun addCallback(callback: suspend () -> Unit) { + callbacks.add(callback) + } + + fun removeCallback(callback: suspend () -> Unit) { + callbacks.remove(callback) + } +} + +@Composable +fun rememberAsyncUpdateDispatcher(): AsyncUpdateDispatcher { + return remember { AsyncUpdateDispatcher() } +} + +@Composable +private fun <T> rememberCommonState( + initialState: () -> T, + setter: suspend T.() -> Unit, + updateDispatcher: AsyncUpdateDispatcher? = null, + keys: Array<*> = emptyArray<Any>(), +): T { + return remember { initialState() }.apply { + var asyncSetCallback by remember { mutableStateOf(suspend {}) } + + LaunchedEffect(Unit) { + asyncSetCallback = { setter(this@apply) } + updateDispatcher?.addCallback(asyncSetCallback) + } + + DisposableEffect(Unit) { + onDispose { updateDispatcher?.removeCallback(asyncSetCallback) } + } + + if (updateDispatcher?.updateOnFirstComposition != false) { + LaunchedEffect(*keys) { + setter(this@apply) + } + } + } +} + +@Composable +fun <T> rememberAsyncMutableState( + defaultValue: T, + updateDispatcher: AsyncUpdateDispatcher? = null, + keys: Array<*> = emptyArray<Any>(), + getter: suspend () -> T, +): MutableState<T> { + return rememberCommonState( + initialState = { mutableStateOf(defaultValue) }, + setter = { + withContext(Dispatchers.Main) { + value = withContext(Dispatchers.IO) { + getter() + } + } + }, + updateDispatcher = updateDispatcher, + keys = keys, + ) +} + +@Composable +fun <T> rememberAsyncMutableStateList( + defaultValue: List<T>, + updateDispatcher: AsyncUpdateDispatcher? = null, + keys: Array<*> = emptyArray<Any>(), + getter: suspend () -> List<T>, +): SnapshotStateList<T> { + return rememberCommonState( + initialState = { mutableStateListOf<T>().apply { + addAll(defaultValue) + }}, + setter = { + withContext(Dispatchers.Main) { + clear() + addAll(withContext(Dispatchers.IO) { + getter() + }) + } + }, + updateDispatcher = updateDispatcher, + keys = keys, + ) +} + diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/Components.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/Components.kt new file mode 100644 index 0000000000..7ec8cdc6cf --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/Components.kt @@ -0,0 +1,75 @@ +package me.rhunk.snapenhance.common.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.material3.ElevatedButton +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper + + +@Composable +fun EditNoteTextField( + modifier: Modifier = Modifier, + primaryColor: Color, + translation: LocaleWrapper, + content: String?, + setContent: (String) -> Unit +) { + TextField( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 180.dp) + .then(modifier), + value = content ?: "", + colors = TextFieldDefaults.colors( + unfocusedContainerColor = Color.Transparent, + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + cursorColor = primaryColor + ), + onValueChange = { + setContent(it) + }, + shape = MaterialTheme.shapes.medium, + textStyle = LocalTextStyle.current.copy(fontSize = 12.sp, color = primaryColor), + placeholder = { Text(text = translation["manager.sections.manage_scope.notes_placeholder"], fontSize = 12.sp) } + ) +} + +@Composable +fun TopBarActionButton( + modifier: Modifier = Modifier, + icon: ImageVector, + text: String, + onClick: () -> Unit = {} +) { + ElevatedButton( + modifier = modifier, + onClick = onClick + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(3.dp), + verticalAlignment = Alignment.CenterVertically + ){ + Icon(icon, contentDescription = null) + Text(text = text, overflow = TextOverflow.Ellipsis) + } + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/ComposeViewFactory.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/ComposeViewFactory.kt new file mode 100644 index 0000000000..b44376c300 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/ComposeViewFactory.kt @@ -0,0 +1,125 @@ +package me.rhunk.snapenhance.common.ui + +import android.app.AlertDialog +import android.content.Context +import android.os.Bundle +import android.view.View +import android.view.View.OnAttachStateChangeListener +import android.view.WindowManager +import androidx.activity.OnBackPressedDispatcher +import androidx.activity.OnBackPressedDispatcherOwner +import androidx.activity.setViewTreeOnBackPressedDispatcherOwner +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Recomposer +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.AndroidUiDispatcher +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.platform.compositionContext +import androidx.compose.ui.unit.dp +import androidx.lifecycle.* +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryController +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +// https://github.com/tberghuis/FloatingCountdownTimer/blob/master/app/src/main/java/xyz/tberghuis/floatingtimer/service/overlayViewFactory.kt +fun createComposeView( + context: Context, + viewCompositionStrategy: ViewCompositionStrategy = ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed, + content: @Composable () -> Unit +) = ComposeView(context).apply { + setViewCompositionStrategy(viewCompositionStrategy) + val lifecycleOwner = OverlayLifecycleOwner().apply { + performRestore(null) + handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + } + setViewTreeLifecycleOwner(lifecycleOwner) + setViewTreeSavedStateRegistryOwner(lifecycleOwner) + + val viewModelStore = ViewModelStore() + setViewTreeViewModelStoreOwner(object : ViewModelStoreOwner { + override val viewModelStore: ViewModelStore + get() = viewModelStore + }) + + val backPressedDispatcherOwner = OnBackPressedDispatcher() + setViewTreeOnBackPressedDispatcherOwner(object: OnBackPressedDispatcherOwner { + override val lifecycle: Lifecycle + get() = lifecycleOwner.lifecycle + override val onBackPressedDispatcher: OnBackPressedDispatcher + get() = backPressedDispatcherOwner + }) + + val coroutineContext = AndroidUiDispatcher.CurrentThread + val runRecomposeScope = CoroutineScope(coroutineContext) + val recomposer = Recomposer(coroutineContext) + compositionContext = recomposer + runRecomposeScope.launch { + recomposer.runRecomposeAndApplyChanges() + } + + setContent { + AppMaterialTheme { + content() + } + } +} + +fun createComposeAlertDialog(context: Context, builder: AlertDialog.Builder.() -> Unit = {}, content: @Composable (alertDialog: AlertDialog) -> Unit): AlertDialog { + lateinit var alertDialog: AlertDialog + + return AlertDialog.Builder(context) + .apply(builder) + .setView(createComposeView(context) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + .clip(MaterialTheme.shapes.large), + color = MaterialTheme.colorScheme.surface + ) { + content(alertDialog) + } + }.apply { + addOnAttachStateChangeListener(object: OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + alertDialog.window?.apply { + setBackgroundDrawableResource(android.R.color.transparent) + clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM) + setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) + } + } + override fun onViewDetachedFromWindow(v: View) {} + }) + }) + .create().apply { + alertDialog = this + } +} + +private class OverlayLifecycleOwner : SavedStateRegistryOwner { + private var mLifecycleRegistry: LifecycleRegistry = LifecycleRegistry(this) + private var mSavedStateRegistryController: SavedStateRegistryController = + SavedStateRegistryController.create(this) + override val lifecycle: Lifecycle + get() = mLifecycleRegistry + override val savedStateRegistry: SavedStateRegistry + get() = mSavedStateRegistryController.savedStateRegistry + fun handleLifecycleEvent(event: Lifecycle.Event) { + mLifecycleRegistry.handleLifecycleEvent(event) + } + fun performRestore(savedState: Bundle?) { + mSavedStateRegistryController.performRestore(savedState) + } + fun performSave(outBundle: Bundle) { + mSavedStateRegistryController.performSave(outBundle) + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/Keyboard.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/Keyboard.kt new file mode 100644 index 0000000000..561ff46c22 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/Keyboard.kt @@ -0,0 +1,47 @@ +package me.rhunk.snapenhance.common.ui + + +import android.view.ViewTreeObserver +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalView +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.compose.runtime.getValue + +@Composable +fun keyboardState(): State<Boolean> { + val keyboardState = remember { mutableStateOf(false) } + val localView = LocalView.current + val viewTreeObserver = localView.viewTreeObserver + + DisposableEffect(viewTreeObserver) { + val listener = ViewTreeObserver.OnGlobalLayoutListener { + keyboardState.value = ViewCompat.getRootWindowInsets(localView) + ?.isVisible(WindowInsetsCompat.Type.ime()) != false + } + viewTreeObserver.addOnGlobalLayoutListener(listener) + onDispose { + viewTreeObserver.takeIf { it.isAlive }?.removeOnGlobalLayoutListener(listener) + } + } + + return keyboardState +} + +@Composable +fun AutoClearKeyboardFocus( + onFocusClear: () -> Unit = {} +) { + val focusManager = LocalFocusManager.current + val keyboardState by keyboardState() + + if (!keyboardState) { + onFocusClear() + focusManager.clearFocus() + } +} diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/OverlayType.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/OverlayType.kt new file mode 100644 index 0000000000..a8ff88998e --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/OverlayType.kt @@ -0,0 +1,14 @@ +package me.rhunk.snapenhance.common.ui + +enum class OverlayType( + val key: String +) { + SETTINGS("settings"), + BETTER_LOCATION("better_location"); + + companion object { + fun fromKey(key: String): OverlayType? { + return entries.find { it.key == key } + } + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/TextFieldColors.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/TextFieldColors.kt new file mode 100644 index 0000000000..6500125b71 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/TextFieldColors.kt @@ -0,0 +1,17 @@ +package me.rhunk.snapenhance.common.ui + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + + +@Composable +fun transparentTextFieldColors() = TextFieldDefaults.colors( + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + focusedContainerColor = MaterialTheme.colorScheme.surface, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + cursorColor = MaterialTheme.colorScheme.primary +) diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/Theme.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/Theme.kt new file mode 100644 index 0000000000..a73f2f2944 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/Theme.kt @@ -0,0 +1,162 @@ +package me.rhunk.snapenhance.common.ui + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext + +val md_theme_light_primary = Color(0xFF6750A4) +val md_theme_light_onPrimary = Color(0xFFFFFFFF) +val md_theme_light_primaryContainer = Color(0xFFE9DDFF) +val md_theme_light_onPrimaryContainer = Color(0xFF22005D) +val md_theme_light_secondary = Color(0xFF625B71) +val md_theme_light_onSecondary = Color(0xFFFFFFFF) +val md_theme_light_secondaryContainer = Color(0xFFE8DEF8) +val md_theme_light_onSecondaryContainer = Color(0xFF1E192B) +val md_theme_light_tertiary = Color(0xFF3C5BA9) +val md_theme_light_onTertiary = Color(0xFFFFFFFF) +val md_theme_light_tertiaryContainer = Color(0xFFDBE1FF) +val md_theme_light_onTertiaryContainer = Color(0xFF001849) +val md_theme_light_error = Color(0xFFBA1A1A) +val md_theme_light_errorContainer = Color(0xFFFFDAD6) +val md_theme_light_onError = Color(0xFFFFFFFF) +val md_theme_light_onErrorContainer = Color(0xFF410002) +val md_theme_light_background = Color(0xFFFFFBFF) +val md_theme_light_onBackground = Color(0xFF1C1B1E) +val md_theme_light_surface = Color(0xFFFFFBFF) +val md_theme_light_onSurface = Color(0xFF1C1B1E) +val md_theme_light_surfaceVariant = Color(0xFFE7E0EB) +val md_theme_light_onSurfaceVariant = Color(0xFF49454E) +val md_theme_light_outline = Color(0xFF7A757F) +val md_theme_light_inverseOnSurface = Color(0xFFF4EFF4) +val md_theme_light_inverseSurface = Color(0xFF313033) +val md_theme_light_inversePrimary = Color(0xFFCFBCFF) +val md_theme_light_surfaceTint = Color(0xFF6750A4) +val md_theme_light_outlineVariant = Color(0xFFCAC4CF) +val md_theme_light_scrim = Color(0xFF000000) + +val md_theme_dark_primary = Color(0xFFCFBCFF) +val md_theme_dark_onPrimary = Color(0xFF381E72) +val md_theme_dark_primaryContainer = Color(0xFF4F378A) +val md_theme_dark_onPrimaryContainer = Color(0xFFE9DDFF) +val md_theme_dark_secondary = Color(0xFFCBC2DB) +val md_theme_dark_onSecondary = Color(0xFF332D41) +val md_theme_dark_secondaryContainer = Color(0xFF4A4458) +val md_theme_dark_onSecondaryContainer = Color(0xFFE8DEF8) +val md_theme_dark_tertiary = Color(0xFFB3C5FF) +val md_theme_dark_onTertiary = Color(0xFF002B75) +val md_theme_dark_tertiaryContainer = Color(0xFF21428F) +val md_theme_dark_onTertiaryContainer = Color(0xFFDBE1FF) +val md_theme_dark_error = Color(0xFFFFB4AB) +val md_theme_dark_errorContainer = Color(0xFF93000A) +val md_theme_dark_onError = Color(0xFF690005) +val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) +val md_theme_dark_background = Color(0xFF1C1B1E) +val md_theme_dark_onBackground = Color(0xFFE6E1E6) +val md_theme_dark_surface = Color(0xFF1C1B1E) +val md_theme_dark_onSurface = Color(0xFFE6E1E6) +val md_theme_dark_surfaceVariant = Color(0xFF49454E) +val md_theme_dark_onSurfaceVariant = Color(0xFFCAC4CF) +val md_theme_dark_outline = Color(0xFF948F99) +val md_theme_dark_inverseOnSurface = Color(0xFF1C1B1E) +val md_theme_dark_inverseSurface = Color(0xFFE6E1E6) +val md_theme_dark_inversePrimary = Color(0xFF6750A4) +val md_theme_dark_surfaceTint = Color(0xFFCFBCFF) +val md_theme_dark_outlineVariant = Color(0xFF49454E) +val md_theme_dark_scrim = Color(0xFF000000) + + +val seed = Color(0xFF6750A4) + + +private val LightThemeColors = lightColorScheme( + primary = md_theme_light_primary, + onPrimary = md_theme_light_onPrimary, + primaryContainer = md_theme_light_primaryContainer, + onPrimaryContainer = md_theme_light_onPrimaryContainer, + secondary = md_theme_light_secondary, + onSecondary = md_theme_light_onSecondary, + secondaryContainer = md_theme_light_secondaryContainer, + onSecondaryContainer = md_theme_light_onSecondaryContainer, + tertiary = md_theme_light_tertiary, + onTertiary = md_theme_light_onTertiary, + tertiaryContainer = md_theme_light_tertiaryContainer, + onTertiaryContainer = md_theme_light_onTertiaryContainer, + error = md_theme_light_error, + onError = md_theme_light_onError, + errorContainer = md_theme_light_errorContainer, + onErrorContainer = md_theme_light_onErrorContainer, + background = md_theme_light_background, + onBackground = md_theme_light_onBackground, + surface = md_theme_light_surface, + onSurface = md_theme_light_onSurface, + surfaceVariant = md_theme_light_surfaceVariant, + onSurfaceVariant = md_theme_light_onSurfaceVariant, + outline = md_theme_light_outline, + inverseOnSurface = md_theme_light_inverseOnSurface, + inverseSurface = md_theme_light_inverseSurface, + inversePrimary = md_theme_light_inversePrimary, + surfaceTint = md_theme_light_surfaceTint, + outlineVariant = md_theme_light_outlineVariant, + scrim = md_theme_light_scrim +) + +private val DarkThemeColors = lightColorScheme( + primary = md_theme_dark_primary, + onPrimary = md_theme_dark_onPrimary, + primaryContainer = md_theme_dark_primaryContainer, + onPrimaryContainer = md_theme_dark_onPrimaryContainer, + secondary = md_theme_dark_secondary, + onSecondary = md_theme_dark_onSecondary, + secondaryContainer = md_theme_dark_secondaryContainer, + onSecondaryContainer = md_theme_dark_onSecondaryContainer, + tertiary = md_theme_dark_tertiary, + onTertiary = md_theme_dark_onTertiary, + tertiaryContainer = md_theme_dark_tertiaryContainer, + onTertiaryContainer = md_theme_dark_onTertiaryContainer, + error = md_theme_dark_error, + onError = md_theme_dark_onError, + errorContainer = md_theme_dark_errorContainer, + onErrorContainer = md_theme_dark_onErrorContainer, + background = md_theme_dark_background, + onBackground = md_theme_dark_onBackground, + surface = md_theme_dark_surface, + onSurface = md_theme_dark_onSurface, + surfaceVariant = md_theme_dark_surfaceVariant, + onSurfaceVariant = md_theme_dark_onSurfaceVariant, + outline = md_theme_dark_outline, + inverseOnSurface = md_theme_dark_inverseOnSurface, + inverseSurface = md_theme_dark_inverseSurface, + inversePrimary = md_theme_dark_inversePrimary, + surfaceTint = md_theme_dark_surfaceTint, + outlineVariant = md_theme_dark_outlineVariant, + scrim = md_theme_dark_scrim +) + +@Composable +fun AppMaterialTheme( + isDarkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + val colorScheme = when { + dynamicColor && isDarkTheme -> { + dynamicDarkColorScheme(LocalContext.current) + } + dynamicColor && !isDarkTheme -> { + dynamicLightColorScheme(LocalContext.current) + } + !isDarkTheme -> LightThemeColors + else -> DarkThemeColors + } + + MaterialTheme( + colorScheme = colorScheme, + content = content + ) +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/LazyBridge.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/LazyBridge.kt new file mode 100644 index 0000000000..0750ac85fb --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/LazyBridge.kt @@ -0,0 +1,49 @@ +package me.rhunk.snapenhance.common.util + +import android.os.IInterface + + +open class LazyBridgeValue<T: IInterface>( + private val block: () -> T, + private val isConstant: Boolean = false +): Lazy<T> { + private val lock = Any() + private var _value: T? = null + + override val value: T get() = run { + synchronized(lock) { + if (_value == null || (!isConstant && !_value!!.asBinder().pingBinder())) { + _value = block() + } + } + return _value!! + } + + override fun isInitialized(): Boolean { + return _value != null && (isConstant || _value!!.asBinder().pingBinder()) + } + + operator fun getValue(thisRef: Any?, property: Any?): T { + return value + } +} + + +fun <T : IInterface, R> mappedLazyBridge(lazyBridgeValue: LazyBridgeValue<T>, map: (T) -> R): Lazy<R> { + return object : Lazy<R> { + private var _value: T? = null + private var _mappedValue: R? = null + + override val value: R get() = run { + if (_value != lazyBridgeValue.value) { + _value = lazyBridgeValue.value + _mappedValue = map(_value!!) + } + return _mappedValue!! + } + override fun isInitialized(): Boolean = lazyBridgeValue.isInitialized() + } +} + +fun <T: IInterface> lazyBridge(block: () -> T) = LazyBridgeValue(block) +fun <T: IInterface> constantLazyBridge(value: () -> T) = LazyBridgeValue(value, isConstant = true) diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ParcelableExt.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ParcelableExt.kt new file mode 100644 index 0000000000..bca51c4e38 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ParcelableExt.kt @@ -0,0 +1,33 @@ +package me.rhunk.snapenhance.common.util + +import android.os.Parcelable +import kotlinx.parcelize.parcelableCreator +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +@OptIn(ExperimentalEncodingApi::class) +fun Parcelable.toSerialized(): String? { + val parcel = android.os.Parcel.obtain() + return try { + writeToParcel(parcel, 0) + parcel.marshall()?.let { + Base64.encode(it) + } + } finally { + parcel.recycle() + } +} + +@OptIn(ExperimentalEncodingApi::class) +inline fun <reified T : Parcelable> toParcelable(serialized: String): T? { + val parcel = android.os.Parcel.obtain() + return try { + Base64.decode(serialized).let { + parcel.unmarshall(it, 0, it.size) + } + parcel.setDataPosition(0) + parcelableCreator<T>().createFromParcel(parcel) + } finally { + parcel.recycle() + } +} diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/Purge.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/Purge.kt new file mode 100644 index 0000000000..3807b4c41f --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/Purge.kt @@ -0,0 +1,24 @@ +package me.rhunk.snapenhance.common.util + +val PURGE_VALUES = arrayOf("1_hour", "3_hours", "6_hours", "12_hours", "1_day", "3_days", "1_week", "2_weeks", "1_month", "3_months", "6_months") +const val PURGE_TRANSLATION_KEY = "features.options.auto_purge" +const val PURGE_DISABLED_KEY = "never" + +fun getPurgeTime( + value: String? +): Long? { + return when (value) { + "1_hour" -> 3600000L + "3_hours" -> 10800000L + "6_hours" -> 21600000L + "12_hours" -> 43200000L + "1_day" -> 86400000L + "3_days" -> 259200000L + "1_week" -> 604800000L + "2_weeks" -> 1209600000L + "1_month" -> 2592000000L + "3_months" -> 7776000000L + "6_months" -> 15552000000L + else -> null + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/SQLiteDatabaseHelper.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/SQLiteDatabaseHelper.kt new file mode 100644 index 0000000000..e7e552c2e8 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/SQLiteDatabaseHelper.kt @@ -0,0 +1,31 @@ +package me.rhunk.snapenhance.common.util + +import android.annotation.SuppressLint +import android.database.sqlite.SQLiteDatabase +import me.rhunk.snapenhance.common.logger.AbstractLogger + +object SQLiteDatabaseHelper { + @SuppressLint("Range") + fun createTablesFromSchema(sqLiteDatabase: SQLiteDatabase, databaseSchema: Map<String, List<String>>) { + databaseSchema.forEach { (tableName, columns) -> + sqLiteDatabase.execSQL("CREATE TABLE IF NOT EXISTS $tableName (${columns.joinToString(", ")})") + + val cursor = sqLiteDatabase.rawQuery("PRAGMA table_info($tableName)", null) + val existingColumns = mutableListOf<String>() + while (cursor.moveToNext()) { + existingColumns.add(cursor.getString(cursor.getColumnIndex("name")) + " " + cursor.getString(cursor.getColumnIndex("type"))) + } + cursor.close() + + val newColumns = columns.filter { + existingColumns.none { existingColumn -> it.startsWith(existingColumn) } + } + + if (newColumns.isEmpty()) return@forEach + + AbstractLogger.directDebug("Schema for table $tableName has changed") + sqLiteDatabase.execSQL("DROP TABLE $tableName") + sqLiteDatabase.execSQL("CREATE TABLE IF NOT EXISTS $tableName (${columns.joinToString(", ")})") + } + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/AndroidCompatExtensions.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/AndroidCompatExtensions.kt new file mode 100644 index 0000000000..984b17054f --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/AndroidCompatExtensions.kt @@ -0,0 +1,72 @@ +package me.rhunk.snapenhance.common.util.ktx + +import android.content.ClipData +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.PackageManager.ApplicationInfoFlags +import android.os.Build +import android.os.ParcelFileDescriptor +import android.widget.Toast +import androidx.core.net.toUri +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.InputStream + +fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int) = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getApplicationInfo(packageName, ApplicationInfoFlags.of(flags.toLong())) + } else { + @Suppress("DEPRECATION") + getApplicationInfo(packageName, flags) + } + +fun Context.copyToClipboard(data: String, label: String = "Copied Text") { + runCatching { + getSystemService(android.content.ClipboardManager::class.java).setPrimaryClip( + ClipData.newPlainText(label, data)) + } +} + +fun Context.getTextFromClipboard(): String? { + return runCatching { + getSystemService(android.content.ClipboardManager::class.java).primaryClip + ?.takeIf { it.itemCount > 0 } + ?.getItemAt(0) + ?.text?.toString() + }.getOrNull() +} + +fun Context.getUrlFromClipboard(): String? { + return getTextFromClipboard()?.takeIf { it.startsWith("http") } +} + +fun Context.openLink(url: String, shouldThrow: Boolean = false) { + runCatching { + startActivity(Intent(Intent.ACTION_VIEW).apply { + data = url.toUri() + flags = Intent.FLAG_ACTIVITY_NEW_TASK + }) + }.onFailure { + if (shouldThrow) throw it + Toast.makeText(this, "Failed to open link", Toast.LENGTH_SHORT).show() + } +} + +fun InputStream.toParcelFileDescriptor(coroutineScope: CoroutineScope): ParcelFileDescriptor { + val pfd = ParcelFileDescriptor.createPipe() + val fos = ParcelFileDescriptor.AutoCloseOutputStream(pfd[1]) + + coroutineScope.launch(Dispatchers.IO) { + try { + copyTo(fos) + } finally { + close() + fos.flush() + fos.close() + } + } + + return pfd[0] +} diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/DbCursorExt.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/DbCursorExt.kt new file mode 100644 index 0000000000..8a6715921b --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/DbCursorExt.kt @@ -0,0 +1,37 @@ +package me.rhunk.snapenhance.common.util.ktx + +import android.database.Cursor + +fun Cursor.getStringOrNull(columnName: String): String? { + val columnIndex = getColumnIndex(columnName) + return if (columnIndex == -1) null else getString(columnIndex) +} + +fun Cursor.getIntOrNull(columnName: String): Int? { + val columnIndex = getColumnIndex(columnName) + return if (columnIndex == -1) null else getInt(columnIndex) +} + +fun Cursor.getInteger(columnName: String) = getIntOrNull(columnName) ?: throw NullPointerException("Column $columnName is null") +fun Cursor.getLong(columnName: String) = getLongOrNull(columnName) ?: throw NullPointerException("Column $columnName is null") + +fun Cursor.getBlobOrNull(columnName: String): ByteArray? { + val columnIndex = getColumnIndex(columnName) + return if (columnIndex == -1) null else getBlob(columnIndex) +} + + +fun Cursor.getLongOrNull(columnName: String): Long? { + val columnIndex = getColumnIndex(columnName) + return if (columnIndex == -1) null else getLong(columnIndex) +} + +fun Cursor.getDoubleOrNull(columnName: String): Double? { + val columnIndex = getColumnIndex(columnName) + return if (columnIndex == -1) null else getDouble(columnIndex) +} + +fun Cursor.getFloatOrNull(columnName: String): Float? { + val columnIndex = getColumnIndex(columnName) + return if (columnIndex == -1) null else getFloat(columnIndex) +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/JavaExt.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/JavaExt.kt new file mode 100644 index 0000000000..efbe8e2822 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/JavaExt.kt @@ -0,0 +1,42 @@ +package me.rhunk.snapenhance.common.util.ktx + +import java.lang.reflect.Field +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type + +fun String.longHashCode(): Long { + var h = 1125899906842597L + for (element in this) h = 31 * h + element.code.toLong() + return h +} + +inline fun Class<*>.findFields(once: Boolean, crossinline predicate: (field: Field) -> Boolean): List<Field>{ + var clazz: Class<*>? = this + val fields = mutableListOf<Field>() + + while (clazz != null) { + if (once) { + clazz.declaredFields.firstOrNull(predicate)?.let { return listOf(it) } + } else { + fields.addAll(clazz.declaredFields.filter(predicate)) + } + clazz = clazz.superclass ?: break + } + + return fields +} + +inline fun Class<*>.findFieldsToString(instance: Any? = null, once: Boolean = false, crossinline predicate: (field: Field, value: String) -> Boolean): List<Field> { + return this.findFields(once = once) { + try { + it.isAccessible = true + return@findFields it.get(instance)?.let { it1 -> predicate(it, it1.toString()) } == true + } catch (e: Throwable) { + return@findFields false + } + } +} + +fun Type.getTypeArguments(): List<Class<*>> { + return (this as? ParameterizedType)?.actualTypeArguments?.mapNotNull { it as? Class<*> } ?: emptyList() +} diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/OkHttp.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/OkHttp.kt new file mode 100644 index 0000000000..1560c39bb8 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/OkHttp.kt @@ -0,0 +1,29 @@ +package me.rhunk.snapenhance.common.util.ktx + +import kotlinx.coroutines.CompletionHandler +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Response +import okio.IOException +import kotlin.coroutines.resumeWithException + +suspend inline fun Call.await(): Response { + return suspendCancellableCoroutine { continuation -> + val callback = object: CompletionHandler, Callback { + override fun invoke(cause: Throwable?) { + runCatching { cancel() } + } + + override fun onFailure(call: Call, e: IOException) { + continuation.resumeWithException(e) + } + + override fun onResponse(call: Call, response: Response) { + continuation.resumeWith(runCatching { response }) + } + } + enqueue(callback) + continuation.invokeOnCancellation(callback) + } +} diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/protobuf/GrpcReader.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/protobuf/GrpcReader.kt new file mode 100644 index 0000000000..10d79c7647 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/protobuf/GrpcReader.kt @@ -0,0 +1,58 @@ +package me.rhunk.snapenhance.common.util.protobuf + +import org.mozilla.javascript.annotations.JSFunction + +class GrpcReader( + private val buffer: ByteArray +) { + private val _messages = mutableListOf<ProtoReader>() + private val _headers = mutableMapOf<String, String>() + + @get:JSFunction + val headers get() = _headers.toMap() + @get:JSFunction + val messages get() = _messages.toList() + + @JSFunction + fun read(reader: ProtoReader.() -> Unit) { + messages.forEach { message -> + message.reader() + } + } + + private var position: Int = 0 + + init { + read() + } + + private fun readByte() = buffer[position++].toInt() + + private fun readUInt32() = (readByte() and 0xFF) shl 24 or + ((readByte() and 0xFF) shl 16) or + ((readByte() and 0xFF) shl 8) or + (readByte() and 0xFF) + + private fun read() { + while (position < buffer.size) { + when (val type = readByte() and 0xFF) { + 0 -> { + val length = readUInt32() + val value = buffer.copyOfRange(position, position + length) + position += length + _messages.add(ProtoReader(value)) + } + 128 -> { + val length = readUInt32() + val rawHeaders = String(buffer.copyOfRange(position, position + length), Charsets.UTF_8) + position += length + rawHeaders.trim().split("\n").forEach { header -> + val (key, value) = header.split(":") + _headers[key] = value + } + } + else -> throw Exception("Unknown type $type") + } + } + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/protobuf/GrpcWriter.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/protobuf/GrpcWriter.kt new file mode 100644 index 0000000000..0b5b9b65c6 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/protobuf/GrpcWriter.kt @@ -0,0 +1,46 @@ +package me.rhunk.snapenhance.common.util.protobuf + +import org.mozilla.javascript.annotations.JSFunction +import java.io.ByteArrayOutputStream + +fun ProtoWriter.toGrpcWriter() = GrpcWriter(toByteArray()) + +class GrpcWriter( + vararg val messages: ByteArray +) { + private val headers = mutableMapOf<String, String>() + + @JSFunction + fun addHeader(key: String, value: String) { + headers[key] = value + } + + @JSFunction + fun toByteArray(): ByteArray { + val stream = ByteArrayOutputStream() + + fun writeByte(value: Int) = stream.write(value) + fun writeUInt(value: Int) { + writeByte(value ushr 24) + writeByte(value ushr 16) + writeByte(value ushr 8) + writeByte(value) + } + + messages.forEach { message -> + writeByte(0) + writeUInt(message.size) + stream.write(message) + } + + if (headers.isNotEmpty()){ + val rawHeaders = headers.map { (key, value) -> "$key:$value" }.joinToString("\n") + val rawHeadersBytes = rawHeaders.toByteArray(Charsets.UTF_8) + writeByte(-128) + writeUInt(rawHeadersBytes.size) + stream.write(rawHeadersBytes) + } + + return stream.toByteArray() + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/protobuf/ProtoEditor.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/protobuf/ProtoEditor.kt new file mode 100644 index 0000000000..15821da9a0 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/protobuf/ProtoEditor.kt @@ -0,0 +1,126 @@ +package me.rhunk.snapenhance.common.util.protobuf + +import org.mozilla.javascript.annotations.JSFunction + + +typealias WireCallback = EditorContext.() -> Unit + +class EditorContext( + private val wires: MutableMap<Int, MutableList<Wire>> +) { + @JSFunction + fun clear() { + wires.clear() + } + @JSFunction + fun addWire(wire: Wire) { + wires.getOrPut(wire.id) { mutableListOf() }.add(wire) + } + @JSFunction + fun addVarInt(id: Int, value: Int) = addVarInt(id, value.toLong()) + @JSFunction + fun addVarInt(id: Int, value: Long) = addWire(Wire(id, WireType.VARINT, value)) + @JSFunction + fun addBuffer(id: Int, value: ByteArray) = addWire(Wire(id, WireType.CHUNK, value)) + @JSFunction + fun add(id: Int, content: ProtoWriter.() -> Unit) = addBuffer(id, ProtoWriter().apply(content).toByteArray()) + @JSFunction + fun addString(id: Int, value: String) = addBuffer(id, value.toByteArray()) + @JSFunction + fun addFixed64(id: Int, value: Long) = addWire(Wire(id, WireType.FIXED64, value)) + @JSFunction + fun addFixed32(id: Int, value: Float) = addWire(Wire(id, WireType.FIXED32, value.toRawBits())) + + @JSFunction + fun firstOrNull(id: Int) = wires[id]?.firstOrNull() + @JSFunction + fun getOrNull(id: Int) = wires[id] + @JSFunction + fun get(id: Int) = wires[id]!! + + @JSFunction + fun remove(id: Int) = wires.remove(id) + @JSFunction + fun remove(id: Int, index: Int) = wires[id]?.removeAt(index) + + @JSFunction + fun edit(id: Int, callback: EditorContext.() -> Unit) { + val wire = wires[id]?.firstOrNull() ?: return + val editor = ProtoEditor(wire.value as ByteArray) + editor.edit { + callback() + } + remove(id) + addBuffer(id, editor.toByteArray()) + } + + @JSFunction + fun editEach(id: Int, callback: EditorContext.() -> Unit) { + val wires = wires[id] ?: return + val newWires = mutableListOf<Wire>() + wires.toList().forEachIndexed { _, wire -> + val editor = ProtoEditor(wire.value as ByteArray) + editor.edit { + callback() + } + newWires.add(Wire(wire.id, WireType.CHUNK, editor.toByteArray())) + } + wires.clear() + wires.addAll(newWires) + } + + fun removeIf(id: Int, predicate: (Wire) -> Boolean) { + wires[id]?.removeIf { + predicate(it) + } + } + + override fun toString(): String { + return ProtoWriter().apply { + wires.values.flatten().forEach { addWire(it) } + }.toByteArray().let { ProtoReader(it).toString() } + } +} + +class ProtoEditor( + private var buffer: ByteArray +) { + @JSFunction + fun edit(vararg path: Int, callback: WireCallback) { + buffer = writeAtPath(path, 0, ProtoReader(buffer), callback) + } + + private fun writeAtPath(path: IntArray, currentIndex: Int, rootReader: ProtoReader, wireToWriteCallback: WireCallback): ByteArray { + val id = path.getOrNull(currentIndex) + val output = ProtoWriter() + val wires = sortedMapOf<Int, MutableList<Wire>>() + + rootReader.forEach { wireId, value -> + wires.putIfAbsent(wireId, mutableListOf()) + if (id != null && wireId == id) { + val childReader = rootReader.followPath(id) + if (childReader == null) { + wires.getOrPut(wireId) { mutableListOf() }.add(value) + return@forEach + } + wires[wireId]!!.add(Wire(wireId, WireType.CHUNK, writeAtPath(path, currentIndex + 1, childReader, wireToWriteCallback))) + return@forEach + } + wires[wireId]!!.add(value) + } + + if (currentIndex == path.size) { + wireToWriteCallback(EditorContext(wires)) + } + + wires.values.flatten().forEach(output::addWire) + + return output.toByteArray() + } + + @JSFunction + fun toByteArray() = buffer + + @JSFunction + override fun toString() = ProtoReader(buffer).toString() +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/protobuf/ProtoReader.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/protobuf/ProtoReader.kt new file mode 100644 index 0000000000..abbf9d4670 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/protobuf/ProtoReader.kt @@ -0,0 +1,277 @@ +package me.rhunk.snapenhance.common.util.protobuf + +import org.mozilla.javascript.annotations.JSFunction +import java.nio.ByteBuffer +import java.util.UUID + +data class Wire(val id: Int, val type: WireType, val value: Any) { + @JSFunction + fun toReader() = ProtoReader(value as ByteArray) +} + +class ProtoReader(private val buffer: ByteArray) { + private var offset: Int = 0 + private val values = mutableMapOf<Int, MutableList<Wire>>() + + init { + read() + } + + @JSFunction + fun getBuffer() = buffer + + private fun readByte() = buffer[offset++] + + private fun readVarInt(): Long { + var result = 0L + var shift = 0 + while (true) { + val b = readByte() + result = result or ((b.toLong() and 0x7F) shl shift) + if (b.toInt() and 0x80 == 0) { + break + } + shift += 7 + } + return result + } + + private fun read() { + while (offset < buffer.size) { + try { + val tag = readVarInt().toInt() + val id = tag ushr 3 + val type = WireType.fromValue(tag and 0x7) ?: break + val value = when (type) { + WireType.VARINT -> readVarInt() + WireType.FIXED64 -> { + val bytes = ByteArray(8) + for (i in 0..7) { + bytes[i] = readByte() + } + bytes + } + WireType.CHUNK -> { + val length = readVarInt().toInt() + val bytes = ByteArray(length) + for (i in 0 until length) { + bytes[i] = readByte() + } + bytes + } + WireType.START_GROUP -> { + val bytes = mutableListOf<Byte>() + while (true) { + val b = readByte() + if (b.toInt() == WireType.END_GROUP.value) { + break + } + bytes.add(b) + } + bytes.toByteArray() + } + WireType.FIXED32 -> { + val bytes = ByteArray(4) + for (i in 0..3) { + bytes[i] = readByte() + } + bytes + } + WireType.END_GROUP -> continue + } + values.getOrPut(id) { mutableListOf() }.add(Wire(id, type, value)) + } catch (t: Throwable) { + values.clear() + break + } + } + } + + @JSFunction + fun followPath(vararg ids: Int, excludeLast: Boolean = false, reader: (ProtoReader.() -> Unit)? = null): ProtoReader? { + var thisReader = this + ids.let { + if (excludeLast) { + it.sliceArray(0 until it.size - 1) + } else { + it + } + }.forEach { id -> + if (!thisReader.contains(id)) { + return null + } + thisReader = ProtoReader(thisReader.getByteArray(id) ?: return null) + } + if (reader != null) { + thisReader.reader() + } + return thisReader + } + + @JSFunction + fun containsPath(vararg ids: Int): Boolean { + var thisReader = this + ids.forEach { id -> + if (!thisReader.contains(id)) { + return false + } + thisReader = ProtoReader(thisReader.getByteArray(id) ?: return false) + } + return true + } + + @JSFunction + fun forEach(reader: (Int, Wire) -> Unit) { + values.forEach { (id, wires) -> + wires.forEach { wire -> + reader(id, wire) + } + } + } + + @JSFunction + fun forEach(vararg id: Int, reader: ProtoReader.() -> Unit) { + followPath(*id)?.eachBuffer { _, buffer -> + ProtoReader(buffer).reader() + } + } + + @JSFunction + fun eachBuffer(vararg ids: Int, reader: ProtoReader.() -> Unit) { + followPath(*ids, excludeLast = true)?.eachBuffer { id, buffer -> + if (id == ids.last()) { + ProtoReader(buffer).reader() + } + } + } + + @JSFunction + fun eachBuffer(reader: (Int, ByteArray) -> Unit) { + values.forEach { (id, wires) -> + wires.forEach { wire -> + if (wire.type == WireType.CHUNK) { + reader(id, wire.value as ByteArray) + } + } + } + } + + @JSFunction + fun contains(id: Int) = values.containsKey(id) + + @JSFunction + fun getWire(id: Int) = values[id]?.firstOrNull() + @JSFunction + fun getRawValue(id: Int) = getWire(id)?.value + @JSFunction + fun getByteArray(id: Int) = getRawValue(id) as? ByteArray + @JSFunction + fun getByteArray(vararg ids: Int) = followPath(*ids, excludeLast = true)?.getByteArray(ids.last()) + @JSFunction + fun getString(id: Int) = getByteArray(id)?.toString(Charsets.UTF_8) + @JSFunction + fun getString(vararg ids: Int) = followPath(*ids, excludeLast = true)?.getString(ids.last()) + @JSFunction + fun getVarInt(id: Int) = getRawValue(id) as? Long + @JSFunction + fun getVarInt(vararg ids: Int) = followPath(*ids, excludeLast = true)?.getVarInt(ids.last()) + @JSFunction + fun getCount(id: Int) = values[id]?.size ?: 0 + + @JSFunction + fun getFixed64(id: Int): Long { + val bytes = getByteArray(id) ?: return 0L + var value = 0L + for (i in 0..7) { + value = value or ((bytes[i].toLong() and 0xFF) shl (i * 8)) + } + return value + } + @JSFunction + fun getFixed64(vararg ids: Int) = followPath(*ids, excludeLast = true)?.getFixed64(ids.last()) + + + @JSFunction + fun getFixed32(id: Int): Int? { + val bytes = getByteArray(id) ?: return null + var value = 0 + for (i in 0..3) { + value = value or ((bytes[i].toInt() and 0xFF) shl (i * 8)) + } + return value + } + + @JSFunction + fun getFixed32(vararg ids: Int) = followPath(*ids, excludeLast = true)?.getFixed32(ids.last()) + + private fun prettyPrint(tabSize: Int): String { + val tabLine = " ".repeat(tabSize) + val stringBuilder = StringBuilder() + values.forEach v@{ (id, wires) -> + wires.forEach { wire -> + stringBuilder.append(tabLine) + stringBuilder.append("$id <${wire.type.name.lowercase()}> = ") + when (wire.type) { + WireType.VARINT -> stringBuilder.append("${wire.value}\n") + WireType.FIXED64, WireType.FIXED32 -> { + val byteBuffer = ByteBuffer.wrap(wire.value as ByteArray).order(java.nio.ByteOrder.LITTLE_ENDIAN) + val hexValue = wire.value.joinToString("") { byte -> "%02x".format(byte) } + val intValue = if (wire.type == WireType.FIXED32) byteBuffer.int else byteBuffer.long + byteBuffer.position(0) + val decimalValue = if (wire.type == WireType.FIXED32) byteBuffer.float else byteBuffer.double + stringBuilder.append("$intValue/0x$hexValue/$decimalValue\n") + } + WireType.CHUNK -> { + val array = (wire.value as? ByteArray) ?: return@forEach + + fun printArray() { + // auto detect uuids + if (array.size == 16) { + val longs = LongArray(2) + for (i in 0 .. 7) { + longs[0] = longs[0] or ((array[i].toLong() and 0xFF) shl ((7 - i) * 8)) + } + for (i in 8 .. 15) { + longs[1] = longs[1] or ((array[i].toLong() and 0xFF) shl ((15 - i) * 8)) + } + stringBuilder.append("uuid: ${UUID(longs[0], longs[1])}\n") + return + } + + //auto detect ascii strings + if (array.all { it in (0x20..0x7E) || it == 0x0A.toByte() || it == 0x0D.toByte() }) { + stringBuilder.append("string: ${array.toString(Charsets.UTF_8)}\n") + return + } + + stringBuilder.append("\n") + stringBuilder.append("$tabLine ") + stringBuilder.append(array.joinToString(" ") { byte -> "%02x".format(byte) }) + stringBuilder.append("\n") + } + + runCatching { + if (array.isEmpty()) { + stringBuilder.append("empty\n") + return@runCatching + } + + ProtoReader(array).prettyPrint(tabSize + 1).takeIf { it.isNotEmpty() }?.let { + stringBuilder.append("message:\n") + stringBuilder.append(it) + } ?: printArray() + }.onFailure { + printArray() + } + } + else -> stringBuilder.append("unknown\n") + } + } + } + + return stringBuilder.toString() + } + + @JSFunction + override fun toString() = prettyPrint(0) +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/protobuf/ProtoWriter.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/protobuf/ProtoWriter.kt new file mode 100644 index 0000000000..b820170407 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/protobuf/ProtoWriter.kt @@ -0,0 +1,128 @@ +package me.rhunk.snapenhance.common.util.protobuf + +import org.mozilla.javascript.annotations.JSFunction +import java.io.ByteArrayOutputStream + +class ProtoWriter { + private val stream: ByteArrayOutputStream = ByteArrayOutputStream() + + private fun writeVarInt(value: Int) { + var v = value + while (v and -0x80 != 0) { + stream.write(v and 0x7F or 0x80) + v = v ushr 7 + } + stream.write(v) + } + + private fun writeVarLong(value: Long) { + var v = value + while (v and -0x80L != 0L) { + stream.write((v and 0x7FL or 0x80L).toInt()) + v = v ushr 7 + } + stream.write(v.toInt()) + } + + @JSFunction + fun addBuffer(id: Int, value: ByteArray) { + writeVarInt(id shl 3 or WireType.CHUNK.value) + writeVarInt(value.size) + stream.write(value) + } + + @JSFunction + fun addVarInt(id: Int, value: Int) = addVarInt(id, value.toLong()) + + @JSFunction + fun addVarInt(id: Int, value: Long) { + writeVarInt(id shl 3) + writeVarLong(value) + } + + @JSFunction + fun addString(id: Int, value: String) = addBuffer(id, value.toByteArray()) + + @JSFunction + fun addFixed32(id: Int, value: Int) { + writeVarInt(id shl 3 or WireType.FIXED32.value) + val bytes = ByteArray(4) + for (i in 0..3) { + bytes[i] = (value shr (i * 8)).toByte() + } + stream.write(bytes) + } + + @JSFunction + fun addFixed64(id: Int, value: Long) { + writeVarInt(id shl 3 or WireType.FIXED64.value) + val bytes = ByteArray(8) + for (i in 0..7) { + bytes[i] = (value shr (i * 8)).toByte() + } + stream.write(bytes) + } + + @JSFunction + fun from(id: Int, writer: ProtoWriter.() -> Unit) { + val writerStream = ProtoWriter() + writer(writerStream) + addBuffer(id, writerStream.stream.toByteArray()) + } + + @JSFunction + fun from(vararg ids: Int, writer: ProtoWriter.() -> Unit) { + val writerStream = ProtoWriter() + writer(writerStream) + var stream = writerStream.stream.toByteArray() + ids.reversed().forEach { id -> + with(ProtoWriter()) { + addBuffer(id, stream) + stream = this.stream.toByteArray() + } + } + stream.let(this.stream::write) + } + + @JSFunction + fun addWire(wire: Wire) { + writeVarInt(wire.id shl 3 or wire.type.value) + when (wire.type) { + WireType.VARINT -> writeVarLong(wire.value as Long) + WireType.FIXED64, WireType.FIXED32 -> { + when (wire.value) { + is Int -> { + val bytes = ByteArray(4) + for (i in 0..3) { + bytes[i] = (wire.value shr (i * 8)).toByte() + } + stream.write(bytes) + } + is Long -> { + val bytes = ByteArray(8) + for (i in 0..7) { + bytes[i] = (wire.value shr (i * 8)).toByte() + } + stream.write(bytes) + } + is ByteArray -> stream.write(wire.value) + } + } + WireType.CHUNK -> { + val value = wire.value as ByteArray + writeVarInt(value.size) + stream.write(value) + } + WireType.START_GROUP -> { + val value = wire.value as ByteArray + stream.write(value) + } + WireType.END_GROUP -> return + } + } + + @JSFunction + fun toByteArray(): ByteArray { + return stream.toByteArray() + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/protobuf/WireType.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/protobuf/WireType.kt new file mode 100644 index 0000000000..09f39dce93 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/protobuf/WireType.kt @@ -0,0 +1,14 @@ +package me.rhunk.snapenhance.common.util.protobuf; + +enum class WireType(val value: Int) { + VARINT(0), + FIXED64(1), + CHUNK(2), + START_GROUP(3), + END_GROUP(4), + FIXED32(5); + + companion object { + fun fromValue(value: Int) = entries.firstOrNull { it.value == value } + } +} diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/BitmojiSelfie.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/BitmojiSelfie.kt new file mode 100644 index 0000000000..98de28f6c4 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/BitmojiSelfie.kt @@ -0,0 +1,22 @@ +package me.rhunk.snapenhance.common.util.snap + +object BitmojiSelfie { + enum class BitmojiSelfieType( + val prefixUrl: String, + ) { + STANDARD("https://sdk.bitmoji.com/render/panel/"), + THREE_D("https://images.bitmoji.com/3d/render/"), + NEW_THREE_D("https://images.bitmoji.com/3d/render/"), + } + + fun getBitmojiSelfie(selfieId: String?, avatarId: String?, type: BitmojiSelfieType): String? { + if (selfieId.isNullOrEmpty() || avatarId.isNullOrEmpty()) { + return null + } + return when (type) { + BitmojiSelfieType.STANDARD -> "${type.prefixUrl}$selfieId-$avatarId-v1.webp?transparent=1" + BitmojiSelfieType.THREE_D -> "${type.prefixUrl}$selfieId-$avatarId-v1.webp?trim=circle" + BitmojiSelfieType.NEW_THREE_D -> "${type.prefixUrl}$selfieId-$avatarId-v1.webp?trim=circle&ua=2" + } + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/MediaDownloaderHelper.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/MediaDownloaderHelper.kt new file mode 100644 index 0000000000..6e9d206fb5 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/MediaDownloaderHelper.kt @@ -0,0 +1,45 @@ +package me.rhunk.snapenhance.common.util.snap + +import me.rhunk.snapenhance.common.data.FileType +import me.rhunk.snapenhance.common.data.download.SplitMediaAssetType +import java.io.BufferedInputStream +import java.io.InputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream + + +object MediaDownloaderHelper { + fun getFileType(bufferedInputStream: BufferedInputStream): FileType { + val buffer = ByteArray(16) + bufferedInputStream.mark(16) + bufferedInputStream.read(buffer) + bufferedInputStream.reset() + return FileType.fromByteArray(buffer) + } + + + fun getSplitElements( + inputStream: InputStream, + callback: (SplitMediaAssetType, InputStream) -> Unit + ) { + val bufferedInputStream = inputStream.buffered() + val fileType = getFileType(bufferedInputStream) + + if (fileType != FileType.ZIP) { + callback(SplitMediaAssetType.ORIGINAL, bufferedInputStream) + return + } + + ZipInputStream(bufferedInputStream).use { zipInputStream -> + var entry: ZipEntry? = zipInputStream.nextEntry + while (entry != null) { + if (entry.name.startsWith("overlay")) { + callback(SplitMediaAssetType.OVERLAY, zipInputStream) + } else if (entry.name.startsWith("media")) { + callback(SplitMediaAssetType.ORIGINAL, zipInputStream) + } + entry = zipInputStream.nextEntry + } + } + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/RemoteMediaResolver.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/RemoteMediaResolver.kt new file mode 100644 index 0000000000..f2c89600ea --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/RemoteMediaResolver.kt @@ -0,0 +1,58 @@ +package me.rhunk.snapenhance.common.util.snap + +import me.rhunk.snapenhance.common.Constants +import me.rhunk.snapenhance.common.util.ktx.await +import okhttp3.Headers +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.InputStream +import java.util.Base64 + +object RemoteMediaResolver { + const val CF_ST_CDN_D = "https://cf-st.sc-cdn.net/d/" + + val okHttpClient = OkHttpClient.Builder() + .followRedirects(true) + .retryOnConnectionFailure(true) + .readTimeout(20, java.util.concurrent.TimeUnit.SECONDS) + .build() + + fun newResolveRequest(protoKey: ByteArray): Request { + return Request.Builder() + .url("https://gcp.api.snapchat.com/bolt-http/resolve?co=" + Base64.getUrlEncoder().encodeToString(protoKey)) + .addHeader("User-Agent", Constants.USER_AGENT) + .build() + } + + suspend inline fun downloadMedia(url: String, decryptionCallback: (InputStream) -> InputStream = { it }, result: (InputStream, Long) -> Unit) { + okHttpClient.newCall(Request.Builder().url(url).build()).await().use { response -> + if (!response.isSuccessful) { + throw Throwable("invalid response ${response.code}") + } + result(decryptionCallback(response.body.byteStream()), response.body.contentLength()) + } + } + + suspend inline fun downloadBoltMedia( + protoKey: ByteArray, + decryptionCallback: (InputStream) -> InputStream = { it }, + resultCallback: (stream: InputStream, length: Long) -> Unit + ) { + okHttpClient.newCall(newResolveRequest(protoKey)).await().use { response -> + if (!response.isSuccessful) { + throw Throwable("invalid response ${response.code}") + } + resultCallback( + decryptionCallback( + response.body.byteStream() + ), + response.body.contentLength() + ) + } + } + + fun getMediaHeaders(protoKey: ByteArray): Headers { + val request = newResolveRequest(protoKey) + return okHttpClient.newCall(request.newBuilder().method("HEAD", null).build()).execute().headers + } +} diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/SnapWidgetBroadcastReceiverHelper.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/SnapWidgetBroadcastReceiverHelper.kt new file mode 100644 index 0000000000..f0b5a65baf --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/SnapWidgetBroadcastReceiverHelper.kt @@ -0,0 +1,24 @@ +package me.rhunk.snapenhance.common.util.snap + +import android.content.Intent +import me.rhunk.snapenhance.common.Constants + +object SnapWidgetBroadcastReceiverHelper { + private const val ACTION_WIDGET_UPDATE = "com.snap.android.WIDGET_APP_START_UPDATE_ACTION" + const val CLASS_NAME = "com.snap.widgets.core.BestFriendsWidgetProvider" + + fun create(targetAction: String, callback: Intent.() -> Unit): Intent { + with(Intent()) { + callback(this) + action = ACTION_WIDGET_UPDATE + putExtra(":)", true) + putExtra("action", targetAction) + setClassName(Constants.SNAPCHAT_PACKAGE_NAME, CLASS_NAME) + return this + } + } + + fun isIncomingIntentValid(intent: Intent): Boolean { + return intent.action == ACTION_WIDGET_UPDATE && intent.getBooleanExtra(":)", false) + } +} \ No newline at end of file diff --git a/composer/.gitignore b/composer/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/composer/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/composer/build.gradle.kts b/composer/build.gradle.kts new file mode 100644 index 0000000000..a9de74e0fc --- /dev/null +++ b/composer/build.gradle.kts @@ -0,0 +1,45 @@ +import org.apache.tools.ant.taskdefs.condition.Os +plugins { + alias(libs.plugins.androidLibrary) + alias(libs.plugins.kotlinAndroid) +} + +android { + namespace = rootProject.ext["applicationId"].toString() + ".composer" + compileSdk = 34 + + sourceSets { + getByName("main") { + assets.srcDirs("build/assets") + } + } +} + +task("compileTypeScript") { + doLast { + if (Os.isFamily(Os.FAMILY_WINDOWS)) { + project.exec { + commandLine("npx.cmd", "--yes", "tsc", "--project", "tsconfig.json") + } + project.exec { + commandLine("npx.cmd", "--yes", "rollup", "--config", "rollup.config.js", "--bundleConfigAsCjs") + } + } else { + project.exec { + commandLine("npx", "--yes", "tsc", "--project", "tsconfig.json") + } + project.exec { + commandLine("npx", "--yes", "rollup", "--config", "rollup.config.js", "--bundleConfigAsCjs") + } + } + + project.copy { + from("build/loader.js") + into("build/assets/composer") + } + } +} + +tasks.named("preBuild").configure { + dependsOn("compileTypeScript") +} \ No newline at end of file diff --git a/composer/rollup.config.js b/composer/rollup.config.js new file mode 100644 index 0000000000..9abfff4038 --- /dev/null +++ b/composer/rollup.config.js @@ -0,0 +1,7 @@ +export default { + input: "./build/typescript/main.js", + output: { + file: "./build/loader.js", + format: "iife", + } +}; \ No newline at end of file diff --git a/composer/src/main/ts/composer.ts b/composer/src/main/ts/composer.ts new file mode 100644 index 0000000000..01ed38eda7 --- /dev/null +++ b/composer/src/main/ts/composer.ts @@ -0,0 +1,6 @@ +import { runtimeName } from "./imports" + +export const jsx = require(runtimeName + '_core/src/JSX').jsx; +export const assetCatalog = require(runtimeName + "_core/src/AssetCatalog") +export const style = require(runtimeName + "_core/src/Style"); +export const colors = require("coreui/src/styles/semanticColors"); diff --git a/composer/src/main/ts/imports.ts b/composer/src/main/ts/imports.ts new file mode 100644 index 0000000000..b70ff47bd0 --- /dev/null +++ b/composer/src/main/ts/imports.ts @@ -0,0 +1,22 @@ +import { Config } from "./types"; + +declare var _getImportsFunctionName: string; +declare var _runtimeName: boolean; +export const runtimeName = _runtimeName; + +const remoteImports = require(_runtimeName + '_core/src/DeviceBridge')[_getImportsFunctionName](); + +function callRemoteFunction(method: string, ...args: any[]): any | null { + return remoteImports[method](...args); +} + + +export const log = (logLevel: string, message: string) => callRemoteFunction("log", logLevel, message); + +export const getConfig = () => callRemoteFunction("getConfig") as Config; + +export const downloadLastOperaMedia = (isLongPress: boolean) => callRemoteFunction("downloadLastOperaMedia", isLongPress); + +export function getFriendOriginalUsername(username: string): string | null { + return callRemoteFunction("getFriendOriginalUsername", username); +} diff --git a/composer/src/main/ts/main.ts b/composer/src/main/ts/main.ts new file mode 100644 index 0000000000..afcb27e23c --- /dev/null +++ b/composer/src/main/ts/main.ts @@ -0,0 +1,34 @@ +import { getConfig, log } from "./imports"; +import { modules } from "./types"; + +import "./modules/operaDownloadButton"; +import "./modules/firstCreatedUsername"; +import "./modules/bypassCameraRollSelectionLimit"; +import "./modules/selfDestructSnapDelay"; + + +try { + const config = getConfig(); + + if (config.composerLogs) { + ["log", "error", "warn", "info", "debug"].forEach(method => { + console[method] = (...args: any) => log(method, Array.from(args).join(" ")); + }) + } + + modules.forEach(m => { + if (!m.enabled(config)) { + return + } + try { + m.init(); + console.debug(`module ${m.name} initialized`); + } catch (e) { + console.error(`failed to initialize module ${m.name}`, e, e.stack); + } + }); + + console.debug("modules loaded!"); +} catch (e) { + log("error", "Failed to load composer modules\n" + e + "\n" + e.stack) +} diff --git a/composer/src/main/ts/modules/bypassCameraRollSelectionLimit.ts b/composer/src/main/ts/modules/bypassCameraRollSelectionLimit.ts new file mode 100644 index 0000000000..f04d306115 --- /dev/null +++ b/composer/src/main/ts/modules/bypassCameraRollSelectionLimit.ts @@ -0,0 +1,19 @@ +import { defineModule } from "../types"; +import { interceptComponent } from "../utils"; + +export default defineModule({ + name: "Bypass Camera Roll Selection Limit", + enabled: config => config.bypassCameraRollLimit, + init() { + interceptComponent( + 'memories_ui/src/clickhandlers/MultiSelectClickHandler', + 'MultiSelectClickHandler', + { + "<init>": (args: any[], superCall: () => void) => { + args[1].selectionLimit = 9999999; + superCall(); + } + } + ) + } +}); \ No newline at end of file diff --git a/composer/src/main/ts/modules/firstCreatedUsername.ts b/composer/src/main/ts/modules/firstCreatedUsername.ts new file mode 100644 index 0000000000..9cf17ec0f3 --- /dev/null +++ b/composer/src/main/ts/modules/firstCreatedUsername.ts @@ -0,0 +1,27 @@ +import { defineModule } from "../types"; +import { getFriendOriginalUsername } from "../imports"; +import { interceptComponent } from "../utils"; + +export default defineModule({ + name: "Show First Created Username", + enabled: config => config.showFirstCreatedUsername, + init() { + interceptComponent( + 'common_profile/src/identity/ProfileIdentityView', + 'ProfileIdentityView', + { + onRender: (component: any, _args: any[], render: () => void) => { + if (component.viewModel) { + let firstCreatedUsername = getFriendOriginalUsername(component.viewModel.username); + if (firstCreatedUsername) { + if (firstCreatedUsername != component.viewModel.username) { + component.viewModel.username += " (" + firstCreatedUsername + ")"; + } + } + } + render(); + } + } + ) + } +}); \ No newline at end of file diff --git a/composer/src/main/ts/modules/operaDownloadButton.ts b/composer/src/main/ts/modules/operaDownloadButton.ts new file mode 100644 index 0000000000..ba6f94dc84 --- /dev/null +++ b/composer/src/main/ts/modules/operaDownloadButton.ts @@ -0,0 +1,32 @@ +import { assetCatalog, jsx, style } from "../composer" +import { defineModule } from "../types" +import { downloadLastOperaMedia } from "../imports" +import { interceptComponent } from "../utils" + + +export default defineModule({ + name: "Opera Download Button", + enabled: config => config.operaDownloadButton, + init() { + interceptComponent( + 'context_chrome_header/src/ChromeHeaderRenderer', + 'ChromeHeaderRenderer', + { + onRenderBaseHeader: (_component: any, _args: any[], render: () => void) => { + render() + jsx.beginRender(jsx.makeNodePrototype("image")) + jsx.setAttributeStyle("style", new style.Style({ + height: 32, + marginTop: 4, + marginLeft: 8, + marginRight: 12, + })) + jsx.setAttribute("src", assetCatalog.loadCatalog("share_sheet/res").download) + jsx.setAttributeFunction("onTap", () => downloadLastOperaMedia(false)) + jsx.setAttributeFunction("onLongPress", () => downloadLastOperaMedia(true)) + jsx.endRender() + } + } + ) + } +}) \ No newline at end of file diff --git a/composer/src/main/ts/modules/selfDestructSnapDelay.ts b/composer/src/main/ts/modules/selfDestructSnapDelay.ts new file mode 100644 index 0000000000..68ded6ac47 --- /dev/null +++ b/composer/src/main/ts/modules/selfDestructSnapDelay.ts @@ -0,0 +1,47 @@ +import { defineModule } from "../types"; +import { interceptComponent } from "../utils"; + +export default defineModule({ + name: "Self Destruct Snap Delay", + enabled: config => config.customSelfDestructSnapDelay, + init() { + interceptComponent( + 'snap_editor_timer_tool/src/TimerPickerView', + 'TimerPickerView', + { + "<init>": (args: any[], superCall: () => void) => { + if (args[1].options[0] == 30) { + args[1].style = 0; // seconds format + + args[1].options = [ + 5, // 5 seconds + 10, // 10 seconds + 20, // 20 seconds + 30, // 30 seconds + 60, // 1 minute + 120, // 2 minutes + 180, // 3 minutes + 240, // 4 minutes + 300, // 5 minutes + 600, // 10 minutes + 900, // 15 minutes + 1200, // 20 minutes + 1800, // 30 minutes + 3600, // 1 hour + 7200, // 2 hours + 10800, // 3 hours + 14400, // 4 hours + 21600, // 6 hours + 28800, // 8 hours + 43200, // 12 hours + 86400, // 1 day + 172800, // 2 days + ] + } + + superCall(); + } + } + ) + } +}); \ No newline at end of file diff --git a/composer/src/main/ts/types.ts b/composer/src/main/ts/types.ts new file mode 100644 index 0000000000..e19a40c8ab --- /dev/null +++ b/composer/src/main/ts/types.ts @@ -0,0 +1,48 @@ +export interface Config { + readonly operaDownloadButton: boolean + readonly bypassCameraRollLimit: boolean + readonly showFirstCreatedUsername: boolean + readonly composerLogs: boolean + readonly customSelfDestructSnapDelay: boolean +} + +export interface FriendInfo { + readonly id: number + readonly lastModifiedTimestamp: number + readonly username: string + readonly userId: string + readonly displayName: string + readonly bitmojiAvatarId: string + readonly bitmojiSelfieId: string + readonly bitmojiSceneId: string + readonly bitmojiBackgroundId: string + readonly friendmojis: string + readonly friendmojiCategories: string + readonly snapScore: number + readonly birthday: number + readonly addedTimestamp: number + readonly reverseAddedTimestamp: number + readonly serverDisplayName: string + readonly streakLength: number + readonly streakExpirationTimestamp: number + readonly reverseBestFriendRanking: number + readonly isPinnedBestFriend: number + readonly plusBadgeVisibility: number + readonly usernameForSorting: string + readonly friendLinkType: number + readonly postViewEmoji: string + readonly businessCategory: number +} + +export interface Module { + readonly name: string + enabled: (config: Config) => boolean + init: () => void +} + +export const modules: Module[] = [] + +export function defineModule<T extends Module>(module: T & Record<string, any>): T { + modules.push(module) + return module +} diff --git a/composer/src/main/ts/utils.ts b/composer/src/main/ts/utils.ts new file mode 100644 index 0000000000..83b043c30f --- /dev/null +++ b/composer/src/main/ts/utils.ts @@ -0,0 +1,57 @@ +export function dumpObject(obj: any, indent = 0) { + if (typeof obj !== "object") return console.log(obj); + let prefix = "" + for (let i = 0; i < indent; i++) { + prefix += " "; + } + for (let key of Object.keys(obj)) { + try { + console.log(prefix, key, typeof obj[key], obj[key]); + if (key == "renderer") continue + if (typeof obj[key] === "object" && indent < 10) dumpObject(obj[key], indent + 1); + } catch (e) {} + } +} + +export function proxyProperty(module: any, functionName: string, handler: any) { + if (!module || !module[functionName]) { + console.warn("Function not found", functionName); + return; + } + module[functionName] = new Proxy(module[functionName], { + apply: (a, b, c) => handler(a, b, c), + construct: (a, b, c) => handler(a, b, c) + }); +} + +export function interceptComponent(moduleName: string, className: string, functions: any) { + proxyProperty(require(moduleName), className, (target: any, args: any[], newTarget: any) => { + let initProxy = functions["<init>"] + let component: any; + + if (initProxy) { + initProxy(args, (newArgs: any[]) => { + component = Reflect.construct(target, newArgs || args, newTarget); + }); + } else { + component = Reflect.construct(target, args, newTarget); + } + + for (let funcName of Object.keys(functions)) { + if (funcName == "<init>" || !component[funcName]) continue + proxyProperty(component, funcName, (target: any, thisArg: any, argumentsList: any[]) => { + let result: any; + try { + functions[funcName](component, argumentsList, (newArgs: any[]) => { + result = Reflect.apply(target, thisArg, newArgs || argumentsList); + }); + } catch (e) { + console.error("Error in", funcName, e); + } + return result; + }); + } + + return component; + }) +} diff --git a/composer/tsconfig.json b/composer/tsconfig.json new file mode 100644 index 0000000000..6fe20ae7cc --- /dev/null +++ b/composer/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "outDir": "build/typescript", + "target": "ES2023", + "typeRoots": ["types/*"] + }, + "include": ["./src/main/ts/**/*", "./types/**/*"] +} \ No newline at end of file diff --git a/composer/types/index.d.ts b/composer/types/index.d.ts new file mode 100644 index 0000000000..b2df699ddb --- /dev/null +++ b/composer/types/index.d.ts @@ -0,0 +1 @@ +declare function require(module: string): any; \ No newline at end of file diff --git a/core/.gitignore b/core/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/core/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/build.gradle.kts b/core/build.gradle.kts new file mode 100644 index 0000000000..b55166da8a --- /dev/null +++ b/core/build.gradle.kts @@ -0,0 +1,51 @@ +plugins { + alias(libs.plugins.androidLibrary) + alias(libs.plugins.kotlinAndroid) + alias(libs.plugins.compose.compiler) +} + +android { + namespace = rootProject.ext["applicationId"].toString() + ".core" + compileSdk = 34 + + defaultConfig { + minSdk = 28 + } + + buildFeatures { + compose = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + kotlinOptions { + jvmTarget = "21" + } +} + +dependencies { + compileOnly(files("libs/LSPosed-api-1.0-SNAPSHOT.jar")) + implementation(libs.coroutines) + implementation(libs.recyclerview) + implementation(libs.gson) + implementation(libs.okhttp) + implementation(libs.androidx.documentfile) + implementation(libs.rhino) + + implementation(project(":common")) + implementation(project(":mapper")) + implementation(project(":native")) + implementation(project(":composer")) + + implementation(libs.androidx.activity.ktx) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.material.icons.core) + implementation(libs.androidx.material.ripple) + implementation(libs.androidx.material.icons.extended) + implementation(libs.androidx.material3) + implementation(libs.hiddenapibypass) +} \ No newline at end of file diff --git a/app/libs/LSPosed-api-1.0-SNAPSHOT-javadoc.jar b/core/libs/LSPosed-api-1.0-SNAPSHOT-javadoc.jar similarity index 100% rename from app/libs/LSPosed-api-1.0-SNAPSHOT-javadoc.jar rename to core/libs/LSPosed-api-1.0-SNAPSHOT-javadoc.jar diff --git a/app/libs/LSPosed-api-1.0-SNAPSHOT-sources.jar b/core/libs/LSPosed-api-1.0-SNAPSHOT-sources.jar similarity index 100% rename from app/libs/LSPosed-api-1.0-SNAPSHOT-sources.jar rename to core/libs/LSPosed-api-1.0-SNAPSHOT-sources.jar diff --git a/app/libs/LSPosed-api-1.0-SNAPSHOT.jar b/core/libs/LSPosed-api-1.0-SNAPSHOT.jar similarity index 100% rename from app/libs/LSPosed-api-1.0-SNAPSHOT.jar rename to core/libs/LSPosed-api-1.0-SNAPSHOT.jar diff --git a/core/src/main/assets/web/avenir_next_medium.ttf b/core/src/main/assets/web/avenir_next_medium.ttf new file mode 100644 index 0000000000..41f5d1ea02 Binary files /dev/null and b/core/src/main/assets/web/avenir_next_medium.ttf differ diff --git a/core/src/main/assets/web/export_template.html b/core/src/main/assets/web/export_template.html new file mode 100644 index 0000000000..a99ef0652a --- /dev/null +++ b/core/src/main/assets/web/export_template.html @@ -0,0 +1,387 @@ +<style> + :root { + --Snap-sigIconPrimary: #dedede; + --Snap-sigIconSecondary: #999; + --Snap-sigIconTertiary: #616161; + --Snap-sigIconNegative: #f23c57; + --Snap-sigTextPrimary: #dedede; + --Snap-sigTextPrimaryInverse: #000; + --Snap-sigTextSecondary: #999; + --Snap-sigTextTertiary: #616161; + --Snap-sigTextPlayer: #fff; + --Snap-sigTextNegative: #f23c57; + --Snap-sigColorBackgroundBorder: rgba(255, 255, 255, 0.1); + --Snap-sigBackgroundPrimary: #121212; + --Snap-sigBackgroundPrimaryInverse: #fff; + --Snap-sigBackgroundSecondary: #1e1e1e; + --Snap-sigBackgroundSecondaryHover: #2b2b2b; + --Snap-sigBackgroundFeedHover: rgba(255, 255, 255, 0.1); + --Snap-sigBackgroundMessageHover: #292929; + --Snap-sigBackgroundMessageSaved: #333232; + --Snap-sigBackgroundMessageSavedHover: #3a3a3a; + --Snap-sigMediaControlContainerBackground: rgba(255, 255, 255, 0.1); + --Snap-sigStartupFooterBackground: rgba(0, 0, 0, 0.05); + --Snap-sigButtonPrimary: #0fadff; + --Snap-sigButtonPrimaryHover: #42bfff; + --Snap-sigButtonSecondary: #2b2b2b; + --Snap-sigButtonSecondaryHover: #424242; + --Snap-sigButtonSecondaryActive: #5c5c5c; + --Snap-sigButtonTertiary: #4e565f; + --Snap-sigButtonQuaternary: #fff; + --Snap-sigButtonInactive: #1e1e1e; + --Snap-sigButtonNegative: #e1143d; + --Snap-sigButtonOnPrimary: #fff; + --Snap-sigButtonOnSecondary: #dedede; + --Snap-sigButtonOnTertiary: #fff; + --Snap-sigButtonOnQuaternary: #1e1e1e; + --Snap-sigButtonOnInactive: rgba(255, 255, 255, 0.3); + --Snap-sigButtonOnNegative: #fff; + --Snap-sigMain: #121212; + --Snap-sigSubscreen: #121212; + --Snap-sigOverlay: rgba(0, 0, 0, 0.4); + --Snap-sigOverlayHover: rgba(0, 0, 0, 0.35); + --Snap-sigSurface: #1e1e1e; + --Snap-sigSurfaceRGB: 30, 30, 30; + --Snap-sigSurfaceDown: #212121; + --Snap-sigAboveSurface: #292929; + --Snap-sigObject: rgba(255, 255, 255, 0.1); + --Snap-sigObjectDown: rgba(255, 255, 255, 0.19); + --Snap-sigConversationBoxBackground: rgba(255, 255, 255, 0.25); + --Snap-sigDivider: rgba(255, 255, 255, 0.1); + --Snap-sigDividerLight: rgba(255, 255, 255, 0.2); + --Snap-sigPlaceholder: #1e1e1e; + --Snap-sigDisabled: rgba(255, 255, 255, 0.1); + --Snap-sigCallTileHighlight: rgba(255, 255, 255, 0.8); + --Snap-sigChat: #0fadff; + --Snap-sigSnapWithoutSound: #f23c57; + --Snap-sigSnapWithSound: #a05dcd; + --Snap-sigChatSurfaceCalling: #39ca8e; + --Snap-sigChatSurfaceCallingDisabled: #105e3d; + --Snap-sigChatPending: #767676; + --Snap-sigChatPendingHover: #8f8f8f; + --Snap-sigChatIcon: #0fadff; + --Snap-sigChatIconCaret: #f8616d; + --Snap-sigChatShadowOne: 0 0 17px rgba(33, 33, 33, 0.07), 0 0 22px rgba(0, 0, 0, 0.06), 0 0 8px rgba(84, 84, 84, 0.1); + --Snap-selectedMiddleColorGradient: rgba(4, 4, 4, 0.1); + --Snap-selectedRightColorGradient: rgba(4, 4, 4, 0); + --Border: 1px solid var(--Snap-sigColorBackgroundBorder); + } + + body { + font-family: 'Avenir Next', sans-serif; + color: var(--Snap-sigTextPrimary); + background-color: var(--Snap-sigBackgroundPrimary); + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + } + + header { + width: 100%; + padding: 10px 0px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + } + + header .title { + background-color: var(--Snap-sigButtonSecondary); + height: 40px; + font-weight: 600; + padding-inline-end: 16px; + border-radius: 999px; + line-height: 40px; + padding: 0 20px; + } + + main { + background-color: var(--Snap-sigBackgroundSecondary); + border: var(--Border); + border-radius: 12px; + width: calc(100% - 30px); + display: flex; + flex-direction: column; + } + + main>.message { + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: flex-start; + flex-wrap: nowrap; + margin: 5px 15px; + } + + main>.message .header { + width: 100%; + display: flex; + vertical-align: top; + align-self: flex-start; + flex-direction: row; + flex-wrap: nowrap; + justify-content: space-between; + align-items: center; + } + + main>.message:nth-child(2n) .username { + color: #dcedc1; + } + + main>.message:nth-child(2n + 1) .username { + color: #ffd3b6; + } + + main>.message .username { + font-weight: bold; + } + + main>.message .time { + color: var(--Snap-sigTextSecondary); + font-size: 12px; + font-weight: 600; + } + + main>.message:nth-child(2n) .content { + border-color: #dcedc1; + } + + main>.message:nth-child(2n + 1) .content { + border-color: #ffd3b6; + } + + main>.message .content { + background-color: var(--Snap-sigBackgroundMessageSaved); + border-left: 3px solid; + border-radius: 3px; + margin-top: 4px; + padding-left: 4px; + padding: 3px 0 3px 6px; + } + + main>.message .content div:has(.chat_media:not(audio):not(.overlay_media)) { + display: inline-block; + resize: horizontal; + overflow: hidden; + line-height: 0; + height: auto; + width: 300px; + } + + main>.message .chat_media:not(audio):not(.overlay_media) { + width: 100%; + height: auto; + } + + @-moz-document url-prefix() { + main>.message .content div { + display: inline-block; + resize: horizontal; + overflow: hidden; + line-height: 0; + height: auto; + width: 300px; + } + + main>.message .chat_media:not(.overlay_media) { + width: 100%; + height: auto; + } + } + + + main>.message .overlay_media { + width: inherit; + height: inherit; + position: absolute; + pointer-events: none; + } + + main>.message .red_snap_svg { + color: var(--Snap-sigSnapWithoutSound); + } +</style> +<body> +<header> + <div class="title"></div> + <div> + <label> + <input type="checkbox" class="sort_by_date" onchange="makeMain();"> Sort by date + </label> + </div> +</header> + +<main></main> + +<div style="display: none;"> + <svg class="red_snap_svg" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> + <rect x="4" y="5" width="10.5" height="10.5" rx="1.808" stroke="currentColor" stroke-width="1.5"></rect> + </svg> +</div> + +<script> + function base64decode(data) { + return new Uint8Array(atob(data).split('').map(c => c.charCodeAt(0))) + } + + const conversationData = JSON.parse(new TextDecoder().decode(new Uint8Array(inflate(base64decode(document.querySelector(".exported_content").innerHTML))))) + const participants = Object.values(conversationData.participants) + + function makeHeader() { + const conversationTitle = conversationData.conversationName != null ? conversationData.conversationName : "DM with " + Object.values(participants).map(user => user.username).join(", ") + document.querySelector("header > .title").textContent = conversationTitle + document.title = conversationTitle + } + + function decodeMedia(element) { + try { + const decodedData = new Uint8Array( + inflate( + base64decode( + element.innerHTML.substring(5, element.innerHTML.length - 4) + ) + ) + ) + return URL.createObjectURL(new Blob([decodedData])) + } catch (e) { + return null + } + } + + function makeMain() { + document.querySelector('main').innerHTML = "" + const messageTemplate = document.querySelector("#message_template") + let messageList = Object.values(conversationData.messages) + + if (document.querySelector(".sort_by_date").checked) { + messageList = messageList.reverse() + } + + messageList.forEach(message => { + const messageObject = document.createElement("div") + messageObject.classList.add("message") + + messageObject.appendChild(((headerElement) => { + headerElement.classList.add("header") + + headerElement.appendChild(((elem) => { + elem.classList.add("username") + const participant = participants[message.senderId] + elem.innerHTML = (participant == null) ? "Unknown user" : participant.username + return elem + })(document.createElement("div"))) + + + headerElement.appendChild(((elem) => { + elem.classList.add("time") + elem.innerHTML = new Date(message.createdTimestamp).toUTCString() + return elem + })(document.createElement("div"))) + + return headerElement + })(document.createElement("div"))) + + messageObject.appendChild(((messageContainer) => { + messageContainer.classList.add("content") + + const observers = [] + + function loadContent() { + if (!message.serializedContent) { + let messageData = "" + switch (message.type) { + case "SNAP": + messageContainer.appendChild(document.querySelector('.red_snap_svg').cloneNode(true)) + messageData += "Snap" + break + default: + messageData += message.type + } + messageContainer.innerHTML = messageData + messageContainer.onclick = () => { + messageContainer.prepend(document.createElement("br")) + observers.forEach(f => f()) + } + } else { + messageContainer.innerHTML = message.serializedContent + } + } + + loadContent() + + if (message.attachments && message.attachments.length > 0) { + message.attachments.reverse().forEach((attachment, index) => { + const mediaKey = attachment.key.replace(/(=)/g, "") + + observers.push(() => { + messageContainer.onclick = () => {} + const originalMedia = document.querySelector('.media-ORIGINAL_' + mediaKey) + if (!originalMedia) { + return + } + + const originalMediaUrl = decodeMedia(originalMedia) + + const mediaContainer = document.createElement("div") + messageContainer.prepend(mediaContainer) + + const imageTag = document.createElement("img") + imageTag.src = originalMediaUrl + imageTag.classList.add("chat_media") + mediaContainer.appendChild(imageTag) + + imageTag.onerror = () => { + mediaContainer.removeChild(imageTag) + const mediaTag = document.createElement(message.type === "NOTE" ? "audio" : "video") + mediaTag.classList.add("chat_media") + mediaTag.src = originalMediaUrl + mediaTag.preload = "metadata" + mediaTag.controls = true + mediaContainer.appendChild(mediaTag) + } + + const overlay = document.querySelector('.media-OVERLAY_' + mediaKey) + if (!overlay) { + return + } + + const overlayImage = document.createElement("img") + overlayImage.src = decodeMedia(overlay) + overlayImage.classList.add("chat_media") + overlayImage.classList.add("overlay_media") + mediaContainer.appendChild(overlayImage) + }) + }) + + let fetched = false + + new IntersectionObserver(entries => { + if (!fetched && entries[0].isIntersecting === true) { + fetched = true + loadContent() + messageContainer.prepend(document.createElement("br")) + observers.forEach(c => { + try { + c() + } catch (e) { + console.log(e) + } + }) + } + }).observe(messageContainer) + } + + return messageContainer + })(document.createElement("div"))) + + document.querySelector('main').appendChild(messageObject) + }) + } + + makeHeader() + makeMain() +</script> +</body> \ No newline at end of file diff --git a/core/src/main/assets/web/rawinflate.js b/core/src/main/assets/web/rawinflate.js new file mode 100644 index 0000000000..75e782f415 --- /dev/null +++ b/core/src/main/assets/web/rawinflate.js @@ -0,0 +1,6 @@ +/* Copyright (C) 1999 Masanao Izumo <iz@onicos.co.jp> + * Version: 1.0.0.1 + * LastModified: Dec 25 1999 + */ + +(function(){var WSIZE=32768,STORED_BLOCK=0,STATIC_TREES=1,DYN_TREES=2,lbits=9,dbits=6,slide,wp,fixed_tl=null,fixed_td,fixed_bl,fixed_bd,bit_buf,bit_len,method,eof,copy_leng,copy_dist,tl,td,bl,bd,inflate_data,inflate_pos,MASK_BITS=[0x0000,0x0001,0x0003,0x0007,0x000f,0x001f,0x003f,0x007f,0x00ff,0x01ff,0x03ff,0x07ff,0x0fff,0x1fff,0x3fff,0x7fff,0xffff],cplens=[3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99,115,131,163,195,227,258,0,0],cplext=[0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,99,99],cpdist=[1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193,257,385,513,769,1025,1537,2049,3073,4097,6145,8193,12289,16385,24577],cpdext=[0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13],border=[16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15];function HuftList(){this.next=null;this.list=null}function HuftNode(){this.e=0;this.b=0;this.n=0;this.t=null;}function HuftBuild(b,n,s,d,e,mm){this.BMAX=16;this.N_MAX=288;this.status=0;this.root=null;this.m=0;var a;var c=[];var el;var f;var g;var h;var i;var j;var k;var lx=[];var p;var pidx;var q;var r=new HuftNode();var u=[];var v=[];var w;var x=[];var xp;var y;var z;var o;var tail;tail=this.root=null;for(i=0;i<this.BMAX+1;i+=1){c[i]=0}for(i=0;i<this.BMAX+1;i+=1){lx[i]=0}for(i=0;i<this.BMAX;i+=1){u[i]=null}for(i=0;i<this.N_MAX;i+=1){v[i]=0}for(i=0;i<this.BMAX+1;i+=1){x[i]=0}el=n>256?b[256]:this.BMAX;p=b;pidx=0;i=n;do{c[p[pidx]]+=1;pidx+=1}while(--i>0);if(c[0]===n){this.root=null;this.m=0;this.status=0;return}for(j=1;j<=this.BMAX;j+=1){if(c[j]!==0){break}}k=j;if(mm<j){mm=j}for(i=this.BMAX;i!==0;i-=1){if(c[i]!==0){break}}g=i;if(mm>i){mm=i}for(y=1<<j;j<i;j+=1,y<<=1){if((y-=c[j])<0){this.status=2;this.m=mm;return}}if((y-=c[i])<0){this.status=2;this.m=mm;return}c[i]+=y;x[1]=j=0;p=c;pidx=1;xp=2;while(--i>0){x[xp++]=(j+=p[pidx++])}p=b;pidx=0;i=0;do{if((j=p[pidx++])!==0){v[x[j]++]=i}}while(++i<n);n=x[g];x[0]=i=0;p=v;pidx=0;h=-1;w=lx[0]=0;q=null;z=0;for(null;k<=g;k+=1){a=c[k];while(a-- >0){while(k>w+lx[1+h]){w+=lx[1+h];h+=1;z=(z=g-w)>mm?mm:z;if((f=1<<(j=k-w))>a+1){f-=a+1;xp=k;while(++j<z){if((f<<=1)<=c[xp+=1]){break;}f-=c[xp];}}if(w+j>el&&w<el){j=el-w;}z=1<<j;lx[1+h]=j;q=[];for(o=0;o<z;o+=1){q[o]=new HuftNode()}if(!tail){tail=this.root=new HuftList()}else{tail=tail.next=new HuftList()}tail.next=null;tail.list=q;u[h]=q;if(h>0){x[h]=i;r.b=lx[h];r.e=16+j;r.t=q;j=(i&((1<<w)-1))>>(w-lx[h]);u[h-1][j].e=r.e;u[h-1][j].b=r.b;u[h-1][j].n=r.n;u[h-1][j].t=r.t}}r.b=k-w;if(pidx>=n){r.e=99;}else if(p[pidx]<s){r.e=(p[pidx]<256?16:15);r.n=p[pidx++];}else{r.e=e[p[pidx]-s];r.n=d[p[pidx++]-s]}f=1<<(k-w);for(j=i>>w;j<z;j+=f){q[j].e=r.e;q[j].b=r.b;q[j].n=r.n;q[j].t=r.t}for(j=1<<(k-1);(i&j)!==0;j>>=1){i^=j}i^=j;while((i&((1<<w)-1))!==x[h]){w-=lx[h];h-=1}}}this.m=lx[1];this.status=((y!==0&&g!==1)?1:0)}function GET_BYTE(){if(inflate_data.length===inflate_pos){return -1}return inflate_data[inflate_pos++]&0xff}function NEEDBITS(n){while(bit_len<n){bit_buf|=GET_BYTE()<<bit_len;bit_len+=8}}function GETBITS(n){return bit_buf&MASK_BITS[n]}function DUMPBITS(n){bit_buf>>=n;bit_len-=n}function inflate_codes(buff,off,size){var e;var t;var n;if(size===0){return 0}n=0;for(;;){NEEDBITS(bl);t=tl.list[GETBITS(bl)];e=t.e;while(e>16){if(e===99){return -1}DUMPBITS(t.b);e-=16;NEEDBITS(e);t=t.t[GETBITS(e)];e=t.e}DUMPBITS(t.b);if(e===16){wp&=WSIZE-1;buff[off+n++]=slide[wp++]=t.n;if(n===size){return size}continue}if(e===15){break}NEEDBITS(e);copy_leng=t.n+GETBITS(e);DUMPBITS(e);NEEDBITS(bd);t=td.list[GETBITS(bd)];e=t.e;while(e>16){if(e===99){return -1}DUMPBITS(t.b);e-=16;NEEDBITS(e);t=t.t[GETBITS(e)];e=t.e}DUMPBITS(t.b);NEEDBITS(e);copy_dist=wp-t.n-GETBITS(e);DUMPBITS(e);while(copy_leng>0&&n<size){copy_leng-=1;copy_dist&=WSIZE-1;wp&=WSIZE-1;buff[off+n++]=slide[wp++]=slide[copy_dist++]}if(n===size){return size}}method=-1;return n}function inflate_stored(buff,off,size){var n;n=bit_len&7;DUMPBITS(n);NEEDBITS(16);n=GETBITS(16);DUMPBITS(16);NEEDBITS(16);if(n!==((~bit_buf)&0xffff)){return -1;}DUMPBITS(16);copy_leng=n;n=0;while(copy_leng>0&&n<size){copy_leng-=1;wp&=WSIZE-1;NEEDBITS(8);buff[off+n++]=slide[wp++]=GETBITS(8);DUMPBITS(8)}if(copy_leng===0){method=-1;}return n}function inflate_fixed(buff,off,size){if(!fixed_tl){var i;var l=[];var h;for(i=0;i<144;i+=1){l[i]=8}for(null;i<256;i+=1){l[i]=9}for(null;i<280;i+=1){l[i]=7}for(null;i<288;i+=1){l[i]=8}fixed_bl=7;h=new HuftBuild(l,288,257,cplens,cplext,fixed_bl);if(h.status!==0){console.error("HufBuild error: "+h.status);return -1}fixed_tl=h.root;fixed_bl=h.m;for(i=0;i<30;i+=1){l[i]=5}fixed_bd=5;h=new HuftBuild(l,30,0,cpdist,cpdext,fixed_bd);if(h.status>1){fixed_tl=null;console.error("HufBuild error: "+h.status);return -1}fixed_td=h.root;fixed_bd=h.m}tl=fixed_tl;td=fixed_td;bl=fixed_bl;bd=fixed_bd;return inflate_codes(buff,off,size)}function inflate_dynamic(buff,off,size){var i;var j;var l;var n;var t;var nb;var nl;var nd;var ll=[];var h;for(i=0;i<286+30;i+=1){ll[i]=0}NEEDBITS(5);nl=257+GETBITS(5);DUMPBITS(5);NEEDBITS(5);nd=1+GETBITS(5);DUMPBITS(5);NEEDBITS(4);nb=4+GETBITS(4);DUMPBITS(4);if(nl>286||nd>30){return -1;}for(j=0;j<nb;j+=1){NEEDBITS(3);ll[border[j]]=GETBITS(3);DUMPBITS(3)}for(null;j<19;j+=1){ll[border[j]]=0}bl=7;h=new HuftBuild(ll,19,19,null,null,bl);if(h.status!==0){return -1;}tl=h.root;bl=h.m;n=nl+nd;i=l=0;while(i<n){NEEDBITS(bl);t=tl.list[GETBITS(bl)];j=t.b;DUMPBITS(j);j=t.n;if(j<16){ll[i++]=l=j;}else if(j===16){NEEDBITS(2);j=3+GETBITS(2);DUMPBITS(2);if(i+j>n){return -1}while(j-- >0){ll[i++]=l}}else if(j===17){NEEDBITS(3);j=3+GETBITS(3);DUMPBITS(3);if(i+j>n){return -1}while(j-- >0){ll[i++]=0}l=0}else{NEEDBITS(7);j=11+GETBITS(7);DUMPBITS(7);if(i+j>n){return -1}while(j-- >0){ll[i++]=0}l=0}}bl=lbits;h=new HuftBuild(ll,nl,257,cplens,cplext,bl);if(bl===0){h.status=1}if(h.status!==0){if(h.status!==1){return -1;}}tl=h.root;bl=h.m;for(i=0;i<nd;i+=1){ll[i]=ll[i+nl]}bd=dbits;h=new HuftBuild(ll,nd,0,cpdist,cpdext,bd);td=h.root;bd=h.m;if(bd===0&&nl>257){return -1}if(h.status!==0){return -1}return inflate_codes(buff,off,size)}function inflate_start(){if(!slide){slide=[];}wp=0;bit_buf=0;bit_len=0;method=-1;eof=false;copy_leng=copy_dist=0;tl=null}function inflate_internal(buff,off,size){var n,i;n=0;while(n<size){if(eof&&method===-1){return n}if(copy_leng>0){if(method!==STORED_BLOCK){while(copy_leng>0&&n<size){copy_leng-=1;copy_dist&=WSIZE-1;wp&=WSIZE-1;buff[off+n++]=slide[wp++]=slide[copy_dist++]}}else{while(copy_leng>0&&n<size){copy_leng-=1;wp&=WSIZE-1;NEEDBITS(8);buff[off+n++]=slide[wp++]=GETBITS(8);DUMPBITS(8)}if(copy_leng===0){method=-1;}}if(n===size){return n}}if(method===-1){if(eof){break}NEEDBITS(1);if(GETBITS(1)!==0){eof=true}DUMPBITS(1);NEEDBITS(2);method=GETBITS(2);DUMPBITS(2);tl=null;copy_leng=0}switch(method){case STORED_BLOCK:i=inflate_stored(buff,off+n,size-n);break;case STATIC_TREES:if(tl){i=inflate_codes(buff,off+n,size-n)}else{i=inflate_fixed(buff,off+n,size-n)}break;case DYN_TREES:if(tl){i=inflate_codes(buff,off+n,size-n)}else{i=inflate_dynamic(buff,off+n,size-n)}break;default:i=-1;break}if(i===-1){if(eof){return 0}return -1}n+=i}return n}function inflate(arr){var buff=[],i;inflate_start();inflate_data=arr;inflate_pos=0;do{i=inflate_internal(buff,buff.length,1024)}while(i>0);inflate_data=null;return buff}window.inflate=inflate}()); \ No newline at end of file diff --git a/core/src/main/assets/xposed_init b/core/src/main/assets/xposed_init new file mode 100644 index 0000000000..46ffe14b4b --- /dev/null +++ b/core/src/main/assets/xposed_init @@ -0,0 +1 @@ +me.rhunk.snapenhance.core.XposedLoader \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/DownloadManagerClient.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/DownloadManagerClient.kt new file mode 100644 index 0000000000..ab08d89ff2 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/DownloadManagerClient.kt @@ -0,0 +1,96 @@ +package me.rhunk.snapenhance.core + +import android.content.Intent +import android.os.Bundle +import me.rhunk.snapenhance.bridge.DownloadCallback +import me.rhunk.snapenhance.common.ReceiversConfig +import me.rhunk.snapenhance.common.data.download.* +import me.rhunk.snapenhance.core.features.impl.downloader.decoder.AttachmentType + +class DownloadManagerClient ( + private val context: ModContext, + private val metadata: DownloadMetadata, + private val callback: DownloadCallback +) { + private fun enqueueDownloadRequest(request: DownloadRequest) { + context.bridgeClient.enqueueDownload(Intent().apply { + putExtras(Bundle().apply { + putString(ReceiversConfig.DOWNLOAD_REQUEST_EXTRA, context.gson.toJson(request)) + putString(ReceiversConfig.DOWNLOAD_METADATA_EXTRA, context.gson.toJson(metadata)) + }) + }, callback) + } + + fun downloadDashMedia(playlistUrl: String, offsetTime: Long, duration: Long?) { + enqueueDownloadRequest( + DownloadRequest( + inputMedias = arrayOf( + InputMedia( + content = playlistUrl, + type = DownloadMediaType.REMOTE_MEDIA + ) + ), + dashOptions = DashOptions(offsetTime, duration), + flags = DownloadRequest.Flags.DASH_PLAYLIST + ) + ) + } + + fun downloadSingleMedia( + mediaData: String, + mediaType: DownloadMediaType, + encryption: MediaEncryptionKeyPair? = null, + attachmentType: AttachmentType? = null + ) { + enqueueDownloadRequest( + DownloadRequest( + inputMedias = arrayOf( + InputMedia( + content = mediaData, + type = mediaType, + encryption = encryption, + attachmentType = attachmentType?.name + ) + ) + ) + ) + } + + fun downloadMediaWithOverlay( + original: InputMedia, + overlay: InputMedia, + ) { + enqueueDownloadRequest( + DownloadRequest( + inputMedias = arrayOf(original, overlay), + flags = DownloadRequest.Flags.MERGE_OVERLAY + ) + ) + } + + fun downloadInputMedias(inputMedias: Array<InputMedia>) { + enqueueDownloadRequest( + DownloadRequest( + inputMedias = inputMedias + ) + ) + } + + fun downloadStream( + streamUrl: String, + audioStreamFormat: AudioStreamFormat + ) { + enqueueDownloadRequest( + DownloadRequest( + inputMedias = arrayOf( + InputMedia( + content = streamUrl, + type = DownloadMediaType.REMOTE_MEDIA + ) + ), + flags = DownloadRequest.Flags.AUDIO_STREAM, + audioStreamFormat = audioStreamFormat + ) + ) + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt new file mode 100644 index 0000000000..d9088cdf2d --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt @@ -0,0 +1,174 @@ +package me.rhunk.snapenhance.core + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.res.Resources +import android.os.Handler +import android.os.Looper +import android.os.Process +import android.widget.Toast +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.common.Constants +import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper +import me.rhunk.snapenhance.common.bridge.wrapper.MappingsWrapper +import me.rhunk.snapenhance.common.config.ModConfig +import me.rhunk.snapenhance.common.util.lazyBridge +import me.rhunk.snapenhance.core.action.ActionManager +import me.rhunk.snapenhance.core.bridge.BridgeClient +import me.rhunk.snapenhance.core.database.DatabaseAccess +import me.rhunk.snapenhance.core.event.EventBus +import me.rhunk.snapenhance.core.event.EventDispatcher +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.FeatureManager +import me.rhunk.snapenhance.core.features.impl.experiments.getCustomEmojiFontPath +import me.rhunk.snapenhance.core.logger.CoreLogger +import me.rhunk.snapenhance.core.messaging.CoreMessagingBridge +import me.rhunk.snapenhance.core.messaging.MessageSender +import me.rhunk.snapenhance.core.scripting.CoreScriptRuntime +import me.rhunk.snapenhance.core.ui.InAppOverlay +import me.rhunk.snapenhance.core.ui.UserInterface +import me.rhunk.snapenhance.core.util.media.HttpServer +import me.rhunk.snapenhance.nativelib.NativeConfig +import me.rhunk.snapenhance.nativelib.NativeLib +import kotlin.reflect.KClass +import kotlin.system.exitProcess + +class ModContext( + val androidContext: Context +) { + val coroutineScope = CoroutineScope(Dispatchers.IO) + + lateinit var bridgeClient: BridgeClient + var mainActivity: Activity? = null + + val classCache get() = SnapEnhance.classCache + val resources: Resources get() = androidContext.resources + val gson: Gson = GsonBuilder().create() + + private val lazyFileHandlerManager = lazyBridge { bridgeClient.getFileHandlerManager() } + val fileHandlerManager by lazyFileHandlerManager + + private val _config by lazy { ModConfig(androidContext, lazyFileHandlerManager) } + val config get() = _config.root + val log by lazy { CoreLogger(this.bridgeClient) } + val translation by lazy { LocaleWrapper(lazyFileHandlerManager) } + val httpServer = HttpServer() + val messageSender = MessageSender(this) + + val features = FeatureManager(this) + val mappings by lazy { MappingsWrapper(lazyFileHandlerManager).apply { init(androidContext) } } + val actionManager = ActionManager(this) + val database = DatabaseAccess(this) + val event = EventBus(this) + val eventDispatcher = EventDispatcher(this) + val native = NativeLib() + val scriptRuntime by lazy { CoreScriptRuntime(this, log) } + val messagingBridge = CoreMessagingBridge(this) + val inAppOverlay = InAppOverlay(this) + val userInterface = UserInterface(this) + + val isDeveloper by lazy { config.scripting.developerMode.get() } + + var isMainActivityPaused = true + var disablePlugin = false + + fun <T : Feature> feature(featureClass: KClass<T>): T { + return features.get(featureClass)!! + } + + fun runOnUiThread(runnable: () -> Unit) { + if (Looper.getMainLooper().isCurrentThread) { + runnable() + return + } + Handler(Looper.getMainLooper()).post { + runCatching(runnable).onFailure { + CoreLogger.xposedLog("UI thread runnable failed", it) + } + } + } + + fun executeAsync(runnable: suspend ModContext.() -> Unit) { + coroutineScope.launch { + runCatching { + runnable() + }.onFailure { + longToast("Async task failed: " + it.message) + log.error("Async task failed", it) + } + } + } + + fun shortToast(message: Any?) { + runOnUiThread { + Toast.makeText(androidContext, message.toString(), Toast.LENGTH_SHORT).show() + } + } + + fun longToast(message: Any?) { + runOnUiThread { + Toast.makeText(androidContext, message.toString(), Toast.LENGTH_LONG).show() + } + } + + fun softRestartApp(saveSettings: Boolean = false) { + if (saveSettings) { + _config.writeConfig() + } + val intent: Intent? = androidContext.packageManager.getLaunchIntentForPackage( + Constants.SNAPCHAT_PACKAGE_NAME + ) + intent?.let { + val mainIntent = Intent.makeRestartActivityTask(intent.component) + androidContext.startActivity(mainIntent) + } + exitProcess(1) + } + + fun crash(message: String, throwable: Throwable? = null) { + logCritical(message, throwable ?: Throwable()) + delayForceCloseApp(100) + } + + fun logCritical(message: Any?, throwable: Throwable = Throwable()) { + log.error(message ?: "Snapchat crash", throwable) + longToast(message ?: "Snapchat has crashed! Please check logs for more details.") + } + + private fun delayForceCloseApp(delay: Long) = Handler(Looper.getMainLooper()).postDelayed({ + forceCloseApp() + }, delay) + + fun forceCloseApp() { + Process.killProcess(Process.myPid()) + exitProcess(1) + } + + fun reloadConfig() { + log.verbose("reloading config") + _config.load() + reloadNativeConfig() + } + + fun reloadNativeConfig() { + native.loadNativeConfig( + NativeConfig( + disableBitmoji = config.experimental.nativeHooks.disableBitmoji.get(), + disableMetrics = config.global.disableMetrics.get(), + composerHooks = config.experimental.nativeHooks.composerHooks.globalState == true, + customEmojiFontPath = getCustomEmojiFontPath(this) + ) + ) + } + + fun getConfigLocale(): String { + return _config.locale + } + + fun isLoggedIn() = androidContext.getSharedPreferences("user_session_shared_pref", 0).getString("key_user_id", null) != null +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/SecurityFeatures.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/SecurityFeatures.kt new file mode 100644 index 0000000000..c24f180a9c --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/SecurityFeatures.kt @@ -0,0 +1,225 @@ +package me.rhunk.snapenhance.core + +import android.system.Os +import android.view.ViewGroup +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.rounded.NotInterested +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import me.rhunk.snapenhance.common.bridge.FileHandleScope +import me.rhunk.snapenhance.common.bridge.toWrapper +import me.rhunk.snapenhance.common.config.MOD_DETECTION_VERSION_CHECK +import me.rhunk.snapenhance.common.config.VersionRequirement +import me.rhunk.snapenhance.common.ui.createComposeView +import me.rhunk.snapenhance.core.event.events.impl.UnaryCallEvent +import me.rhunk.snapenhance.core.ui.CustomComposable +import me.rhunk.snapenhance.core.util.dataBuilder +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.hook.hookConstructor +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.mapper.impl.CallbackMapper +import me.rhunk.snapenhance.mapper.impl.PlatformClientAttestationMapper +import java.io.IOException +import java.lang.reflect.Method +import kotlin.system.exitProcess + +class SecurityFeatures( + private val context: ModContext +) { + private fun transact(option: Int, option2: Long) = runCatching { Os.prctl(option, option2, 0, 0, 0) }.getOrNull() + + private val token by lazy { + transact(0, 0) + } + + private fun getStatus() = token?.run { + transact(this, 0)?.toString(2)?.padStart(32, '0')?.count { it == '1' } + } + + fun init() { + val snapchatVersionCode = context.androidContext.packageManager?.getPackageInfo(context.androidContext.packageName, 0)?.longVersionCode ?: throw IllegalStateException("Failed to get version code") + var shouldDisablePlugin = MOD_DETECTION_VERSION_CHECK.checkVersion(snapchatVersionCode)?.second == VersionRequirement.OLDER_REQUIRED + + // load user shared library + context.config.experimental.nativeHooks.customSharedLibrary.get().takeIf { it.isNotEmpty() }?.let { + runCatching { + context.native.loadSharedLibrary( + context.fileHandlerManager.getFileHandle(FileHandleScope.USER_IMPORT.key, it).toWrapper().readBytes() + ) + context.log.verbose("loaded custom shared library") + shouldDisablePlugin = false + + lateinit var composable: CustomComposable + composable = { + Row( + modifier = Modifier + .padding(16.dp) + .align(Alignment.TopCenter), + ) { + Icon(Icons.Filled.Check, contentDescription = null, tint = Color(0xFF85A947)) + } + + LaunchedEffect(Unit) { + delay(2500) + context.inAppOverlay.removeCustomComposable(composable) + } + } + + context.inAppOverlay.addCustomComposable(composable) + }.onFailure { + context.log.error("Failed to load custom shared library", it) + } + } + + if (context.bridgeClient.getDebugProp("test_mode", "false") == "true") { + shouldDisablePlugin = false + } + + context.disablePlugin = shouldDisablePlugin + context.log.verbose("disablePlugin=${context.disablePlugin}") + if (!context.disablePlugin) return + + val allowedEPs = listOf( + "/messagingcoreservice.MessagingCoreService/", + "/GetConvoSafetyPrompt", + "/GetSnapchatterPublicInfo", + "/UserRecentlyActive", + "/socialsms.SocialSms/UpdateLink", // Direct link sharing + ) + + context.event.subscribe(UnaryCallEvent::class) { event -> + val callOptions = event.adapter.arg<Any>(2).let { it.javaClass.getMethod("build").invoke(it) } ?: return@subscribe + if (callOptions.getObjectField("mAttestation") != null || event.uri.endsWith("/IncomingFriendSync")) { + context.log.verbose("blocked ep ${event.adapter.arg<Any>(0)}", "UnaryCallEvent") + event.canceled = true + val eventHandler = event.adapter.arg<Any>(3) + eventHandler.javaClass.methods.first { it.name == "onEvent" }.also { method -> + method.invoke(eventHandler, null, method.parameterTypes[0].dataBuilder { + set("mStatusCode", "CANCELLED") + }) + } + } + } + + context.androidContext.classLoader.apply { + val argosClientClass = loadClass("com.snapchat.client.client_attestation.ArgosClient\$CppProxy") + argosClientClass.apply { + hookConstructor(HookStage.BEFORE) { it.setResult(null) } + hook("getArgosTokenAsync", HookStage.BEFORE) { it.setResult(null) } + hook("getAttestationHeaders", HookStage.BEFORE) { it.setResult(null) } + } + loadClass("com.snapchat.client.client_attestation.ArgosClient").hook("createInstance", HookStage.BEFORE) { param -> + param.setResult(argosClientClass.declaredConstructors.first().also { it.isAccessible = true }.newInstance(0)) + } + loadClass("com.snap.security.attestation.impl.SCClientAttestationDurableJob").hookConstructor(HookStage.BEFORE) { param -> + param.setArg(0, null) + } + loadClass("com.snapchat.client.grpc.AuthContext").hookConstructor(HookStage.AFTER) { param -> + val headers by lazy { (param.thisObject<Any>().getObjectField("mHeaders") as? List<*>)?.filterNotNull() ?: emptyList() } + + if (param.thisObject<Any>().getObjectField("mAuthTokenErrorCode") != null || + headers.isEmpty() || + headers.mapNotNull { it.getObjectField("mKey")?.toString()?.lowercase() }.any { it != "x-snap-access-token" } + ) { + context.log.error("invalid headers ${headers.size}") + exitProcess(139) + } + } + loadClass("com.snapchat.client.duplex.DuplexClient\$CppProxy").hook("registerHandler", + HookStage.BEFORE) { param -> + val path = param.arg<String>(0) + if (path == "hermod_dup") { + param.setResult(null) + return@hook + } + } + } + + context.mappings.useMapper(CallbackMapper::class) { + callbacks.getClass("AuthContextDelegate")?.hook("getAuthContext", HookStage.BEFORE) { param -> + val authContextRequest = param.arg<Any>(0) + val requestPath = authContextRequest.getObjectField("mRequestPath").toString() + + if (authContextRequest.getObjectField("mAttestationRequired") == true) { + if (allowedEPs.any { requestPath.contains(it) }) { + context.log.verbose("ep $requestPath", "AuthContextDelegate") + return@hook + } + + context.log.verbose("blocked ep $requestPath", "AuthContextDelegate") + param.setResult(null) + } + } ?: error("AuthContextDelegate not found in mappings") + } + + context.mappings.useMapper(PlatformClientAttestationMapper::class) { + apiInvocationHandler.getAsClass()?.hook("invoke", HookStage.BEFORE) { param -> + val method = param.arg<Method>(1) + if (method.annotations.any { it.toString().contains("attestation") }) { + context.log.verbose("blocked call ${method.declaringClass.name}.${method.name}(...)") + if (method.returnType.name.endsWith("Single")) { + param.setResult( + method.returnType.methods.first { + java.lang.reflect.Modifier.isStatic(it.modifiers) && it.parameterCount == 1 && it.parameterTypes[0] == Throwable::class.java + }.invoke(null, IOException()) + ) + return@hook + } + + param.setResult(null) + } + } ?: context.log.warn("apiInvocationHandler not found in mappings") + } + + context.features.addActivityCreateListener { activity -> + if (!activity.javaClass.name.endsWith("LoginSignupActivity")) return@addActivityCreateListener + + activity.findViewById<ViewGroup>(android.R.id.content).apply { + visibility = ViewGroup.INVISIBLE + + post { + addView(createComposeView(activity) { + Surface( + modifier = Modifier.fillMaxSize() + ) { + Box( + modifier = Modifier.fillMaxSize() + ) { + Column( + modifier = Modifier + .align(Alignment.Center) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon(Icons.Rounded.NotInterested, contentDescription = null, tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(110.dp)) + Spacer(Modifier.height(50.dp)) + Text( + "SnapEnhance can't be used to login or signup because your Snapchat version isn't the recommended one. Please downgrade to Snapchat v${MOD_DETECTION_VERSION_CHECK.maxVersion?.first ?: "0.0.0"} or disable SnapEnhance in LSPosed to continue.\n\nFor more details, join t.me/snapenhance_chat", + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + } + } + } + + LaunchedEffect(Unit) { + visibility = ViewGroup.VISIBLE + } + }) + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt new file mode 100644 index 0000000000..6b03bb25da --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt @@ -0,0 +1,399 @@ +package me.rhunk.snapenhance.core + +import android.app.Activity +import android.content.Context +import android.content.res.Resources +import android.os.Build +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Cancel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import me.rhunk.snapenhance.bridge.ConfigStateListener +import me.rhunk.snapenhance.bridge.SyncCallback +import me.rhunk.snapenhance.common.Constants +import me.rhunk.snapenhance.common.ReceiversConfig +import me.rhunk.snapenhance.common.action.EnumAction +import me.rhunk.snapenhance.common.bridge.FileHandleScope +import me.rhunk.snapenhance.common.bridge.InternalFileHandleType +import me.rhunk.snapenhance.common.bridge.toWrapper +import me.rhunk.snapenhance.common.data.FriendStreaks +import me.rhunk.snapenhance.common.data.MessagingFriendInfo +import me.rhunk.snapenhance.common.data.MessagingGroupInfo +import me.rhunk.snapenhance.common.util.toSerialized +import me.rhunk.snapenhance.core.bridge.BridgeClient +import me.rhunk.snapenhance.core.data.SnapClassCache +import me.rhunk.snapenhance.core.event.events.impl.NativeUnaryCallEvent +import me.rhunk.snapenhance.core.event.events.impl.SnapWidgetBroadcastReceiveEvent +import me.rhunk.snapenhance.core.ui.InAppOverlay +import me.rhunk.snapenhance.core.util.LSPatchUpdater +import me.rhunk.snapenhance.core.util.hook.HookAdapter +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.findRestrictedMethod +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.mapper.impl.PlatformClientAttestationMapper +import kotlin.reflect.KClass +import kotlin.system.exitProcess +import kotlin.system.measureTimeMillis + + +class SnapEnhance { + companion object { + lateinit var classLoader: ClassLoader + private set + val classCache by lazy { + SnapClassCache(classLoader) + } + } + private lateinit var appContext: ModContext + private var isBridgeInitialized = false + + private fun hookMainActivity(methodName: String, stage: HookStage = HookStage.AFTER, block: Activity.(param: HookAdapter) -> Unit) { + Activity::class.java.hook(methodName, stage, { isBridgeInitialized }) { param -> + val activity = param.thisObject() as Activity + if (!activity.packageName.equals(Constants.SNAPCHAT_PACKAGE_NAME)) return@hook + block(activity, param) + } + } + + fun init(context: Context) { + appContext = ModContext( + androidContext = context.also { classLoader = it.classLoader } + ) + appContext.apply { + bridgeClient = BridgeClient(this) + initConfigListener() + bridgeClient.addOnConnectedCallback { + bridgeClient.registerMessagingBridge(messagingBridge) + coroutineScope.launch { + runCatching { + syncRemote() + }.onFailure { + log.error("Failed to sync remote", it) + } + } + } + } + + runBlocking { + var throwable: Throwable? = null + val canLoad = appContext.bridgeClient.connect { throwable = it } + if (canLoad == null) { + InAppOverlay.showCrashOverlay( + buildString { + append("Snapchat timed out while trying to connect to SnapEnhance\n\n") + append("Make sure you:\n") + append(" - Have installed the latest SnapEnhance version (https://github.com/rhunk/SnapEnhance)\n") + append(" - Disabled battery optimizations\n") + append(" - Excluded SnapEnhance and Snapchat in HideMyApplist") + }, + throwable + ) + appContext.logCritical("Cannot connect to the SnapEnhance app") + return@runBlocking + } + if (!canLoad) exitProcess(1) + runCatching { + LSPatchUpdater.onBridgeConnected(appContext) + }.onFailure { + appContext.log.error("Failed to init LSPatchUpdater", it) + } + jetpackComposeResourceHook() + runCatching { + measureTimeMillis { + init(this) + }.also { + appContext.log.verbose("init took ${it}ms") + } + + hookMainActivity("onPostCreate") { + appContext.mainActivity = this + if (!appContext.mappings.isMappingsLoaded) return@hookMainActivity + appContext.isMainActivityPaused = false + onActivityCreate(this) + appContext.actionManager.onNewIntent(intent) + } + + hookMainActivity("onPause") { + appContext.bridgeClient.closeOverlay() + appContext.isMainActivityPaused = true + } + + hookMainActivity("onNewIntent") { param -> + appContext.actionManager.onNewIntent(param.argNullable(0)) + } + + hookMainActivity("onResume") { + appContext.mainActivity = this + if (appContext.isMainActivityPaused.also { + appContext.isMainActivityPaused = false + }) { + appContext.reloadConfig() + appContext.executeAsync { + syncRemote() + } + } + } + }.onSuccess { + isBridgeInitialized = true + }.onFailure { + appContext.logCritical("Failed to initialize bridge", it) + InAppOverlay.showCrashOverlay("SnapEnhance failed to initialize. Please check logs for more details.", it) + } + } + } + + private fun init(scope: CoroutineScope) { + with(appContext) { + Thread::class.java.hook("dispatchUncaughtException", HookStage.BEFORE) { param -> + runCatching { + val throwable = param.argNullable(0) ?: Throwable() + logCritical(null, throwable) + } + } + + reloadConfig() + initNative() + initWidgetListener() + scope.launch(Dispatchers.IO) { + translation.userLocale = getConfigLocale() + translation.load() + } + + database.init() + eventDispatcher.init() + userInterface.init() + //if mappings aren't loaded, we can't initialize features + if (!mappings.isMappingsLoaded) return + features.init() + scriptRuntime.init() + scriptRuntime.eachModule { callFunction("module.onSnapApplicationLoad", androidContext) } + } + } + + private var safeMode = false + + private fun onActivityCreate(activity: Activity) { + measureTimeMillis { + with(appContext) { + features.onActivityCreate(activity) + inAppOverlay.onActivityCreate(activity) + scriptRuntime.eachModule { callFunction("module.onSnapMainActivityCreate", activity) } + actionManager.onActivityCreate() + + if (safeMode) { + appContext.inAppOverlay.showStatusToast( + Icons.Outlined.Cancel, + "Failed to load security features! Snapchat may not work properly.", + durationMs = 3000 + ) + } + } + }.also { time -> + appContext.log.verbose("onActivityCreate took $time") + } + } + + private fun initNative() { + val nativeSigCacheFileHandle = appContext.fileHandlerManager.getFileHandle(FileHandleScope.INTERNAL.key, InternalFileHandleType.NATIVE_SIG_CACHE.key).toWrapper() + + val oldSignatureCache = nativeSigCacheFileHandle.readBytes() + .takeIf { + it.isNotEmpty() + }?.toString(Charsets.UTF_8)?.also { + appContext.native.signatureCache = it + } + + val lateInit = appContext.native.initOnce { + nativeUnaryCallCallback = { request -> + appContext.event.post(NativeUnaryCallEvent(request.uri, request.buffer)) { + request.buffer = buffer + request.canceled = canceled + } + } + appContext.reloadNativeConfig() + }.let { init -> + { + init() + appContext.native.signatureCache.takeIf { it != oldSignatureCache }?.let { + appContext.log.verbose("new signature cache $it") + nativeSigCacheFileHandle.writeBytes(it.toByteArray(Charsets.UTF_8)) + } + } + } + + SecurityFeatures(appContext).init() + + Runtime::class.java.findRestrictedMethod { + it.name == "loadLibrary0" && it.parameterTypes.contentEquals( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) arrayOf(Class::class.java, String::class.java) + else arrayOf(ClassLoader::class.java, String::class.java) + ) + }!!.apply { + if (appContext.disablePlugin) { + hook(HookStage.BEFORE) { param -> + if (param.arg<String>(1) != "scplugin") return@hook + param.setResult(null) + appContext.log.verbose("skipped scplugin load") + + appContext.mappings.useMapper(PlatformClientAttestationMapper::class) { + pluginNativeClass.getAsClass()?.methods?.filter { + it.declaringClass == pluginNativeClass.getAsClass() + }?.forEach { method -> + method.hook(HookStage.BEFORE) { + appContext.log.error("Calling $method", Throwable()) + it.setResult(null) + runCatching { exitProcess(139) } + runCatching { Thread.sleep(Long.MAX_VALUE) } + } + } ?: error("Failed to get pluginNativeClass class") + } + } + } + + lateinit var unhook: () -> Unit + hook(HookStage.AFTER) { param -> + if (param.arg<String>(1) != "client") return@hook + unhook() + lateInit() + }.also { unhook = { it.unhook() } } + } + } + + private fun initConfigListener() { + val tasks = linkedSetOf<() -> Unit>() + hookMainActivity("onResume") { + tasks.forEach { it() } + } + + fun runLater(task: () -> Unit) { + if (appContext.isMainActivityPaused) { + tasks.add(task) + } else { + task() + } + } + + appContext.bridgeClient.addOnConnectedCallback { + appContext.bridgeClient.registerConfigStateListener(object: ConfigStateListener.Stub() { + override fun onConfigChanged() { + appContext.log.verbose("onConfigChanged") + appContext.reloadConfig() + } + + override fun onRestartRequired() { + appContext.log.verbose("onRestartRequired") + runLater { + appContext.log.verbose("softRestart") + appContext.softRestartApp(saveSettings = false) + } + } + + override fun onCleanCacheRequired() { + appContext.log.verbose("onCleanCacheRequired") + tasks.clear() + runLater { + appContext.log.verbose("cleanCache") + appContext.actionManager.execute(EnumAction.CLEAN_CACHE) + } + } + }) + } + } + + private fun initWidgetListener() { + appContext.event.subscribe(SnapWidgetBroadcastReceiveEvent::class) { event -> + if (event.action != ReceiversConfig.BRIDGE_SYNC_ACTION) return@subscribe + event.canceled = true + val feedEntries = appContext.database.getFeedEntries(Int.MAX_VALUE) + + val groups = feedEntries.filter { it.conversationType == 1 }.map { + MessagingGroupInfo( + it.key!!, + it.feedDisplayName ?: "", + it.participantsSize + ) + } + + val friends = feedEntries.filter { it.conversationType == 0 }.mapNotNull { + val friendUserId = it.friendUserId ?: it.participants?.firstOrNull { it != appContext.database.myUserId } + ?: return@mapNotNull null + val friend = appContext.database.getFriendInfo(friendUserId) ?: return@mapNotNull null + + MessagingFriendInfo( + friendUserId, + it.key, + friend.displayName, + friend.mutableUsername ?: friend.usernameForSorting!!, + friend.bitmojiAvatarId, + friend.bitmojiSelfieId, + streaks = null + ) + } + + appContext.bridgeClient.passGroupsAndFriends(groups, friends) + } + } + + private fun syncRemote() { + if (!appContext.isLoggedIn()) return + + val myUserId = appContext.database.myUserId + val streakEntries = appContext.database.getFeedEntries(Int.MAX_VALUE, whereClause = "streak_count IS NOT NULL AND streak_count > 0") + .associateBy { entry -> (entry.friendUserId ?: entry.participants?.firstOrNull { it != myUserId }) } + .filter { it.key != null } + + appContext.bridgeClient.sync(object : SyncCallback.Stub() { + override fun syncFriend(uuid: String): String? { + return appContext.database.getFriendInfo(uuid)?.let { + MessagingFriendInfo( + userId = it.userId!!, + dmConversationId = null, + displayName = it.displayName, + mutableUsername = it.mutableUsername!!, + bitmojiId = it.bitmojiAvatarId, + selfieId = it.bitmojiSelfieId, + streaks = if (it.streakLength > 0) { + FriendStreaks( + expirationTimestamp = it.streakExpirationTimestamp, + length = it.streakLength + ) + } else streakEntries[it.userId]?.let { + FriendStreaks( + expirationTimestamp = it.streakExpirationTimestampMs ?: return@let null, + length = it.streakCount ?: return@let null + ) + } + ).toSerialized() + } + } + + override fun syncGroup(uuid: String): String? { + return appContext.database.getFeedEntryByConversationId(uuid)?.let { + MessagingGroupInfo( + it.key!!, + it.feedDisplayName ?: "", + it.participantsSize + ).toSerialized() + } + } + }) + } + + private fun jetpackComposeResourceHook() { + fun strings(vararg classes: KClass<*>): Map<Int, String> { + return classes.fold(mapOf()) { map, clazz -> + map + clazz.java.fields.filter { + java.lang.reflect.Modifier.isStatic(it.modifiers) && it.type == Int::class.javaPrimitiveType + }.associate { it.getInt(null) to it.name } + } + } + val stringResources = strings(androidx.compose.material3.R.string::class, androidx.compose.ui.R.string::class) + Resources::class.java.getMethod("getString", Int::class.javaPrimitiveType).hook(HookStage.BEFORE) { param -> + val key = param.arg<Int>(0) + val name = stringResources[key]?.replaceFirst("m3c_", "") ?: return@hook + param.setResult(appContext.translation.getOrNull("material3_strings.${name}") ?: "") + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/XposedLoader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/XposedLoader.kt new file mode 100644 index 0000000000..5d3e5dac2d --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/XposedLoader.kt @@ -0,0 +1,22 @@ +package me.rhunk.snapenhance.core + +import android.app.Application +import de.robv.android.xposed.IXposedHookLoadPackage +import de.robv.android.xposed.XposedBridge +import de.robv.android.xposed.callbacks.XC_LoadPackage +import me.rhunk.snapenhance.common.BuildConfig +import me.rhunk.snapenhance.common.Constants +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook + +class XposedLoader : IXposedHookLoadPackage { + override fun handleLoadPackage(p0: XC_LoadPackage.LoadPackageParam) { + if (p0.packageName != Constants.SNAPCHAT_PACKAGE_NAME) return + // prevent loading in sub-processes + if (p0.processName.contains(":")) return + XposedBridge.log("Loading SnapEnhance v${BuildConfig.VERSION_NAME}#${BuildConfig.GIT_HASH} (package: ${BuildConfig.APPLICATION_ID})") + Application::class.java.hook("attach", HookStage.BEFORE) { param -> + SnapEnhance().init(param.arg(0)) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/action/AbstractAction.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/AbstractAction.kt similarity index 58% rename from app/src/main/kotlin/me/rhunk/snapenhance/action/AbstractAction.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/action/AbstractAction.kt index 4bc21cc094..0effa7f17c 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/action/AbstractAction.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/AbstractAction.kt @@ -1,25 +1,18 @@ -package me.rhunk.snapenhance.action +package me.rhunk.snapenhance.core.action -import me.rhunk.snapenhance.ModContext -import me.rhunk.snapenhance.config.ConfigProperty +import me.rhunk.snapenhance.core.ModContext import java.io.File -abstract class AbstractAction( - val nameKey: String, - val dependsOnProperty: ConfigProperty? = null, -) { +abstract class AbstractAction{ lateinit var context: ModContext - /** - * called on the main thread when the mod initialize - */ - open fun init() {} - /** * called when the action is triggered */ open fun run() {} + open fun onActivityCreate() {} + protected open fun deleteRecursively(parent: File?) { if (parent == null) return if (parent.isDirectory) for (child in parent.listFiles()!!) deleteRecursively( diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/ActionManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/ActionManager.kt new file mode 100644 index 0000000000..8db4583f6e --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/ActionManager.kt @@ -0,0 +1,47 @@ +package me.rhunk.snapenhance.core.action + +import android.content.Intent +import me.rhunk.snapenhance.common.action.EnumAction +import me.rhunk.snapenhance.core.ModContext +import me.rhunk.snapenhance.core.action.impl.BulkMessagingAction +import me.rhunk.snapenhance.core.action.impl.CleanCache +import me.rhunk.snapenhance.core.action.impl.ExportChatMessages +import me.rhunk.snapenhance.core.action.impl.ExportMemories +import me.rhunk.snapenhance.core.action.impl.ManageFriendList + +class ActionManager( + private val modContext: ModContext, +) { + + private val actions by lazy { + mapOf( + EnumAction.CLEAN_CACHE to CleanCache(), + EnumAction.EXPORT_CHAT_MESSAGES to ExportChatMessages(), + EnumAction.BULK_MESSAGING_ACTION to BulkMessagingAction(), + EnumAction.MANAGE_FRIEND_LIST to ManageFriendList(), + EnumAction.EXPORT_MEMORIES to ExportMemories(), + ).map { + it.key to it.value.apply { + this.context = modContext + } + }.toMap().toMutableMap() + } + + fun onNewIntent(intent: Intent?) { + val action = intent?.getStringExtra(EnumAction.ACTION_PARAMETER) ?: return + intent.removeExtra(EnumAction.ACTION_PARAMETER) + execute(EnumAction.entries.find { it.key == action } ?: return) + } + + fun onActivityCreate() { + actions.values.forEach { it.onActivityCreate() } + } + + fun execute(enumAction: EnumAction) { + val action = actions[enumAction] ?: return + action.run() + if (enumAction.exitOnFinish) { + modContext.forceCloseApp() + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkMessagingAction.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkMessagingAction.kt new file mode 100644 index 0000000000..3473dd0519 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkMessagingAction.kt @@ -0,0 +1,636 @@ +package me.rhunk.snapenhance.core.action.impl + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.view.Gravity +import android.view.View +import android.widget.LinearLayout +import android.widget.ProgressBar +import android.widget.TextView +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.* +import androidx.compose.runtime.* +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.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.data.FriendLinkType +import me.rhunk.snapenhance.common.database.impl.ConversationMessage +import me.rhunk.snapenhance.common.database.impl.FriendInfo +import me.rhunk.snapenhance.common.messaging.MessagingConstraints +import me.rhunk.snapenhance.common.messaging.MessagingTask +import me.rhunk.snapenhance.common.messaging.MessagingTaskType +import me.rhunk.snapenhance.common.ui.createComposeAlertDialog +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState +import me.rhunk.snapenhance.common.util.ktx.copyToClipboard +import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie +import me.rhunk.snapenhance.common.util.snap.RemoteMediaResolver +import me.rhunk.snapenhance.core.action.AbstractAction +import me.rhunk.snapenhance.core.features.impl.experiments.AddFriendSourceSpoof +import me.rhunk.snapenhance.core.features.impl.experiments.BetterLocation +import me.rhunk.snapenhance.core.features.impl.messaging.Messaging +import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper +import me.rhunk.snapenhance.core.util.EvictingMap +import me.rhunk.snapenhance.core.util.dataBuilder +import me.rhunk.snapenhance.mapper.impl.FriendRelationshipChangerMapper +import java.text.DateFormat +import java.util.Date +import kotlin.random.Random + +class BulkMessagingAction : AbstractAction() { + enum class SortBy { + NONE, + USERNAME, + ADDED_TIMESTAMP, + SNAP_SCORE, + STREAK_LENGTH, + MOST_MESSAGES_SENT, + MOST_RECENT_MESSAGE, + NEAREST_LOCATION + } + + enum class Filter { + ALL, + MY_FRIENDS, + BLOCKED, + REMOVED_ME, + DELETED, + SUGGESTED, + BUSINESS_ACCOUNTS, + STREAKS, + NON_STREAKS, + LOCATION_ON_MAP + } + + private val translation by lazy { context.translation.getCategory("bulk_messaging_action") } + private val betterLocation by lazy { context.feature(BetterLocation::class) } + + private fun removeAction( + ctx: Context, + ids: List<String>, + delay: Pair<Long, Long>, + action: suspend (id: String, setDialogMessage: (String) -> Unit) -> Unit = { _, _ -> } + ) = context.coroutineScope.launch { + val statusTextView = TextView(ctx) + val dialog = withContext(Dispatchers.Main) { + ViewAppearanceHelper.newAlertDialogBuilder(ctx) + .setTitle("...") + .setView(LinearLayout(ctx).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + addView(statusTextView.apply { + layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) + textAlignment = View.TEXT_ALIGNMENT_CENTER + }) + addView(ProgressBar(ctx)) + }) + .setCancelable(false) + .show() + } + + ids.forEachIndexed { index, id -> + launch(Dispatchers.Main) { + dialog.setTitle( + translation.format("progress_status", "index" to (index + 1).toString(), "total" to ids.size.toString()) + ) + } + runCatching { + action(id) { + launch(Dispatchers.Main) { + statusTextView.text = it + } + } + }.onFailure { + context.log.error("Failed to process $it", it) + context.shortToast("Failed to process $id") + } + delay(Random.nextLong(delay.first, delay.second)) + } + withContext(Dispatchers.Main) { + dialog.dismiss() + } + } + + @Composable + private fun ConfirmationDialog( + onConfirm: () -> Unit, + onCancel: () -> Unit, + ) { + AlertDialog( + onDismissRequest = onCancel, + title = { Text(text = translation["confirmation_dialog.title"]) }, + text = { Text(text = translation["confirmation_dialog.message"]) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(text = context.translation["button.positive"]) + } + }, + dismissButton = { + TextButton(onClick = onCancel) { + Text(text = context.translation["button.negative"]) + } + } + ) + } + + private fun filterFriends(friends: List<FriendInfo>, filter: Filter, nameFilter: String): List<FriendInfo> { + val userIdBlacklist = arrayOf( + context.database.myUserId, + "b42f1f70-5a8b-4c53-8c25-34e7ec9e6781", // myai + "84ee8839-3911-492d-8b94-72dd80f3713a", // teamsnapchat + ) + + return friends.filter { friend -> + friend.userId !in userIdBlacklist && when (filter) { + Filter.ALL -> true + Filter.MY_FRIENDS -> friend.friendLinkType == FriendLinkType.MUTUAL.value && friend.addedTimestamp > 0 + Filter.BLOCKED -> friend.friendLinkType == FriendLinkType.BLOCKED.value + Filter.REMOVED_ME -> friend.friendLinkType == FriendLinkType.OUTGOING.value && friend.addedTimestamp > 0 && friend.businessCategory == 0 // ignore followed accounts + Filter.SUGGESTED -> friend.friendLinkType == FriendLinkType.SUGGESTED.value + Filter.DELETED -> friend.friendLinkType == FriendLinkType.DELETED.value + Filter.BUSINESS_ACCOUNTS -> friend.businessCategory > 0 + Filter.STREAKS -> friend.friendLinkType == FriendLinkType.MUTUAL.value && friend.addedTimestamp > 0 && friend.streakLength != 0 + Filter.NON_STREAKS -> friend.friendLinkType == FriendLinkType.MUTUAL.value&& friend.addedTimestamp > 0 && friend.streakLength == 0 + Filter.LOCATION_ON_MAP -> betterLocation.locationHistory.contains(friend.userId) + } && nameFilter.takeIf { it.isNotBlank() }?.let { name -> + friend.mutableUsername?.contains( + name, + ignoreCase = true + ) == true || friend.displayName?.contains(name, ignoreCase = true) == true + } ?: true + } + } + + private fun getDMLastMessage(userId: String?): ConversationMessage? { + return context.database.getDMConversationId(userId ?: return null)?.let { + context.database.getMessagesFromConversationId(it, 1) + }?.firstOrNull() + } + + @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) + @Composable + private fun BulkMessagingDialog() { + val coroutineScope = rememberCoroutineScope { Dispatchers.IO } + var sortBy by remember { mutableStateOf(SortBy.USERNAME) } + var filter by remember { mutableStateOf(Filter.REMOVED_ME) } + var sortReverseOrder by remember { mutableStateOf(false) } + val selectedFriends = remember { mutableStateListOf<String>() } + val friends = remember { mutableStateListOf<FriendInfo>() } + val bitmojiCache = remember { EvictingMap<String, Bitmap>(50) } + val noBitmojiBitmap = remember { BitmapFactory.decodeResource(context.resources, android.R.drawable.ic_menu_report_image).asImageBitmap() } + + val focusManager = LocalFocusManager.current + var nameFilter by remember { mutableStateOf("") } + + suspend fun refreshList(clearSelected: Boolean = true) { + val myLocation = betterLocation.locationHistory[context.database.myUserId] + + withContext(Dispatchers.IO) { + val newFriends = context.database.getAllFriends().let { friends -> + filterFriends(friends, filter, nameFilter) + }.toMutableList() + when (sortBy) { + SortBy.NONE -> {} + SortBy.USERNAME -> newFriends.sortBy { it.mutableUsername } + SortBy.ADDED_TIMESTAMP -> newFriends.sortBy { it.addedTimestamp } + SortBy.SNAP_SCORE -> newFriends.sortBy { it.snapScore } + SortBy.STREAK_LENGTH -> newFriends.sortBy { it.streakLength } + SortBy.MOST_MESSAGES_SENT -> newFriends.sortByDescending { + getDMLastMessage(it.userId)?.serverMessageId ?: 0 + } + SortBy.MOST_RECENT_MESSAGE -> newFriends.sortByDescending { + getDMLastMessage(it.userId)?.creationTimestamp + } + SortBy.NEAREST_LOCATION -> { + if (myLocation != null) { + newFriends.sortBy { + betterLocation.locationHistory[it.userId]?.distanceTo(myLocation) + ?: Double.MAX_VALUE + } + } + } + } + if (sortReverseOrder) newFriends.reverse() + withContext(Dispatchers.Main) { + if (clearSelected) selectedFriends.clear() + friends.clear() + friends.addAll(newFriends) + } + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + var filterMenuExpanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = filterMenuExpanded, + onExpandedChange = { filterMenuExpanded = it }, + ) { + ElevatedCard( + modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable) + ) { + Text(text = filter.name, modifier = Modifier.padding(5.dp)) + } + + DropdownMenu( + expanded = filterMenuExpanded, + onDismissRequest = { filterMenuExpanded = false } + ) { + Filter.entries.forEach { entry -> + DropdownMenuItem(onClick = { + filter = entry + filterMenuExpanded = false + }, text = { + Text(text = entry.name, fontWeight = if (entry == filter) FontWeight.Bold else FontWeight.Normal) + }) + } + } + } + + var sortMenuExpanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = sortMenuExpanded, + onExpandedChange = { sortMenuExpanded = it }, + ) { + ElevatedCard( + modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable) + ) { + Text(text = "Sort by", modifier = Modifier.padding(5.dp)) + } + + DropdownMenu( + expanded = sortMenuExpanded, + onDismissRequest = { sortMenuExpanded = false } + ) { + SortBy.entries.forEach { entry -> + DropdownMenuItem(onClick = { + sortBy = entry + sortMenuExpanded = false + }, text = { + Text(text = entry.name, fontWeight = if (entry == sortBy) FontWeight.Bold else FontWeight.Normal) + }) + } + } + } + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = sortReverseOrder, + onCheckedChange = { sortReverseOrder = it }, + ) + Text(text = "Reverse order", fontSize = 15.sp, fontWeight = FontWeight.Light, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + verticalArrangement = Arrangement.spacedBy(3.dp) + ) { + stickyHeader { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(bottom = 2.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + TextField( + value = nameFilter, + onValueChange = { + nameFilter = it + coroutineScope.launch { refreshList(clearSelected = false) } + }, + placeholder = { Text(text = "Search by name") }, + singleLine = true, + modifier = Modifier + .padding(end = 5.dp), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent + ), + ) + + Checkbox( + checked = if (friends.isEmpty() || selectedFriends.size < friends.size) false else friends.all { friend -> selectedFriends.contains(friend.userId) }, + onCheckedChange = { state -> + if (state) { + friends.mapNotNull { it.userId }.forEach { userId -> + if (!selectedFriends.contains(userId)) { + selectedFriends.add(userId) + } + } + } else { + if (nameFilter.isNotBlank()) { + filterFriends(friends, filter, nameFilter).mapNotNull { it.userId }.forEach { userId -> + selectedFriends.remove(userId) + } + } else { + selectedFriends.clear() + } + } + } + ) + } + } + item { + if (friends.isEmpty()) { + Text(text = "No friends found", fontSize = 12.sp, fontWeight = FontWeight.Light, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center) + } + } + items(friends, key = { it.userId!! }) { friendInfo -> + var bitmojiBitmap by remember(friendInfo) { mutableStateOf(bitmojiCache[friendInfo.bitmojiAvatarId]) } + + fun selectFriend(state: Boolean) { + friendInfo.userId?.let { + if (state) { + selectedFriends.add(it) + } else { + selectedFriends.remove(it) + } + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + selectFriend(!selectedFriends.contains(friendInfo.userId)) + }.pointerInput(Unit) { + detectTapGestures( + onLongPress = { context.androidContext.copyToClipboard(friendInfo.mutableUsername.toString()) } + ) + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + LaunchedEffect(friendInfo) { + withContext(Dispatchers.IO) { + if (bitmojiBitmap != null || friendInfo.bitmojiAvatarId == null || friendInfo.bitmojiSelfieId == null) return@withContext + + val bitmojiUrl = BitmojiSelfie.getBitmojiSelfie(friendInfo.bitmojiSelfieId, friendInfo.bitmojiAvatarId, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D) ?: return@withContext + + runCatching { + RemoteMediaResolver.downloadMedia(bitmojiUrl) { inputStream, length -> + bitmojiCache[friendInfo.bitmojiAvatarId ?: return@withContext] = BitmapFactory.decodeStream(inputStream).also { + bitmojiBitmap = it + } + } + } + } + } + + Image( + bitmap = remember (bitmojiBitmap) { bitmojiBitmap?.asImageBitmap() ?: noBitmojiBitmap }, + contentDescription = null, + modifier = Modifier.size(35.dp) + ) + + Column( + modifier = Modifier.weight(1f), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(3.dp), + verticalAlignment = Alignment.CenterVertically + ){ + Text(text = (friendInfo.displayName ?: friendInfo.mutableUsername).toString(), fontSize = 16.sp, fontWeight = FontWeight.Bold, overflow = TextOverflow.Ellipsis, maxLines = 1, lineHeight = 10.sp) + Text(text = friendInfo.mutableUsername.toString(), fontSize = 10.sp, fontWeight = FontWeight.Light, overflow = TextOverflow.Ellipsis, maxLines = 1, lineHeight = 10.sp) + } + val lastMessage by rememberAsyncMutableState(defaultValue = null) { + getDMLastMessage(friendInfo.userId) + } + + val userInfo = remember(friendInfo, lastMessage) { + buildString { + append("Relationship: ") + append(context.translation["friendship_link_type.${FriendLinkType.fromValue(friendInfo.friendLinkType).shortName}"]) + friendInfo.addedTimestamp.takeIf { it > 0L }?.let { + append("\nAdded ${DateFormat.getDateTimeInstance().format(Date(it))}") + } + friendInfo.snapScore.takeIf { it > 0 }?.let { + append("\nSnap Score: $it") + } + friendInfo.streakLength.takeIf { it > 0 }?.let { + append("\nStreaks length: $it") + } + lastMessage?.let { + append("\nSent messages: ${it.serverMessageId}") + append("\nLast message: ${DateFormat.getDateTimeInstance().format(Date(it.creationTimestamp))}") + } + betterLocation.locationHistory[context.database.myUserId]?.let { myLocation -> + betterLocation.locationHistory[friendInfo.userId]?.let { + append("\n${myLocation.distanceTo(it).let { distance -> + if (distance < 1) "${(distance * 1000).toInt()} m" else "${distance.toInt()} km" + } } away") + } + } + } + } + Text(text = userInfo, fontSize = 12.sp, fontWeight = FontWeight.Light, lineHeight = 12.sp, overflow = TextOverflow.Ellipsis) + } + + Checkbox( + checked = selectedFriends.contains(friendInfo.userId), + onCheckedChange = { selectFriend(it) } + ) + } + } + } + + var showConfirmationDialog by remember { mutableStateOf(false) } + var action by remember { mutableStateOf({}) } + + if (showConfirmationDialog) { + ConfirmationDialog( + onConfirm = { + action() + action = {} + showConfirmationDialog = false + }, + onCancel = { + action = {} + showConfirmationDialog = false + } + ) + } + + val ctx = LocalContext.current + + val actions = remember { + mapOf<() -> String, () -> Unit>( + { "Clean " + selectedFriends.size + " conversations" } to { + context.feature(Messaging::class).conversationManager?.getOneOnOneConversationIds(selectedFriends.toList().also { + selectedFriends.clear() + }, onError = { error -> + context.shortToast("Failed to fetch conversations: $error") + }, onSuccess = { conversations -> + removeAction(ctx, conversations.map { it.second }.distinct(), delay = 10L to 40L) { conversationId, setDialogMessage -> + cleanConversation( + conversationId, setDialogMessage + ) + }.invokeOnCompletion { + coroutineScope.launch { refreshList() } + } + }) + }, + { "Remove " + selectedFriends.size + " friends" } to { + removeAction(ctx, selectedFriends.toList().also { + selectedFriends.clear() + }, delay = 500L to 1200L) { userId, _ -> removeFriend(userId) }.invokeOnCompletion { + coroutineScope.launch { refreshList() } + } + }, + { "Clean " + selectedFriends.size + " conversations and remove " + selectedFriends.size + " friends" } to { + context.feature(Messaging::class).conversationManager?.getOneOnOneConversationIds(selectedFriends.toList().also { + selectedFriends.clear() + }, onError = { error -> + context.shortToast("Failed to fetch conversations: $error") + }, onSuccess = { conversations -> + removeAction(ctx, conversations.map { it.second }.distinct(), delay = 500L to 1200L) { conversationId, setDialogMessage -> + cleanConversation( + conversationId, setDialogMessage + ) + removeFriend(conversations.firstOrNull { it.second == conversationId }?.first ?: return@removeAction) + }.invokeOnCompletion { + coroutineScope.launch { refreshList() } + } + }) + } + ) + } + + Column( + modifier = Modifier.fillMaxWidth(), + ) { + actions.forEach { (text, actionFunction) -> + Button( + modifier = Modifier + .fillMaxWidth() + .padding(2.dp), + onClick = { + showConfirmationDialog = true + action = actionFunction + }, + enabled = selectedFriends.isNotEmpty() + ) { + Text(text = remember(selectedFriends.size) { text() }) + } + } + } + } + + LaunchedEffect(sortBy, sortReverseOrder) { + coroutineScope.launch { + refreshList(clearSelected = false) + } + focusManager.clearFocus() + } + + LaunchedEffect(filter) { + coroutineScope.launch { + refreshList() + } + focusManager.clearFocus() + } + } + + override fun run() { + context.coroutineScope.launch(Dispatchers.Main) { + createComposeAlertDialog(context.mainActivity!!) { + BulkMessagingDialog() + }.apply { + setCanceledOnTouchOutside(false) + show() + } + } + } + + private fun removeFriend(userId: String) { + context.mappings.useMapper(FriendRelationshipChangerMapper::class) { + val friendRelationshipChangerInstance = context.feature(AddFriendSourceSpoof::class).friendRelationshipChangerInstance!! + val runFriendDurableJobMethod = classReference.getAsClass()?.methods?.first { + it.name == runFriendDurableJob.getAsString() + } ?: throw Exception("Failed to find runFriendDurableJobMethod method") + + val removeFriendDurableJob = context.androidContext.classLoader.loadClass("com.snap.identity.job.snapchatter.RemoveFriendDurableJob") + .constructors.firstOrNull { + it.parameterTypes.size == 1 + }?.run { + newInstance( + parameterTypes[0].dataBuilder { + set("a", userId) // userId + set("b", "DELETED_BY_MY_FRIENDS") // deleteSourceType + set("f", "") + } + ) + } ?: throw Exception("Failed to create RemoveFriendDurableJob instance") + + val completable = runFriendDurableJobMethod.invoke(null, + friendRelationshipChangerInstance, + userId, // userId + removeFriendDurableJob, // friend durable job + 0x5, // action type + "DELETED_BY_MY_FRIENDS", // deleteSourceType + )!! + completable::class.java.methods.first { + it.name == "subscribe" && it.parameterTypes.isEmpty() + }.invoke(completable) + } + } + + private suspend fun cleanConversation( + conversationId: String, + setDialogMessage: (String) -> Unit + ) { + val messageCount = mutableIntStateOf(0) + MessagingTask( + context.messagingBridge, + conversationId, + taskType = MessagingTaskType.DELETE, + constraints = listOf(MessagingConstraints.MY_USER_ID(context.messagingBridge), { + contentType != ContentType.STATUS.id + }), + processedMessageCount = messageCount, + onSuccess = { + setDialogMessage("${messageCount.intValue} deleted messages") + }, + ).run() + } +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/action/impl/CleanCache.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/CleanCache.kt similarity index 66% rename from app/src/main/kotlin/me/rhunk/snapenhance/action/impl/CleanCache.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/CleanCache.kt index e3bfad091d..9482a94518 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/action/impl/CleanCache.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/CleanCache.kt @@ -1,25 +1,33 @@ -package me.rhunk.snapenhance.action.impl +package me.rhunk.snapenhance.core.action.impl -import me.rhunk.snapenhance.action.AbstractAction +import me.rhunk.snapenhance.core.action.AbstractAction import java.io.File -class CleanCache : AbstractAction("action.clean_cache") { +class CleanCache : AbstractAction() { companion object { private val FILES = arrayOf( "files/mbgl-offline.db", "files/native_content_manager/*", "files/file_manager/*", + "files/composer_cache/*", "files/blizzardv2/*", "files/streaming/*", "cache/*", + "files/streaming/*", + "databases/media_packages", + "databases/simple_db_helper.db", + "databases/simple_db_helper.db-wal", + "databases/simple_db_helper.db-shm", + "databases/journal.db", "databases/arroyo.db", "databases/arroyo.db-wal", + "databases/arroyo.db-shm", "databases/native_content_manager/*" ) } override fun run() { - FILES.forEach {fileName -> + FILES.forEach { fileName -> val fileCache = File(context.androidContext.dataDir, fileName) if (fileName.endsWith("*")) { val parent = fileCache.parentFile ?: throw IllegalStateException("Parent file is null") diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ExportChatMessages.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ExportChatMessages.kt new file mode 100644 index 0000000000..3cd812d4c9 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ExportChatMessages.kt @@ -0,0 +1,425 @@ +package me.rhunk.snapenhance.core.action.impl + +import android.app.AlertDialog +import android.content.DialogInterface +import android.os.Environment +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.* +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.database.impl.FriendFeedEntry +import me.rhunk.snapenhance.common.ui.createComposeAlertDialog +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState +import me.rhunk.snapenhance.core.action.AbstractAction +import me.rhunk.snapenhance.core.features.impl.messaging.Messaging +import me.rhunk.snapenhance.core.logger.CoreLogger +import me.rhunk.snapenhance.core.messaging.ConversationExporter +import me.rhunk.snapenhance.core.messaging.ExportFormat +import me.rhunk.snapenhance.core.messaging.ExportParams +import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper +import me.rhunk.snapenhance.core.wrapper.impl.Message +import java.io.File +import kotlin.math.absoluteValue + +class ExportChatMessages : AbstractAction() { + private val translation by lazy { context.translation.getCategory("chat_export") } + private val dialogLogs = mutableListOf<String>() + private var currentActionDialog: AlertDialog? = null + + private fun logDialog(message: String) { + context.runOnUiThread { + if (dialogLogs.size > 10) dialogLogs.removeAt(0) + dialogLogs.add(message) + context.log.debug("dialog: $message", "ExportChatMessages") + currentActionDialog!!.setMessage(dialogLogs.joinToString("\n")) + } + } + + private fun setStatus(message: String) { + context.runOnUiThread { + currentActionDialog!!.setTitle(message) + } + } + + @OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) + @Composable + private fun ExporterDialog( + getDialog: () -> AlertDialog? = { null } + ) { + val exporterTranslation = remember { + translation.getCategory("exporter_dialog") + } + + var feedEntries by remember { mutableStateOf(emptyList<FriendFeedEntry>()) } + var exportType by remember { mutableStateOf(ExportFormat.HTML) } + val selectedFeedEntries = remember { mutableStateListOf<FriendFeedEntry>() } + val messageTypeFilter = remember { mutableStateListOf<ContentType>() } + var amountOfMessages by remember { mutableIntStateOf(-1) } + var downloadMedias by remember { mutableStateOf(false) } + val allFriends by rememberAsyncMutableState(null) { context.database.getAllFriends().associateBy { it.userId!! } } + val myUserId = context.database.myUserId + + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(remember { ScrollState(0) }) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text(exporterTranslation["select_conversations_title"]) + run { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + TextField( + value = selectedFeedEntries.let { + exporterTranslation.format("text_field_selection", "amount" to it.size.toString()) + }, + onValueChange = {}, + readOnly = true, + singleLine = true, + modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable) + ) + + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + LazyColumn( + modifier = Modifier.size(LocalConfiguration.current.screenWidthDp.dp, 300.dp) + ) { + items(feedEntries, key = { it.key!! }) { feedEntry -> + DropdownMenuItem( + modifier = Modifier.fillMaxWidth(), + onClick = { + if (selectedFeedEntries.contains(feedEntry)) selectedFeedEntries -= feedEntry + else selectedFeedEntries += feedEntry + }, + text = { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox(checked = selectedFeedEntries.contains(feedEntry), onCheckedChange = null) + Column { + Text( + text = remember(feedEntry) { + (if (feedEntry.conversationType == 1) feedEntry.feedDisplayName else feedEntry.participants?.filter { it != myUserId }?.firstOrNull()?.let { userId -> + allFriends?.get(userId)?.let { friend -> friend.displayName?.let { "$it (${friend.mutableUsername})" } ?: friend.mutableUsername } + }) ?: "Unknown" + }, + overflow = TextOverflow.Ellipsis, + lineHeight = 15.sp, + maxLines = 1 + ) + if (feedEntry.conversationType == 1) { + Text( + text = "${feedEntry.participantsSize} participants", + fontSize = 10.sp, + lineHeight = 15.sp, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + ) + } + } + } + } + } + + Text(exporterTranslation["export_file_format_title"]) + run { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + TextField( + value = exportType.extension, + onValueChange = {}, + readOnly = true, + modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable) + ) + + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + ExportFormat.entries.forEach { exportFormat -> + DropdownMenuItem(onClick = { + exportType = exportFormat + expanded = false + }, text = { + Text(text = exportFormat.name) + }) + } + } + } + } + Text(exporterTranslation["message_type_filter_title"]) + run { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + TextField( + value = messageTypeFilter.takeIf { it.isNotEmpty() }?.let { + exporterTranslation.format("text_field_selection", "amount" to it.size.toString()) + } ?: exporterTranslation["text_field_selection_all"], + onValueChange = {}, + readOnly = true, + modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable) + ) + + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + arrayOf( + ContentType.CHAT, + ContentType.SNAP, + ContentType.EXTERNAL_MEDIA, + ContentType.NOTE, + ContentType.STICKER + ).forEach { contentType -> + DropdownMenuItem(onClick = { + if (messageTypeFilter.contains(contentType)) messageTypeFilter -= contentType + else messageTypeFilter += contentType + }, text = { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox(checked = messageTypeFilter.contains(contentType), onCheckedChange = null) + Text(text = contentType.name) + } + }) + } + } + } + } + + Text(exporterTranslation["amount_of_messages_title"]) + val focusManager = LocalFocusManager.current + val keyboard = LocalSoftwareKeyboardController.current + TextField( + value = amountOfMessages.takeIf { it != -1 }?.toString() ?: "", + onValueChange = { amountOfMessages = it.toIntOrNull()?.absoluteValue ?: -1 }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Number), + keyboardActions = KeyboardActions( + onDone = { + focusManager.clearFocus() + keyboard?.hide() + }) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox(checked = downloadMedias, onCheckedChange = { downloadMedias = it }) + Text(exporterTranslation["download_medias_title"]) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Button( + onClick = { getDialog()?.dismiss() } + ) { + Text(text = translation["dialog_negative_button"]) + } + Button( + enabled = selectedFeedEntries.isNotEmpty(), + onClick = { + exportChatForConversations(selectedFeedEntries, ExportParams( + exportFormat = exportType, + messageTypeFilter = messageTypeFilter.takeIf { it.isNotEmpty() }, + amountOfMessages = amountOfMessages.takeIf { it != -1 }, + downloadMedias = downloadMedias + )) + } + ) { + Text(text = translation["dialog_positive_button"]) + } + } + + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + feedEntries = context.database.getFeedEntries(500) + } + } + } + } + + override fun run() { + context.coroutineScope.launch(Dispatchers.Main) { + createComposeAlertDialog(context.mainActivity!!) { alertDialog -> + ExporterDialog { alertDialog } + }.apply { + setCanceledOnTouchOutside(false) + show() + } + } + } + + private suspend fun fetchMessagesPaginated(conversationId: String, lastMessageId: Long, amount: Int): List<Message> = runBlocking { + for (i in 0..5) { + val messages: List<Message>? = suspendCancellableCoroutine { continuation -> + context.feature(Messaging::class).conversationManager?.fetchConversationWithMessagesPaginated(conversationId, + lastMessageId, + amount, onSuccess = { messages -> + continuation.resumeWith(Result.success(messages)) + }, onError = { + continuation.resumeWith(Result.success(null)) + }) ?: continuation.resumeWith(Result.success(null)) + } + if (messages != null) return@runBlocking messages + logDialog("Retrying in 1 second...") + delay(1000) + } + logDialog("Failed to fetch messages") + emptyList() + } + + private fun exportChatForConversations( + conversations: List<FriendFeedEntry>, + exportParams: ExportParams, + ) { + dialogLogs.clear() + val jobs = mutableListOf<Job>() + + currentActionDialog = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) + .setTitle(translation["exporting_chats"]) + .setCancelable(false) + .setMessage("") + .create() + + val conversationSize = translation.format("processing_chats", "amount" to conversations.size.toString()) + + logDialog(conversationSize) + + context.coroutineScope.launch { + conversations.forEach { conversation -> + launch { + runCatching { + exportFullConversation(conversation, exportParams) + }.onFailure { + logDialog(translation.format("export_fail", "conversation" to conversation.key.toString())) + logDialog(it.stackTraceToString()) + CoreLogger.xposedLog(it) + } + }.also { jobs.add(it) } + } + jobs.joinAll() + logDialog(translation["finished"]) + }.also { + currentActionDialog?.setButton(DialogInterface.BUTTON_POSITIVE, translation["dialog_negative_button"]) { dialog, _ -> + it.cancel() + jobs.forEach { it.cancel() } + dialog.dismiss() + } + } + + currentActionDialog!!.also { + it.setCanceledOnTouchOutside(false) + }.show() + } + + private suspend fun exportFullConversation( + feedEntry: FriendFeedEntry, + exportParams: ExportParams, + ) { + //first fetch the first message + val conversationId = feedEntry.key!! + val conversationParticipants = context.database.getConversationParticipants(feedEntry.key!!, useCache = false) + ?.mapNotNull { + context.database.getFriendInfo(it) + }?.associateBy { it.userId!! } ?: emptyMap() + + val conversationName = feedEntry.feedDisplayName ?: conversationParticipants.values.take(3).joinToString("_") { it.mutableUsername ?: "" } + + val publicFolder = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "SnapEnhance").also { if (!it.exists()) it.mkdirs() } + val outputFile = publicFolder.resolve("conversation_${conversationName}_${System.currentTimeMillis()}.${exportParams.exportFormat.extension}") + + logDialog(translation.format("exporting_message", "conversation" to conversationName)) + + val conversationExporter = ConversationExporter( + context = context, + friendFeedEntry = feedEntry, + conversationParticipants = conversationParticipants, + exportParams = exportParams, + cacheFolder = publicFolder.resolve("cache").also { if (!it.exists()) it.mkdirs() }, + outputFile = outputFile, + ).apply { init(); printLog = { + logDialog(it.toString()) + } } + + var foundMessageCount = 0 + + var lastMessageId = fetchMessagesPaginated(conversationId, Long.MAX_VALUE, amount = 1).firstOrNull()?.also { + conversationExporter.readMessage(it) + foundMessageCount++ + }?.messageDescriptor?.messageId ?: run { + logDialog(translation["no_messages_found"]) + return + } + + while (true) { + val fetchedMessages = fetchMessagesPaginated(conversationId, lastMessageId, amount = 500).toMutableList() + if (fetchedMessages.isEmpty()) break + + fetchedMessages.firstOrNull()?.let { + lastMessageId = it.messageDescriptor!!.messageId!! + } + + exportParams.messageTypeFilter?.let { filter -> + fetchedMessages.removeIf { message -> + !filter.contains(message.messageContent?.contentType ?: return@removeIf false) + } + } + + foundMessageCount += fetchedMessages.size + + if (exportParams.amountOfMessages != null && foundMessageCount >= exportParams.amountOfMessages) { + fetchedMessages.reversed().subList(0, exportParams.amountOfMessages - (foundMessageCount - fetchedMessages.size)).forEach { message -> + conversationExporter.readMessage(message) + } + break + } + + fetchedMessages.reversed().forEach { message -> + conversationExporter.readMessage(message) + } + + setStatus("Exporting (found ${foundMessageCount})") + } + + if (exportParams.exportFormat == ExportFormat.HTML) conversationExporter.awaitDownload() + conversationExporter.close() + logDialog(translation["writing_output"]) + dialogLogs.clear() + logDialog("\n" + translation.format("exported_to", + "path" to outputFile.absolutePath.toString() + ) + "\n") + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ExportMemories.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ExportMemories.kt new file mode 100644 index 0000000000..54da905955 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ExportMemories.kt @@ -0,0 +1,387 @@ +package me.rhunk.snapenhance.core.action.impl + +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteDatabase.OpenParams +import android.os.Environment +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.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.* +import me.rhunk.snapenhance.common.data.FileType +import me.rhunk.snapenhance.common.ui.createComposeAlertDialog +import me.rhunk.snapenhance.common.util.ktx.getLongOrNull +import me.rhunk.snapenhance.common.util.ktx.getStringOrNull +import me.rhunk.snapenhance.core.action.AbstractAction +import okhttp3.OkHttpClient +import java.io.File +import java.io.FileOutputStream +import java.nio.file.attribute.FileTime +import java.time.Instant +import java.time.OffsetDateTime +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import javax.crypto.Cipher +import javax.crypto.CipherInputStream +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.math.absoluteValue + +class ExportMemories : AbstractAction() { + data class TimeRange( + val start: Long?, + val end: Long?, + ) + + data class MemoriesEntry( + val storyTitle: String, + val createTime: Long, + val mediaKey: String?, + val mediaIv: String?, + val downloadUrl: String + ) { + val folderName: String + get() = storyTitle.replace(Regex("[^a-zA-Z0-9\\s]"), "").trim().replace(Regex("\\s+"), "_") + } + + @OptIn(ExperimentalCoroutinesApi::class, ExperimentalEncodingApi::class) + private suspend fun exportMemories( + scope: CoroutineScope = context.coroutineScope, + database: SQLiteDatabase, + timeRange: TimeRange?, + includeMEO: Boolean, + folders: Boolean, + progress: (Int, Int) -> Unit + ) { + val downloadContext = Dispatchers.IO.limitedParallelism(10) + val writeToZipContext = Dispatchers.IO.limitedParallelism(1) + val outputZip = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), "memories_" + System.currentTimeMillis() + ".zip").also { + if (it.exists()) it.delete() + } + val okHttpClient = OkHttpClient.Builder().build() + val outputZipFile = withContext(Dispatchers.IO) { + ZipOutputStream(FileOutputStream(outputZip)).apply { + setComment("Exported from SnapEnhance") + setMethod(ZipOutputStream.DEFLATED) + } + } + var totalCount = 0 + var currentCount = 0 + var failed = 0 + + fun updateProgress() { + progress((currentCount.toFloat() / totalCount.toFloat() * 100f).toInt(), failed) + } + + val jobs = mutableListOf<Job>() + + val meoMasterKeyPair = if (includeMEO) { + runCatching { + database.rawQuery("SELECT * FROM memories_meo_confidential", null).use { cursor -> + if (cursor.moveToNext()) { + cursor.getStringOrNull("master_key")!!.trim() to cursor.getStringOrNull("master_key_iv")!!.trim() + } else null + } + }.getOrNull() + } else null + + database.rawQuery("SELECT memories_entry.title as story_title, memories_snap.create_time, " + + "memories_snap.media_key, memories_snap.media_iv, memories_snap.encrypted_media_key, memories_snap.encrypted_media_iv, " + + "memories_media.download_url FROM memories_snap " + + "INNER JOIN memories_entry ON memories_snap.memories_entry_id = memories_entry._id " + + "INNER JOIN memories_media ON memories_snap.media_id = memories_media._id " + + "WHERE memories_snap.create_time >= ? AND memories_snap.create_time <= ? " + + "ORDER BY memories_snap.create_time ASC", arrayOf(timeRange?.start?.toString() ?: "-1", timeRange?.end?.toString() ?: Long.MAX_VALUE.toString()) + ).use { cursor -> + while (cursor.moveToNext()) { + val encryptedMediaKey = cursor.getStringOrNull("encrypted_media_key")?.trim() + val encryptedMediaIv = cursor.getStringOrNull("encrypted_media_iv")?.trim() + var mediaKey = cursor.getStringOrNull("media_key")?.trim() + var mediaIv = cursor.getStringOrNull("media_iv")?.trim() + + if (!includeMEO && encryptedMediaKey != null && encryptedMediaIv != null) continue + + meoMasterKeyPair.takeIf { encryptedMediaKey != null && encryptedMediaIv != null }?.let { keyPair -> + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + runCatching { + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(Base64.decode(keyPair.first), "AES"), IvParameterSpec(Base64.decode(keyPair.second))) + mediaKey = Base64.encode(cipher.doFinal(Base64.decode(encryptedMediaKey ?: return@let))) + mediaIv = Base64.encode(cipher.doFinal(Base64.decode(encryptedMediaIv ?: return@let))) + context.log.verbose("decrypted meo $mediaKey/$mediaIv") + }.onFailure { + context.log.error("failed to decrypt meo", it) + } + } + + if (mediaKey == null || mediaIv == null) { + context.log.error("missing media key or iv for ${cursor.getStringOrNull("download_url")}") + failed++ + updateProgress() + continue + } + + val entry = MemoriesEntry( + storyTitle = cursor.getStringOrNull("story_title") ?: "unknown", + createTime = cursor.getLongOrNull("create_time") ?: -1L, + mediaKey = mediaKey, + mediaIv = mediaIv, + downloadUrl = cursor.getStringOrNull("download_url") ?: continue + ) + + totalCount++ + + scope.launch(downloadContext) { + var downloadedFile = File.createTempFile("memories", ".tmp", context.androidContext.cacheDir) + + runCatching { + okHttpClient.newCall( + okhttp3.Request.Builder() + .url(entry.downloadUrl) + .build() + ).execute().use { response -> + val inputStream = response.body.byteStream().let { + if (entry.mediaKey != null && entry.mediaIv != null) { + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(Base64.decode(entry.mediaKey), "AES"), IvParameterSpec(Base64.decode(entry.mediaIv))) + CipherInputStream(it, cipher) + } else it + } + + downloadedFile.outputStream().use { outputStream -> + inputStream.use { inputStream -> + inputStream.copyTo(outputStream) + } + } + + val fileType = FileType.fromFile(downloadedFile) + + downloadedFile = File( + downloadedFile.parentFile, + "${entry.createTime}-${entry.downloadUrl.hashCode().absoluteValue.toString(16)}.${fileType.fileExtension}" + ).also { + downloadedFile.renameTo(it) + } + + withContext(writeToZipContext) { + val zipEntry = ZipEntry("${if (folders) entry.folderName + "/" else entry.folderName}${downloadedFile.name}") + FileTime.fromMillis(entry.createTime).let { + zipEntry.lastModifiedTime = it + zipEntry.lastAccessTime = it + zipEntry.creationTime = it + } + outputZipFile.apply { + putNextEntry(zipEntry) + downloadedFile.inputStream().use { it.copyTo(outputZipFile) } + closeEntry() + flush() + } + currentCount++ + updateProgress() + } + } + }.onFailure { + context.log.error("failed to download ${entry.downloadUrl}", it) + failed++ + updateProgress() + } + downloadedFile.delete() + }.also { jobs.add(it) } + } + } + + jobs.joinAll() + withContext(Dispatchers.IO) { + outputZipFile.close() + } + context.longToast("Exported to ${outputZip.absolutePath}") + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun ExporterDialog(database: SQLiteDatabase, onDismiss: () -> Unit) { + var exportJob by remember { mutableStateOf(null as Job?) } + var exportFinished by remember { mutableStateOf(false) } + var exportProgress by remember { mutableStateOf(Pair(0, 0)) } // progress, failed + + var dateRangeFilter by remember { mutableStateOf(false) } + var sortByFolder by remember { mutableStateOf(false) } + var includeMEO by remember { mutableStateOf(false) } + val dateRangePickerState = rememberDateRangePickerState( + initialSelectedStartDateMillis = OffsetDateTime.now().minusDays(8).toInstant().toEpochMilli(), + initialSelectedEndDateMillis = Instant.now().toEpochMilli(), + initialDisplayMode = DisplayMode.Input + ) + + val totalCount = remember(dateRangePickerState.selectedStartDateMillis, dateRangePickerState.selectedEndDateMillis, dateRangeFilter) { + val timeRange = dateRangePickerState.takeIf { dateRangeFilter }?.let { + TimeRange(it.selectedStartDateMillis, it.selectedEndDateMillis) + } + + database.rawQuery("SELECT COUNT(*) FROM memories_snap WHERE create_time >= ? AND create_time <= ? ", arrayOf(timeRange?.start?.toString() ?: "-1", timeRange?.end?.toString() ?: Long.MAX_VALUE.toString())).use { + it.moveToFirst() + it.getInt(0) + } + } + + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("Export memories", modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, fontSize = 20.sp) + + if (exportJob != null) { + Text(text = "Exporting memories... (${exportProgress.second} failed)", modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center) + LinearProgressIndicator( + progress = { exportProgress.first / 100f }, + modifier = Modifier.fillMaxWidth(), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + Button(onClick = { + exportJob?.cancel() + exportJob = null + onDismiss() + }) { + Text("Quit") + } + if (exportFinished) { + Button(onClick = { + exportJob = null + onDismiss() + }) { + Text("Done") + } + } + } + } else { + Text("Total memories: $totalCount", modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + var dateRangeDialog by remember { mutableStateOf(false) } + Checkbox(checked = dateRangeFilter, onCheckedChange = { dateRangeFilter = it }) + Text("Date Range", modifier = Modifier.weight(1f)) + Button(onClick = { dateRangeDialog = true }, enabled = dateRangeFilter) { + Text("Select") + } + + if (dateRangeDialog) { + DatePickerDialog(onDismissRequest = { + dateRangeDialog = false + }, confirmButton = {}) { + DateRangePicker( + state = dateRangePickerState, + modifier = Modifier.weight(1f), + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.End + ) { + Button(onClick = { + dateRangeDialog = false + }) { + Text("OK") + } + } + } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox(checked = sortByFolder, onCheckedChange = { sortByFolder = it }) + Text("Sort by folder", modifier = Modifier.weight(1f)) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox(checked = includeMEO, onCheckedChange = { includeMEO = it }) + Text("Include My Eyes Only", modifier = Modifier.weight(1f)) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + Button(onClick = onDismiss) { + Text("Cancel") + } + Button(onClick = { + context.coroutineScope.launch { + exportMemories( + scope = this, + database = database, + timeRange = dateRangePickerState.takeIf { dateRangeFilter }?.let { + TimeRange(it.selectedStartDateMillis, it.selectedEndDateMillis) + }, + folders = sortByFolder, + includeMEO = includeMEO, + ) { progress, failed -> + exportProgress = Pair(progress, failed) + } + }.also { exportJob = it }.invokeOnCompletion { + exportFinished = true + } + }) { + Text("Export") + } + } + } + + + } + } + + override fun run() { + context.coroutineScope.launch(Dispatchers.Main) { + val database = runCatching { + SQLiteDatabase.openDatabase( + context.androidContext.getDatabasePath("memories.db"), + OpenParams.Builder().setOpenFlags(SQLiteDatabase.OPEN_READONLY).build() + ) + }.getOrNull() + + if (database == null) { + context.longToast("Failed to open memories database") + return@launch + } + + createComposeAlertDialog(context.mainActivity!!) { alertDialog -> + ExporterDialog(database) { alertDialog.dismiss() } + }.apply { + setOnDismissListener { database.close() } + setCanceledOnTouchOutside(false) + show() + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ManageFriendList.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ManageFriendList.kt new file mode 100644 index 0000000000..93cba82a0a --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ManageFriendList.kt @@ -0,0 +1,268 @@ +package me.rhunk.snapenhance.core.action.impl + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import me.rhunk.snapenhance.common.data.FriendLinkType +import me.rhunk.snapenhance.common.ui.createComposeAlertDialog +import me.rhunk.snapenhance.core.action.AbstractAction +import me.rhunk.snapenhance.core.event.events.impl.ActivityResultEvent +import me.rhunk.snapenhance.core.features.impl.experiments.AddFriendSourceSpoof +import me.rhunk.snapenhance.core.features.impl.messaging.Messaging +import me.rhunk.snapenhance.core.wrapper.impl.Snapchatter +import me.rhunk.snapenhance.mapper.impl.FriendRelationshipChangerMapper +import kotlin.random.Random + +class ManageFriendList : AbstractAction() { + private var pendingPickerAction: Pair<Int, (data: Uri) -> Unit>? = null + + private val uuidRegex by lazy { + Regex("[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}") + } + + private fun addFriend(userId: String) { + val friendRelationshipChangerInstance = context.feature(AddFriendSourceSpoof::class).friendRelationshipChangerInstance + context.mappings.useMapper(FriendRelationshipChangerMapper::class) { + val addFriend = friendshipRelationshipChangerKtx.get()?.methods?.firstOrNull { it.name == addFriendMethod.get() } + ?: return@useMapper + + addFriend.invoke( + null, + friendRelationshipChangerInstance, + userId, + addFriend.parameterTypes[2].enumConstants!!.first { it.toString() == "ADDED_BY_USERNAME" }, + addFriend.parameterTypes[3].enumConstants!!.first { it.toString() == "SEARCH" }, + addFriend.parameterTypes[4].enumConstants!!.first { it.toString() == "SEARCH" }, + 0 + ) + } + } + + override fun onActivityCreate() { + context.event.subscribe(ActivityResultEvent::class) { event -> + if (event.requestCode == pendingPickerAction?.first) { + val pendingAction = pendingPickerAction ?: return@subscribe + this.pendingPickerAction = null + event.canceled = true + pendingAction.second(event.intent.data!!) + } + } + } + + private fun exportFriends( + userIds: List<String> + ) { + pendingPickerAction = Random.nextInt(0, 65535) to { data -> + context.androidContext.contentResolver.openOutputStream(data).use { output -> + output?.bufferedWriter()?.use { writer -> + userIds.forEach { + writer.write(it) + writer.newLine() + } + } + context.longToast("Exported ${userIds.size} friends!") + } + } + context.mainActivity?.startActivityForResult( + Intent.createChooser( + Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TITLE, "my_friends.txt") + }, + "Select a location to save the file" + ), + pendingPickerAction!!.first + ) + } + + private val userIdToSnapchatter = mutableMapOf<String, Snapchatter>() + + @Composable + private fun ManagerDialog() { + val pendingFriendRequests = remember { mutableStateMapOf<String, Job>() } + var fetchedFriends by remember { mutableStateOf<List<String>?>(null) } // list of uuids + val coroutineScope = rememberCoroutineScope() + + if (fetchedFriends == null) { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 200.dp), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("Manage Friend List", fontSize = 20.sp) + Spacer(modifier = Modifier.height(5.dp)) + Text( + text = "Export friends allows you to save a list of your friends' IDs in a text file. Importing from a file will display the friends in a list where you can add them.", + fontSize = 14.sp, + fontWeight = FontWeight.Light, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(10.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Button(onClick = { + exportFriends(context.database.getAllFriends().filter { it.friendLinkType == FriendLinkType.MUTUAL.value && it.addedTimestamp > 0L }.mapNotNull { it.userId }) + }) { + Text("Export friends") + } + Button(onClick = { + pendingPickerAction = Random.nextInt(0, 65535) to { data -> + runCatching { + fetchedFriends = null + context.androidContext.contentResolver.openInputStream(data).use { input -> + fetchedFriends = input?.bufferedReader()?.readLines()?.filter { + it.matches(uuidRegex) + }?.map { it.trim() }?.toMutableList() ?: mutableListOf() + } + }.onFailure { + context.log.error("Failed to import friends", it) + context.longToast("Failed to import friends: ${it.message}") + } + } + // launch file picker + context.mainActivity?.startActivityForResult( + Intent.createChooser( + Intent(Intent.ACTION_GET_CONTENT).apply { type = "*/*" }, + "Select a file" + ), + pendingPickerAction!!.first + ) + }) { + Text("Import from file") + } + } + } + } + } else { + Column( + modifier = Modifier.fillMaxSize(), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + IconButton( + modifier = Modifier.padding(8.dp), + onClick = { + fetchedFriends = null + } + ) { + Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back") + } + } + LazyColumn( + modifier = Modifier.weight(1f).padding(8.dp) + ) { + item { + if (fetchedFriends?.isEmpty() == true) { + Text("No friends found", modifier = Modifier.padding(8.dp)) + } + } + items(fetchedFriends ?: emptyList()) { userId -> + fun fetchLocalLinkType(): FriendLinkType? { + return context.database.getFriendInfo(userId)?.friendLinkType?.let { FriendLinkType.fromValue(it) } + } + + var friendSnapchatter by remember(userId) { mutableStateOf<Snapchatter?>(null) } + var failedToFetch by remember(userId) { mutableStateOf(false) } + var friendLinkType by remember(userId) { mutableStateOf(fetchLocalLinkType()) } + + LaunchedEffect(userId) { + launch(Dispatchers.IO) { + friendSnapchatter = userIdToSnapchatter.getOrPut(userId) { + context.feature(Messaging::class).fetchSnapchatterInfos(listOf(userId)).firstOrNull() ?: run { + failedToFetch = true + return@launch + } + } + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(6.dp), + verticalAlignment = Alignment.CenterVertically + ){ + Column( + modifier = Modifier.weight(1f) + ) { + friendSnapchatter?.let { snapchatter -> + Text(snapchatter.displayName?.let { "$it (${snapchatter.username}) " } ?: snapchatter.username ?: "Unknown") + } + Text(userId, fontSize = 12.sp, fontWeight = FontWeight.Light) + } + + if (friendSnapchatter != null && friendLinkType != FriendLinkType.FOLLOWING) { + Button( + enabled = friendLinkType != FriendLinkType.MUTUAL, + onClick = { + val prevLinkType = fetchLocalLinkType() + if (prevLinkType == FriendLinkType.MUTUAL || pendingFriendRequests[userId]?.isActive == true) return@Button + addFriend(userId) + pendingFriendRequests[userId] = coroutineScope.launch { + withTimeout(10000) { + while (fetchLocalLinkType()?.value == prevLinkType?.value) { + delay(500) + } + } + }.apply { + invokeOnCompletion { + pendingFriendRequests.remove(userId) + friendLinkType = fetchLocalLinkType() + } + } + } + ) { + if (friendLinkType == FriendLinkType.MUTUAL) { + Text("Added") + } else if (pendingFriendRequests[userId]?.isActive == true) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(20.dp), strokeWidth = 1.dp) + } else { + Text("Add") + } + } + } + } + } + } + } + } + } + + override fun run() { + context.coroutineScope.launch(Dispatchers.Main) { + createComposeAlertDialog(context.mainActivity!!) { + ManagerDialog() + }.apply { + setCanceledOnTouchOutside(false) + show() + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt new file mode 100644 index 0000000000..23999aa949 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt @@ -0,0 +1,280 @@ +package me.rhunk.snapenhance.core.bridge + + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.* +import android.util.Log +import de.robv.android.xposed.XposedHelpers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withTimeoutOrNull +import me.rhunk.snapenhance.bridge.* +import me.rhunk.snapenhance.bridge.e2ee.E2eeInterface +import me.rhunk.snapenhance.bridge.location.LocationManager +import me.rhunk.snapenhance.bridge.logger.LoggerInterface +import me.rhunk.snapenhance.bridge.logger.TrackerInterface +import me.rhunk.snapenhance.bridge.scripting.IScripting +import me.rhunk.snapenhance.bridge.snapclient.MessagingBridge +import me.rhunk.snapenhance.bridge.storage.FileHandleManager +import me.rhunk.snapenhance.common.Constants +import me.rhunk.snapenhance.common.data.MessagingFriendInfo +import me.rhunk.snapenhance.common.data.MessagingGroupInfo +import me.rhunk.snapenhance.common.data.MessagingRuleType +import me.rhunk.snapenhance.common.data.SocialScope +import me.rhunk.snapenhance.common.ui.OverlayType +import me.rhunk.snapenhance.common.util.toSerialized +import me.rhunk.snapenhance.core.ModContext +import java.util.concurrent.Executors +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume + +class BridgeClient( + private val context: ModContext +): ServiceConnection { + private var continuation: Continuation<Boolean>? = null + private val connectSemaphore = Semaphore(permits = 1) + private val reconnectSemaphore = Semaphore(permits = 1) + private lateinit var service: BridgeInterface + + private val onConnectedCallbacks = mutableListOf<suspend () -> Unit>() + private var cacheSnapEnhanceApkPath: String? = null + + fun addOnConnectedCallback(initNow: Boolean = false, callback: suspend () -> Unit) { + synchronized(onConnectedCallbacks) { + onConnectedCallbacks.add(callback) + } + initNow.takeIf { it && this::service.isInitialized }?.let { + runBlocking { + callback() + } + } + } + + private fun resumeContinuation(state: Boolean) { + runBlocking { + connectSemaphore.withPermit { + runCatching { continuation?.resume(state) } + continuation = null + } + } + } + + suspend fun connect(onFailure: (Throwable) -> Unit): Boolean? { + if (this::service.isInitialized && service.asBinder().pingBinder()) { + return true + } + + val connectionTimeout = 15000L + val retryDelay = 3000L + + return withTimeoutOrNull(connectionTimeout) { + var result: Boolean? = null + + for (retry in 0.. (connectionTimeout / retryDelay).toInt()) { + result = withTimeoutOrNull(retryDelay) { + suspendCancellableCoroutine { cancellableContinuation -> + continuation = cancellableContinuation + with(context.androidContext) { + //ensure the remote process is running + runCatching { + startActivity(Intent() + .setClassName(Constants.SE_PACKAGE_NAME, "me.rhunk.snapenhance.bridge.ForceStartActivity") + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK) + ) + } + + runCatching { + val intent = Intent() + .setClassName(Constants.SE_PACKAGE_NAME, "me.rhunk.snapenhance.bridge.BridgeService") + runCatching { + if (this@BridgeClient::service.isInitialized) { + unbindService(this@BridgeClient) + } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + bindService( + intent, + Context.BIND_AUTO_CREATE, + Executors.newSingleThreadExecutor(), + this@BridgeClient + ) + } else { + XposedHelpers.callMethod( + this, + "bindServiceAsUser", + intent, + this@BridgeClient, + Context.BIND_AUTO_CREATE, + Handler(HandlerThread("BridgeClient").apply { + start() + }.looper), + Process.myUserHandle() + ) + } + }.onFailure { + onFailure(it) + resumeContinuation(false) + } + } + } + } + if (result != null) break + } + + result + } + } + + override fun onServiceConnected(name: ComponentName, service: IBinder) { + this.service = BridgeInterface.Stub.asInterface(service) + runBlocking { + onConnectedCallbacks.forEach { + runCatching { + it() + }.onFailure { + context.log.error("Failed to run onConnectedCallback", it) + } + } + } + cacheSnapEnhanceApkPath = this.service.applicationApkPath.also { + if (cacheSnapEnhanceApkPath != null && cacheSnapEnhanceApkPath != it) { + context.log.verbose("Restarting Snapchat due to SnapEnhance update") + context.softRestartApp() + return + } + } + resumeContinuation(true) + } + + override fun onNullBinding(name: ComponentName) { + resumeContinuation(false) + } + + override fun onServiceDisconnected(name: ComponentName) { + continuation = null + } + + private fun tryReconnect() { + runBlocking { + reconnectSemaphore.withPermit { + if (service.asBinder().pingBinder()) return@runBlocking + Log.d("BridgeClient", "service is dead, restarting") + val canLoad = connect { + Log.e("BridgeClient", "connection failed", it) + context.softRestartApp() + } + if (canLoad != true) { + Log.e("BridgeClient", "failed to reconnect to service, result=$canLoad") + context.softRestartApp() + } + } + } + } + + private fun <T> safeServiceCall(block: () -> T): T { + return runCatching { + block() + }.getOrElse { throwable -> + if (throwable is DeadObjectException) { + tryReconnect() + return@getOrElse runCatching { + block() + }.getOrElse { + Log.e("BridgeClient", "service call failed", it) + if (it is DeadObjectException) { + context.softRestartApp() + } + throw it + } + } + throw throwable + } + } + + fun broadcastLog(tag: String, level: String, message: String) { + message.chunked(1024 * 256).forEach { + runCatching { + service.broadcastLog(tag, level, it) + } + } + } + + fun getApplicationApkPath(): String = safeServiceCall { service.applicationApkPath } + + fun enqueueDownload(intent: Intent, callback: DownloadCallback) = safeServiceCall { + service.enqueueDownload(intent, callback) + } + + fun convertMedia( + input: ParcelFileDescriptor, + inputExtension: String, + outputExtension: String, + audioCodec: String?, + videoCodec: String? + ): ParcelFileDescriptor? = safeServiceCall { + service.convertMedia(input, inputExtension, outputExtension, audioCodec, videoCodec) + } + + fun sync(callback: SyncCallback) { + if (!context.database.hasMain()) return + safeServiceCall { + service.sync(callback) + } + } + + fun triggerSync(scope: SocialScope, id: String) = safeServiceCall { + service.triggerSync(scope.key, id) + } + + fun passGroupsAndFriends(groups: List<MessagingGroupInfo>, friends: List<MessagingFriendInfo>) = + safeServiceCall { + service.passGroupsAndFriends( + groups.mapNotNull { it.toSerialized() }, + friends.mapNotNull { it.toSerialized() } + ) + } + + fun getRules(targetUuid: String): List<MessagingRuleType> = safeServiceCall { + service.getRules(targetUuid).mapNotNull { MessagingRuleType.getByName(it) } + } + + fun getRuleIds(ruleType: MessagingRuleType): List<String> = safeServiceCall { + service.getRuleIds(ruleType.key) + } + + fun setRule(targetUuid: String, type: MessagingRuleType, state: Boolean) = safeServiceCall { + service.setRule(targetUuid, type.key, state) + } + + fun getScopeNotes(id: String): String? = safeServiceCall { service.getScopeNotes(id) } + + fun setScopeNotes(id: String, content: String?) = safeServiceCall { service.setScopeNotes(id, content) } + + fun getScriptingInterface(): IScripting = safeServiceCall { service.scriptingInterface } + + fun getE2eeInterface(): E2eeInterface = safeServiceCall { service.e2eeInterface } + + fun getMessageLogger(): LoggerInterface = safeServiceCall { service.logger } + + fun getTracker(): TrackerInterface = safeServiceCall { service.tracker } + + fun getAccountStorage(): AccountStorage = safeServiceCall { service.accountStorage } + + fun getFileHandlerManager(): FileHandleManager = safeServiceCall { service.fileHandleManager } + + fun getLocationManager(): LocationManager = safeServiceCall { service.locationManager } + + fun registerMessagingBridge(bridge: MessagingBridge) = safeServiceCall { service.registerMessagingBridge(bridge) } + + fun openOverlay(type: OverlayType) = safeServiceCall { service.openOverlay(type.key) } + fun closeOverlay() = safeServiceCall { service.closeOverlay() } + + fun registerConfigStateListener(listener: ConfigStateListener) = safeServiceCall { service.registerConfigStateListener(listener) } + + fun getDebugProp(name: String, defaultValue: String? = null): String? = safeServiceCall { service.getDebugProp(name, defaultValue) } +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/SnapClassCache.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/data/SnapClassCache.kt similarity index 59% rename from app/src/main/kotlin/me/rhunk/snapenhance/data/SnapClassCache.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/data/SnapClassCache.kt index e568f0a10f..822d2fff77 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/data/SnapClassCache.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/data/SnapClassCache.kt @@ -1,19 +1,26 @@ -package me.rhunk.snapenhance.data +package me.rhunk.snapenhance.core.data class SnapClassCache ( private val classLoader: ClassLoader ) { val snapUUID by lazy { findClass("com.snapchat.client.messaging.UUID") } - val composerLocalSubscriptionStore by lazy { findClass("com.snap.plus.lib.common.ComposerLocalSubscriptionStore") } val snapManager by lazy { findClass("com.snapchat.client.messaging.SnapManager\$CppProxy") } val conversationManager by lazy { findClass("com.snapchat.client.messaging.ConversationManager\$CppProxy") } val presenceSession by lazy { findClass("com.snapchat.talkcorev3.PresenceSession\$CppProxy") } val message by lazy { findClass("com.snapchat.client.messaging.Message") } val messageUpdateEnum by lazy { findClass("com.snapchat.client.messaging.MessageUpdate") } + val serverMessageIdentifier by lazy { findClass("com.snapchat.client.messaging.ServerMessageIdentifier") } val unifiedGrpcService by lazy { findClass("com.snapchat.client.grpc.UnifiedGrpcService\$CppProxy") } val networkApi by lazy { findClass("com.snapchat.client.network_api.NetworkApi\$CppProxy") } val messageDestinations by lazy { findClass("com.snapchat.client.messaging.MessageDestinations") } val localMessageContent by lazy { findClass("com.snapchat.client.messaging.LocalMessageContent") } + val feedEntry by lazy { findClass("com.snapchat.client.messaging.FeedEntry") } + val conversation by lazy { findClass("com.snapchat.client.messaging.Conversation") } + val feedManager by lazy { findClass("com.snapchat.client.messaging.FeedManager\$CppProxy") } + val nativeBridge by lazy { runCatching { findClass("com.snapchat.client.valdi.NativeBridge") }.getOrNull() ?: findClass("com.snapchat.client.composer.NativeBridge") } + val composerView by lazy { findClass("com.snap.composer.views.ComposerView") } + val composerAction by lazy { findClass("com.snap.composer.actions.ComposerAction") } + val composerFunctionActionAdapter by lazy { findClass("com.snap.composer.callable.ComposerFunctionActionAdapter") } private fun findClass(className: String): Class<*> { return try { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt new file mode 100644 index 0000000000..085e30799e --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt @@ -0,0 +1,609 @@ +package me.rhunk.snapenhance.core.database + +import android.content.ContentValues +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteDatabase.OpenParams +import android.database.sqlite.SQLiteDatabaseCorruptException +import me.rhunk.snapenhance.common.database.DatabaseObject +import me.rhunk.snapenhance.common.database.impl.* +import me.rhunk.snapenhance.common.util.ktx.getBlobOrNull +import me.rhunk.snapenhance.common.util.ktx.getIntOrNull +import me.rhunk.snapenhance.common.util.ktx.getInteger +import me.rhunk.snapenhance.common.util.ktx.getStringOrNull +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.core.ModContext +import me.rhunk.snapenhance.core.wrapper.impl.toSnapUUID +import me.rhunk.snapenhance.nativelib.NativeLib + + +enum class DatabaseType( + val fileName: String +) { + MAIN("main.db"), + CORE("core.db"), + ARROYO("arroyo.db"), + SIMPLE_DB_HELPER("simple_db_helper.db") +} + +class DatabaseAccess( + private val context: ModContext +) { + private val openedDatabases = mutableMapOf<DatabaseType, SQLiteDatabase>() + + private val hasArroyoConversationTable by lazy { + useDatabase(DatabaseType.ARROYO)?.performOperation { + safeRawQuery("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'conversation'")?.use { query -> + query.moveToFirst() && query.getStringOrNull("name") == "conversation" + } + } == true + } + + private fun useDatabase(database: DatabaseType, writeMode: Boolean = false): SQLiteDatabase? { + // only cache read-only databases + if (!writeMode && openedDatabases.containsKey(database) && openedDatabases[database]?.isOpen == true) { + return openedDatabases[database] + } + + val dbPath = context.androidContext.getDatabasePath(database.fileName) + if (!dbPath.exists()) return null + return runCatching { + SQLiteDatabase.openDatabase( + dbPath, + OpenParams.Builder() + .setOpenFlags( + if (writeMode) SQLiteDatabase.OPEN_READWRITE or SQLiteDatabase.ENABLE_WRITE_AHEAD_LOGGING + else SQLiteDatabase.OPEN_READONLY + ) + .setErrorHandler { + context.androidContext.deleteDatabase(dbPath.absolutePath) + context.softRestartApp() + }.build() + ) + }.onFailure { + context.log.error("Failed to open database ${database.fileName}!", it) + }.getOrNull()?.also { + if (!writeMode) openedDatabases[database] = it + } + } + + private fun <T> SQLiteDatabase.performOperation(query: SQLiteDatabase.() -> T?): T? { + return runCatching { + if (NativeLib.initialized && openedDatabases[DatabaseType.ARROYO] == this) { + var result: T? = null + context.native.lockNativeDatabase(DatabaseType.ARROYO.fileName) { + result = query() + } + result + } else synchronized(this) { + query() + } + }.onFailure { + context.log.error("Database operation failed", it) + }.getOrNull() + } + + private fun SQLiteDatabase.safeRawQuery(query: String, args: Array<String>? = null): Cursor? { + return runCatching { + rawQuery(query, args) + }.onFailure { + if (it !is SQLiteDatabaseCorruptException) { + context.log.error("Failed to execute query $query", it) + return@onFailure + } + context.longToast("Database ${this.path} is corrupted! Restarting ...") + context.androidContext.deleteDatabase(this.path) + context.crash("Database ${this.path} is corrupted!", it) + }.getOrNull() + } + + private val friendDMsCache by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { + getFeedEntries(Int.MAX_VALUE) + .filter { it.conversationType == 0 && it.participantsSize == 2 } + .associate { it.participants?.firstOrNull { it != myUserId } to it.key } + .toMutableMap() + } + + private val dmOtherParticipantCache by lazy { + if (hasArroyoConversationTable) { + return@lazy useDatabase(DatabaseType.ARROYO)?.performOperation { + safeRawQuery( + "SELECT client_conversation_id, conversation_metadata FROM conversation", + )?.use { query -> + val result = mutableMapOf<String, String?>() + if (!query.moveToFirst()) { + return@performOperation null + } + do { + val conversationId = query.getStringOrNull("client_conversation_id") ?: continue + val conversationMetadata = ProtoReader(query.getBlobOrNull("conversation_metadata") ?: continue) + + val participants = mutableListOf<String>() + conversationMetadata.eachBuffer(3) { + participants.add(getByteArray(1, 1)?.toSnapUUID()?.toString() ?: return@eachBuffer) + } + + result[conversationId] = if (participants.size == 2) { + participants.firstOrNull { it != myUserId }?.also { + result[it] = null + } + } else null + } while (query.moveToNext()) + + result + } + }?.toMutableMap() ?: mutableMapOf() + } + + (useDatabase(DatabaseType.ARROYO)?.performOperation { + safeRawQuery( + "SELECT client_conversation_id, conversation_type, user_id FROM user_conversation WHERE user_id != ?", + arrayOf(myUserId) + )?.use { query -> + val participants = mutableMapOf<String, String?>() + if (!query.moveToFirst()) { + return@performOperation null + } + do { + val conversationId = query.getStringOrNull("client_conversation_id") ?: continue + val userId = query.getStringOrNull("user_id") ?: continue + participants[conversationId] = when (query.getIntOrNull("conversation_type")) { + 0 -> userId + else -> null + } + participants[userId] = null + } while (query.moveToNext()) + participants + } + } ?: emptyMap()).toMutableMap() + } + + fun hasMain(): Boolean = useDatabase(DatabaseType.MAIN)?.isOpen == true + fun hasArroyo(): Boolean = useDatabase(DatabaseType.ARROYO)?.isOpen == true + + fun init() { + // perform integrity check on databases + DatabaseType.entries.forEach { type -> + useDatabase(type, writeMode = true)?.apply { + rawQuery("PRAGMA integrity_check", null).use { query -> + if (!query.moveToFirst() || query.getString(0).lowercase() != "ok") { + context.log.error("Failed to perform integrity check on ${type.fileName}") + context.androidContext.deleteDatabase(type.fileName) + } + } + }?.close() + } + } + + fun finalize() { + openedDatabases.values.forEach { it.close() } + } + + private fun <T : DatabaseObject> SQLiteDatabase.readDatabaseObject( + obj: T, + table: String, + where: String, + args: Array<String> + ): T? = this.safeRawQuery("SELECT * FROM $table WHERE $where", args)?.use { + if (!it.moveToFirst()) { + return null + } + try { + obj.write(it) + } catch (e: Throwable) { + context.log.error("Failed to read database object", e) + } + obj + } + + val myUserId by lazy { + context.androidContext.getSharedPreferences("user_session_shared_pref", 0).getString("key_user_id", null) ?: + useDatabase(DatabaseType.ARROYO)?.performOperation { + safeRawQuery(buildString { + append("SELECT value FROM required_values WHERE key = 'USERID'") + }, null)?.use { query -> + if (!query.moveToFirst()) { + return@performOperation null + } + query.getStringOrNull("value")!! + } + }!! + } + + fun getFeedEntryByConversationId(conversationId: String): FriendFeedEntry? { + return useDatabase(DatabaseType.ARROYO)?.performOperation { + readDatabaseObject( + FriendFeedEntry(), + "feed_entry", + "client_conversation_id = ?", + arrayOf(conversationId) + ) + } ?: useDatabase(DatabaseType.MAIN)?.performOperation { + readDatabaseObject( + FriendFeedEntry(), + "FriendsFeedView", + "key = ?", + arrayOf(conversationId) + ) + } + } + + fun getFriendInfo(userId: String): FriendInfo? { + return useDatabase(DatabaseType.MAIN)?.performOperation { + readDatabaseObject( + FriendInfo(), + "FriendWithUsername", + "userId = ?", + arrayOf(userId) + ) + } + } + + fun getFriendOriginalUsername(mutableUsername: String): String? { + return useDatabase(DatabaseType.MAIN)?.performOperation { + safeRawQuery( + "SELECT originalUsername FROM CombinedUsername WHERE mutableUsername = ?", + arrayOf(mutableUsername) + )?.use { query -> + if (!query.moveToFirst()) { + return@performOperation null + } + query.getStringOrNull("originalUsername") + } + } + } + + fun getFriendInfoByUsername(username: String): FriendInfo? { + return useDatabase(DatabaseType.MAIN)?.performOperation { + readDatabaseObject( + FriendInfo(), + "FriendWithUsername", + "usernameForSorting = ?", + arrayOf(username) + ) + } + } + + fun getAllFriends(): List<FriendInfo> { + return useDatabase(DatabaseType.MAIN)?.performOperation { + safeRawQuery( + "SELECT * FROM FriendWithUsername", + null + )?.use { query -> + val list = mutableListOf<FriendInfo>() + while (query.moveToNext()) { + val friendInfo = FriendInfo() + try { + friendInfo.write(query) + } catch (_: Throwable) {} + list.add(friendInfo) + } + list + } + } ?: emptyList() + } + + fun getFeedEntries(limit: Int, whereClause: String? = null): List<FriendFeedEntry> { + val entries = mutableListOf<FriendFeedEntry>() + return useDatabase(DatabaseType.ARROYO)?.performOperation { + safeRawQuery( + "SELECT * FROM feed_entry ${whereClause?.let { "WHERE $it" }.orEmpty()} ORDER BY last_updated_timestamp DESC LIMIT ?", + arrayOf(limit.toString()) + )?.use { query -> + while (query.moveToNext()) { + val friendFeedEntry = FriendFeedEntry() + try { + friendFeedEntry.write(query) + } catch (_: Throwable) {} + entries.add(friendFeedEntry) + } + entries + } + } ?: useDatabase(DatabaseType.MAIN)?.performOperation { + safeRawQuery( + "SELECT * FROM FriendsFeedView ORDER BY _id LIMIT ?", + arrayOf(limit.toString()) + )?.use { query -> + while (query.moveToNext()) { + val friendFeedEntry = FriendFeedEntry() + try { + friendFeedEntry.write(query) + } catch (_: Throwable) {} + entries.add(friendFeedEntry) + } + entries + } + } ?: emptyList() + } + + fun getConversationMessageFromId(clientMessageId: Long): ConversationMessage? { + return useDatabase(DatabaseType.ARROYO)?.performOperation { + readDatabaseObject( + ConversationMessage(), + "conversation_message", + "client_message_id = ?", + arrayOf(clientMessageId.toString()) + ) + } + } + + fun getConversationServerMessage(conversationId: String, serverId: Long): ConversationMessage? { + return useDatabase(DatabaseType.ARROYO)?.performOperation { + readDatabaseObject( + ConversationMessage(), + "conversation_message", + "client_conversation_id = ? AND server_message_id = ?", + arrayOf(conversationId, serverId.toString()) + ) + } + } + + fun getConversationType(conversationId: String): Int? { + if (hasArroyoConversationTable) { + return getFeedEntryByConversationId(conversationId)?.conversationType + } + + return useDatabase(DatabaseType.ARROYO)?.performOperation { + safeRawQuery( + "SELECT conversation_type FROM user_conversation WHERE client_conversation_id = ?", + arrayOf(conversationId) + )?.use { query -> + if (!query.moveToFirst()) { + return@performOperation null + } + query.getInteger("conversation_type") + } + } + } + + fun getDMConversationId(userId: String): String? { + if (hasArroyoConversationTable) { + return friendDMsCache[userId] + } + + return useDatabase(DatabaseType.ARROYO)?.performOperation { + readDatabaseObject( + UserConversationLink(), + "user_conversation", + "user_id = ? AND conversation_type = 0", + arrayOf(userId) + )?.clientConversationId + } + } + + private fun getConversationParticipantsRaw(conversationId: String): List<String>? { + if (hasArroyoConversationTable) { + return useDatabase(DatabaseType.ARROYO)?.performOperation { + safeRawQuery( + "SELECT conversation_metadata FROM conversation WHERE client_conversation_id = ?", + arrayOf(conversationId) + )?.use { query -> + val participants = mutableListOf<String>() + if (!query.moveToFirst()) { + return@performOperation null + } + val conversationMetadata = ProtoReader(query.getBlobOrNull("conversation_metadata") ?: return@performOperation null) + + conversationMetadata.eachBuffer(3) { + participants.add(getByteArray(1, 1)?.toSnapUUID()?.toString() ?: return@eachBuffer) + } + + participants + } + } + } + + return useDatabase(DatabaseType.ARROYO)?.performOperation { + safeRawQuery( + "SELECT user_id FROM user_conversation WHERE client_conversation_id = ?", + arrayOf(conversationId) + )?.use { query -> + if (!query.moveToFirst()) { + return@performOperation emptyList() + } + val participants = mutableListOf<String>() + do { + query.getStringOrNull("user_id")?.let { participants.add(it) } + } while (query.moveToNext()) + participants + } + } + } + + fun getDMOtherParticipant(conversationId: String): String? { + if (dmOtherParticipantCache.containsKey(conversationId)) return dmOtherParticipantCache[conversationId] + + return getConversationParticipantsRaw(conversationId)?.takeIf { it.size == 2 }?.firstOrNull { it != myUserId }.also { + dmOtherParticipantCache[conversationId] = it + } + } + + fun getStoryEntryFromId(storyId: String): StoryEntry? { + return useDatabase(DatabaseType.MAIN)?.performOperation { + readDatabaseObject(StoryEntry(), "Story", "storyId = ?", arrayOf(storyId)) + } + } + + fun getConversationParticipants(conversationId: String, useCache: Boolean = true): List<String>? { + if (dmOtherParticipantCache[conversationId] != null && useCache) return dmOtherParticipantCache[conversationId]?.let { listOf(myUserId, it) } + + return getConversationParticipantsRaw(conversationId)?.also { + if (!dmOtherParticipantCache.containsKey(conversationId)) { + dmOtherParticipantCache[conversationId] = it.firstOrNull { it != myUserId } + } + } + } + + fun getMessagesFromConversationId( + conversationId: String, + limit: Int, + page: Int = 0, + ): List<ConversationMessage>? { + return useDatabase(DatabaseType.ARROYO)?.performOperation { + safeRawQuery( + "SELECT * FROM conversation_message WHERE client_conversation_id = ? ORDER BY creation_timestamp DESC LIMIT ? OFFSET ?", + arrayOf(conversationId, limit.toString(), (limit * page).toString()) + )?.use { query -> + if (!query.moveToFirst()) { + return@performOperation null + } + val messages = mutableListOf<ConversationMessage>() + do { + val message = ConversationMessage() + message.write(query) + messages.add(message) + } while (query.moveToNext()) + messages + } + } + } + + fun getAddSource(userId: String): String? { + return useDatabase(DatabaseType.MAIN)?.performOperation { + rawQuery( + "SELECT addSource FROM FriendWhoAddedMe WHERE userId = ?", + arrayOf(userId) + ).use { + if (!it.moveToFirst()) { + return@performOperation null + } + it.getStringOrNull("addSource") + } + } + } + + fun setStoriesViewedState(userId: String, viewed: Boolean): Boolean { + var success = false + useDatabase(DatabaseType.MAIN, writeMode = true)?.apply { + performOperation { + success = update( + "StorySnap", + ContentValues().apply { + put("viewed", if (viewed) 1 else 0) + }, + "userId = ? AND viewed != ?", + arrayOf(userId, if (viewed) "1" else "0") + ) > 0 + } + close() + } + return success + } + + fun getAccessTokens(userId: String): Map<String, String>? { + return useDatabase(DatabaseType.MAIN)?.performOperation { + rawQuery( + "SELECT accessTokensPb FROM SnapToken WHERE userId = ?", + arrayOf(userId) + ).use { + if (!it.moveToFirst()) { + return@performOperation null + } + val reader = ProtoReader(it.getBlobOrNull("accessTokensPb") ?: return@performOperation null) + val services = mutableMapOf<String, String>() + + reader.eachBuffer(1) { + val token = getString(1) ?: return@eachBuffer + val service = getString(2)?.substringAfterLast("/") ?: return@eachBuffer + services[service] = token + } + + services + } + } + } + + private fun getBestFriends(): List<FriendInfo> { + return useDatabase(DatabaseType.MAIN)?.performOperation { + safeRawQuery( + "SELECT * FROM Friend WHERE friendmojiCategories != ''", + null + )?.use { query -> + val list = mutableListOf<FriendInfo>() + while (query.moveToNext()) { + val friendInfo = FriendInfo() + try { + friendInfo.write(query) + } catch (_: Throwable) {} + list.add(friendInfo) + } + list + } + } ?: emptyList() + } + + fun updatePinnedBestFriendStatus(userId: String, friendmoji: String) { + useDatabase(DatabaseType.MAIN, writeMode = true)?.apply { + val numberOneBestFriends = getBestFriends().filter { friend -> + friend.friendmojiCategories?.split(",")?.any { it.startsWith("number_one") } == true + } + + numberOneBestFriends.forEach { friendInfo -> + performOperation { + update( + "Friend", + ContentValues().apply { + put("friendmojiCategories", friendInfo.friendmojiCategories?.split(",")?.filter { + it == "on_fire" || it == "birthday" + }?.joinToString(",") ?: "") + put("isPinnedBestFriend", 0) + }, + "userId = ?", + arrayOf(friendInfo.userId) + ) + } + } + + val friend = getFriendInfo(userId) ?: return@apply + performOperation { + update( + "Friend", + ContentValues().apply { + put("friendmojiCategories", (friend.friendmojiCategories?.split(",") ?: listOf()).toMutableList().apply { + add(friendmoji) + }.joinToString(",")) + put("isPinnedBestFriend", 1) + }, + "userId = ?", + arrayOf(userId) + ) + } + }?.close() + } + + fun getStorySnapEntry(rawSnapId: String): StorySnapEntry? { + return useDatabase(DatabaseType.SIMPLE_DB_HELPER)?.performOperation { + readDatabaseObject( + StorySnapEntry(), + "DiscoverStorySnap", + "rawSnapId = ?", + arrayOf(rawSnapId) + ) + } + } + + fun setCameraType(cameraType: String) { + useDatabase(DatabaseType.CORE, writeMode = true)?.use { database -> + database.performOperation { + if (rawQuery("SELECT * FROM Preferences WHERE 'key' = 'CAMERA~CAMERA_TYPE'", null).use { !it.moveToFirst() }) { + insert( + "Preferences", + null, + ContentValues().apply { + put("key", "CAMERA~CAMERA_TYPE") + put("type", 0) + put("stringValue", cameraType) + } + ) + } else update( + "Preferences", + ContentValues().apply { + put("stringValue", cameraType) + }, + "key = ?", + arrayOf("CAMERA~CAMERA_TYPE") + ) + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/EventBus.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/EventBus.kt new file mode 100644 index 0000000000..5d74b37098 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/EventBus.kt @@ -0,0 +1,76 @@ +package me.rhunk.snapenhance.core.event + +import me.rhunk.snapenhance.core.ModContext +import java.util.concurrent.ConcurrentHashMap +import kotlin.reflect.KClass + +abstract class Event { + lateinit var context: ModContext + var canceled = false +} + +interface IListener<T> { + fun handle(event: T) +} + +class EventBus( + val context: ModContext +) { + private val subscribers = ConcurrentHashMap<KClass<out Event>, MutableMap<Int, IListener<out Event>>>() + + fun <T : Event> subscribe(event: KClass<T>, listener: IListener<T>, priority: Int? = null) { + synchronized(subscribers) { + if (!subscribers.containsKey(event)) { + subscribers[event] = sortedMapOf() + } + val lastSubscriber = subscribers[event]?.keys?.lastOrNull() ?: 0 + subscribers[event]?.put(priority ?: (lastSubscriber + 1), listener) + } + } + + fun <T : Event> subscribe(event: KClass<T>, priority: Int? = null, listener: (T) -> Unit) = subscribe(event, { true }, priority, listener) + + fun <T : Event> subscribe(event: KClass<T>, filter: (T) -> Boolean, priority: Int? = null, listener: (T) -> Unit): () -> Unit { + val obj = object : IListener<T> { + override fun handle(event: T) { + if (!filter(event)) return + runCatching { + listener(event) + }.onFailure { + context.log.error("Error while handling event ${event::class.simpleName}", it) + } + } + } + subscribe(event, obj, priority) + return { unsubscribe(event, obj) } + } + + fun <T : Event> unsubscribe(event: KClass<T>, listener: IListener<T>) { + synchronized(subscribers) { + subscribers[event]?.values?.remove(listener) + } + } + + fun <T : Event> post(event: T, afterBlock: T.() -> Unit = {}): T? { + if (!subscribers.containsKey(event::class)) { + return null + } + + event.context = context + + subscribers[event::class]?.toSortedMap()?.forEach { (_, listener) -> + @Suppress("UNCHECKED_CAST") + runCatching { + (listener as IListener<T>).handle(event) + }.onFailure { t -> + context.log.error("Error while handling event ${event::class.simpleName} by ${listener::class.simpleName}", t) + } + } + afterBlock(event) + return event + } + + fun clear() { + subscribers.clear() + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/EventDispatcher.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/EventDispatcher.kt new file mode 100644 index 0000000000..dd7770de39 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/EventDispatcher.kt @@ -0,0 +1,305 @@ +package me.rhunk.snapenhance.core.event + +import android.app.Activity +import android.content.Intent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import me.rhunk.snapenhance.common.util.snap.SnapWidgetBroadcastReceiverHelper +import me.rhunk.snapenhance.core.ModContext +import me.rhunk.snapenhance.core.event.events.impl.* +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.Hooker +import me.rhunk.snapenhance.core.util.hook.findRestrictedMethod +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.hook.hookConstructor +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.util.ktx.setObjectField +import me.rhunk.snapenhance.core.wrapper.impl.Message +import me.rhunk.snapenhance.core.wrapper.impl.MessageContent +import me.rhunk.snapenhance.core.wrapper.impl.MessageDestinations +import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID +import me.rhunk.snapenhance.mapper.impl.CallbackMapper +import me.rhunk.snapenhance.mapper.impl.ViewBinderMapper +import java.nio.ByteBuffer + +class EventDispatcher( + private val context: ModContext +) { + private fun hookViewBinder() { + context.mappings.useMapper(ViewBinderMapper::class) { + val cachedHooks = mutableListOf<String>() + fun cacheHook(clazz: Class<*>, block: Class<*>.() -> Unit) { + synchronized(cachedHooks) { + if (!cachedHooks.contains(clazz.name)) { + clazz.block() + cachedHooks.add(clazz.name) + } + } + } + + classReference.get()?.hookConstructor(HookStage.AFTER) { methodParam -> + cacheHook( + methodParam.thisObject<Any>()::class.java + ) { + hook(bindMethod.get().toString(), HookStage.BEFORE) bindViewMethod@{ param -> + val instance = param.thisObject<Any>() + val view = instance::class.java.methods.firstOrNull { + it.name == getViewMethod.get().toString() + }?.invoke(instance) as? View ?: return@bindViewMethod + + context.event.post( + BindViewEvent( + prevModel = param.arg(0), + nextModel = param.argNullable(1), + view = view + ) + ) + } + } + } + } + + } + + fun init() { + context.classCache.conversationManager.hook("sendMessageWithContent", HookStage.BEFORE) { param -> + context.event.post(SendMessageWithContentEvent( + destinations = MessageDestinations(param.arg(0)), + messageContent = MessageContent(param.arg(1)), + callback = param.arg(2) + ).apply { adapter = param }) { + postHookEvent() + } + } + + context.classCache.snapManager.hook("onSnapInteraction", HookStage.BEFORE) { param -> + val interactionType = param.arg<Any>(0).toString() + val conversationId = SnapUUID(param.arg(1)) + val messageId = param.arg<Long>(2) + context.event.post( + OnSnapInteractionEvent( + interactionType = interactionType, + conversationId = conversationId, + messageId = messageId + ).apply { + adapter = param + } + ) { + postHookEvent() + } + } + + context.androidContext.classLoader.loadClass(SnapWidgetBroadcastReceiverHelper.CLASS_NAME) + .hook("onReceive", HookStage.BEFORE) { param -> + val intent = param.arg(1) as? Intent ?: return@hook + if (!SnapWidgetBroadcastReceiverHelper.isIncomingIntentValid(intent)) return@hook + val action = intent.getStringExtra("action") ?: return@hook + + context.event.post( + SnapWidgetBroadcastReceiveEvent( + androidContext = context.androidContext, + intent = intent, + action = action + ).apply { + adapter = param + } + ) { + postHookEvent() + } + } + + ViewGroup::class.java.findRestrictedMethod { + it.name == "addViewInner" + }!!.hook(HookStage.BEFORE) { param -> + context.event.post( + AddViewEvent( + parent = param.thisObject(), + view = param.arg(0), + index = param.arg(1), + layoutParams = param.arg(2) + ).apply { + adapter = param + } + ) { + with(param) { + setArg(0, view) + setArg(1, index) + setArg(2, layoutParams) + } + postHookEvent() + } + } + + LayoutInflater::class.java.getMethod( + "inflate", + Int::class.java, + ViewGroup::class.java, + Boolean::class.javaPrimitiveType + ).hook(HookStage.AFTER) { param -> + val layoutId = param.argNullable<Int>(0) ?: return@hook + val parent = param.argNullable<ViewGroup>(1) + val result = param.getResult() as? View + + context.event.post( + LayoutInflateEvent( + layoutId = layoutId, + parent = parent, + view = result + ).apply { + adapter = param + } + ) { + if (canceled) param.setResult(null) + postHookEvent() + } + } + + context.classCache.networkApi.hook("submit", HookStage.BEFORE) { param -> + val request = param.arg<Any>(0) + + context.event.post( + NetworkApiRequestEvent( + url = request.getObjectField("mUrl") as String, + callback = param.arg(4), + uploadDataProvider = param.argNullable(5), + request = request, + ).apply { + adapter = param + } + ) { + if (canceled) param.setResult(null) + request.setObjectField("mUrl", url) + postHookEvent() + } + } + + context.classCache.message.hookConstructor(HookStage.AFTER) { param -> + context.event.post( + BuildMessageEvent( + message = Message(param.thisObject()) + ) + ) + } + + context.classCache.unifiedGrpcService.hook("unaryCall", HookStage.BEFORE) { param -> + val uri = param.arg<String>(0) + val buffer = param.argNullable<ByteBuffer>(1)?.run { + val array = ByteArray(limit()) + position(0) + get(array) + rewind() + array + } ?: return@hook + val unaryEventHandler = param.argNullable<Any>(3) ?: return@hook + + val event = context.event.post( + UnaryCallEvent( + uri = uri, + buffer = buffer + ).apply { + adapter = param + } + ) ?: return@hook + + if (event.canceled) { + param.setResult(null) + return@hook + } + + if (!event.buffer.contentEquals(buffer)) { + param.setArg(1, ByteBuffer.allocateDirect(event.buffer.size).apply { + put(event.buffer) + rewind() + }) + } + + if (event.callbacks.size == 0) { + return@hook + } + + Hooker.ephemeralHookObjectMethod(unaryEventHandler::class.java, unaryEventHandler, "onEvent", HookStage.BEFORE) { methodParam -> + val byteBuffer = methodParam.argNullable<ByteBuffer>(0) ?: return@ephemeralHookObjectMethod + val array = byteBuffer.run { + val array = ByteArray(limit()) + position(0) + get(array) + rewind() + array + } + + val responseUnaryCallEvent = UnaryCallEvent( + uri = uri, + buffer = array + ).also { it.context = context} + + event.callbacks.forEach { callback -> + callback(responseUnaryCallEvent) + } + + if (responseUnaryCallEvent.canceled) { + param.setResult(null) + return@ephemeralHookObjectMethod + } + + methodParam.setArg(0, ByteBuffer.wrap(responseUnaryCallEvent.buffer)) + } + } + + arrayOf( + "com.snap.mushroom.MainActivity", + "com.snap.identity.loginsignup.ui.LoginSignupActivity" + ).forEach { + context.androidContext.classLoader.loadClass(it).hook("onActivityResult", HookStage.BEFORE) { param -> + val instance = param.thisObject<Activity>() + val requestCode = param.arg<Int>(0) + val resultCode = param.arg<Int>(1) + val intent = param.argNullable<Intent>(2) ?: return@hook + + context.event.post( + ActivityResultEvent( + activity = instance, + requestCode = requestCode, + resultCode = resultCode, + intent = intent + ).apply { + adapter = param + } + ) { + if (canceled) param.setResult(null) + postHookEvent() + } + } + } + + context.mappings.useMapper(CallbackMapper::class) { + callbacks.getClass("UploadDelegate")?.hook("uploadMedia", HookStage.BEFORE) { param -> + val uploadCallback = param.arg<Any>(2) + + val event = context.event.post(MediaUploadEvent( + localMessageContent = MessageContent(param.arg(0)), + destinations = MessageDestinations(param.arg(1)), + callback = uploadCallback + ).apply { + adapter = param + }) + + if (event?.canceled == true) { + param.setResult(null) + return@hook + } + + event?.mediaUploadCallbacks?.takeIf { it.isNotEmpty() }?.let { callbacks -> + Hooker.ephemeralHookObjectMethod(uploadCallback::class.java, uploadCallback, "onUploadFinished", HookStage.BEFORE) { methodParam -> + val messageContent = MessageContent(methodParam.arg(1)) + callbacks.forEach { + it(MediaUploadEvent.MediaUploadResult(messageContent)) + } + } + } + } + } + + hookViewBinder() + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/AbstractHookEvent.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/AbstractHookEvent.kt new file mode 100644 index 0000000000..04ee553a1c --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/AbstractHookEvent.kt @@ -0,0 +1,29 @@ +package me.rhunk.snapenhance.core.event.events + +import me.rhunk.snapenhance.core.event.Event +import me.rhunk.snapenhance.core.util.hook.HookAdapter + +abstract class AbstractHookEvent : Event() { + lateinit var adapter: HookAdapter + private val invokeLaterCallbacks = mutableListOf<() -> Unit>() + + fun addInvokeLater(callback: () -> Unit) { + invokeLaterCallbacks.add(callback) + } + + private fun invokeLater() { + invokeLaterCallbacks.forEach { it() } + } + + fun postHookEvent(block: AbstractHookEvent.() -> Unit = {}) { + block().apply { + invokeLater() + if (canceled) adapter.setResult(null) + } + } + + fun invokeOriginal() { + invokeLater() + adapter.invokeOriginal() + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/ActivityResultEvent.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/ActivityResultEvent.kt new file mode 100644 index 0000000000..11412403bd --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/ActivityResultEvent.kt @@ -0,0 +1,12 @@ +package me.rhunk.snapenhance.core.event.events.impl + +import android.app.Activity +import android.content.Intent +import me.rhunk.snapenhance.core.event.events.AbstractHookEvent + +class ActivityResultEvent( + val activity: Activity, + val requestCode: Int, + val resultCode: Int, + val intent: Intent +): AbstractHookEvent() \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/AddViewEvent.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/AddViewEvent.kt new file mode 100644 index 0000000000..b82c1504f8 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/AddViewEvent.kt @@ -0,0 +1,14 @@ +package me.rhunk.snapenhance.core.event.events.impl + +import android.view.View +import android.view.ViewGroup +import me.rhunk.snapenhance.core.event.events.AbstractHookEvent + +class AddViewEvent( + val parent: ViewGroup, + var view: View, + var index: Int, + var layoutParams: ViewGroup.LayoutParams +) : AbstractHookEvent() { + val viewClassName by lazy { view.javaClass.name } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/BindViewEvent.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/BindViewEvent.kt new file mode 100644 index 0000000000..9bb56ed79c --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/BindViewEvent.kt @@ -0,0 +1,40 @@ +package me.rhunk.snapenhance.core.event.events.impl + +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import me.rhunk.snapenhance.common.database.impl.ConversationMessage +import me.rhunk.snapenhance.core.event.Event + +class BindViewEvent( + val prevModel: Any, + val nextModel: Any?, + var view: View +): Event() { + val databaseMessage by lazy { + var message: ConversationMessage? = null + chatMessage { _, messageId -> + message = context.database.getConversationMessageFromId(messageId.toLong()) + } + message + } + + inline fun chatMessage(block: (conversationId: String, messageId: String) -> Unit) { + val modelToString = prevModel.toString() + if (!modelToString.startsWith("ChatViewModel")) return + if (view !is LinearLayout) { + view = (view as ViewGroup).getChildAt(0) + } + modelToString.substringAfter("messageId=").substringBefore(",").split(":").apply { + if (size != 3) return + block(this[0], this[2]) + } + } + + inline fun friendFeedItem(block: (conversationId: String) -> Unit) { + val modelToString = prevModel.toString() + if (!modelToString.startsWith("FriendFeedItemViewModel")) return + val conversationId = modelToString.substringAfter("conversationId: ").substringBefore("\n") + block(conversationId) + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/BuildMessageEvent.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/BuildMessageEvent.kt new file mode 100644 index 0000000000..7133be169d --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/BuildMessageEvent.kt @@ -0,0 +1,8 @@ +package me.rhunk.snapenhance.core.event.events.impl + +import me.rhunk.snapenhance.core.event.Event +import me.rhunk.snapenhance.core.wrapper.impl.Message + +class BuildMessageEvent( + val message: Message +): Event() \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/ConversationUpdateEvent.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/ConversationUpdateEvent.kt new file mode 100644 index 0000000000..276cda2910 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/ConversationUpdateEvent.kt @@ -0,0 +1,10 @@ +package me.rhunk.snapenhance.core.event.events.impl + +import me.rhunk.snapenhance.core.event.events.AbstractHookEvent +import me.rhunk.snapenhance.core.wrapper.impl.Message + +class ConversationUpdateEvent( + val conversationId: String, + val conversation: Any?, + val messages: List<Message> +) : AbstractHookEvent() \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/LayoutInflateEvent.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/LayoutInflateEvent.kt new file mode 100644 index 0000000000..55c1283ad5 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/LayoutInflateEvent.kt @@ -0,0 +1,11 @@ +package me.rhunk.snapenhance.core.event.events.impl + +import android.view.View +import android.view.ViewGroup +import me.rhunk.snapenhance.core.event.events.AbstractHookEvent + +class LayoutInflateEvent( + val layoutId: Int, + val parent: ViewGroup?, + val view: View? +) : AbstractHookEvent() \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/MediaUploadEvent.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/MediaUploadEvent.kt new file mode 100644 index 0000000000..29ba42e396 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/MediaUploadEvent.kt @@ -0,0 +1,21 @@ +package me.rhunk.snapenhance.core.event.events.impl + +import me.rhunk.snapenhance.core.event.events.AbstractHookEvent +import me.rhunk.snapenhance.core.wrapper.impl.MessageContent +import me.rhunk.snapenhance.core.wrapper.impl.MessageDestinations + +class MediaUploadEvent( + val localMessageContent: MessageContent, + val destinations: MessageDestinations, + val callback: Any, +): AbstractHookEvent() { + class MediaUploadResult( + val messageContent: MessageContent + ) + + val mediaUploadCallbacks = mutableListOf<(MediaUploadResult) -> Unit>() + + fun onMediaUploaded(callback: (MediaUploadResult) -> Unit) { + mediaUploadCallbacks.add(callback) + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/NativeUnaryCallEvent.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/NativeUnaryCallEvent.kt new file mode 100644 index 0000000000..5d08af3918 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/NativeUnaryCallEvent.kt @@ -0,0 +1,8 @@ +package me.rhunk.snapenhance.core.event.events.impl + +import me.rhunk.snapenhance.core.event.events.AbstractHookEvent + +class NativeUnaryCallEvent( + val uri: String, + var buffer: ByteArray +) : AbstractHookEvent() \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/NetworkApiRequestEvent.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/NetworkApiRequestEvent.kt new file mode 100644 index 0000000000..d19e018b44 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/NetworkApiRequestEvent.kt @@ -0,0 +1,93 @@ +package me.rhunk.snapenhance.core.event.events.impl + +import me.rhunk.snapenhance.core.event.events.AbstractHookEvent +import me.rhunk.snapenhance.core.util.hook.HookAdapter +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.Hooker +import java.nio.ByteBuffer + +class NetworkApiRequestEvent( + val request: Any, + val uploadDataProvider: Any?, + val callback: Any, + var url: String, +) : AbstractHookEvent() { + fun addResultHook(methodName: String, stage: HookStage = HookStage.BEFORE, callback: (HookAdapter) -> Unit) { + Hooker.ephemeralHookObjectMethod( + this.callback::class.java, + this.callback, + methodName, + stage + ) { callback.invoke(it) } + } + + fun onSuccess(callback: HookAdapter.(ByteArray?) -> Unit) { + addResultHook("onSucceeded") { param -> + callback.invoke(param, param.argNullable<ByteBuffer>(2)?.let { + ByteArray(it.capacity()).also { buffer -> it.get(buffer); it.position(0) } + }) + } + } + + fun hookRequestBuffer(onRequest: (ByteArray) -> ByteArray) { + val streamDataProvider = this.uploadDataProvider?.let { provider -> + provider::class.java.methods.find { it.name == "getUploadStreamDataProvider" }?.invoke(provider) + } ?: return + val streamDataProviderMethods = streamDataProvider::class.java.methods + + val originalBufferSize = streamDataProviderMethods.find { it.name == "getLength" }?.invoke(streamDataProvider) as? Long ?: return + var originalRequestBuffer = ByteArray(originalBufferSize.toInt()) + streamDataProviderMethods.find { it.name == "read" }?.invoke(streamDataProvider, ByteBuffer.wrap(originalRequestBuffer)) + streamDataProviderMethods.find { it.name == "close" }?.invoke(streamDataProvider) + + runCatching { + originalRequestBuffer = onRequest.invoke(originalRequestBuffer) + }.onFailure { + context.log.error("Failed to hook request buffer", it) + } + + var offset = 0L + val unhooks = mutableListOf<() -> Unit>() + + fun hookObjectMethod(methodName: String, callback: (HookAdapter) -> Unit) { + Hooker.hookObjectMethod( + streamDataProvider::class.java, + streamDataProvider, + methodName, + HookStage.BEFORE + ) { + callback.invoke(it) + }.also { unhooks.addAll(it) } + } + + hookObjectMethod("getLength") { it.setResult(originalRequestBuffer.size.toLong()) } + hookObjectMethod("getOffset") { it.setResult(offset) } + hookObjectMethod("close") { param -> + unhooks.forEach { it.invoke() } + param.setResult(null) + } + hookObjectMethod("rewind") { + offset = 0 + it.setResult(true) + } + hookObjectMethod("read") { param -> + val byteBuffer = param.arg<ByteBuffer>(0) + val length = originalRequestBuffer.size.coerceAtMost(byteBuffer.remaining()) + byteBuffer.put(originalRequestBuffer, offset.toInt(), length) + offset += length + param.setResult(byteBuffer.position().toLong()) + } + + Hooker.hookObjectMethod( + this.uploadDataProvider::class.java, + this.uploadDataProvider, + "getUploadStreamDataProvider", + HookStage.BEFORE + ) { + if (it.nullableThisObject<Any>() != this.uploadDataProvider) return@hookObjectMethod + it.setResult(streamDataProvider) + }.also { + unhooks.addAll(it) + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/OnSnapInteractionEvent.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/OnSnapInteractionEvent.kt new file mode 100644 index 0000000000..9ef0784c43 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/OnSnapInteractionEvent.kt @@ -0,0 +1,10 @@ +package me.rhunk.snapenhance.core.event.events.impl + +import me.rhunk.snapenhance.core.event.events.AbstractHookEvent +import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID + +class OnSnapInteractionEvent( + val interactionType: String, + val conversationId: SnapUUID, + val messageId: Long +) : AbstractHookEvent() \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/SendMessageWithContentEvent.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/SendMessageWithContentEvent.kt new file mode 100644 index 0000000000..2b7f8e00f4 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/SendMessageWithContentEvent.kt @@ -0,0 +1,23 @@ +package me.rhunk.snapenhance.core.event.events.impl + +import me.rhunk.snapenhance.core.event.events.AbstractHookEvent +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.Hooker +import me.rhunk.snapenhance.core.wrapper.impl.MessageContent +import me.rhunk.snapenhance.core.wrapper.impl.MessageDestinations + +class SendMessageWithContentEvent( + val destinations: MessageDestinations, + val messageContent: MessageContent, + private val callback: Any +) : AbstractHookEvent() { + + fun addCallbackResult(methodName: String, block: (args: Array<Any?>) -> Unit) { + Hooker.ephemeralHookObjectMethod( + callback::class.java, + callback, + methodName, + HookStage.BEFORE + ) { block(it.args()) } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/SnapWidgetBroadcastReceiveEvent.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/SnapWidgetBroadcastReceiveEvent.kt new file mode 100644 index 0000000000..737a18e34f --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/SnapWidgetBroadcastReceiveEvent.kt @@ -0,0 +1,11 @@ +package me.rhunk.snapenhance.core.event.events.impl + +import android.content.Context +import android.content.Intent +import me.rhunk.snapenhance.core.event.events.AbstractHookEvent + +class SnapWidgetBroadcastReceiveEvent( + val androidContext: Context, + val intent: Intent?, + val action: String +) : AbstractHookEvent() \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/UnaryCallEvent.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/UnaryCallEvent.kt new file mode 100644 index 0000000000..26245ed0ef --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/UnaryCallEvent.kt @@ -0,0 +1,14 @@ +package me.rhunk.snapenhance.core.event.events.impl + +import me.rhunk.snapenhance.core.event.events.AbstractHookEvent + +class UnaryCallEvent( + val uri: String, + var buffer: ByteArray +): AbstractHookEvent() { + val callbacks = mutableListOf<(UnaryCallEvent) -> Unit>() + + fun addResponseCallback(responseCallback: UnaryCallEvent.() -> Unit) { + callbacks.add(responseCallback) + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/BridgeFileFeature.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/BridgeFileFeature.kt new file mode 100644 index 0000000000..cc6ce27a06 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/BridgeFileFeature.kt @@ -0,0 +1,69 @@ +package me.rhunk.snapenhance.core.features + +import me.rhunk.snapenhance.common.bridge.FileHandleScope +import me.rhunk.snapenhance.common.bridge.InternalFileHandleType +import me.rhunk.snapenhance.common.bridge.toWrapper +import me.rhunk.snapenhance.common.util.LazyBridgeValue +import me.rhunk.snapenhance.common.util.mappedLazyBridge +import java.io.BufferedReader +import java.io.InputStreamReader +import java.nio.charset.StandardCharsets + +abstract class BridgeFileFeature(name: String, private val bridgeFileType: InternalFileHandleType) : Feature(name) { + private val fileLines = mutableListOf<String>() + private val fileWrapper by mappedLazyBridge(LazyBridgeValue({ context.fileHandlerManager.getFileHandle(FileHandleScope.INTERNAL.key, bridgeFileType.key)!! }), map = { it.toWrapper() }) + + private fun readFile() { + val temporaryLines = mutableListOf<String>() + fileWrapper.inputStream { stream -> + with(BufferedReader(InputStreamReader(stream, StandardCharsets.UTF_8))) { + var line = "" + while (readLine()?.also { line = it } != null) temporaryLines.add(line) + close() + } + } + + fileLines.clear() + fileLines.addAll(temporaryLines) + } + + private fun updateFile() { + fileWrapper.outputStream { stream -> + fileLines.forEach { + stream.write(it.toByteArray()) + stream.write("\n".toByteArray()) + stream.flush() + } + } + } + + protected fun exists(line: String) = fileLines.contains(line) + + protected fun toggle(line: String) { + if (exists(line)) fileLines.remove(line) else fileLines.add(line) + updateFile() + } + + protected fun setState(line: String, state: Boolean) { + if (state) { + if (!exists(line)) fileLines.add(line) + } else { + if (exists(line)) fileLines.remove(line) + } + updateFile() + } + + protected fun reload() = readFile() + + protected fun put(line: String) { + fileLines.add(line) + updateFile() + } + + protected fun clear() { + fileLines.clear() + updateFile() + } + + protected fun lines() = fileLines.toList() +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/Feature.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/Feature.kt new file mode 100644 index 0000000000..aa02cd1c23 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/Feature.kt @@ -0,0 +1,45 @@ +package me.rhunk.snapenhance.core.features + +import android.app.Activity +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.core.ModContext + +abstract class Feature( + val key: String +) { + lateinit var context: ModContext + lateinit var registerNextActivityCallback: ((Activity) -> Unit) -> Unit + + protected fun defer(block: suspend () -> Unit) { + context.coroutineScope.launch { + runCatching { + block() + }.onFailure { + context.log.error("Failed to run defer callback", it) + } + } + } + + protected fun onNextActivityCreate(defer: Boolean = false, block: (Activity) -> Unit) { + if (defer) { + registerNextActivityCallback { + defer { + block(it) + } + } + return + } + registerNextActivityCallback(block) + } + + open fun init() {} + + + protected fun findClass(name: String): Class<*> { + return context.androidContext.classLoader.loadClass(name) + } + + protected fun runOnUiThread(block: () -> Unit) { + context.runOnUiThread(block) + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/FeatureLoadParams.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/FeatureLoadParams.kt new file mode 100644 index 0000000000..e951de8334 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/FeatureLoadParams.kt @@ -0,0 +1,11 @@ +package me.rhunk.snapenhance.core.features + +object FeatureLoadParams { + const val NO_INIT = 0 + + const val INIT_SYNC = 0b0001 + const val ACTIVITY_CREATE_SYNC = 0b0010 + + const val INIT_ASYNC = 0b0100 + const val ACTIVITY_CREATE_ASYNC = 0b1000 +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/FeatureManager.kt new file mode 100644 index 0000000000..82b7d25894 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/FeatureManager.kt @@ -0,0 +1,175 @@ +package me.rhunk.snapenhance.core.features + +import android.app.Activity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import me.rhunk.snapenhance.core.ModContext +import me.rhunk.snapenhance.core.features.impl.* +import me.rhunk.snapenhance.core.features.impl.downloader.MediaDownloader +import me.rhunk.snapenhance.core.features.impl.downloader.ProfilePictureDownloader +import me.rhunk.snapenhance.core.features.impl.experiments.* +import me.rhunk.snapenhance.core.features.impl.global.* +import me.rhunk.snapenhance.core.features.impl.messaging.* +import me.rhunk.snapenhance.core.features.impl.spying.FriendTracker +import me.rhunk.snapenhance.core.features.impl.spying.HalfSwipeNotifier +import me.rhunk.snapenhance.core.features.impl.spying.MessageLogger +import me.rhunk.snapenhance.core.features.impl.spying.StealthMode +import me.rhunk.snapenhance.core.features.impl.tweaks.* +import me.rhunk.snapenhance.core.features.impl.ui.* +import me.rhunk.snapenhance.core.logger.CoreLogger +import me.rhunk.snapenhance.core.ui.menu.MenuViewInjector +import kotlin.reflect.KClass +import kotlin.system.measureTimeMillis + +class FeatureManager( + private val context: ModContext +) { + private val features = mutableMapOf<KClass<out Feature>, Feature>() + private val onActivityCreateListeners = mutableListOf<(Activity) -> Unit>() + + fun addActivityCreateListener(block: (Activity) -> Unit) { + onActivityCreateListeners.add(block) + } + + private fun register(vararg featureList: Feature) { + if (context.bridgeClient.getDebugProp("disable_feature_loading") == "true") { + context.log.warn("Feature loading is disabled") + return + } + + runBlocking { + featureList.forEach { feature -> + launch(Dispatchers.IO) { + runCatching { + feature.context = context + feature.registerNextActivityCallback = { block -> onActivityCreateListeners.add(block) } + synchronized(features) { + features[feature::class] = feature + } + }.onFailure { + CoreLogger.xposedLog("Failed to register feature ${feature.key}", it) + } + } + } + } + } + + @Suppress("UNCHECKED_CAST") + fun <T : Feature> get(featureClass: KClass<T>): T? { + return features[featureClass] as? T + } + + fun getRuleFeatures() = features.values.filterIsInstance<MessagingRuleFeature>().sortedBy { it.ruleType.ordinal } + + fun init() { + register( + Debug(), + EndToEndEncryption(), + ScopeSync(), + PreventMessageListAutoScroll(), + Messaging(), + FriendMutationObserver(), + AutoMarkAsRead(), + MediaDownloader(), + StealthMode(), + MenuViewInjector(), + MessageLogger(), + ConvertMessageLocally(), + SnapchatPlus(), + DisableMetrics(), + PreventMessageSending(), + Notifications(), + AutoSave(), + UITweaks(), + ConfigurationOverride(), + COFOverride(), + UnsaveableMessages(), + SendOverride(), + UnlimitedSnapViewTime(), + BypassVideoLengthRestriction(), + MediaUploadQualityOverride(), + MeoPasscodeBypass(), + AppLock(), + CameraTweaks(), + InfiniteStoryBoost(), + PinConversations(), + DeviceSpooferHook(), + ClientBootstrapOverride(), + GooglePlayServicesDialogs(), + NoFriendScoreDelay(), + ProfilePictureDownloader(), + AddFriendSourceSpoof(), + DisableReplayInFF(), + OldBitmojiSelfie(), + FriendFeedMessagePreview(), + HideStreakRestore(), + HideFriendFeedEntry(), + RequerySqlite(), + CallButtonsOverride(), + SnapPreview(), + BypassScreenshotDetection(), + HalfSwipeNotifier(), + DisableConfirmationDialogs(), + MixerStories(), + MessageIndicators(), + EditTextOverride(), + PreventForcedLogout(), + ConversationToolbox(), + SpotlightCommentsUsername(), + OperaViewerParamsOverride(), + StealthModeIndicator(), + DisablePermissionRequests(), + FriendTracker(), + DefaultVolumeControls(), + CallRecorder(), + DisableMemoriesSnapFeed(), + AccountSwitcher(), + RemoveGroupsLockedStatus(), + BypassMessageActionRestrictions(), + BetterLocation(), + MediaFilePicker(), + HideActiveMusic(), + AutoOpenSnaps(), + CustomStreaksExpirationFormat(), + ComposerHooks(), + DisableCustomTabs(), + BestFriendPinning(), + ContextMenuFix(), + DisableTelecomFramework(), + BetterTranscript(), + VoiceNoteOverride(), + FriendNotes(), + DoubleTapChatAction(), + SnapScoreChanges(), + DisableSnapModeRestrictions(), + PreventForcedKeyboard(), + ) + + features.values.toList().forEach { feature -> + runCatching { + measureTimeMillis { + feature.init() + } + }.onFailure { + context.log.error("Failed to init feature ${feature.key}", it) + context.longToast("Failed to init feature ${feature.key}! Check logcat for more details.") + } + } + } + + fun onActivityCreate(activity: Activity) { + context.log.verbose("Activity created: ${activity.javaClass.simpleName}") + onActivityCreateListeners.toList().also { + onActivityCreateListeners.clear() + }.forEach { activityListener -> + measureTimeMillis { + runCatching { + activityListener(activity) + }.onFailure { + context.log.error("Failed to run activity listener ${activityListener::class.simpleName}", it) + } + } + } + } +} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/MessagingRuleFeature.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/MessagingRuleFeature.kt new file mode 100644 index 0000000000..9b4ae5f0af --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/MessagingRuleFeature.kt @@ -0,0 +1,36 @@ +package me.rhunk.snapenhance.core.features + +import me.rhunk.snapenhance.common.data.MessagingRuleType +import me.rhunk.snapenhance.common.data.RuleState + +abstract class MessagingRuleFeature(name: String, val ruleType: MessagingRuleType) : Feature(name) { + private val listeners = mutableListOf<(String, Boolean) -> Unit>() + + fun addStateListener(listener: (conversationId: String, newState: Boolean) -> Unit) { + listeners.add(listener) + } + + open fun getRuleState() = context.config.rules.getRuleState(ruleType) + + fun setState(conversationId: String, state: Boolean) { + context.bridgeClient.setRule( + context.database.getDMOtherParticipant(conversationId) ?: conversationId, + ruleType, + state + ) + listeners.forEach { it(conversationId, state) } + } + + fun getState(conversationId: String) = + context.bridgeClient.getRules( + context.database.getDMOtherParticipant(conversationId) ?: conversationId + ).contains(ruleType) && getRuleState() != null + + fun canUseRule(conversationId: String): Boolean { + val state = getState(conversationId) + if (context.config.rules.getRuleState(ruleType) == RuleState.BLACKLIST) { + return !state + } + return state + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/COFOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/COFOverride.kt new file mode 100644 index 0000000000..5f62810907 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/COFOverride.kt @@ -0,0 +1,40 @@ +package me.rhunk.snapenhance.core.features.impl + +import me.rhunk.snapenhance.core.features.Feature + +import me.rhunk.snapenhance.core.util.dataBuilder +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.mapper.impl.COFObservableMapper +import java.lang.reflect.Method + +class COFOverride : Feature("COF Override") { + var hasActionMenuV2 = false + + override fun init() { + val cofExperiments by context.config.experimental.cofExperiments + + context.mappings.useMapper(COFObservableMapper::class) { + classReference.getAsClass()?.hook(getBooleanObservable.get() ?: return@useMapper, HookStage.AFTER) { param -> + val configId = param.arg<String>(0) + val result by lazy { param.getResult()?.getObjectField("b") } + + fun setBooleanResult(state: Boolean) { + param.setResult((param.method() as Method).returnType.dataBuilder { + set("a", 4) + set("b", state) + }) + } + + if (cofExperiments.contains(configId.lowercase())) { + setBooleanResult(true) + } + + if ((configId == "ANDROID_ACTION_MENU_V2" || configId == "ANDROID_ACTION_MENU_ADJUST_MESSAGE_POSITION") && result == true) { + hasActionMenuV2 = true + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ConfigurationOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ConfigurationOverride.kt new file mode 100644 index 0000000000..fbd501a9ac --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ConfigurationOverride.kt @@ -0,0 +1,151 @@ +package me.rhunk.snapenhance.core.features.impl + +import de.robv.android.xposed.XposedHelpers +import me.rhunk.snapenhance.core.features.Feature + +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.Hooker +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.util.ktx.setObjectField +import me.rhunk.snapenhance.mapper.impl.CompositeConfigurationProviderMapper + +data class ConfigKeyInfo( + val category: String?, + val name: String?, + val defaultValue: Any? +) + +data class ConfigFilter( + val filter: (ConfigKeyInfo) -> Boolean, + val defaultValue: (ConfigKeyInfo) -> Any?, + val isAppExperiment: Boolean? +) + +class ConfigurationOverride : Feature("Configuration Override") { + override fun init() { + context.mappings.useMapper(CompositeConfigurationProviderMapper::class) { + fun getConfigKeyInfo(key: Any?) = runCatching { + if (key == null) return@runCatching null + val keyClassMethods = key::class.java.methods + val keyName = keyClassMethods.firstOrNull { it.name == "getName" }?.invoke(key)?.toString() ?: key.toString() + val category = keyClassMethods.firstOrNull { it.name == configEnumMapping["getCategory"]?.get().toString() }?.invoke(key)?.toString() ?: return null + val valueHolder = keyClassMethods.firstOrNull { it.name == configEnumMapping["getValue"]?.get().toString() }?.invoke(key) ?: return null + val defaultValue = valueHolder.getObjectField(configEnumMapping["defaultValueField"]?.get().toString()) ?: return null + ConfigKeyInfo(category, keyName, defaultValue) + }.onFailure { + context.log.error("Failed to get config key info", it) + }.getOrNull() + + val propertyOverrides = mutableMapOf<String, ConfigFilter>() + + fun overrideProperty(key: String, filter: (ConfigKeyInfo) -> Boolean, value: (ConfigKeyInfo) -> Any?, isAppExperiment: Boolean = false) { + propertyOverrides[key] = ConfigFilter(filter, value, isAppExperiment) + } + + overrideProperty("STREAK_EXPIRATION_INFO", { context.config.userInterface.streakExpirationInfo.get() }, + { true }) + overrideProperty("TRANSCODING_MAX_QUALITY", { context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get() }, + { true }, isAppExperiment = true) + + overrideProperty("CAMERA_ME_ENABLE_HEVC_RECORDING", { context.config.camera.hevcRecording.get() }, + { true }) + overrideProperty("MEDIA_RECORDER_MAX_QUALITY_LEVEL", { context.config.camera.forceCameraSourceEncoding.get() }, + { true }) + overrideProperty("REDUCE_MY_PROFILE_UI_COMPLEXITY", { context.config.userInterface.mapFriendNameTags.get() }, + { true }) + + arrayOf("DISABLE_SPLIT_RENDER_PASS_CONTROLLER", "ENABLE_LONG_SNAP_SENDING").forEach { + overrideProperty(it, { context.config.global.disableSnapSplitting.get() }, { true }) + } + + overrideProperty("DF_VOPERA_FOR_STORIES", { context.config.userInterface.verticalStoryViewer.get() }, + { true }, isAppExperiment = true) + overrideProperty("SPOTLIGHT_5TH_TAB_ENABLED", { context.config.userInterface.disableSpotlight.get() }, + { false }) + + overrideProperty("BYPASS_AD_FEATURE_GATE", { context.config.global.blockAds.get() }, + { true }) + + overrideProperty("SPONSORED_SNAPS_ENABLED", { context.config.global.blockAds.get() }, { false }) + overrideProperty("SPONSORED_SNAP_UPDATE_SPONSORED_FEED_ITEM", { context.config.global.blockAds.get() }, { false }) + + arrayOf("CUSTOM_AD_TRACKER_URL", "CUSTOM_AD_INIT_SERVER_URL", "CUSTOM_AD_SERVER_URL", "INIT_PRIMARY_URL", "INIT_SHADOW_URL", "GRAPHENE_HOST").forEach { + overrideProperty(it, { context.config.global.blockAds.get() }, { "http://127.0.0.1" }) + } + overrideProperty("GIFTING_CHAT_BIRTHDAY_UPSELL_ENABLED", { context.config.userInterface.hideUiComponents.get().contains("hide_snapchat_plus_gift_reminders") }, { false }) + + classReference.getAsClass()?.hook( + getProperty.getAsString()!!, + HookStage.AFTER + ) { param -> + val propertyKey = getConfigKeyInfo(param.argNullable<Any>(0)) ?: return@hook + + propertyOverrides[propertyKey.name]?.let { (filter, value) -> + if (!filter(propertyKey)) return@let + param.setResult(value(propertyKey)) + } + } + + classReference.get()?.hook( + observeProperty.getAsString()!!, + HookStage.BEFORE + ) { param -> + val enumData = param.arg<Any>(0) + val key = enumData.toString() + val setValue: (Any?) -> Unit = { value -> + val valueHolder = XposedHelpers.callMethod(enumData, configEnumMapping["getValue"]?.getAsString()) + valueHolder.setObjectField(configEnumMapping["defaultValueField"]?.getAsString()!!, value) + } + + propertyOverrides[key]?.let { (filter, value) -> + val keyInfo = getConfigKeyInfo(enumData) ?: return@let + if (!filter(keyInfo)) return@let + setValue(value(keyInfo)) + } + } + + runCatching { + val customBooleanPropertyRules = mutableListOf<(ConfigKeyInfo) -> Boolean>() + + appExperimentProvider["getBooleanAppExperimentClass"]?.getAsClass() + ?.hook("invoke", HookStage.BEFORE) { param -> + val keyInfo = getConfigKeyInfo(param.arg(1)) ?: return@hook + if (customBooleanPropertyRules.any { it(keyInfo) }) { + param.setResult(true) + return@hook + } + propertyOverrides[keyInfo.name]?.let { (filter, value, isAppExperiment) -> + if (isAppExperiment != true || !filter(keyInfo)) return@let + param.setResult(value(keyInfo)) + } + } + + Hooker.ephemeralHookConstructor( + classReference.get()!!, + HookStage.AFTER + ) { constructorParam -> + val instance = constructorParam.thisObject<Any>() + val appExperimentProviderInstance = instance::class.java.fields.firstOrNull { + appExperimentProvider["class"]?.getAsClass()?.isAssignableFrom(it.type) == true + }?.get(instance) ?: return@ephemeralHookConstructor + + appExperimentProviderInstance::class.java.methods.first { + it.name == appExperimentProvider["hasExperimentMethod"]?.getAsString().toString() + }.hook(HookStage.BEFORE) { param -> + val keyInfo = getConfigKeyInfo(param.arg(0)) ?: return@hook + if (customBooleanPropertyRules.any { it(keyInfo) }) { + param.setResult(true) + return@hook + } + + val propertyOverride = propertyOverrides[keyInfo.name] ?: return@hook + propertyOverride.isAppExperiment.takeIf { propertyOverride.filter(keyInfo) }?.let { param.setResult(it) } + } + } + }.onFailure { + context.log.error("Failed to hook appExperimentProvider", it) + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/Debug.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/Debug.kt new file mode 100644 index 0000000000..ebc45aba8e --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/Debug.kt @@ -0,0 +1,17 @@ +package me.rhunk.snapenhance.core.features.impl + +import android.widget.TextView +import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent +import me.rhunk.snapenhance.core.features.Feature + +class Debug : Feature("Debug") { + override fun init() { + if (!context.isDeveloper) return + context.event.subscribe(AddViewEvent::class) { event -> + event.view.post { + val viewText = event.view.takeIf { it is TextView }?.let { (it as TextView).text } ?: "" + event.view.contentDescription = "0x" + (event.view.id.takeIf { it > 0 }?.toString(16) ?: "") + " " + viewText + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/FriendMutationObserver.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/FriendMutationObserver.kt new file mode 100644 index 0000000000..9061cf7698 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/FriendMutationObserver.kt @@ -0,0 +1,157 @@ +package me.rhunk.snapenhance.core.features.impl + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.WarningAmber +import com.google.gson.JsonObject +import me.rhunk.snapenhance.common.data.FriendLinkType +import me.rhunk.snapenhance.common.database.impl.FriendInfo +import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent +import me.rhunk.snapenhance.core.features.Feature + +import me.rhunk.snapenhance.core.util.EvictingMap +import java.io.InputStreamReader +import java.util.Calendar + +class FriendMutationObserver: Feature("FriendMutationObserver") { + private val translation by lazy { context.translation.getCategory("friend_mutation_observer") } + private val addSourceCache = EvictingMap<String, String>(500) + + private val notificationManager by lazy { context.androidContext.getSystemService(NotificationManager::class.java) } + private val channelId by lazy { + "friend_mutation_observer".also { + notificationManager.createNotificationChannel( + NotificationChannel( + it, + translation["notification_channel_name"], + NotificationManager.IMPORTANCE_HIGH + ) + ) + } + } + + fun getFriendAddSource(userId: String): String? { + return addSourceCache[userId] + } + + private fun sendWarnNotification( + contentText: String + ) { + notificationManager.notify(System.nanoTime().toInt(), + Notification.Builder(context.androidContext, channelId) + .setSmallIcon(android.R.drawable.ic_dialog_alert) + .setContentTitle(translation["notification_channel_name"]) + .setContentText(contentText) + .setShowWhen(true) + .setWhen(System.currentTimeMillis()) + .build() + ) + + context.inAppOverlay.showStatusToast( + Icons.Default.WarningAmber, + contentText, + durationMs = 7000 + ) + } + + private fun formatUsername(friendInfo: FriendInfo): String { + return friendInfo.displayName?.takeIf { it.isNotBlank() }?.let { + "$it (${friendInfo.mutableUsername})" + } ?: friendInfo.mutableUsername ?: "" + } + + private fun prettyPrintBirthday(month: Int, day: Int): String { + val calendar = Calendar.getInstance() + calendar[Calendar.MONTH] = month + return calendar.getDisplayName( + Calendar.MONTH, + Calendar.LONG, + context.translation.loadedLocale + )?.toString() + " " + day + } + + override fun init() { + val config by context.config.messaging.friendMutationNotifier + + context.event.subscribe(NetworkApiRequestEvent::class) { event -> + if (!event.url.contains("ami/friends")) return@subscribe + event.onSuccess { buffer -> + runCatching { + val jsonObject = context.gson.fromJson(InputStreamReader(buffer?.inputStream() ?: return@onSuccess, Charsets.UTF_8), JsonObject::class.java) + + jsonObject.getAsJsonArray("added_friends").map { it.asJsonObject }.forEach { friend -> + val userId = friend.get("user_id").asString + (friend.get("add_source")?.asString?.takeIf { + it.isNotBlank() + } ?: friend.get("add_source_type")?.asString?.takeIf { + it.isNotBlank() + })?.let { + addSourceCache[userId] = it + } + } + + if (config.isEmpty()) return@runCatching + + jsonObject.getAsJsonArray("friends").map { it.asJsonObject }.forEach { friend -> + runCatching { + val userId = friend.get("user_id")?.asString + if (userId == context.database.myUserId) return@forEach + val databaseFriend = context.database.getFriendInfo(userId ?: return@forEach) ?: return@forEach + if (FriendLinkType.fromValue(databaseFriend.friendLinkType) != FriendLinkType.MUTUAL) return@forEach + + if (config.contains("remove_friend") && friend.get("direction")?.asString == "OUTGOING" && !friend.has("fidelius_info")) { + sendWarnNotification(translation.format("friend_removed", "username" to formatUsername(databaseFriend))) + return@forEach + } + + if (config.contains("birthday_changes") && + databaseFriend.birthday.takeIf { it != 0L }?.let { + ((it shr 32).toInt()).toString().padStart(2, '0') + "-" + (it.toInt()).toString().padStart(2, '0') + } != friend.get("birthday")?.asString + ) { + val oldBirthday = databaseFriend.birthday.takeIf { it != 0L }?.let { + prettyPrintBirthday((it shr 32).toInt() - 1, it.toInt()) + } + + if (!friend.has("birthday")) { + sendWarnNotification(translation.format("birthday_removed", "username" to formatUsername(databaseFriend), "birthday" to oldBirthday.orEmpty())) + } else { + val newBirthday = friend.get("birthday")?.asString?.split("-")?.let { + prettyPrintBirthday(it[0].toInt() - 1, it[1].toInt()) + } + if (oldBirthday == null) { + sendWarnNotification(translation.format("birthday_added", "username" to formatUsername(databaseFriend), "birthday" to newBirthday.orEmpty())) + } else { + sendWarnNotification(translation.format("birthday_changed", "username" to formatUsername(databaseFriend), "oldBirthday" to oldBirthday, "newBirthday" to newBirthday.orEmpty())) + } + } + } + + if (config.contains("bitmoji_avatar_changes") && databaseFriend.bitmojiAvatarId != friend.get("bitmoji_avatar_id")?.asString) { + sendWarnNotification(translation.format("bitmoji_avatar_changed", "username" to formatUsername(databaseFriend))) + } + + if (config.contains("bitmoji_selfie_changes") && databaseFriend.bitmojiSelfieId != friend.get("bitmoji_selfie_id")?.asString) { + sendWarnNotification(translation.format("bitmoji_selfie_changed", "username" to formatUsername(databaseFriend))) + } + + if (config.contains("bitmoji_background_changes") && databaseFriend.bitmojiBackgroundId != friend.get("bitmoji_background_id")?.asString) { + sendWarnNotification(translation.format("bitmoji_background_changed", "username" to formatUsername(databaseFriend))) + } + + if (config.contains("bitmoji_scene_changes") && databaseFriend.bitmojiSceneId != friend.get("bitmoji_scene_id")?.asString) { + sendWarnNotification(translation.format("bitmoji_scene_changed", "username" to formatUsername(databaseFriend))) + } + }.onFailure { + context.log.error("Failed to process friend", it) + } + } + }.onFailure { + context.log.error("Failed to process friends", it) + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/MixerStories.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/MixerStories.kt new file mode 100644 index 0000000000..2c0fc86818 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/MixerStories.kt @@ -0,0 +1,127 @@ +package me.rhunk.snapenhance.core.features.impl + +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import me.rhunk.snapenhance.common.data.MixerStoryType +import me.rhunk.snapenhance.common.data.StoryData +import me.rhunk.snapenhance.common.util.protobuf.ProtoEditor +import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent +import me.rhunk.snapenhance.core.features.Feature + +import java.nio.ByteBuffer +import kotlin.coroutines.suspendCoroutine +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +class MixerStories : Feature("MixerStories") { + @OptIn(ExperimentalEncodingApi::class) + override fun init() { + val disableDiscoverSections by context.config.global.disableStorySections + + fun canRemoveDiscoverSection(id: Int): Boolean { + val storyType = MixerStoryType.fromIndex(id) + return (storyType == MixerStoryType.SUBSCRIPTIONS && disableDiscoverSections.contains("following")) || + (storyType == MixerStoryType.DISCOVER && disableDiscoverSections.contains("discover")) || + (storyType == MixerStoryType.FRIENDS && disableDiscoverSections.contains("friends")) + } + + context.event.subscribe(NetworkApiRequestEvent::class) { event -> + fun cancelRequest() { + runBlocking { + suspendCoroutine { + context.httpServer.ensureServerStarted()?.let { server -> + event.url = "http://127.0.0.1:${server.port}" + it.resumeWith(Result.success(Unit)) + } ?: run { + event.canceled = true + it.resumeWith(Result.success(Unit)) + } + } + } + } + + if (event.url.endsWith("readreceipt-indexer/batchuploadreadreceipts")) { + if (context.config.messaging.anonymousStoryViewing.get()) { + cancelRequest() + return@subscribe + } + if (!context.config.messaging.preventStoryRewatchIndicator.get()) return@subscribe + event.hookRequestBuffer { buffer -> + ProtoEditor(buffer).apply { + edit { + getOrNull(2)?.removeIf { + it.toReader().getVarInt(7, 4) == 1L + } + } + }.toByteArray() + } + return@subscribe + } + + if (event.url.endsWith("df-mixer-prod/stories") || + event.url.endsWith("df-mixer-prod/batch_stories") || + event.url.endsWith("df-mixer-prod/soma/stories") || + event.url.endsWith("df-mixer-prod/soma/batch_stories") + ) { + event.onSuccess { buffer -> + val editor = ProtoEditor(buffer ?: return@onSuccess) + editor.edit { + editEach(3) { + val sectionType = firstOrNull(10)?.toReader()?.getVarInt(1)?.toInt() ?: return@editEach + + edit(3) { + removeIf(3) { wire -> + val reader = wire.toReader() + val storySubType = reader.getVarInt(23) + val isSuggested = storySubType == 39L + + if (!isSuggested && sectionType == MixerStoryType.FRIENDS.index && context.config.experimental.storyLogger.get()) { + val storyMap = mutableMapOf<String, MutableList<StoryData>>() + + reader.followPath(36) { + eachBuffer(1) data@{ + val userId = getString(8, 1) ?: return@data + + storyMap.getOrPut(userId) { + mutableListOf() + }.add(StoryData( + url = getString(2, 2)?.substringBefore("?") ?: return@data, + postedAt = getVarInt(3) ?: -1L, + createdAt = getVarInt(27) ?: -1L, + key = Base64.decode(getString(2, 5) ?: return@data), + iv = Base64.decode(getString(2, 4) ?: return@data) + )) + } + } + + context.coroutineScope.launch { + storyMap.forEach { (userId, stories) -> + stories.forEach { story -> + runCatching { + context.bridgeClient.getMessageLogger().addStory(userId, story.url, story.postedAt, story.createdAt, story.key, story.iv) + }.onFailure { + context.log.error("Failed to log story", it) + } + } + } + } + } + + isSuggested && disableDiscoverSections.contains("suggested_stories") + } + } + + if (canRemoveDiscoverSection(sectionType)) { + remove(3) + addBuffer(3, byteArrayOf()) + } + } + } + + setArg(2, ByteBuffer.wrap(editor.toByteArray())) + } + return@subscribe + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/OperaViewerParamsOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/OperaViewerParamsOverride.kt new file mode 100644 index 0000000000..6798a1a3a2 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/OperaViewerParamsOverride.kt @@ -0,0 +1,88 @@ +package me.rhunk.snapenhance.core.features.impl + +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hookConstructor +import me.rhunk.snapenhance.core.wrapper.impl.media.opera.ParamMap +import me.rhunk.snapenhance.mapper.impl.OperaViewerParamsMapper +import java.util.concurrent.ConcurrentHashMap + +class OperaViewerParamsOverride : Feature("OperaViewerParamsOverride") { + var currentPlaybackRate = 1.0F + + data class OverrideKey( + val name: String, + val defaultValue: Any? + ) + + data class Override( + val filter: (value: Any?) -> Boolean, + val value: (key: OverrideKey, value: Any?) -> Any? + ) + + override fun init() { + val overrideMap = mutableMapOf<String, Override>() + + fun overrideParam(key: String, filter: (value: Any?) -> Boolean, value: (overrideKey: OverrideKey, value: Any?) -> Any?) { + overrideMap[key] = Override(filter, value) + } + + currentPlaybackRate = context.config.global.defaultVideoPlaybackRate.getNullable()?.takeIf { it > 0 } ?: 1.0F + + if (context.config.global.videoPlaybackRateSlider.get() || currentPlaybackRate != 1.0F) { + overrideParam("video_playback_rate", { currentPlaybackRate != 1.0F }, { _, _ -> currentPlaybackRate.toDouble() }) + } + + if (context.config.messaging.loopMediaPlayback.get()) { + //https://github.com/rodit/SnapMod/blob/master/app/src/main/java/xyz/rodit/snapmod/features/opera/SnapDurationModifier.kt + overrideParam("auto_advance_mode", { true }, { key, _ -> key.defaultValue }) + overrideParam("auto_advance_max_loop_number", { true }, { _, _ -> Int.MAX_VALUE }) + overrideParam("media_playback_mode", { true }, { _, value -> + val playbackMode = value ?: return@overrideParam null + playbackMode::class.java.enumConstants?.firstOrNull { + it.toString() == "LOOPING" + } ?: return@overrideParam value + }) + } + + onNextActivityCreate { + context.mappings.useMapper(OperaViewerParamsMapper::class) { + fun overrideParamResult(paramKey: Any, value: Any?): Any? { + val fields = paramKey::class.java.fields + val key = OverrideKey( + name = fields.firstOrNull { + it.type == String::class.java + }?.get(paramKey)?.toString() ?: return value, + defaultValue = fields.firstOrNull { + it.type == Object::class.java + }?.get(paramKey) + ) + + overrideMap[key.name]?.let { override -> + if (override.filter(value)) { + runCatching { + return override.value(key, value) + }.onFailure { + context.log.error("Failed to override param $key", it) + } + } + } + + return value + } + + classReference.get()?.hookConstructor(HookStage.AFTER) { param -> + ParamMap(param.thisObject()).paramMapField.set(param.thisObject(), object: ConcurrentHashMap<Any, Any>() { + override fun put(key: Any, value: Any): Any? { + return super.put(key, overrideParamResult(key, value) ?: return value) + } + + override fun get(key: Any): Any? { + return overrideParamResult(key, super.get(key)) + } + }) + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ScopeSync.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ScopeSync.kt new file mode 100644 index 0000000000..7a8ec6faba --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ScopeSync.kt @@ -0,0 +1,42 @@ +package me.rhunk.snapenhance.core.features.impl + +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.data.SocialScope +import me.rhunk.snapenhance.core.event.events.impl.SendMessageWithContentEvent +import me.rhunk.snapenhance.core.features.Feature + +class ScopeSync : Feature("Scope Sync") { + companion object { + private const val DELAY_BEFORE_SYNC = 2000L + } + + private val updateJobs = mutableMapOf<String, Job>() + + private fun sync(conversationId: String) { + context.database.getDMOtherParticipant(conversationId)?.also { participant -> + context.bridgeClient.triggerSync(SocialScope.FRIEND, participant) + } ?: run { + context.bridgeClient.triggerSync(SocialScope.GROUP, conversationId) + } + } + + override fun init() { + context.event.subscribe(SendMessageWithContentEvent::class) { event -> + if (event.messageContent.contentType != ContentType.SNAP) return@subscribe + + event.addCallbackResult("onSuccess") { + event.destinations.conversations!!.map { it.toString() }.forEach { conversationId -> + updateJobs[conversationId]?.also { it.cancel() } + + updateJobs[conversationId] = (context.coroutineScope.launch { + delay(DELAY_BEFORE_SYNC) + sync(conversationId) + }) + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt new file mode 100644 index 0000000000..8361ca9238 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt @@ -0,0 +1,805 @@ +package me.rhunk.snapenhance.core.features.impl.downloader + +import android.annotation.SuppressLint +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.view.Gravity +import android.view.ViewGroup.MarginLayoutParams +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.ProgressBar +import android.widget.TextView +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.* +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import me.rhunk.snapenhance.bridge.DownloadCallback +import me.rhunk.snapenhance.common.data.FileType +import me.rhunk.snapenhance.common.data.MessagingRuleType +import me.rhunk.snapenhance.common.data.download.* +import me.rhunk.snapenhance.common.database.impl.ConversationMessage +import me.rhunk.snapenhance.common.database.impl.FriendInfo +import me.rhunk.snapenhance.common.util.ktx.copyToClipboard +import me.rhunk.snapenhance.common.util.ktx.longHashCode +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie +import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper +import me.rhunk.snapenhance.common.util.snap.RemoteMediaResolver +import me.rhunk.snapenhance.core.DownloadManagerClient +import me.rhunk.snapenhance.core.SnapEnhance +import me.rhunk.snapenhance.core.features.MessagingRuleFeature +import me.rhunk.snapenhance.core.features.impl.downloader.decoder.DecodedAttachment +import me.rhunk.snapenhance.core.features.impl.downloader.decoder.MessageDecoder +import me.rhunk.snapenhance.core.features.impl.messaging.Messaging +import me.rhunk.snapenhance.core.features.impl.spying.MessageLogger +import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper +import me.rhunk.snapenhance.core.ui.debugEditText +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.util.media.PreviewUtils +import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID +import me.rhunk.snapenhance.core.wrapper.impl.media.MediaInfo +import me.rhunk.snapenhance.core.wrapper.impl.media.dash.LongformVideoPlaylistItem +import me.rhunk.snapenhance.core.wrapper.impl.media.dash.SnapPlaylistItem +import me.rhunk.snapenhance.core.wrapper.impl.media.opera.Layer +import me.rhunk.snapenhance.core.wrapper.impl.media.opera.ParamMap +import me.rhunk.snapenhance.core.wrapper.impl.media.toKeyPair +import me.rhunk.snapenhance.mapper.impl.OperaPageViewControllerMapper +import java.nio.file.Paths +import java.util.UUID +import kotlin.coroutines.suspendCoroutine +import kotlin.math.absoluteValue + +class SnapChapterInfo( + val offset: Long, + val duration: Long? +) + + +class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleType.AUTO_DOWNLOAD) { + private var lastSeenMediaInfoMap: MutableMap<SplitMediaAssetType, MediaInfo>? = null + var lastSeenMapParams: ParamMap? = null + private set + private val translations by lazy { + context.translation.getCategory("download_processor") + } + + fun provideDownloadManagerClient( + mediaIdentifier: String, + mediaAuthor: String, + creationTimestamp: Long? = null, + downloadSource: MediaDownloadSource, + friendInfo: FriendInfo? = null, + forceAllowDuplicate: Boolean = false + ): DownloadManagerClient { + val generatedHash = ( + if (!context.config.downloader.allowDuplicate.get() && !forceAllowDuplicate) mediaIdentifier + else UUID.randomUUID().toString() + ).longHashCode().absoluteValue.toString(16) + + val iconUrl = BitmojiSelfie.getBitmojiSelfie(friendInfo?.bitmojiSelfieId, friendInfo?.bitmojiAvatarId, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D) + + val downloadLogging by context.config.downloader.logging + if (downloadLogging.contains("started")) { + context.shortToast(translations["download_started_toast"]) + } + + val outputPath = createNewFilePath( + context.config, + generatedHash.substring(0, generatedHash.length.coerceAtMost(8)), + downloadSource, + mediaAuthor, + creationTimestamp?.takeIf { it > 0L } + ) + + return DownloadManagerClient( + context = context, + metadata = DownloadMetadata( + mediaIdentifier = generatedHash, + mediaAuthor = mediaAuthor, + downloadSource = downloadSource.translate(context.translation), + iconUrl = iconUrl, + outputPath = outputPath + ), + callback = object: DownloadCallback.Stub() { + override fun onSuccess(outputFile: String) { + if (!downloadLogging.contains("success")) return + context.log.verbose("onSuccess: outputFile=$outputFile") + context.inAppOverlay.showStatusToast( + icon = Icons.Outlined.DownloadDone, + durationMs = 1300, + text = translations["content_saved_toast"].also { + if (context.isMainActivityPaused) { + context.shortToast(it) + } + }, + ) + } + + override fun onProgress(message: String) { + if (!downloadLogging.contains("progress")) return + context.log.verbose("onProgress: message=$message") + context.inAppOverlay.showStatusToast( + icon = Icons.Outlined.Info, + durationMs = 1300, + text = message, + ) + if (context.isMainActivityPaused) { + context.shortToast(message) + } + } + + override fun onFailure(message: String, throwable: String?) { + if (!downloadLogging.contains("failure")) return + context.log.verbose("onFailure: message=$message, throwable=$throwable") + if (context.isMainActivityPaused) { + context.shortToast(message) + } + throwable?.let { t -> + context.inAppOverlay.showStatusToast( + icon = Icons.Outlined.Error, + text = message + t.takeIf { it.isNotEmpty() }?.let { " $it" }.orEmpty(), + ) + return + } + + context.inAppOverlay.showStatusToast( + icon = Icons.Outlined.Warning, + durationMs = 1300, + text = message, + ) + } + } + ) + } + + /* + * Download the last seen media + */ + fun downloadLastOperaMediaAsync(allowDuplicate: Boolean) { + if (lastSeenMapParams == null || lastSeenMediaInfoMap == null) return + context.executeAsync { + handleOperaMedia(lastSeenMapParams!!, lastSeenMediaInfoMap!!, true, allowDuplicate) + } + } + + fun showLastOperaDebugMediaInfo() { + if (lastSeenMapParams == null || lastSeenMediaInfoMap == null) return + + context.runOnUiThread { + val mediaInfoText = lastSeenMapParams?.concurrentHashMap?.map { (key, value) -> + val transformedValue = value.let { + if (it::class.java == SnapEnhance.classCache.snapUUID) { + SnapUUID(it).toString() + } + it + } + "- $key: $transformedValue" + }?.joinToString("\n") ?: "No media info found" + + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!).apply { + setTitle("Debug Media Info") + setView(debugEditText(context, mediaInfoText)) + setNeutralButton("Copy") { _, _ -> + context.copyToClipboard(mediaInfoText) + } + setNegativeButton("Cancel") { dialog, _ -> dialog.dismiss() } + }.show() + } + } + + private fun handleLocalReferences(path: String) = runBlocking { + Uri.parse(path).let { uri -> + if (uri.scheme == "file" || uri.scheme == null) { + return@let suspendCoroutine<String> { continuation -> + context.httpServer.ensureServerStarted()?.let { server -> + val file = Paths.get(uri.path).toFile() + val url = server.putDownloadableContent(file.inputStream(), file.length()) + continuation.resumeWith(Result.success(url)) + } ?: run { + continuation.resumeWith(Result.failure(Exception("Failed to start http server"))) + } + } + } + path + } + } + + private fun downloadOperaMedia(downloadManagerClient: DownloadManagerClient, mediaInfoMap: Map<SplitMediaAssetType, MediaInfo>, paramMap: ParamMap) { + if (mediaInfoMap.isEmpty()) return + + paramMap["SNAP_ID"]?.toString()?.let { snapId -> + context.database.getStorySnapEntry(snapId)?.let { storySnapEntry -> + downloadManagerClient.downloadSingleMedia( + storySnapEntry.mediaUrl ?: throw Exception("Media URL not found"), + DownloadMediaType.fromUri(Uri.parse(storySnapEntry.mediaUrl)), + (storySnapEntry.mediaKey to storySnapEntry.mediaIv).takeIf { it.first != null && it.second != null }?.let { (key, iv) -> + MediaEncryptionKeyPair(key!!, iv!!, urlSafe = false) + } + ) + return + } + } + + val originalMediaInfo = mediaInfoMap[SplitMediaAssetType.ORIGINAL]!! + val originalMediaInfoReference = handleLocalReferences(originalMediaInfo.uri) + + mediaInfoMap[SplitMediaAssetType.OVERLAY]?.let { overlay -> + val overlayReference = handleLocalReferences(overlay.uri) + + downloadManagerClient.downloadMediaWithOverlay( + original = InputMedia( + originalMediaInfoReference, + DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)), + originalMediaInfo.encryption?.toKeyPair() + ), + overlay = InputMedia( + overlayReference, + DownloadMediaType.fromUri(Uri.parse(overlayReference)), + overlay.encryption?.toKeyPair(), + isOverlay = true + ) + ) + return + } + + downloadManagerClient.downloadSingleMedia( + originalMediaInfoReference, + DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)), + originalMediaInfo.encryption?.toKeyPair() + ) + } + + fun canAutoDownloadMessage(databaseMessage: ConversationMessage): Boolean { + if (context.config.downloader.preventSelfAutoDownload.get() && databaseMessage.senderId == context.database.myUserId) return false + return canUseRule(databaseMessage.clientConversationId!!) + } + + /** + * Handles the media from the opera viewer + * + * @param paramMap the parameters from the opera viewer + * @param mediaInfoMap the media info map + * @param forceDownload if the media should be downloaded + */ + private fun handleOperaMedia( + paramMap: ParamMap, + mediaInfoMap: Map<SplitMediaAssetType, MediaInfo>, + forceDownload: Boolean, + forceAllowDuplicate: Boolean = false + ) { + //messages + paramMap["MESSAGE_ID"]?.toString()?.takeIf { forceDownload || shouldAutoDownload("friend_snaps") }?.let { id -> + val messageId = id.substring(id.lastIndexOf(":") + 1).toLong() + val conversationMessage = context.database.getConversationMessageFromId(messageId)!! + + val conversationId = conversationMessage.clientConversationId!! + + if (!forceDownload && !canUseRule(conversationId)) { + return + } + + val senderId = conversationMessage.senderId!! + + if (!forceDownload && context.config.downloader.preventSelfAutoDownload.get() && senderId == context.database.myUserId) return + + val author = context.database.getFriendInfo(senderId) ?: return + val authorUsername = author.usernameForSorting!! + val mediaId = paramMap["MEDIA_ID"]?.toString()?.let { + if (it.contains("-")) it.substringAfter("-") + else it + }?.substringBefore(".") + + downloadOperaMedia(provideDownloadManagerClient( + mediaIdentifier = "$conversationId$senderId${conversationMessage.serverMessageId}$mediaId", + mediaAuthor = authorUsername, + creationTimestamp = conversationMessage.creationTimestamp, + downloadSource = MediaDownloadSource.CHAT_MEDIA, + friendInfo = author, + forceAllowDuplicate = forceAllowDuplicate + ), mediaInfoMap, paramMap) + + return + } + + //private stories + paramMap["PLAYLIST_V2_GROUP"]?.takeIf { + forceDownload || shouldAutoDownload("friend_stories") + }?.let { playlistGroup -> + val playlistGroupString = playlistGroup.toString() + + val storyUserId = paramMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString() ?: paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()?.let { + if (it.contains("userId=")) it.substringAfter("userId=").substringBefore(",") else null + } ?: if (playlistGroupString.contains("storyUserId=")) { + playlistGroupString.substringAfter("storyUserId=").substringBefore(",") + } else { + //story replies + val arroyoMessageId = playlistGroup::class.java.methods.firstOrNull { it.name == "getId" } + ?.invoke(playlistGroup)?.toString() + ?.split(":")?.getOrNull(2) ?: return@let + + val conversationMessage = context.database.getConversationMessageFromId(arroyoMessageId.toLong()) ?: return@let + val conversationParticipants = context.database.getConversationParticipants(conversationMessage.clientConversationId.toString()) ?: return@let + + conversationParticipants.firstOrNull { it != conversationMessage.senderId } + } + + val author = context.database.getFriendInfo( + if (storyUserId == null || storyUserId == "null") + context.database.myUserId + else storyUserId + ) ?: throw Exception("Friend not found in database") + val authorName = author.usernameForSorting!! + + if (!forceDownload) { + if (context.config.downloader.preventSelfAutoDownload.get() && author.userId == context.database.myUserId) return + if (!canUseRule(author.userId!!)) return + } + + downloadOperaMedia(provideDownloadManagerClient( + mediaIdentifier = paramMap["MEDIA_ID"].toString(), + mediaAuthor = authorName, + creationTimestamp = paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()?.substringAfter("timestamp=") + ?.substringBefore(",")?.toLongOrNull(), + downloadSource = MediaDownloadSource.STORY, + friendInfo = author, + forceAllowDuplicate = forceAllowDuplicate, + ), mediaInfoMap, paramMap) + return + } + + val snapSource = paramMap["SNAP_SOURCE"].toString() + + //spotlight + if (snapSource == "SINGLE_SNAP_STORY" && (forceDownload || shouldAutoDownload("spotlight"))) { + downloadOperaMedia(provideDownloadManagerClient( + mediaIdentifier = paramMap["SNAP_ID"].toString(), + downloadSource = MediaDownloadSource.SPOTLIGHT, + mediaAuthor = paramMap["CREATOR_DISPLAY_NAME"].toString(), + creationTimestamp = paramMap["SNAP_TIMESTAMP"]?.toString()?.toLongOrNull(), + forceAllowDuplicate = forceAllowDuplicate, + ), mediaInfoMap, paramMap) + return + } + + //stories with mpeg dash media + if (paramMap.containsKey("LONGFORM_VIDEO_PLAYLIST_ITEM") && forceDownload) { + val storyName = paramMap["STORY_NAME"].toString().sanitizeForPath() + //get the position of the media in the playlist and the duration + val snapItem = SnapPlaylistItem(paramMap["SNAP_PLAYLIST_ITEM"]!!) + val snapChapterList = LongformVideoPlaylistItem(paramMap["LONGFORM_VIDEO_PLAYLIST_ITEM"]!!).chapters + val currentChapterIndex = snapChapterList.indexOfFirst { it.snapId == snapItem.snapId } + + if (snapChapterList.isEmpty()) { + context.shortToast(translations["dash_no_chapter"]) + return + } + + fun prettyPrintTime(time: Long): String { + val seconds = time / 1000 + val minutes = seconds / 60 + val hours = minutes / 60 + return "${(hours % 24).toString().padStart(2, '0')}:${(minutes % 60).toString().padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}" + } + + val playlistUrl = paramMap["MEDIA_ID"].toString().let { + val urlIndexes = arrayOf(it.indexOf("https://cf-st.sc-cdn.net"), it.indexOf("https://bolt-gcdn.sc-cdn.net")) + + urlIndexes.firstOrNull { index -> index != -1 }?.let { validIndex -> + it.substring(validIndex) + } ?: "${RemoteMediaResolver.CF_ST_CDN_D}$it" + } + + context.runOnUiThread { + val selectedChapters = mutableListOf<Int>() + val dialogTranslation = translations.getCategory("dash_dialog") + val chapters = snapChapterList.mapIndexed { index, snapChapter -> + val nextChapter = snapChapterList.getOrNull(index + 1) + val duration = nextChapter?.startTimeMs?.minus(snapChapter.startTimeMs) + SnapChapterInfo(snapChapter.startTimeMs, duration) + } + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!).apply { + setTitle(dialogTranslation["title"]) + setMultiChoiceItems( + chapters.map { dialogTranslation.format("segment_text", "from" to prettyPrintTime(it.offset), "to" to prettyPrintTime(it.offset + (it.duration ?: 0))) }.toTypedArray(), + List(chapters.size) { index -> + if (currentChapterIndex == index) { + selectedChapters.add(index) + true + } else false + }.toBooleanArray() + ) { _, which, isChecked -> + if (isChecked) { + selectedChapters.add(which) + } else if (selectedChapters.contains(which)) { + selectedChapters.remove(which) + } + } + setNegativeButton(this@MediaDownloader.context.translation["button.cancel"]) { dialog, _ -> dialog.dismiss() } + setNeutralButton(dialogTranslation["download_all"]) { _, _ -> + provideDownloadManagerClient( + mediaIdentifier = paramMap["STORY_ID"].toString(), + downloadSource = MediaDownloadSource.PUBLIC_STORY, + mediaAuthor = storyName + ).downloadDashMedia(playlistUrl, 0, null) + } + setPositiveButton(this@MediaDownloader.context.translation["button.download"]) { _, _ -> + val groups = mutableListOf<MutableList<SnapChapterInfo>>() + + var lastChapterIndex = -1 + // group consecutive chapters + chapters.forEachIndexed { index, snapChapter -> + lastChapterIndex = if (selectedChapters.contains(index)) { + if (lastChapterIndex == -1) { + groups.add(mutableListOf()) + } + groups.last().add(snapChapter) + index + } else { + -1 + } + } + + groups.forEach { group -> + val firstChapter = group.first() + val lastChapter = group.last() + val duration = if (firstChapter == lastChapter) { + firstChapter.duration + } else { + lastChapter.duration?.let { lastChapter.offset - firstChapter.offset + it } + } + + provideDownloadManagerClient( + mediaIdentifier = "${paramMap["STORY_ID"]}-${firstChapter.offset}-${lastChapter.offset}", + downloadSource = MediaDownloadSource.PUBLIC_STORY, + mediaAuthor = storyName, + forceAllowDuplicate = forceAllowDuplicate, + ).downloadDashMedia( + playlistUrl, + firstChapter.offset.plus(100), + duration + ) + } + } + }.show() + } + } + + if (!forceDownload && !shouldAutoDownload("public_stories")) return + + //public stories + val author = ( + paramMap["USER_ID"]?.let { context.database.getFriendInfo(it.toString())?.mutableUsername } // only for following users + ?: paramMap["USERNAME"]?.toString()?.takeIf { + it.contains("value=") + }?.substringAfter("value=")?.substringBefore(")")?.substringBefore(",") + ?: paramMap["CONTEXT_USER_IDENTITY"]?.toString()?.takeIf { + it.contains("username=") + }?.substringAfter("username=")?.substringBefore(",") + // fallback display name + ?: paramMap["USER_DISPLAY_NAME"]?.toString()?.takeIf { it.isNotEmpty() } + ?: paramMap["TIME_STAMP"]?.toString() + ?: "unknown" + ).sanitizeForPath() + + downloadOperaMedia(provideDownloadManagerClient( + mediaIdentifier = paramMap["SNAP_ID"].toString(), + mediaAuthor = author, + downloadSource = MediaDownloadSource.PUBLIC_STORY, + creationTimestamp = paramMap["SNAP_TIMESTAMP"]?.toString()?.toLongOrNull(), + forceAllowDuplicate = forceAllowDuplicate, + ), mediaInfoMap, paramMap) + } + + private fun shouldAutoDownload(keyFilter: String? = null): Boolean { + val options by context.config.downloader.autoDownloadSources + return options.any { keyFilter == null || it.contains(keyFilter, true) } + } + + override fun init() { + onNextActivityCreate { + context.mappings.useMapper(OperaPageViewControllerMapper::class) { + arrayOf(onDisplayStateChange, onDisplayStateChangeGesture).forEach { methodName -> + classReference.get()?.hook( + methodName.get() ?: return@forEach, + HookStage.AFTER + ) onOperaViewStateCallback@{ param -> + val viewState = (param.thisObject() as Any).getObjectField(viewStateField.get()!!).toString() + + if (viewState != "FULLY_DISPLAYED") { + return@onOperaViewStateCallback + } + + val operaLayerList = (param.thisObject() as Any).getObjectField(layerListField.get()!!) as ArrayList<*> + val mediaParamMap: ParamMap = operaLayerList.map { Layer(it) }.first().paramMap + + if (!mediaParamMap.containsKey("image_media_info") && !mediaParamMap.containsKey("video_media_info_list")) { + return@onOperaViewStateCallback + } + + val mediaInfoMap = mutableMapOf<SplitMediaAssetType, MediaInfo>() + val isVideo = mediaParamMap.containsKey("video_media_info_list") + + mediaInfoMap[SplitMediaAssetType.ORIGINAL] = MediaInfo( + (if (isVideo) mediaParamMap["video_media_info_list"] else mediaParamMap["image_media_info"])!! + ) + + if (context.config.downloader.mergeOverlays.get() && mediaParamMap.containsKey("overlay_image_media_info")) { + mediaInfoMap[SplitMediaAssetType.OVERLAY] = + MediaInfo(mediaParamMap["overlay_image_media_info"]!!) + } + + val shouldAutoDownload = shouldAutoDownload() + + if (shouldAutoDownload && lastSeenMediaInfoMap?.get(SplitMediaAssetType.ORIGINAL)?.uri == mediaInfoMap[SplitMediaAssetType.ORIGINAL]?.uri) return@onOperaViewStateCallback + + lastSeenMapParams = mediaParamMap + lastSeenMediaInfoMap = mediaInfoMap + + if (!shouldAutoDownload) { + return@onOperaViewStateCallback + } + + context.executeAsync { + runCatching { + handleOperaMedia(mediaParamMap, mediaInfoMap, false) + }.onFailure { + context.log.error("Failed to handle opera media", it) + context.longToast(it.message) + } + } + } + } + } + } + } + + private fun downloadMessageAttachments( + friendInfo: FriendInfo, + message: ConversationMessage, + authorName: String, + attachments: List<DecodedAttachment>, + forceAllowDuplicate: Boolean = false + ) { + attachments.forEach { attachment -> + runCatching { + provideDownloadManagerClient( + mediaIdentifier = "${message.clientConversationId}${message.senderId}${message.serverMessageId}${attachment.mediaUniqueId}", + downloadSource = MediaDownloadSource.CHAT_MEDIA, + mediaAuthor = authorName, + friendInfo = friendInfo, + forceAllowDuplicate = forceAllowDuplicate, + creationTimestamp = message.creationTimestamp, + ).apply { + downloadInputMedias( + arrayOf(attachment.createInputMedia()!!) + ) + } + }.onFailure { + context.longToast(translations["failed_generic_toast"]) + context.log.error("Failed to download", it) + } + } + } + + private fun DecodedAttachment.getInfo(): String { + return "${translations["attachment_type.${type.key}"]} ${attachmentInfo?.resolution?.let { "(${it.first}x${it.second})" } ?: ""}" + } + + @SuppressLint("SetTextI18n") + private fun previewAttachment( + attachment: DecodedAttachment + ) { + var previewBitmap: Bitmap? = null + val previewCoroutine = context.coroutineScope.launch { + runCatching { + attachment.openStream { attachmentStream, _ -> + val downloadedMediaList = mutableMapOf<SplitMediaAssetType, ByteArray>() + + MediaDownloaderHelper.getSplitElements(attachmentStream!!) { + type, inputStream -> + downloadedMediaList[type] = inputStream.readBytes() + } + + val originalMedia = downloadedMediaList[SplitMediaAssetType.ORIGINAL] ?: return@openStream + val overlay = downloadedMediaList[SplitMediaAssetType.OVERLAY] + + var bitmap = PreviewUtils.createPreview(originalMedia, isVideo = FileType.fromByteArray(originalMedia).isVideo) + ?: throw Exception("preview is null") + + overlay?.also { + bitmap = PreviewUtils.mergeBitmapOverlay(bitmap, BitmapFactory.decodeByteArray(it, 0, it.size)) + } + + previewBitmap = bitmap + } + }.onFailure { + context.shortToast(translations["failed_to_create_preview_toast"]) + context.log.error("Failed to create preview", it) + } + } + + with(ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity)) { + val viewGroup = LinearLayout(context).apply { + layoutParams = MarginLayoutParams( + MarginLayoutParams.MATCH_PARENT, + MarginLayoutParams.MATCH_PARENT + ) + gravity = Gravity.CENTER_HORIZONTAL or Gravity.CENTER_VERTICAL + addView(ProgressBar(context).apply { + isIndeterminate = true + }) + } + + setOnDismissListener { + previewCoroutine.cancel() + } + + previewCoroutine.invokeOnCompletion { cause -> + if (previewCoroutine.isCancelled) return@invokeOnCompletion + runOnUiThread { + viewGroup.removeAllViews() + if (cause != null) { + viewGroup.addView(TextView(context).apply { + text = + translations["failed_to_create_preview_toast"] + "\n" + cause.message + setPadding(30, 30, 30, 30) + }) + return@runOnUiThread + } + + viewGroup.addView(ImageView(context).apply { + setImageBitmap(previewBitmap) + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT + ) + adjustViewBounds = true + }) + } + } + + runOnUiThread { + show().apply { + setContentView(viewGroup) + window?.setLayout( + context.resources.displayMetrics.widthPixels, + context.resources.displayMetrics.heightPixels + ) + } + } + } + } + + @SuppressLint("SetTextI18n") + fun downloadMessageId(messageId: Long, forceAllowDuplicate: Boolean = false, isPreview: Boolean = false, forceDownloadFirst: Boolean = false) { + val messageLogger = context.feature(MessageLogger::class) + val message = context.database.getConversationMessageFromId(messageId) ?: throw Exception("Message not found in database") + + val friendInfo = context.database.getFriendInfo(message.senderId!!) ?: throw Exception("Friend not found in database") + val authorName = friendInfo.usernameForSorting!! + + val decodedAttachments = ( + messageLogger.takeIf { it.isEnabled }?.getMessageObject(message.clientConversationId!!, message.clientMessageId.toLong())?.let { + MessageDecoder.decode(it.getAsJsonObject("mMessageContent")) + } ?: MessageDecoder.decode( + protoReader = ProtoReader(message.messageContent!!) + ).toMutableList().apply { + val quotedMessage = message.quotedServerMessageId?.takeIf { it > 0 }?.let { quotedMessageId -> + context.database.getConversationServerMessage(message.clientConversationId!!, quotedMessageId) + } ?: return@apply + addAll(0, MessageDecoder.decode( + protoReader = ProtoReader(quotedMessage.messageContent ?: return@apply) + )) + } + ).toMutableList() + + context.feature(Messaging::class).conversationManager?.takeIf { + decodedAttachments.isEmpty() + }?.also { conversationManager -> + runBlocking { + suspendCoroutine { continuation -> + conversationManager.fetchMessage(message.clientConversationId!!, message.clientMessageId.toLong(), onSuccess = { message -> + decodedAttachments.addAll(MessageDecoder.decode(message.messageContent!!)) + continuation.resumeWith(Result.success(Unit)) + }, onError = { + continuation.resumeWith(Result.success(Unit)) + }) + } + } + } + + if (decodedAttachments.isEmpty()) { + context.shortToast(translations["no_attachments_toast"]) + return + } + + if (!isPreview) { + if (forceDownloadFirst || + decodedAttachments.size == 1 || + context.isMainActivityPaused // we can't show alert dialogs when it downloads from a notification, so it downloads the first one + ) { + downloadMessageAttachments(friendInfo, message, authorName, + listOf(decodedAttachments.first()), + forceAllowDuplicate = forceAllowDuplicate + ) + return + } + + runOnUiThread { + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity).apply { + val selectedAttachments = mutableListOf<Int>().apply { + addAll(decodedAttachments.indices) + } + setMultiChoiceItems( + decodedAttachments.mapIndexed { index, decodedAttachment -> + "${index + 1}: ${decodedAttachment.getInfo()}" + }.toTypedArray(), + decodedAttachments.map { true }.toBooleanArray() + ) { _, which, isChecked -> + if (isChecked) { + selectedAttachments.add(which) + } else if (selectedAttachments.contains(which)) { + selectedAttachments.remove(which) + } + } + setTitle(translations["select_attachments_title"]) + setNegativeButton(this@MediaDownloader.context.translation["button.cancel"]) { dialog, _ -> dialog.dismiss() } + setPositiveButton(this@MediaDownloader.context.translation["button.download"]) { _, _ -> + downloadMessageAttachments(friendInfo, message, authorName, selectedAttachments.map { decodedAttachments[it] }, + forceAllowDuplicate = forceAllowDuplicate + ) + } + }.show() + } + return + } + + if (decodedAttachments.size == 1) { + previewAttachment(decodedAttachments.first()) + return + } + + runOnUiThread { + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity).apply { + var selectedAttachment = 0 + setSingleChoiceItems( + decodedAttachments.mapIndexed { index, decodedAttachment -> "${index + 1}: ${decodedAttachment.getInfo()}" }.toTypedArray(), + 0 + ) { _, which -> + selectedAttachment = which + } + setTitle(translations["select_attachments_title"]) + setNegativeButton(this@MediaDownloader.context.translation["button.cancel"]) { dialog, _ -> dialog.dismiss() } + setPositiveButton(this@MediaDownloader.context.translation["chat_action_menu.preview_button"]) { _, _ -> + previewAttachment(decodedAttachments[selectedAttachment]) + } + }.show() + } + } + + fun downloadProfilePicture(url: String, author: String) { + provideDownloadManagerClient( + mediaIdentifier = url.hashCode().toString(16).replaceFirst("-", ""), + mediaAuthor = author, + downloadSource = MediaDownloadSource.PROFILE_PICTURE + ).downloadSingleMedia( + url, + DownloadMediaType.REMOTE_MEDIA + ) + } + + /** + * Called when a message is focused in chat + */ + fun onMessageActionMenu(isPreviewMode: Boolean, forceAllowDuplicate: Boolean = false) { + val messaging = context.feature(Messaging::class) + if (messaging.openedConversationUUID == null) return + + context.executeAsync { + downloadMessageId(messaging.lastFocusedMessageId, forceAllowDuplicate, isPreviewMode) + } + } +} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/ProfilePictureDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/ProfilePictureDownloader.kt new file mode 100644 index 0000000000..1b95961e46 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/ProfilePictureDownloader.kt @@ -0,0 +1,71 @@ +package me.rhunk.snapenhance.core.features.impl.downloader + +import android.annotation.SuppressLint +import android.widget.Button +import android.widget.RelativeLayout +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent +import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper + +class ProfilePictureDownloader : Feature("ProfilePictureDownloader") { + @SuppressLint("SetTextI18n") + override fun init() { + if (!context.config.downloader.downloadProfilePictures.get()) return + + var friendUsername: String? = null + var backgroundUrl: String? = null + var avatarUrl: String? = null + + onNextActivityCreate(defer = true) { + context.event.subscribe(AddViewEvent::class) { event -> + if (event.view::class.java.name != "com.snap.unifiedpublicprofile.UnifiedPublicProfileView") return@subscribe + + event.parent.addView(Button(event.parent.context).apply { + text = this@ProfilePictureDownloader.context.translation["profile_picture_downloader.button"] + layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT).apply { + setMargins(0, 200, 0, 0) + } + setOnClickListener { + ViewAppearanceHelper.newAlertDialogBuilder( + this@ProfilePictureDownloader.context.mainActivity!! + ).apply { + setTitle(this@ProfilePictureDownloader.context.translation["profile_picture_downloader.title"]) + val choices = mutableMapOf<String, String>() + backgroundUrl?.let { choices["background_option"] = it } + avatarUrl?.let { choices["avatar_option"] = it } + + setItems(choices.keys.map { + this@ProfilePictureDownloader.context.translation["profile_picture_downloader.$it"] + }.toTypedArray()) { _, which -> + runCatching { + this@ProfilePictureDownloader.context.feature(MediaDownloader::class).downloadProfilePicture( + choices.values.elementAt(which), + friendUsername!! + ) + }.onFailure { + this@ProfilePictureDownloader.context.log.error("Failed to download profile picture", it) + } + } + }.show() + } + }) + } + + + context.event.subscribe(NetworkApiRequestEvent::class) { event -> + if (!event.url.endsWith("/rpc/getPublicProfile")) return@subscribe + event.onSuccess { buffer -> + ProtoReader(buffer ?: return@onSuccess).followPath(1, 1, 2) { + friendUsername = getString(2) ?: return@followPath + followPath(4) { + backgroundUrl = getString(2) + avatarUrl = getString(100) + } + } + } + } + } + } +} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/decoder/AttachmentInfo.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/decoder/AttachmentInfo.kt new file mode 100644 index 0000000000..96b30c5b73 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/decoder/AttachmentInfo.kt @@ -0,0 +1,15 @@ +package me.rhunk.snapenhance.core.features.impl.downloader.decoder + +import me.rhunk.snapenhance.common.data.download.MediaEncryptionKeyPair + +data class BitmojiSticker( + val reference: String, +) : AttachmentInfo() + +open class AttachmentInfo( + val encryption: MediaEncryptionKeyPair? = null, + val resolution: Pair<Int, Int>? = null, + val duration: Long? = null +) { + override fun toString() = "AttachmentInfo(encryption=$encryption, resolution=$resolution, duration=$duration)" +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/decoder/AttachmentType.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/decoder/AttachmentType.kt new file mode 100644 index 0000000000..5391ad99a5 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/decoder/AttachmentType.kt @@ -0,0 +1,12 @@ +package me.rhunk.snapenhance.core.features.impl.downloader.decoder + +enum class AttachmentType( + val key: String, +) { + SNAP("snap"), + STICKER("sticker"), + GIF("gif"), + EXTERNAL_MEDIA("external_media"), + NOTE("note"), + ORIGINAL_STORY("original_story"), +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/decoder/MessageDecoder.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/decoder/MessageDecoder.kt new file mode 100644 index 0000000000..41b1b7ef88 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/decoder/MessageDecoder.kt @@ -0,0 +1,334 @@ +package me.rhunk.snapenhance.core.features.impl.downloader.decoder + +import com.google.gson.GsonBuilder +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import me.rhunk.snapenhance.common.data.download.DownloadMediaType +import me.rhunk.snapenhance.common.data.download.InputMedia +import me.rhunk.snapenhance.common.data.download.MediaEncryptionKeyPair +import me.rhunk.snapenhance.common.data.download.toKeyPair +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.common.util.snap.RemoteMediaResolver +import me.rhunk.snapenhance.core.wrapper.impl.MessageContent +import java.io.InputStream +import java.net.URL +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +data class DecodedAttachment( + val boltKey: String?, + val directUrl: String? = null, + val type: AttachmentType, + val attachmentInfo: AttachmentInfo? +) { + @OptIn(ExperimentalEncodingApi::class) + val mediaUniqueId: String? by lazy { + runCatching { + Base64.UrlSafe.decode(boltKey.toString()) + }.getOrNull()?.let { + ProtoReader(it).getString(2, 2)?.substringBefore(".") + } ?: directUrl?.substringAfterLast("/")?.substringBeforeLast("?")?.substringBeforeLast(".")?.let { Base64.UrlSafe.encode(it.toByteArray()) } + } + + @OptIn(ExperimentalEncodingApi::class) + suspend inline fun openStream(callback: (mediaStream: InputStream?, length: Long) -> Unit) { + boltKey?.let { mediaUrlKey -> + RemoteMediaResolver.downloadBoltMedia(Base64.UrlSafe.decode(mediaUrlKey), decryptionCallback = { + attachmentInfo?.encryption?.decryptInputStream(it) ?: it + }, resultCallback = { inputStream, length -> + callback(inputStream, length) + }) + } ?: directUrl?.let { rawMediaUrl -> + RemoteMediaResolver.downloadMedia(rawMediaUrl, decryptionCallback = { + attachmentInfo?.encryption?.decryptInputStream(it) ?: it + }) { inputStream, length -> + callback(inputStream, length) + } + } ?: callback(null, 0) + } + + fun createInputMedia( + isOverlay: Boolean = false + ): InputMedia? { + return InputMedia( + content = boltKey ?: directUrl ?: return null, + type = if (boltKey != null) DownloadMediaType.PROTO_MEDIA else DownloadMediaType.REMOTE_MEDIA, + encryption = attachmentInfo?.encryption, + attachmentType = type.key, + isOverlay = isOverlay + ) + } +} + +@OptIn(ExperimentalEncodingApi::class) +object MessageDecoder { + private val gson = GsonBuilder().create() + + private fun ProtoReader.decodeClearTextEncryption(encoded: Boolean = true): MediaEncryptionKeyPair? { + val key = if (encoded) Base64.decode(getString(1)?.trim() ?: return null) else getByteArray(1) ?: return null + val iv = if (encoded) Base64.decode(getString(2)?.trim() ?: return null) else getByteArray(2) ?: return null + + return Pair(key, iv).toKeyPair() + } + + private fun ProtoReader.decodeMediaMetadata(): AttachmentInfo { + return AttachmentInfo( + encryption = run { + followPath(4)?.apply { + decodeClearTextEncryption(encoded = true)?.let { + return@run it + } + } + + followPath(19)?.apply { + decodeClearTextEncryption( encoded = false)?.let { encryption -> + return@run encryption + } + } + null + }, + resolution = followPath(5)?.let { + (it.getVarInt(1)?.toInt() ?: 0) to (it.getVarInt(2)?.toInt() ?: 0) + }, + duration = getVarInt(15) // external medias + ?: getVarInt(13) // audio notes + ) + } + + private fun ProtoReader.decodeAttachment(): AttachmentInfo? { + return followPath(1, 1)?.decodeMediaMetadata() + } + + @OptIn(ExperimentalEncodingApi::class) + fun getEncodedMediaReferences(messageContent: JsonElement): List<String> { + return getMediaReferences(messageContent).map { reference -> + Base64.UrlSafe.encode( + reference.asJsonObject.getAsJsonArray("mContentObject").map { it.asByte }.toByteArray() + ) + } + .toList() + } + + fun getEncodedMediaReferences(messageContent: MessageContent): List<String> { + return getEncodedMediaReferences(gson.toJsonTree(messageContent.instanceNonNull())) + } + + fun getMediaReferences(messageContent: JsonElement): List<JsonElement> { + return messageContent.asJsonObject.getAsJsonArray("mRemoteMediaReferences") + .asSequence() + .map { it.asJsonObject.getAsJsonArray("mMediaReferences") } + .flatten() + .sortedBy { + it.asJsonObject["mMediaListId"].asLong + }.toList() + } + + + fun decode(messageContent: MessageContent): List<DecodedAttachment> { + return decode( + ProtoReader(messageContent.content!!), + customMediaReferences = getEncodedMediaReferences(gson.toJsonTree(messageContent.instanceNonNull())) + ).toMutableList().apply { + if (messageContent.quotedMessage?.takeIf { it.isPresent() } != null && messageContent.quotedMessage!!.content?.takeIf { it.isPresent() } != null) { + addAll(0, decode( + MessageContent(messageContent.quotedMessage!!.content!!.instanceNonNull()) + )) + } + } + } + + fun decode(messageContent: JsonObject): List<DecodedAttachment> { + return decode( + ProtoReader(messageContent.getAsJsonArray("mContent") + .map { it.asByte } + .toByteArray()), + customMediaReferences = getEncodedMediaReferences(messageContent) + ).toMutableList().apply { + if (messageContent.has("mQuotedMessage") && messageContent.getAsJsonObject("mQuotedMessage").has("mContent")) { + addAll(0, decode(messageContent.getAsJsonObject("mQuotedMessage").getAsJsonObject("mContent"))) + } + } + } + + fun decode( + protoReader: ProtoReader, + customMediaReferences: List<String>? = null // when customReferences is null it means that the message is from arroyo database + ): List<DecodedAttachment> { + val decodedAttachment = mutableListOf<DecodedAttachment>() + val mediaReferences = mutableListOf<String>() + customMediaReferences?.let { mediaReferences.addAll(it) } + var mediaKeyIndex = 0 + + fun ProtoReader.decodeSnapDocMediaPlayback(type: AttachmentType) { + decodedAttachment.add( + DecodedAttachment( + boltKey = mediaReferences.getOrNull(mediaKeyIndex++), + type = type, + attachmentInfo = decodeAttachment() ?: return + ) + ) + } + + fun ProtoReader.decodeSnapDocMedia(type: AttachmentType) { + followPath(5) { decodeSnapDocMediaPlayback(type) } + } + + fun ProtoReader.decodeStickers() { + followPath(1) { + val packId = getString(1) + val reference = getString(2) ?: return@followPath + val stickerUrl = when (packId) { + "snap" -> "https://gcs.sc-cdn.net/sticker-packs-sc/stickers/$reference" + "bitmoji" -> reference.split(":").let { + "https://cf-st.sc-cdn.net/3d/render/${ + it.getOrNull(0) ?: return@followPath + }-${it.drop(2).joinToString("-")}-v${it.getOrNull(1) ?: return@followPath}.webp?ua=2" + } + else -> return@followPath + } + decodedAttachment.add( + DecodedAttachment( + boltKey = null, + directUrl = stickerUrl, + type = AttachmentType.STICKER, + attachmentInfo = BitmojiSticker( + reference = reference + ) + ) + ) + } + followPath(2, 1) { + decodedAttachment.add( + DecodedAttachment( + boltKey = mediaReferences.getOrNull(mediaKeyIndex++), + type = AttachmentType.STICKER, + attachmentInfo = decodeMediaMetadata() + ) + ) + } + } + + fun ProtoReader.decodeShares() { + // saved story + followPath(24, 2) { + decodeSnapDocMedia(AttachmentType.EXTERNAL_MEDIA) + } + // memories story + followPath(11) { + eachBuffer(3) { + decodeSnapDocMedia(AttachmentType.EXTERNAL_MEDIA) + } + } + } + + // media keys + protoReader.eachBuffer(4, 5) { + getByteArray(1, 3)?.also { mediaKey -> + mediaReferences.add(Base64.UrlSafe.encode(mediaKey)) + } + } + + val mediaReader = customMediaReferences?.let { protoReader } ?: protoReader.followPath(4, 4) ?: return emptyList() + + mediaReader.apply { + // external media + eachBuffer(3, 3) { + decodeSnapDocMedia(AttachmentType.EXTERNAL_MEDIA) + } + + // stickers + followPath(4) { decodeStickers() } + + // shares + followPath(5) { + decodeShares() + } + + // audio notes + followPath(6) note@{ + val audioNote = decodeAttachment() ?: return@note + + decodedAttachment.add( + DecodedAttachment( + boltKey = mediaReferences.getOrNull(mediaKeyIndex++), + type = AttachmentType.NOTE, + attachmentInfo = audioNote + ) + ) + } + + // story replies + followPath(7) { + // original story reply + followPath(3) { + decodeSnapDocMedia(AttachmentType.ORIGINAL_STORY) + } + + // external medias + followPath(12) { + eachBuffer(3) { decodeSnapDocMedia(AttachmentType.EXTERNAL_MEDIA) } + } + + // attached sticker + followPath(13) { decodeStickers() } + + // reply shares + followPath(14) { decodeShares() } + + // attached audio note + followPath(15) { decodeSnapDocMediaPlayback(AttachmentType.NOTE) } + + // reply snap + followPath(17) { + decodeSnapDocMedia(AttachmentType.SNAP) + } + } + + // snaps + followPath(11) { + decodeSnapDocMedia(AttachmentType.SNAP) + } + + // creative tools items + followPath(14, 2, 2) { + // custom sticker + followPath(3) sticker@{ + decodedAttachment.add( + DecodedAttachment( + boltKey = if (contains(4)) { + Base64.UrlSafe.encode(getByteArray(4, 4) ?: return@sticker) + } else mediaReferences.getOrNull(mediaKeyIndex++), + type = AttachmentType.STICKER, + attachmentInfo = AttachmentInfo( + encryption = decodeClearTextEncryption(encoded = true) ?: followPath(5) + ?.decodeClearTextEncryption(encoded = false) + ) + ) + ) + } + + // gifs + followPath(13) { + eachBuffer(4) { + followPath(2) { + decodedAttachment.add( + DecodedAttachment( + boltKey = getByteArray(4)?.let { Base64.UrlSafe.encode(it) }, + type = AttachmentType.GIF, + attachmentInfo = null + ) + ) + } + } + } + } + + // map reaction + followPath(20, 2) { + decodeSnapDocMedia(AttachmentType.EXTERNAL_MEDIA) + } + } + + return decodedAttachment + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AccountSwitcher.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AccountSwitcher.kt new file mode 100644 index 0000000000..9bc3a7b00b --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AccountSwitcher.kt @@ -0,0 +1,532 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Intent +import android.database.sqlite.SQLiteDatabase +import android.net.Uri +import android.os.ParcelFileDescriptor +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.SaveAlt +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.common.data.FileType +import me.rhunk.snapenhance.common.ui.AppMaterialTheme +import me.rhunk.snapenhance.common.ui.createComposeAlertDialog +import me.rhunk.snapenhance.common.util.ktx.toParcelFileDescriptor +import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper +import me.rhunk.snapenhance.core.event.events.impl.ActivityResultEvent +import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.ui.CustomComposable +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.ktx.getId +import me.rhunk.snapenhance.core.util.ktx.vibrateLongPress +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream +import kotlin.random.Random + +class AccountSwitcher: Feature("Account Switcher") { + private var exportCallback: Pair<Int, String>? = null // requestCode -> userId + private var importRequestCode: Int? = null + + private val accounts = mutableStateListOf<Pair<String, String>>() + private val isLoginActivity get() = context.mainActivity?.javaClass?.name?.endsWith("LoginSignupActivity") == true + + private fun updateUsers() { + accounts.clear() + runCatching { + accounts.addAll(context.bridgeClient.getAccountStorage().accounts.map { it.key to it.value }) + }.onFailure { + context.log.error("Failed to update users", it) + } + } + + @Composable + private fun ManagementPopup() { + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + updateUsers() + } + } + + + Column( + verticalArrangement = Arrangement.SpaceBetween, + ) { + Text("Account Switcher", modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), textAlign = TextAlign.Center, fontSize = 25.sp) + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + item { + if (accounts.isEmpty()) { + Text("No accounts found! To start, backup your current account.", modifier = Modifier + .padding(16.dp) + .padding(16.dp) + .fillMaxWidth(), textAlign = TextAlign.Center) + } + } + + items(accounts) { user -> + var removeAccountPopup by remember { mutableStateOf(false) } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(5.dp), + colors = CardDefaults.cardColors( + containerColor = if (!isLoginActivity && context.database.myUserId == user.first) MaterialTheme.colorScheme.surfaceBright + else MaterialTheme.colorScheme.surfaceDim + ) , + onClick = { + runCatching { + if (!isLoginActivity && context.database.myUserId == user.first) { + context.shortToast("Already logged in as ${user.second}") + return@runCatching + } + + if (!isLoginActivity && context.config.experimental.accountSwitcher.autoBackupCurrentAccount.get()) { + backupCurrentAccount() + } + + login(userId = user.first, username = user.second) + }.onFailure { + context.shortToast("Failed to login. Check logs for more info.") + context.log.error("Failed to login", it) + } + } + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text(user.second, modifier = Modifier + .padding(10.dp) + .weight(1f)) + Row( + modifier = Modifier + .padding(3.dp), + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + FilledIconButton(onClick = { + val requestCode = Random.nextInt(100, 65535) + exportCallback = requestCode to user.first + + context.mainActivity?.startActivityForResult( + Intent.createChooser( + Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "application/zip" + putExtra(Intent.EXTRA_TITLE, "account_${user.second}.zip") + }, + "Export account" + ), + requestCode + ) + }) { + Icon(Icons.Default.SaveAlt, contentDescription = "Export account") + } + FilledIconButton(onClick = { + removeAccountPopup = true + }) { + Icon(Icons.Default.Delete, contentDescription = "Remove account") + } + } + } + } + + if (removeAccountPopup) { + AlertDialog( + onDismissRequest = { removeAccountPopup = false }, + confirmButton = { + Button(onClick = { + context.bridgeClient.getAccountStorage().removeAccount(user.first) + removeAccountPopup = false + updateUsers() + }) { + Text("Remove") + } + }, + title = { Text("Remove account") }, + text = { Text("Are you sure you want to remove ${user.second}?") }, + dismissButton = { + Button(onClick = { + removeAccountPopup = false + }) { + Text("Cancel") + } + }, + ) + } + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(5.dp), + verticalArrangement = Arrangement.spacedBy(1.dp) + ) { + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + context.mainActivity?.startActivityForResult( + Intent.createChooser( + Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "application/zip" + }, + "Import account" + ), + Random.nextInt(100, 65535).also { + importRequestCode = it + } + ) + } + ) { + Text("Import account") + } + + if (!isLoginActivity) { + Button( + modifier = Modifier + .fillMaxWidth(), + onClick = { + backupCurrentAccount() + updateUsers() + } + ) { + Text("Backup current account") + } + Button( + modifier = Modifier + .fillMaxWidth(), + onClick = { + if (context.config.experimental.accountSwitcher.autoBackupCurrentAccount.get()) { + backupCurrentAccount() + } + logout() + } + ) { + Text("Logout") + } + } + } + } + } + + + private fun showManagementPopup() { + context.runOnUiThread { + createComposeAlertDialog(context.mainActivity!!) { + AppMaterialTheme(isDarkTheme = true) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface + ) { + ManagementPopup() + } + } + }.show() + } + } + + private fun logout() { + context.androidContext.dataDir.resolve( "shared_prefs/user_session_shared_pref.xml").takeIf { it.exists() }?.delete() + context.shortToast("Logged out") + context.softRestartApp() + } + + private fun login(userId: String, username: String) { + val accountData = context.bridgeClient.getAccountStorage().getAccountData(userId)?.let { pfd -> + ParcelFileDescriptor.AutoCloseInputStream(pfd).use { it.readBytes() } + } + if (accountData == null) { + context.shortToast("Account data not found") + return + } + + arrayOf( + context.androidContext.filesDir, + context.androidContext.cacheDir, + context.androidContext.dataDir.resolve("databases"), + ).forEach { dir -> dir.listFiles()?.forEach { it.deleteRecursively() } } + + val zipInputStream = ZipInputStream(accountData.inputStream()) + var entry: ZipEntry? + while (zipInputStream.nextEntry.also { entry = it } != null) { + val file = context.androidContext.dataDir.resolve(entry!!.name) + if (file.exists()) { + file.delete() + } else { + file.parentFile?.mkdirs() + } + context.log.debug("Extracting ${file.absolutePath}") + file.outputStream().use { + zipInputStream.copyTo(it) + } + } + + context.log.debug("Account data restored") + context.shortToast("Logged in as $username") + context.softRestartApp() + } + + private fun getCurrentAccountData(): ParcelFileDescriptor { + val pfd = ParcelFileDescriptor.createPipe() + + context.coroutineScope.launch(Dispatchers.IO) { + val zipOutputStream = ZipOutputStream(ParcelFileDescriptor.AutoCloseOutputStream(pfd[1])) + + fun addFile(path: String, file: File) { + file.takeIf { it.exists() }?.inputStream()?.use { + context.log.verbose("Adding $file to zip") + zipOutputStream.putNextEntry(ZipEntry(path)) + it.copyTo(zipOutputStream) + zipOutputStream.closeEntry() + } + } + + for (path in arrayOf( + "databases/main.db", + "databases/main.db-shm", + "databases/main.db-wal", + "databases/core.db", + "databases/core.db-wal", + "databases/core.db-shm", + "databases/fidelius_database.db", + "databases/fidelius_database.db-wal", + "databases/fidelius_database.db-shm", + "shared_prefs/user_session_shared_pref.xml", + "shared_prefs/user_device_identity_keys.xml", + "shared_prefs/com.google.android.gms.appid.xml", + )) { + addFile(path, context.androidContext.dataDir.resolve(path)) + } + + context.androidContext.dataDir.resolve("databases").listFiles()?.filter { + it.name.contains("_fidelius.db") + }?.forEach { + addFile("databases/${it.name}", it) + } + + zipOutputStream.flush() + zipOutputStream.close() + } + + return pfd[0] + } + + private fun backupCurrentAccount() { + runCatching { + context.bridgeClient.getAccountStorage().addAccount( + context.database.myUserId, + context.database.getFriendInfo(context.database.myUserId)?.mutableUsername ?: "Unknown username", + getCurrentAccountData() + ) + context.shortToast("Account backed up!") + }.onFailure { + context.shortToast("Failed to backup account. Check logs for more info.") + context.log.error("Failed to backup account", it) + } + } + + private fun importAccount(fileUri: Uri) { + var tempZip: File? = null + var mainDbFile: File? = null + var mainDbWalFile: File? = null + var mainDbShmFile: File? = null + + runCatching { + // copy zip file + context.mainActivity!!.contentResolver.openInputStream(fileUri)?.use { input -> + val bufferedInputStream = input.buffered() + val fileType = MediaDownloaderHelper.getFileType(bufferedInputStream) + + if (fileType != FileType.ZIP) { + throw Exception("Invalid file type") + } + + context.androidContext.cacheDir.resolve(System.currentTimeMillis().toString()).also { + tempZip = it + }.outputStream().use { output -> + bufferedInputStream.copyTo(output) + } + } + + context.log.verbose("Extracting account data") + + // extract main.db in cache + tempZip?.inputStream().use { fileInputStream -> + val zipInputStream = ZipInputStream(fileInputStream) + var entry: ZipEntry? + while (zipInputStream.nextEntry.also { entry = it } != null) { + val fileName = entry?.name?.substringAfterLast('/') ?: continue + if (!fileName.startsWith("main.db")) continue + + val file = context.androidContext.cacheDir.resolve(fileName) + context.log.verbose("Found ${entry!!.name} in zip file") + + when (fileName) { + "main.db" -> mainDbFile = file + "main.db-wal" -> mainDbWalFile = file + "main.db-shm" -> mainDbShmFile = file + } + + file.outputStream().use { + zipInputStream.copyTo(it) + } + } + } + + assert(mainDbFile != null) { "main.db not found in zip file" } + + SQLiteDatabase.openDatabase(mainDbFile!!.absolutePath, null, SQLiteDatabase.OPEN_READONLY).use { sqliteDatabase -> + val userId = sqliteDatabase.rawQuery("SELECT userId FROM SnapToken", null).use { + if (!it.moveToFirst()) throw Exception("userId not found in main.db") + it.getString(0) + } + context.log.verbose("Found userId $userId") + val username = sqliteDatabase.rawQuery("SELECT username FROM Friend WHERE userId = ?", arrayOf(userId)).use { + if (!it.moveToFirst()) throw Exception("username not found in main.db") + it.getString(0) + } + context.log.verbose("Found username $username") + tempZip?.inputStream()?.use { + context.bridgeClient.getAccountStorage().addAccount( + userId, + username, + it.toParcelFileDescriptor(context.coroutineScope) + ) + } + context.shortToast("Imported $username!") + updateUsers() + } + }.onFailure { + context.shortToast("Failed to import account: ${it.message}") + context.log.error("Failed to import account", it) + } + + tempZip?.delete() + mainDbFile?.delete() + mainDbWalFile?.delete() + mainDbShmFile?.delete() + } + + @SuppressLint("SetTextI18n") + override fun init() { + if (context.config.experimental.accountSwitcher.globalState != true) return + + onNextActivityCreate { + val hovaHeaderSearchIcon = context.resources.getId("hova_header_search_icon") + + context.event.subscribe(AddViewEvent::class) { event -> + if (event.view.id != hovaHeaderSearchIcon) return@subscribe + + event.view.setOnLongClickListener { + context.mainActivity!!.vibrateLongPress() + showManagementPopup() + false + } + } + } + + context.event.subscribe(ActivityResultEvent::class) { event -> + if (importRequestCode == event.requestCode) { + importRequestCode = null + if (event.resultCode != Activity.RESULT_OK) return@subscribe + event.canceled = true + val uri = event.intent.data ?: return@subscribe + + context.coroutineScope.launch { importAccount(uri) } + } + + if (exportCallback?.first == event.requestCode) { + val userId = exportCallback?.second + exportCallback = null + event.canceled = true + if (event.resultCode != Activity.RESULT_OK) return@subscribe + + context.coroutineScope.launch { + runCatching { + event.intent.data?.let { uri -> + val accountDataPfd = context.bridgeClient.getAccountStorage().getAccountData(userId) ?: throw Exception("Account data not found") + context.androidContext.contentResolver.openOutputStream(uri)?.use { outputStream -> + ParcelFileDescriptor.AutoCloseInputStream(accountDataPfd).use { + it.copyTo(outputStream) + } + } + context.shortToast("Account exported!") + } + }.onFailure { + context.shortToast("Failed to export account. Check logs for more info.") + context.log.error("Failed to export account", it) + } + } + } + } + + findClass("com.snap.identity.service.ForcedLogoutBroadcastReceiver").hook("onReceive", HookStage.BEFORE) { param -> + val intent = param.arg<Intent>(1) + if (isLoginActivity) return@hook + if (intent.getBooleanExtra("forced", false) && !context.config.experimental.preventForcedLogout.get()) { + runCatching { + val accountStorage = context.bridgeClient.getAccountStorage() + + if (accountStorage.isAccountExists(context.database.myUserId)) { + accountStorage.removeAccount(context.database.myUserId) + context.shortToast("Removed account due to forced logout") + } + } + return@hook + } + + if (context.config.experimental.accountSwitcher.autoBackupCurrentAccount.get()) { + backupCurrentAccount() + } + } + + val switchButtonComposable: CustomComposable = { + Row( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.TopStart), + ) { + Button( + onClick = { showManagementPopup() }, + modifier = Modifier.padding(16.dp) + ) { + Text("Switch Account") + } + } + } + + onNextActivityCreate { activity -> + if (!activity.componentName.className.endsWith("LoginSignupActivity")) return@onNextActivityCreate + context.inAppOverlay.addCustomComposable(switchButtonComposable) + onNextActivityCreate { + context.inAppOverlay.removeCustomComposable(switchButtonComposable) + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AddFriendSourceSpoof.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AddFriendSourceSpoof.kt new file mode 100644 index 0000000000..b05dc079cf --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AddFriendSourceSpoof.kt @@ -0,0 +1,72 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +import me.rhunk.snapenhance.common.data.FriendAddSource +import me.rhunk.snapenhance.common.util.protobuf.ProtoEditor +import me.rhunk.snapenhance.core.event.events.impl.UnaryCallEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hookConstructor +import me.rhunk.snapenhance.mapper.impl.FriendRelationshipChangerMapper + +class AddFriendSourceSpoof : Feature("AddFriendSourceSpoof") { + var friendRelationshipChangerInstance: Any? = null + private set + + override fun init() { + onNextActivityCreate { + context.mappings.useMapper(FriendRelationshipChangerMapper::class) { + classReference.get()?.hookConstructor(HookStage.AFTER) { param -> + friendRelationshipChangerInstance = param.thisObject() + } + } + + context.event.subscribe(UnaryCallEvent::class) { event -> + if (event.uri != "/snapchat.friending.server.FriendAction/AddFriends") return@subscribe + val spoofedSource = context.config.experimental.addFriendSourceSpoof.getNullable() ?: return@subscribe + event.buffer = ProtoEditor(event.buffer).apply { + edit { + fun setPage(value: String) { + remove(1) + addString(1, value) + } + + editEach(2) { + remove(3) // remove suggestion token + fun setSource(source: FriendAddSource) { + remove(2) + addVarInt(2, source.id) + } + + when (spoofedSource) { + "added_by_group_chat" -> { + setPage("group_profile") + setSource(FriendAddSource.GROUP_CHAT) + } + "added_by_username" -> { + setPage("search") + setSource(FriendAddSource.USERNAME) + } + "added_by_qr_code" -> { + setPage("scan_snapcode") + setSource(FriendAddSource.QR_CODE) + } + "added_by_mention" -> { + setPage("context_card") + setSource(FriendAddSource.MENTION) + } + "added_by_community" -> { + setPage("profile") + setSource(FriendAddSource.COMMUNITY) + } + "added_by_quick_add" -> { + setPage("add_friends_button_on_top_bar_on_friends_feed") + setSource(FriendAddSource.SUGGESTED) + } + } + } + } + }.toByteArray() + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AppLock.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AppLock.kt new file mode 100644 index 0000000000..74b83638d7 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AppLock.kt @@ -0,0 +1,154 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +import android.app.Activity +import android.content.ComponentName +import android.content.Intent +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.Shape +import android.view.View +import android.widget.FrameLayout +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.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.unit.dp +import me.rhunk.snapenhance.common.Constants +import me.rhunk.snapenhance.common.ui.AppMaterialTheme +import me.rhunk.snapenhance.common.ui.createComposeView +import me.rhunk.snapenhance.core.event.events.impl.ActivityResultEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.ui.addForegroundDrawable +import me.rhunk.snapenhance.core.ui.children +import me.rhunk.snapenhance.core.ui.removeForegroundDrawable +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import kotlin.random.Random + +class AppLock : Feature("AppLock") { + private var isUnlockRequested = false + + private val rootContentView get() = context.mainActivity!!.findViewById<FrameLayout>(android.R.id.content) + private val requestCode = Random.nextInt(100, 65535) + + private fun hideRootView() { + rootContentView.addForegroundDrawable("locked_overlay", ShapeDrawable(object: Shape() { + override fun draw(canvas: Canvas, paint: Paint) { + paint.color = 0xFF000000.toInt() + canvas.drawRect(0F, 0F, canvas.width.toFloat(), canvas.height.toFloat(), paint) + } + })) + } + + private fun requestUnlock() { + isUnlockRequested = true + context.mainActivity!!.startActivityForResult(Intent().apply { + component = ComponentName(Constants.SE_PACKAGE_NAME, "me.rhunk.snapenhance.bridge.BiometricPromptActivity") + }, requestCode) + } + + private fun lock(prompt: Boolean = true) { + isUnlockRequested = true + hideRootView() + + val lockedView = rootContentView.findViewWithTag<View>("locked_view") ?: createComposeView(rootContentView.context) { + AppMaterialTheme(isDarkTheme = true) { + Surface( + color = MaterialTheme.colorScheme.surface, + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Image( + imageVector = Icons.Default.Lock, + contentDescription = "Lock", + modifier = Modifier.size(100.dp), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface) + ) + Button(onClick = { + requestUnlock() + }) { + Text(remember { context.translation["biometric_auth.unlock_button"] }) + } + } + } + } + } + }.apply { + tag = "locked_view" + layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) + rootContentView.addView(this) + } + + rootContentView.postDelayed({ + rootContentView.children().forEach { it.visibility = View.GONE } + lockedView.visibility = View.VISIBLE + rootContentView.removeForegroundDrawable("locked_overlay") + }, 500) + + if (prompt) { + requestUnlock() + } + } + + private fun unlock() { + rootContentView.apply { + removeForegroundDrawable("locked_overlay") + children().forEach { it.visibility = View.VISIBLE } + visibility = View.VISIBLE + findViewWithTag<View>("locked_view")?.visibility = View.GONE + postDelayed({ + isUnlockRequested = false + }, 1000) + } + } + + override fun init() { + if (context.config.experimental.appLock.globalState != true) return + + onNextActivityCreate { + Activity::class.java.apply { + if (context.config.experimental.appLock.lockOnResume.get()) { + hook("onResume", HookStage.BEFORE) { param -> + if (param.thisObject<Activity>().packageName != Constants.SNAPCHAT_PACKAGE_NAME) return@hook + if (isUnlockRequested) return@hook + lock(prompt = true) + } + hook("onPause", HookStage.BEFORE) { param -> + if (param.thisObject<Activity>().packageName != Constants.SNAPCHAT_PACKAGE_NAME) return@hook + if (isUnlockRequested) return@hook + hideRootView() + } + } + } + + context.event.subscribe(ActivityResultEvent::class) { event -> + if (event.requestCode != requestCode) return@subscribe + if (event.resultCode == Activity.RESULT_OK) { + unlock() + return@subscribe + } + lock(prompt = false) + } + lock() + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AutoOpenSnaps.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AutoOpenSnaps.kt new file mode 100644 index 0000000000..6da7f02bcb --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AutoOpenSnaps.kt @@ -0,0 +1,121 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.data.MessageUpdate +import me.rhunk.snapenhance.common.data.MessagingRuleType +import me.rhunk.snapenhance.core.event.events.impl.BuildMessageEvent +import me.rhunk.snapenhance.core.features.MessagingRuleFeature +import me.rhunk.snapenhance.core.features.impl.messaging.Messaging +import java.util.concurrent.atomic.AtomicInteger +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import kotlin.random.Random + +class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.AUTO_OPEN_SNAPS) { + private val snapQueue = MutableSharedFlow<Pair<String, Long>>() + private var snapQueueSize = AtomicInteger(0) + private val openedSnaps = mutableListOf<Long>() + + private val notificationManager by lazy { + context.androidContext.getSystemService(NotificationManager::class.java) + } + + private val notificationId by lazy { Random.nextInt() } + + private val channelId by lazy { + "auto_open_snaps".also { + notificationManager.createNotificationChannel( + NotificationChannel(it, context.translation["auto_open_snaps.title"], NotificationManager.IMPORTANCE_LOW) + ) + } + } + + private fun sendStatusNotification(count: Int) { + notificationManager.notify( + notificationId, + Notification.Builder(context.androidContext, channelId) + .setContentTitle(context.translation["auto_open_snaps.title"]) + .setContentText(context.translation.format("auto_open_snaps.notification_content", "count" to count.toString())) + .setSmallIcon(android.R.drawable.ic_menu_view) + .setProgress(0, 0, true) + .build().apply { + flags = flags or Notification.FLAG_ONLY_ALERT_ONCE + } + ) + } + + override fun init() { + if (getRuleState() == null) return + val messaging = context.feature(Messaging::class) + + context.coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) { + snapQueue.collect { (conversationId, messageId) -> + snapQueueSize.addAndGet(-1) + delay(Random.nextLong(50, 100)) + var result: String? + + for (i in 0..5) { + while (context.isMainActivityPaused || messaging.conversationManager == null) { + delay(2000) + } + + result = suspendCoroutine { continuation -> + runCatching { + messaging.conversationManager?.updateMessage(conversationId, messageId, MessageUpdate.READ) { result -> + continuation.resume(result) + } + }.getOrNull() ?: continuation.resume("ConversationManager is null") + } + + if (result != null && result != "DUPLICATEREQUEST") { + context.log.warn("Failed to mark snap as read, retrying in 3 second") + delay(3000) + continue + } + break + } + + if (snapQueueSize.get() <= 5) { + notificationManager.cancel(notificationId) + synchronized(openedSnaps) { + openedSnaps.clear() + } + } else { + sendStatusNotification(openedSnaps.size) + } + } + } + + context.event.subscribe(BuildMessageEvent::class, priority = 103) { event -> + if (event.message.senderId?.toString() == context.database.myUserId) return@subscribe + val conversationId = event.message.messageDescriptor?.conversationId?.toString() ?: return@subscribe + val clientMessageId = event.message.messageDescriptor?.messageId ?: return@subscribe + + if ( + event.message.messageContent?.contentType != ContentType.SNAP || + event.message.messageMetadata?.openedBy?.any { it.toString() == context.database.myUserId } == true + ) { + return@subscribe + } + + context.coroutineScope.launch { + if (!canUseRule(conversationId)) return@launch + synchronized(openedSnaps) { + if (openedSnaps.contains(clientMessageId)) { + return@launch + } + openedSnaps.add(clientMessageId) + } + snapQueueSize.addAndGet(1) + snapQueue.emit(conversationId to clientMessageId) + } + } + } +} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/BestFriendPinning.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/BestFriendPinning.kt new file mode 100644 index 0000000000..32b6889a5a --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/BestFriendPinning.kt @@ -0,0 +1,93 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FavoriteBorder +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.common.bridge.InternalFileHandleType +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent +import me.rhunk.snapenhance.core.event.events.impl.UnaryCallEvent +import me.rhunk.snapenhance.core.features.BridgeFileFeature +import me.rhunk.snapenhance.core.ui.triggerRootCloseTouchEvent +import java.io.InputStreamReader +import java.nio.ByteBuffer +import java.util.UUID + +class BestFriendPinning: BridgeFileFeature("Best Friend Pinning", InternalFileHandleType.PINNED_BEST_FRIEND) { + private fun updatePinnedBestFriendStatus() { + lines().firstOrNull()?.trim()?.let { + context.database.updatePinnedBestFriendStatus(it.substring(0, 36), "number_one_bf_for_two_months") + } + } + + override fun init() { + if (!context.config.experimental.bestFriendPinning.get()) return + reload() + + context.event.subscribe(UnaryCallEvent::class) { event -> + if (!event.uri.endsWith("/PinBestFriend") && !event.uri.endsWith("/UnpinBestFriend")) return@subscribe + event.canceled = true + val userId = ProtoReader(event.buffer).let { + UUID(it.getFixed64(1, 1) ?: return@subscribe, it.getFixed64(1, 2)?: return@subscribe).toString() + } + + clear() + put(userId) + + updatePinnedBestFriendStatus() + + val username = context.database.getFriendInfo(userId)?.mutableUsername ?: "Unknown" + + context.inAppOverlay.showStatusToast( + icon = Icons.Default.FavoriteBorder, + "Pinned $username as best friend! Please restart the app to apply changes.", + durationMs = 5000 + ) + + context.coroutineScope.launch(Dispatchers.Main) { + delay(500) + @Suppress("DEPRECATION") + context.mainActivity!!.onBackPressed() + context.mainActivity!!.triggerRootCloseTouchEvent() + } + } + + context.event.subscribe(NetworkApiRequestEvent::class) { event -> + if (!event.url.contains("ami/friends")) return@subscribe + val pinnedBFF = lines().firstOrNull()?.trim() ?: return@subscribe + + event.onSuccess { buffer -> + val jsonObject = context.gson.fromJson( + InputStreamReader(buffer?.inputStream() ?: return@onSuccess, Charsets.UTF_8), + JsonObject::class.java + ).apply { + getAsJsonArray("friends").map { it.asJsonObject }.forEach { friend -> + if (friend.get("user_id").asString != pinnedBFF) return@forEach + friend.add("friendmojis", JsonArray().apply { + friend.getAsJsonArray("friendmojis").map { it.asJsonObject }.forEach { friendmoji -> + val category = friendmoji.get("category_name").asString + if (category == "on_fire" || category == "birthday") { + add(friendmoji) + } + } + add(JsonObject().apply { + addProperty("category_name", "number_one_bf_for_two_months") + }) + }) + } + } + + jsonObject.toString().toByteArray(Charsets.UTF_8).let { + setArg(2, ByteBuffer.allocateDirect(it.size).apply { + put(it) + flip() + }) + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/BetterLocation.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/BetterLocation.kt new file mode 100644 index 0000000000..b6474b2413 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/BetterLocation.kt @@ -0,0 +1,308 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +import android.location.Location +import android.location.LocationManager +import android.view.View +import android.view.ViewGroup +import android.widget.RelativeLayout +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.EditLocation +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import me.rhunk.snapenhance.common.ui.OverlayType +import me.rhunk.snapenhance.common.ui.createComposeView +import me.rhunk.snapenhance.common.util.protobuf.EditorContext +import me.rhunk.snapenhance.common.util.protobuf.ProtoEditor +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent +import me.rhunk.snapenhance.core.event.events.impl.UnaryCallEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.ui.children +import me.rhunk.snapenhance.core.util.RandomWalking +import me.rhunk.snapenhance.core.util.dataBuilder +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.hook.hookConstructor +import me.rhunk.snapenhance.core.util.ktx.getId +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.util.ktx.isDarkTheme +import me.rhunk.snapenhance.mapper.impl.CallbackMapper +import java.nio.ByteBuffer +import java.util.UUID +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +data class FriendLocation( + val userId: String, + val latitude: Double, + val longitude: Double, + val lastUpdated: Long, + val locality: String?, + val localityPieces: List<String>, + val batteryLevel: Float, +) { + fun distanceTo(other: FriendLocation): Double { + val deltaLat = Math.toRadians(other.latitude - this.latitude) + val deltaLong = Math.toRadians(other.longitude - this.longitude) + + val a = sin(deltaLat / 2) * sin(deltaLat / 2) + + cos(Math.toRadians(this.latitude)) * cos(Math.toRadians(other.latitude)) * + sin(deltaLong / 2) * sin(deltaLong / 2) + + return 6371 * 2 * atan2(sqrt(a), sqrt(1 - a)) + } +} + +class BetterLocation : Feature("Better Location") { + val locationHistory = mutableMapOf<String, FriendLocation>() + + private val walkRadius by lazy { + context.config.global.betterLocation.walkRadius.getNullable() + } + + private val randomWalking by lazy { + RandomWalking(walkRadius?.toDoubleOrNull()) + } + + private fun getLat() : Double { + var spoofedLatitude = context.config.global.betterLocation.coordinates.get().first + walkRadius?.let { + spoofedLatitude += randomWalking.current_x + } + return spoofedLatitude + } + + private fun getLong() : Double { + var spoofedLongitude = context.config.global.betterLocation.coordinates.get().second + walkRadius?.let { + spoofedLongitude += randomWalking.current_y + } + return spoofedLongitude + } + + private fun editClientUpdate(editor: EditorContext) { + val config = context.config.global.betterLocation + + editor.apply { + // SCVSLocationUpdate + edit(1) { + if (config.spoofLocation.get()) { + randomWalking.updatePosition() + remove(1) + remove(2) + addFixed32(1, getLat().toFloat()) // lat + addFixed32(2, getLong().toFloat()) // lng + } + + if (config.alwaysUpdateLocation.get()) { + remove(7) + addVarInt(7, System.currentTimeMillis()) // timestamp + } + } + + if (context.config.global.betterLocation.suspendLocationUpdates.get()) { + remove(1) + } + + // SCVSDeviceData + edit(3) { + config.spoofBatteryLevel.getNullable()?.takeIf { it.isNotEmpty() }?.let { + val value = it.toIntOrNull()?.toFloat()?.div(100) ?: return@edit + remove(2) + addFixed32(2, value) + if (value == 100F) { + remove(3) + addVarInt(3, 1) // devicePluggedIn + } + } + + if (config.spoofHeadphones.get()) { + remove(4) + addVarInt(4, 1) // headphoneOutput + remove(6) + addVarInt(6, 1) // isOtherAudioPlaying + } + + edit(10) { + remove(1) + addVarInt(1, 4) // type = ALWAYS + remove(2) + addVarInt(2, 1) // precise = true + } + } + } + } + + private fun onLocationEvent(protoReader: ProtoReader) { + protoReader.eachBuffer(3, 1) { + val clusterId = UUID(getFixed64(1, 1) ?: return@eachBuffer, getFixed64(1, 2) ?: return@eachBuffer).toString() + + val latitude = getFixed32(4)?.let { Float.fromBits(it) }?.toDouble() ?: return@eachBuffer + val longitude = getFixed32(5)?.let { Float.fromBits(it) }?.toDouble() ?: return@eachBuffer + + val locality = getString(10) + val localityPieces = mutableListOf<String>().also { + forEach { index, wire -> + if (index != 11) return@forEach + it.add((wire.value as ByteArray).toString(Charsets.UTF_8) ) + } + } + + eachBuffer(7) friend@{ + val userId = if (contains(1)) UUID(getFixed64(1, 1) ?: return@friend, getFixed64(1, 2) ?: return@friend).toString() else clusterId + val friendLocation = FriendLocation( + userId = userId, + latitude = latitude, + longitude = longitude, + lastUpdated = getVarInt(2) ?: -1L, + locality = locality, + localityPieces = localityPieces, + batteryLevel = getFixed32(13)?.let { Float.fromBits(it) } ?: -1F, + ) + + locationHistory[userId] = friendLocation + } + } + } + + private fun openManagementOverlay() { + context.bridgeClient.getLocationManager().provideFriendsLocation( + locationHistory.values.toList().mapNotNull { locationHistory -> + val friendInfo = context.database.getFriendInfo(locationHistory.userId) ?: return@mapNotNull null + + me.rhunk.snapenhance.bridge.location.FriendLocation().also { + it.username = friendInfo.mutableUsername ?: return@mapNotNull null + it.displayName = friendInfo.displayName + it.bitmojiId = friendInfo.bitmojiAvatarId + it.bitmojiSelfieId = friendInfo.bitmojiSelfieId + it.latitude = locationHistory.latitude + it.longitude = locationHistory.longitude + it.lastUpdated = locationHistory.lastUpdated + it.locality = locationHistory.locality + it.localityPieces = locationHistory.localityPieces + } + } + ) + context.bridgeClient.openOverlay(OverlayType.BETTER_LOCATION) + } + + override fun init() { + if (context.config.global.betterLocation.globalState != true) return + + val canSpoofLocation = { context.config.global.betterLocation.spoofLocation.get() } + + LocationManager::class.java.apply { + hook("isProviderEnabled", HookStage.BEFORE, { canSpoofLocation() }) { it.setResult(true) } + hook("isProviderEnabledForUser", HookStage.BEFORE, { canSpoofLocation() }) { it.setResult(true) } + } + Location::class.java.apply { + hook("getLatitude", HookStage.BEFORE, { canSpoofLocation() }) { it.setResult(getLat()) } + hook("getLongitude", HookStage.BEFORE, { canSpoofLocation() }) { it.setResult(getLong()) } + } + + val mapViewId = context.resources.getId("mapview") + + if (context.config.global.betterLocation.showBatteryLevel.get()) { + findClass("snap.snap_maps_sdk.nano.SnapMapsSdk\$PublicUserInfo").hook("setDisplayName", HookStage.BEFORE) { param -> + val instance = param.thisObject<Any>() + val userId = instance.getObjectField("userId_") as? String ?: return@hook + val batteryLevel = locationHistory[userId]?.batteryLevel?.takeIf { it > -1F } ?: return@hook + param.setArg(0, param.arg<String>(0) + " (${(batteryLevel * 100).toInt()}%)") + } + + findClass("com.snap.map_friend_focus_view.MapFocusViewFriendSectionDataModel").hookConstructor(HookStage.AFTER) { param -> + val instance = param.thisObject<Any>() + val userId = instance.getObjectField("_userId") as? String ?: return@hookConstructor + val batteryLevel = locationHistory[userId]?.batteryLevel?.takeIf { it > -1F } ?: return@hookConstructor + + param.thisObject<Any>().dataBuilder { + val prevText = get<String?>("_lastSeen")?.let { " - $it" } ?: "" + set("_lastSeen", "(${(batteryLevel * 100).toInt()}%)$prevText") + } + } + } + + context.event.subscribe(AddViewEvent::class) { event -> + if (!event.viewClassName.endsWith("MapScreenRoot")) return@subscribe + + event.view.addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + val mapView = event.view.findViewById<View>(mapViewId) ?: throw IllegalStateException("Map view not found") + val view = (mapView.parent as ViewGroup).children().firstOrNull { it is RelativeLayout } as? RelativeLayout ?: throw IllegalStateException("Map view parent not found") + + view.addView(createComposeView(view.context) { + val darkTheme = remember { context.androidContext.isDarkTheme() } + Box( + modifier = Modifier.padding(start = 8.dp) + ) { + FilledIconButton( + modifier = Modifier.size(40.dp), + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = if (darkTheme) Color(0xFF1D1D1D) else Color.White, + contentColor = if (darkTheme) Color.White else Color(0xFF151A1A), + ), + onClick = { openManagementOverlay() } + ) { + Icon(Icons.Default.EditLocation, contentDescription = null) + } + } + }.apply { + layoutParams = RelativeLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { + addRule(RelativeLayout.ALIGN_PARENT_LEFT) + setMargins(0, (60 * context.resources.displayMetrics.density).toInt(), 0, 0) + } + }) + } + + override fun onViewDetachedFromWindow(v: View) {} + }) + } + + context.event.subscribe(UnaryCallEvent::class) { event -> + if (event.uri == "/snapchat.valis.Valis/SendClientUpdate") { + event.buffer = ProtoEditor(event.buffer).apply { + edit { + editEach(1) { + editClientUpdate(this) + } + } + }.toByteArray() + } + } + + context.mappings.useMapper(CallbackMapper::class) { + callbacks.getClass("ServerStreamingEventHandler")?.hook("onEvent", HookStage.BEFORE) { param -> + val buffer = param.argNullable<ByteBuffer>(1)?.let { + it.position(0) + ByteArray(it.capacity()).also { buffer -> it.get(buffer); it.position(0) } + } ?: return@hook + onLocationEvent(ProtoReader(buffer)) + } + } + + findClass("com.snapchat.client.grpc.ClientStreamSendHandler\$CppProxy").hook("send", HookStage.BEFORE) { param -> + val array = param.arg<ByteBuffer>(0).let { + it.position(0) + ByteArray(it.capacity()).also { buffer -> it.get(buffer); it.position(0) } + } + + param.setArg(0, ProtoEditor(array).apply { + edit { + editClientUpdate(this) + } + }.toByteArray().let { + ByteBuffer.allocateDirect(it.size).put(it).rewind() + }) + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/BetterTranscript.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/BetterTranscript.kt new file mode 100644 index 0000000000..d2979048f4 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/BetterTranscript.kt @@ -0,0 +1,75 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.util.protobuf.ProtoEditor +import me.rhunk.snapenhance.core.event.events.impl.BuildMessageEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.dataBuilder +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.ktx.getObjectFieldOrNull +import me.rhunk.snapenhance.core.util.ktx.setObjectField +import java.nio.ByteBuffer + +class BetterTranscript: Feature("Better Transcript") { + private val voiceML: Any by lazy { + findClass("com.snapchat.client.voiceml.IVoiceMLSDK").getMethod("create").invoke(null) ?: error("Failed to create IVoiceMLSDK instance") + } + + private fun createAsrConfig(): Any? { + findClass("com.snapchat.client.voiceml.IConfigFactory").methods.first { it.name == "simpleAsrConfig" }.let { method -> + return method.invoke(null, method.parameterTypes[0].dataBuilder { + set("mSampleRate", 44100) + set("mLanguageModel", "en") + set("mUseCase", "VOICENOTESTRANSCRIPTION") + set("mAppVersion", "voice note transcript") + set("mUiLanguage", "en") + set("mAuthType", "SNAPTOKEN") + set("mEncoding", "AAC") + }) + } + } + + fun transcribe(audio: ByteBuffer): String? { + val transcribeMethod = voiceML.javaClass.methods.first { it.name == "asrTranscribe" } + val snapToken = context.database.getAccessTokens(context.database.myUserId)?.get("api-gateway") ?: error("Failed to get api-gateway token") + + return transcribeMethod.invoke(voiceML, snapToken, createAsrConfig(), audio) + ?.let { asrResult -> + asrResult.getObjectFieldOrNull("mTranscription")?.toString() + } + } + + override fun init() { + if (context.config.experimental.betterTranscript.globalState != true) return + + onNextActivityCreate { + val config = context.config.experimental.betterTranscript + + if (config.forceTranscription.get()) { + context.event.subscribe(BuildMessageEvent::class, priority = 104) { event -> + if (event.message.messageContent?.contentType != ContentType.NOTE) return@subscribe + event.message.messageContent!!.content = ProtoEditor(event.message.messageContent!!.content!!).apply { + edit(6, 1) { + if (firstOrNull(3) == null) { + addString(3, context.getConfigLocale()) + } + } + }.toByteArray() + } + } + + findClass("com.snapchat.client.voiceml.IVoiceMLSDK\$CppProxy").hook("asrTranscribe", HookStage.BEFORE) { param -> + config.preferredTranscriptionLang.getNullable()?.takeIf { + it.isNotBlank() + }?.trim()?.lowercase()?.let { + val asrConfig = param.arg<Any>(1) + asrConfig.getObjectFieldOrNull("mBaseConfig")?.apply { + setObjectField("mLanguageModel", it) + setObjectField("mUiLanguage", it) + } + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/CallRecorder.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/CallRecorder.kt new file mode 100644 index 0000000000..247e7da730 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/CallRecorder.kt @@ -0,0 +1,141 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioTrack +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import me.rhunk.snapenhance.common.data.download.AudioStreamFormat +import me.rhunk.snapenhance.common.data.download.MediaDownloadSource +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.impl.downloader.MediaDownloader +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.hook.hookConstructor +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.util.ktx.getObjectFieldOrNull +import me.rhunk.snapenhance.core.util.media.HttpServer +import java.io.PipedInputStream +import java.io.PipedOutputStream +import java.nio.ByteBuffer +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList + +class CallRecorder : Feature("Call Recorder") { + private val httpServer = HttpServer( + timeout = Integer.MAX_VALUE + ) + + override fun init() { + if (!context.config.experimental.callRecorder.get()) return + + val streamHandlers = ConcurrentHashMap<Int, MutableList<(data: ByteArray) -> Unit>>() // audioTrack -> handlers + val participants = CopyOnWriteArrayList<String>() + + runCatching { + findClass("com.snapchat.talkcorev3.CallingSessionState") + }.getOrNull()?.hookConstructor(HookStage.AFTER) { param -> + val instance = param.thisObject<Any>() + val callingState = instance.getObjectFieldOrNull("mLocalUser")?.getObjectField("mCallingState") + + if (callingState.toString() == "IN_CALL") { + participants.clear() + participants.addAll((instance.getObjectField("mParticipants") as Map<*, *>).keys.map { it.toString() }) + } + } ?: findClass("com.snapchat.talkcorev3.TSCallingStateUpdateParams").hookConstructor(HookStage.AFTER) { param -> + val instance = param.thisObject<Any>() + + if (instance.getObjectFieldOrNull("mInCall") == true) { + participants.clear() + participants.addAll((instance.getObjectField("mParticipants") as Set<*>).map { it.toString() }) + } + } + + AudioTrack::class.java.apply { + getConstructor( + AudioAttributes::class.java, + AudioFormat::class.java, + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + ).hook(HookStage.BEFORE) { param -> + val audioAttributes = param.arg<AudioAttributes>(0) + if (audioAttributes.usage != AudioAttributes.USAGE_VOICE_COMMUNICATION) return@hook + val audioFormat = param.arg<AudioFormat>(1) + val hashCode = param.thisObject<Any>().hashCode() + + lateinit var streamUrl: String + streamUrl = httpServer.ensureServerStarted()?.putContent( + object: HttpServer.HttpContent() { + override val contentType: String = "audio/wav" + override val chunked: Boolean = true + override val contentLength: Long? = null + override val newBody: () -> HttpServer.HttpBody = { + object: HttpServer.HttpBody() { + val outputStream = PipedOutputStream() + val inputStream = PipedInputStream(outputStream) + + val handler: (byteArray: ByteArray) -> Unit = handler@{ byteArray -> + if (byteArray.isEmpty()) { + httpServer.removeUrl(streamUrl) + return@handler + } + runCatching { + outputStream.write(byteArray) + outputStream.flush() + }.onFailure { + context.log.warn("Failed to write to streaming url ${it.localizedMessage}") + } + } + + override val onOpen: () -> Unit = { + streamHandlers.getOrPut(hashCode) { CopyOnWriteArrayList() }.add(handler) + } + + override val readBytes: (byteArray: ByteArray) -> Int = { byteArray -> + runBlocking { + withTimeoutOrNull(3000L) { + inputStream.read(byteArray) + } ?: -1 + } + } + + override val onClose: () -> Unit = { + context.log.verbose("Streaming url closed") + streamHandlers[hashCode]?.remove(handler) + outputStream.close() + inputStream.close() + } + } + } + } + ) ?: return@hook + + context.log.verbose("streaming url = $streamUrl, sampleRate = ${audioFormat.sampleRate}, audioFormat = ${audioFormat.encoding}") + + context.feature(MediaDownloader::class).provideDownloadManagerClient( + UUID.randomUUID().toString(), + participants.mapNotNull { context.database.getFriendInfo(it)?.mutableUsername }.joinToString("-"), + System.currentTimeMillis(), + MediaDownloadSource.VOICE_CALL + ).downloadStream(streamUrl, AudioStreamFormat(audioFormat.channelCount, audioFormat.sampleRate, audioFormat.encoding)) + } + + getMethod("write", ByteBuffer::class.java, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType).hook(HookStage.BEFORE) { param -> + streamHandlers[param.thisObject<Any>().hashCode()]?.let { handlers -> + val byteBuffer = param.arg<ByteBuffer>(0) + val position = byteBuffer.position() + val buffer = ByteArray(param.arg(1)) + byteBuffer.get(buffer) + byteBuffer.position(position) + handlers.forEach { it(buffer) } + } + } + + hook("release", HookStage.BEFORE) { + streamHandlers.remove(it.thisObject<Any>().hashCode())?.forEach { it(ByteArray(0)) } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/ComposerHooks.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/ComposerHooks.kt new file mode 100644 index 0000000000..602835eb77 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/ComposerHooks.kt @@ -0,0 +1,212 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material3.* +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.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.common.bridge.FileHandleScope +import me.rhunk.snapenhance.common.bridge.toWrapper +import me.rhunk.snapenhance.common.ui.createComposeAlertDialog +import me.rhunk.snapenhance.core.SnapEnhance +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.impl.downloader.MediaDownloader +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.Hooker +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.wrapper.impl.composer.ComposerMarshaller +import me.rhunk.snapenhance.nativelib.NativeLib +import java.lang.reflect.Proxy +import kotlin.math.absoluteValue +import kotlin.random.Random + +class ComposerHooks: Feature("ComposerHooks") { + private val config by lazy { context.config.experimental.nativeHooks.composerHooks } + private val getImportsFunctionName = Random.nextLong().absoluteValue.toString(16) + + private val composerConsole by lazy { + createComposeAlertDialog(context.mainActivity!!) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + var result by remember { mutableStateOf("") } + var codeContent by remember { mutableStateOf("return 1 + 2") } + + Text("Composer Console", fontSize = 18.sp, fontWeight = FontWeight.Bold) + + TextField( + modifier = Modifier.fillMaxWidth(), + textStyle = TextStyle.Default.copy(fontSize = 12.sp), + value = codeContent, + placeholder = { Text("Enter your JS code here:") }, + onValueChange = { + codeContent = it + } + ) + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + context.log.verbose("input: $codeContent", "ComposerConsole") + result = "Running..." + context.coroutineScope.launch { + result = (context.native.composerEval(""" + (() => { + try { + $codeContent + } catch (e) { + return e.toString() + } + })() + """.trimIndent()) ?: "(no result)").also { + context.log.verbose("result: $it", "ComposerConsole") + } + } + } + ) { + Text("Run") + } + + Column( + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + Text(result) + } + } + } + } + + private fun newComposerFunction(block: ComposerMarshaller.() -> Boolean): Any? { + val composerFunctionClass = findClass("com.snap.composer.callable.ComposerFunction") + return Proxy.newProxyInstance( + composerFunctionClass.classLoader, + arrayOf(composerFunctionClass) + ) { _, method, args -> + if (method.name != "perform") return@newProxyInstance null + block(ComposerMarshaller(args?.get(0) ?: return@newProxyInstance false)) + } + } + + @Suppress("UNCHECKED_CAST") + override fun init() { + if (config.globalState != true) return + + val importedFunctions = mutableMapOf<String, Any?>() + + fun composerFunction(name: String, block: ComposerMarshaller.() -> Unit) { + importedFunctions[name] = newComposerFunction { + block(this) + true + } + } + + composerFunction("getConfig") { + pushUntyped(mapOf<String, Any>( + "operaDownloadButton" to context.config.downloader.operaDownloadButton.get(), + "bypassCameraRollLimit" to config.bypassCameraRollLimit.get(), + "showFirstCreatedUsername" to config.showFirstCreatedUsername.get(), + "composerLogs" to config.composerLogs.get(), + "customSelfDestructSnapDelay" to config.customSelfDestructSnapDelay.get(), + )) + } + + composerFunction("showToast") { + if (getSize() < 1) return@composerFunction + context.shortToast(getUntyped(0) as? String ?: return@composerFunction) + } + + composerFunction("downloadLastOperaMedia") { + context.feature(MediaDownloader::class).downloadLastOperaMediaAsync(getUntyped(0) == true) + } + + composerFunction("getFriendOriginalUsername") { + if (getSize() < 1) return@composerFunction + val username = getUntyped(0) as? String ?: return@composerFunction + + runCatching { + pushUntyped(context.database.getFriendOriginalUsername(username)) + }.onFailure { + pushUntyped(null) + } + } + + composerFunction("log") { + if (getSize() < 2) return@composerFunction + val logLevel = getUntyped(0) as? String ?: return@composerFunction + val message = getUntyped(1) as? String ?: return@composerFunction + + val tag = "ComposerLogs" + + when (logLevel) { + "log" -> context.log.verbose(message, tag) + "debug" -> context.log.debug(message, tag) + "info" -> context.log.info(message, tag) + "warn" -> context.log.warn(message, tag) + "error" -> context.log.error(message, tag) + } + } + + fun loadHooks() { + if (!NativeLib.initialized) { + context.log.error("ComposerHooks cannot be loaded without NativeLib") + return + } + val loaderScript = runCatching { + context.fileHandlerManager.getFileHandle(FileHandleScope.COMPOSER.key, "loader.js").toWrapper().readBytes().toString(Charsets.UTF_8) + }.onFailure { + context.log.error("Failed to load composer loader script", it) + }.getOrNull() ?: return + context.native.setComposerLoader(""" + const i = setInterval(() => { + try { + const _runtimeName = "${if (SnapEnhance.classCache.nativeBridge.name == "com.snapchat.client.valdi.NativeBridge") "valdi" else "composer"}"; + require(_runtimeName + '_core/src/DeviceBridge').getDisplayWidth(); + clearInterval(i); + (() => { const _getImportsFunctionName = "$getImportsFunctionName"; $loaderScript })(); + } catch (e) {} + }, 200) + """.trimIndent().trim()) + } + + loadHooks() + + if (config.composerConsole.get()) { + context.inAppOverlay.addCustomComposable { + FilledIconButton( + onClick = { + composerConsole.show() + }, + modifier = Modifier.align(Alignment.TopEnd).padding(top = 100.dp, end = 16.dp) + ) { + Icon(Icons.Default.BugReport, contentDescription = "Debug Console") + } + } + } + + SnapEnhance.classCache.nativeBridge.hook("registerNativeModuleFactory", HookStage.BEFORE) { param -> + val moduleFactory = param.argNullable<Any>(1) ?: return@hook + if (moduleFactory.javaClass.getMethod("getModulePath").invoke(moduleFactory)?.toString()?.contains("DeviceBridge") != true) return@hook + Hooker.ephemeralHookObjectMethod(moduleFactory.javaClass, moduleFactory, "loadModule", HookStage.AFTER) { methodParam -> + val result = methodParam.getResult() as? MutableMap<String, Any?> ?: return@ephemeralHookObjectMethod + result[getImportsFunctionName] = newComposerFunction { + pushUntyped(importedFunctions) + true + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/ContextMenuFix.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/ContextMenuFix.kt new file mode 100644 index 0000000000..8104b5769b --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/ContextMenuFix.kt @@ -0,0 +1,24 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +import me.rhunk.snapenhance.core.event.events.impl.UnaryCallEvent +import me.rhunk.snapenhance.core.features.Feature +import java.nio.ByteBuffer + +class ContextMenuFix: Feature("Context Menu Fix") { + override fun init() { + if (!context.config.experimental.contextMenuFix.get()) return + context.event.subscribe(UnaryCallEvent::class) { event -> + if (event.uri == "/snapchat.maps.device.MapDevice/IsPrimary") { + event.canceled = true + val unaryEventHandler = event.adapter.arg<Any>(3) + runCatching { + unaryEventHandler::class.java.methods.first { it.name == "onEvent" }.invoke(unaryEventHandler, ByteBuffer.wrap( + byteArrayOf(8, 1) + ), null) + }.onFailure { + context.log.error(null, it) + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/ConvertMessageLocally.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/ConvertMessageLocally.kt new file mode 100644 index 0000000000..2a63de0b2f --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/ConvertMessageLocally.kt @@ -0,0 +1,75 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.common.util.protobuf.ProtoWriter +import me.rhunk.snapenhance.core.event.events.impl.BuildMessageEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.impl.messaging.Messaging +import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper +import me.rhunk.snapenhance.core.wrapper.impl.Message +import me.rhunk.snapenhance.core.wrapper.impl.MessageContent + +class ConvertMessageLocally : Feature("Convert Message Edit") { + private val messageCache = mutableMapOf<Long, MessageContent>() + + private fun dispatchMessageEdit(message: Message, restore: Boolean = false) { + val messageId = message.messageDescriptor!!.messageId!! + if (!restore) messageCache[messageId] = message.messageContent!! + + context.runOnUiThread { + context.feature(Messaging::class).localUpdateMessage( + message.messageDescriptor!!.conversationId!!.toString(), + message + ) + } + } + + fun convertMessageInterface(messageInstance: Message) { + val actions = mutableMapOf<String, (Message) -> Unit>() + actions[context.translation["button.restore_original"]] = actions@{ message -> + val descriptor = message.messageDescriptor ?: return@actions + messageCache.remove(descriptor.messageId!!) + context.feature(Messaging::class).conversationManager?.fetchMessage( + descriptor.conversationId!!.toString(), + descriptor.messageId!!, + onSuccess = { msg -> + dispatchMessageEdit(msg, true) + } + ) + } + + val contentType = messageInstance.messageContent?.contentType + if (contentType == ContentType.SNAP) { + actions[context.translation["button.convert_external_media"]] = convert@{ message -> + val snapMessageContent = ProtoReader(message.messageContent!!.content!!).followPath(11) + ?.getBuffer() ?: return@convert + message.messageContent!!.content = ProtoWriter().apply { + from(3) { + addBuffer(3, snapMessageContent) + } + }.toByteArray() + dispatchMessageEdit(message) + } + } + + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity).apply { + setItems(actions.keys.toTypedArray()) { _, which -> + actions.values.elementAt(which).invoke(messageInstance) + } + setPositiveButton(this@ConvertMessageLocally.context.translation["button.cancel"]) { dialog, _ -> + dialog.dismiss() + } + }.show() + } + + override fun init() { + onNextActivityCreate { + context.event.subscribe(BuildMessageEvent::class, priority = 2) { + val clientMessageId = it.message.messageDescriptor?.messageId ?: return@subscribe + if (!messageCache.containsKey(clientMessageId)) return@subscribe + it.message.messageContent = messageCache[clientMessageId] + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/CustomEmojiFont.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/CustomEmojiFont.kt new file mode 100644 index 0000000000..58540a47d3 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/CustomEmojiFont.kt @@ -0,0 +1,29 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +import me.rhunk.snapenhance.common.bridge.FileHandleScope +import me.rhunk.snapenhance.core.ModContext +import me.rhunk.snapenhance.core.util.ktx.getFileHandleLocalPath + + +private var cacheFontPath: String? = null + +fun getCustomEmojiFontPath( + context: ModContext +): String? { + val customFileName = context.config.experimental.nativeHooks.customEmojiFont.getNullable()?.takeIf { it.isNotBlank() } ?: return null + if (cacheFontPath == null) { + cacheFontPath = runCatching { + context.fileHandlerManager.getFileHandleLocalPath( + context, + FileHandleScope.USER_IMPORT, + customFileName, + "custom_emoji_font" + ) + }.onFailure { + context.log.error("Failed to get custom emoji font", it) + }.getOrNull() ?: "" + } + return cacheFontPath?.takeIf { it.isNotEmpty() } +} + + diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/DeviceSpooferHook.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/DeviceSpooferHook.kt new file mode 100644 index 0000000000..6d18ed71d0 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/DeviceSpooferHook.kt @@ -0,0 +1,57 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +import android.annotation.SuppressLint +import android.location.Location +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.os.Build +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.LSPatchUpdater +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook + +class DeviceSpooferHook: Feature("Device Spoofer") { + private fun hookInstallerPackageName() { + context.androidContext.packageManager::class.java.hook("getInstallerPackageName", HookStage.BEFORE) { param -> + param.setResult("com.android.vending") + } + } + + @SuppressLint("MissingPermission") + override fun init() { + // force installer package name for lspatch users + if (LSPatchUpdater.HAS_LSPATCH) { + hookInstallerPackageName() + } + + if (context.config.experimental.spoof.globalState != true) return + + val removeMockLocationFlag by context.config.experimental.spoof.removeMockLocationFlag + val overridePlayStoreInstallerPackageName by context.config.experimental.spoof.overridePlayStoreInstallerPackageName + val removeVpnTransportFlag by context.config.experimental.spoof.removeVpnTransportFlag + + //Installer package name + if(overridePlayStoreInstallerPackageName) { + hookInstallerPackageName() + } + + if (removeMockLocationFlag) { + Location::class.java.hook("isMock", HookStage.BEFORE) { param -> + param.setResult(false) + } + } + + if (removeVpnTransportFlag) { + ConnectivityManager::class.java.hook("getAllNetworks", HookStage.AFTER) { param -> + val instance = param.thisObject() as? ConnectivityManager ?: return@hook + val networks = param.getResult() as? Array<*> ?: return@hook + + param.setResult(networks.filterIsInstance<Network>().filter { network -> + val capabilities = instance.getNetworkCapabilities(network) ?: return@filter false + !capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) + }.toTypedArray()) + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/EndToEndEncryption.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/EndToEndEncryption.kt new file mode 100644 index 0000000000..93ec0a405a --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/EndToEndEncryption.kt @@ -0,0 +1,564 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +import android.annotation.SuppressLint +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.Shape +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.Text +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.data.MessageState +import me.rhunk.snapenhance.common.data.MessagingRuleType +import me.rhunk.snapenhance.common.data.RuleState +import me.rhunk.snapenhance.common.database.impl.ConversationMessage +import me.rhunk.snapenhance.common.ui.createComposeView +import me.rhunk.snapenhance.common.util.lazyBridge +import me.rhunk.snapenhance.common.util.protobuf.ProtoEditor +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.common.util.protobuf.ProtoWriter +import me.rhunk.snapenhance.core.event.events.impl.* +import me.rhunk.snapenhance.core.features.MessagingRuleFeature +import me.rhunk.snapenhance.core.features.impl.ui.ConversationToolbox +import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper +import me.rhunk.snapenhance.core.ui.addForegroundDrawable +import me.rhunk.snapenhance.core.ui.findParent +import me.rhunk.snapenhance.core.ui.removeForegroundDrawable +import me.rhunk.snapenhance.core.util.EvictingMap +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.util.ktx.getObjectFieldOrNull +import me.rhunk.snapenhance.core.wrapper.impl.MessageContent +import me.rhunk.snapenhance.core.wrapper.impl.MessageDestinations +import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID +import me.rhunk.snapenhance.mapper.impl.CallbackMapper +import me.rhunk.snapenhance.nativelib.NativeLib +import java.security.MessageDigest +import kotlin.random.Random + +class EndToEndEncryption : MessagingRuleFeature( + "EndToEndEncryption", + MessagingRuleType.E2E_ENCRYPTION +) { + val isEnabled get() = context.config.experimental.e2eEncryption.globalState == true + private val e2eeInterface by lazyBridge { context.bridgeClient.getE2eeInterface() } + + private val translation by lazy { context.translation.getCategory("end_to_end_encryption") } + + companion object { + const val REQUEST_PK_MESSAGE_ID = 1 + const val RESPONSE_SK_MESSAGE_ID = 2 + const val ENCRYPTED_MESSAGE_ID = 3 + } + + private val decryptedMessageCache = EvictingMap<Long, Pair<ContentType, ByteArray>>(100) + + private val pkRequests = mutableMapOf<Long, ByteArray>() + private val secretResponses = mutableMapOf<Long, ByteArray>() + private val encryptedMessages = mutableListOf<Long>() + + private fun getE2EParticipants(conversationId: String): List<String> { + return context.database.getConversationParticipants(conversationId)?.filter { friendId -> + friendId != context.database.myUserId && e2eeInterface.friendKeyExists(friendId) + } ?: emptyList() + } + + private fun askForKeys(conversationId: String) { + val friendId = context.database.getDMOtherParticipant(conversationId) ?: run { + context.longToast("Can't find friendId for conversationId $conversationId") + return + } + + val publicKey = e2eeInterface.createKeyExchange(friendId) ?: run { + context.longToast("Can't create key exchange for friendId $friendId") + return + } + + sendCustomMessage(conversationId, REQUEST_PK_MESSAGE_ID) { + addBuffer(2, publicKey) + } + } + + private fun sendCustomMessage(conversationId: String, messageId: Int, message: ProtoWriter.() -> Unit) { + context.messageSender.sendCustomChatMessage( + listOf(SnapUUID(conversationId)), + ContentType.CHAT, + message = { + from(2) { + from(1) { + addVarInt(1, messageId) + addBuffer(2, ProtoWriter().apply(message).toByteArray()) + } + } + } + ) + } + + private fun warnKeyOverwrite(friendId: String, block: () -> Unit) { + if (!e2eeInterface.friendKeyExists(friendId)) { + block() + return + } + + context.mainActivity?.runOnUiThread { + val mainActivity = context.mainActivity ?: return@runOnUiThread + val translation = translation.getCategory("confirmation_dialogs") + ViewAppearanceHelper.newAlertDialogBuilder(mainActivity).apply { + setTitle(translation["title"]) + setMessage(translation["confirmation_1"]) + setPositiveButton(this@EndToEndEncryption.context.translation["button.positive"]) { _, _ -> + ViewAppearanceHelper.newAlertDialogBuilder(mainActivity).apply { + setTitle(translation["title"]) + setMessage(translation["confirmation_2"]) + setNeutralButton(this@EndToEndEncryption.context.translation["button.positive"]) { _, _ -> block() } + setPositiveButton(this@EndToEndEncryption.context.translation["button.negative"]) { _, _ -> } + }.show() + } + setNegativeButton(this@EndToEndEncryption.context.translation["button.negative"]) { _, _ -> } + }.show() + } + } + + private fun handlePublicKeyRequest(conversationId: String, publicKey: ByteArray) { + val friendId = context.database.getDMOtherParticipant(conversationId) ?: run { + context.longToast("Can't find friendId for conversationId $conversationId") + return + } + warnKeyOverwrite(friendId) { + val encapsulatedSecret = e2eeInterface.acceptPairingRequest(friendId, publicKey) + if (encapsulatedSecret == null) { + context.longToast(translation["accept_public_key_failure_toast"]) + return@warnKeyOverwrite + } + setState(conversationId, true) + context.longToast(translation["accept_public_key_success_toast"]) + + sendCustomMessage(conversationId, RESPONSE_SK_MESSAGE_ID) { + addBuffer(2, encapsulatedSecret) + } + } + } + + private fun handleSecretResponse(conversationId: String, secret: ByteArray) { + val friendId = context.database.getDMOtherParticipant(conversationId) ?: run { + context.longToast("Can't find friendId for conversationId $conversationId") + return + } + warnKeyOverwrite(friendId) { + val result = e2eeInterface.acceptPairingResponse(friendId, secret) + if (!result) { + context.longToast(translation["accept_secret_key_failure_toast"]) + return@warnKeyOverwrite + } + setState(conversationId, true) + context.longToast(translation["accept_secret_key_success_toast"]) + } + } + + @SuppressLint("SetTextI18n", "DiscouragedApi") + override fun init() { + if (!isEnabled) return + + context.mappings.useMapper(CallbackMapper::class) { + callbacks.getClass("ConversationManagerDelegate")?.hook("onSendComplete", HookStage.BEFORE) { param -> + val sendMessageResult = param.arg<Any>(0) + val messageDestinations = MessageDestinations(sendMessageResult.getObjectField("mCompletedDestinations") ?: return@hook) + if (messageDestinations.mPhoneNumbers?.isNotEmpty() == true || messageDestinations.stories?.isNotEmpty() == true) return@hook + + val completedConversationDestinations = sendMessageResult.getObjectField("mCompletedConversationDestinations") as? ArrayList<*> ?: return@hook + val messageIds = completedConversationDestinations.filter { getState(SnapUUID(it.getObjectField("mConversationId")).toString()) }.mapNotNull { + it.getObjectFieldOrNull("mMessageId") as? Long + } + + encryptedMessages.addAll(messageIds) + } + } + + context.event.subscribe(BuildMessageEvent::class, priority = 0) { event -> + val message = event.message + val conversationId = message.messageDescriptor!!.conversationId.toString() + val isMessageCommitted = message.messageState == MessageState.COMMITTED + messageHook( + conversationId = conversationId, + messageId = message.messageDescriptor!!.messageId!!, + senderId = message.senderId.toString(), + messageContent = message.messageContent!!, + committed = isMessageCommitted + ) + + message.messageContent!!.instanceNonNull() + .getObjectField("mQuotedMessage") + ?.getObjectField("mContent") + ?.also { quotedMessage -> + messageHook( + conversationId = conversationId, + messageId = quotedMessage.getObjectField("mMessageId")?.toString()?.toLong() ?: return@also, + senderId = SnapUUID(quotedMessage.getObjectField("mSenderId")).toString(), + messageContent = MessageContent(quotedMessage), + committed = isMessageCommitted + ) + } + } + + onNextActivityCreate(defer = true) { + context.feature(ConversationToolbox::class).addComposable(translation["confirmation_dialogs.title"], filter = { + context.database.getDMOtherParticipant(it) != null + }) { dialog, conversationId -> + val friendId = remember { + context.database.getDMOtherParticipant(conversationId) + } ?: return@addComposable + val fingerprint = remember { + runCatching { + e2eeInterface.getSecretFingerprint(friendId) + }.getOrNull() + } + if (fingerprint != null) { + Text(translation.format("toolbox.shared_key_fingerprint", "fingerprint" to fingerprint)) + } else { + Text(translation["toolbox.no_shared_key"]) + } + Spacer(modifier = Modifier.height(10.dp)) + Button(onClick = { + dialog.dismiss() + warnKeyOverwrite(friendId) { + askForKeys(conversationId) + } + }) { + Text(translation["toolbox.initiate_exchange_button"]) + } + } + + val encryptedMessageIndicator by context.config.experimental.e2eEncryption.encryptedMessageIndicator + + val specialCard = Random.nextLong().toString(16) + + context.event.subscribe(BindViewEvent::class) { event -> + event.chatMessage { conversationId, messageId -> + val viewGroup = event.view.findParent(maxIteration = 3) { + it is LinearLayout + } as? ViewGroup ?: event.view.parent as? ViewGroup ?: return@chatMessage + + viewGroup.findViewWithTag<View>(specialCard)?.also { + viewGroup.removeView(it) + } + + if (encryptedMessageIndicator) { + viewGroup.removeForegroundDrawable("encryptedMessage") + + if (encryptedMessages.contains(messageId.toLong())) { + viewGroup.addForegroundDrawable("encryptedMessage", ShapeDrawable(object: Shape() { + override fun draw(canvas: Canvas, paint: Paint) { + paint.textSize = 20f + canvas.drawText("\uD83D\uDD12", 0f, canvas.height / 2f, paint) + } + })) + } + } + + val secret = secretResponses[messageId.toLong()] + val publicKey = pkRequests[messageId.toLong()] + + if (publicKey != null || secret != null) { + viewGroup.addView(createComposeView(context.mainActivity!!) { + Card( + modifier = Modifier.fillMaxWidth().padding(8.dp), + onClick = { + if (publicKey != null) { + handlePublicKeyRequest(conversationId, publicKey) + } + if (secret != null) { + handleSecretResponse(conversationId, secret) + } + } + ) { + Box( + modifier = Modifier.fillMaxWidth().padding(5.dp), + contentAlignment = Alignment.Center + ) { + if (publicKey != null) { + Text(translation["accept_public_key_button"]) + } + if (secret != null) { + Text(translation["accept_secret_button"]) + } + } + } + }.apply { + tag = specialCard + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) + }) + } + } + } + } + + defer { + val forceMessageEncryption by context.config.experimental.e2eEncryption.forceMessageEncryption + + context.event.subscribe(MediaUploadEvent::class) { event -> + val e2eeConversations = event.destinations.getEndToEndConversations() + if (e2eeConversations.isEmpty()) return@subscribe + + if (event.destinations.conversations!!.size != e2eeConversations.size || event.destinations.stories?.isNotEmpty() == true) { + context.log.debug("skipping encryption") + return@subscribe + } + + event.onMediaUploaded { result -> + runCatching { + result.messageContent.content = ProtoWriter().apply { + writeEncryptedMessage(e2eeConversations.map { getE2EParticipants(it) }.flatten().distinct(), result.messageContent.content!!) + }.toByteArray() + }.onFailure { + context.log.error("Failed to encrypt message", it) + context.longToast(translation["encryption_failed_toast"]) + } + } + } + + // trick to disable fidelius encryption + context.event.subscribe(SendMessageWithContentEvent::class) { event -> + val messageContent = event.messageContent + val destinations = event.destinations + + val e2eeConversations = destinations.getEndToEndConversations().takeIf { it.isNotEmpty() } ?: return@subscribe + + if (e2eeConversations.size != destinations.conversations!!.size || destinations.stories?.isNotEmpty() == true) { + if (!forceMessageEncryption) return@subscribe + context.longToast(translation["unencrypted_conversation_send_failure_toast"]) + event.canceled = true + return@subscribe + } + + if (!NativeLib.initialized) { + context.longToast(translation["native_hooks_send_failure_toast"]) + event.canceled = true + return@subscribe + } + + event.addInvokeLater { + // check if the content is already encrypted + if (ProtoReader(messageContent.content!!).getByteArray(2, 1, 2) != null) { + return@addInvokeLater + } + if (event.messageContent.localMediaReferences?.isEmpty() == true) { + runCatching { + event.messageContent.content = ProtoWriter().apply { + writeEncryptedMessage(e2eeConversations.map { getE2EParticipants(it) }.flatten().distinct(), messageContent.content!!) + }.toByteArray() + }.onFailure { + context.log.error("Failed to encrypt message", it) + context.longToast(translation["encryption_failed_toast"]) + } + } + + if (event.messageContent.contentType == ContentType.SNAP) { + event.messageContent.contentType = ContentType.EXTERNAL_MEDIA + } + } + } + + context.event.subscribe(NativeUnaryCallEvent::class) { event -> + if (event.uri != "/messagingcoreservice.MessagingCoreService/CreateContentMessage") return@subscribe + val protoReader = ProtoReader(event.buffer) + val messageReader = protoReader.followPath(4) ?: return@subscribe + + if (messageReader.getVarInt(4, 2, 1, 5) == 1L) { + event.buffer = ProtoEditor(event.buffer).apply { + edit(4) { + remove(2) + addVarInt(2, ContentType.SNAP.id) + context.log.verbose("fixed snap content type") + } + }.toByteArray() + } + } + } + } + + private fun fixContentType(contentType: ContentType?, message: ProtoReader) + = ContentType.fromMessageContainer(message) ?: contentType + + private fun hashParticipantId(participantId: String, salt: ByteArray): ByteArray { + return MessageDigest.getInstance("SHA-256").apply { + update(participantId.toByteArray()) + update(salt) + }.digest() + } + + fun decryptDatabaseMessage(conversationMessage: ConversationMessage): ProtoReader { + return tryDecryptMessage( + senderId = conversationMessage.senderId!!, + clientMessageId = conversationMessage.clientMessageId.toLong(), + conversationId = conversationMessage.clientConversationId!!, + contentType = ContentType.fromId(conversationMessage.contentType), + messageBuffer = ProtoReader(conversationMessage.messageContent!!).getByteArray(4, 4)!! + ).let { (_, buffer) -> + ProtoReader(buffer) + } + } + + private fun tryDecryptMessage(senderId: String, clientMessageId: Long, conversationId: String, contentType: ContentType, messageBuffer: ByteArray): Pair<ContentType, ByteArray> { + if (contentType != ContentType.STATUS && decryptedMessageCache.containsKey(clientMessageId)) { + return decryptedMessageCache[clientMessageId]!! + } + + val reader = ProtoReader(messageBuffer) + var outputBuffer = messageBuffer + var outputContentType = fixContentType(contentType, reader) ?: contentType + val conversationParticipants by lazy { + getE2EParticipants(conversationId) + } + + fun setDecryptedMessage(buffer: ByteArray) { + outputBuffer = buffer + outputContentType = fixContentType(outputContentType, ProtoReader(buffer)) ?: outputContentType + decryptedMessageCache[clientMessageId] = outputContentType to buffer + encryptedMessages.add(clientMessageId) + } + + fun setWarningMessage() { + encryptedMessages.add(clientMessageId) + outputContentType = ContentType.CHAT + outputBuffer = ProtoWriter().apply { + from(2) { + addString(1, "Failed to decrypt message, id=$clientMessageId. Check logs for more details.") + } + }.toByteArray() + } + + fun replaceMessageText(text: String) { + outputBuffer = ProtoWriter().apply { + from(2) { + addString(1, text) + } + }.toByteArray() + } + + // decrypt messages + reader.followPath(2, 1) { + val messageTypeId = getVarInt(1)?.toInt() ?: return@followPath + val isMe = context.database.myUserId == senderId + + if (messageTypeId == ENCRYPTED_MESSAGE_ID) { + runCatching { + eachBuffer(2) { + if (decryptedMessageCache.containsKey(clientMessageId)) return@eachBuffer + + val participantIdHash = getByteArray(1) ?: return@eachBuffer + val iv = getByteArray(2) ?: return@eachBuffer + val ciphertext = getByteArray(3) ?: return@eachBuffer + + if (isMe) { + if (conversationParticipants.isEmpty()) return@eachBuffer + val participantId = conversationParticipants.firstOrNull { participantIdHash.contentEquals(hashParticipantId(it, iv)) } ?: return@eachBuffer + setDecryptedMessage(e2eeInterface.decryptMessage(participantId, ciphertext, iv) ?: run { + context.log.warn("Failed to decrypt message for participant $participantId") + setWarningMessage() + return@eachBuffer + }) + return@eachBuffer + } + + if (!participantIdHash.contentEquals(hashParticipantId(context.database.myUserId, iv))) return@eachBuffer + + setDecryptedMessage(e2eeInterface.decryptMessage(senderId, ciphertext, iv)?: run { + setWarningMessage() + return@eachBuffer + }) + } + }.onFailure { + context.log.error("Failed to decrypt message id: $clientMessageId", it) + setWarningMessage() + } + + return@followPath + } + + val payload = getByteArray(2, 2) ?: return@followPath + + if (senderId == context.database.myUserId) { + when (messageTypeId) { + REQUEST_PK_MESSAGE_ID -> { + replaceMessageText("[${translation["outgoing_pk_message"]}]") + } + RESPONSE_SK_MESSAGE_ID -> { + replaceMessageText("[${translation["outgoing_secret_message"]}]") + } + } + return@followPath + } + + when (messageTypeId) { + REQUEST_PK_MESSAGE_ID -> { + pkRequests[clientMessageId] = payload + replaceMessageText(translation["incoming_pk_message"]) + } + RESPONSE_SK_MESSAGE_ID -> { + secretResponses[clientMessageId] = payload + replaceMessageText(translation["incoming_secret_message"]) + } + } + } + + return outputContentType to outputBuffer + } + + private fun messageHook(conversationId: String, messageId: Long, senderId: String, messageContent: MessageContent, committed: Boolean) { + val (contentType, buffer) = tryDecryptMessage(senderId, messageId, conversationId, messageContent.contentType ?: ContentType.CHAT, messageContent.content!!) + messageContent.contentType = contentType + messageContent.content = buffer + // remove messages currently being sent from the cache + if (!committed) { + decryptedMessageCache.remove(messageId) + encryptedMessages.remove(messageId) + } + } + + private fun ProtoWriter.writeEncryptedMessage( + participantsIds: List<String>, + messageContent: ByteArray, + ) { + from(2) { + from(1) { + addVarInt(1, ENCRYPTED_MESSAGE_ID) + participantsIds.forEach { participantId -> + val encryptedMessage = e2eeInterface.encryptMessage(participantId, + messageContent + ) ?: run { + throw Exception("Failed to encrypt message for participant $participantId") + } + context.log.debug("encrypted message size = ${encryptedMessage.ciphertext.size}") + from(2) { + // participantId is hashed with iv to prevent leaking it when sending to multiple conversations + addBuffer(1, hashParticipantId(participantId, encryptedMessage.iv)) + addBuffer(2, encryptedMessage.iv) + addBuffer(3, encryptedMessage.ciphertext) + } + } + if (ContentType.fromMessageContainer(ProtoReader(messageContent)) == ContentType.SNAP) { + addVarInt(5, 1) + } + } + } + } + + private fun MessageDestinations.getEndToEndConversations(): List<String> { + return conversations!!.filter { getState(it.toString()) && getE2EParticipants(it.toString()).isNotEmpty() }.map { it.toString() } + } + + override fun getRuleState() = RuleState.WHITELIST +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/InfiniteStoryBoost.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/InfiniteStoryBoost.kt new file mode 100644 index 0000000000..7e6450055f --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/InfiniteStoryBoost.kt @@ -0,0 +1,25 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hookConstructor +import me.rhunk.snapenhance.mapper.impl.StoryBoostStateMapper + +class InfiniteStoryBoost : Feature("InfiniteStoryBoost") { + override fun init() { + if (!context.config.experimental.infiniteStoryBoost.get()) return + + onNextActivityCreate(defer = true) { + context.mappings.useMapper(StoryBoostStateMapper::class) { + classReference.get()?.hookConstructor(HookStage.BEFORE) { param -> + val startTimeMillis = param.arg<Long>(1) + //reset timestamp if it's more than 24 hours + if (System.currentTimeMillis() - startTimeMillis > 86400000) { + param.setArg(1, 0) + param.setArg(2, 0) + } + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/MediaFilePicker.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/MediaFilePicker.kt new file mode 100644 index 0000000000..4df7152c97 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/MediaFilePicker.kt @@ -0,0 +1,238 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.ContentResolver +import android.content.Intent +import android.database.Cursor +import android.database.CursorWrapper +import android.media.MediaPlayer +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.provider.MediaStore +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircleOutline +import androidx.compose.material.icons.filled.Crop +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Upload +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.ui.Modifier +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.common.ui.createComposeView +import me.rhunk.snapenhance.common.util.ktx.getLongOrNull +import me.rhunk.snapenhance.common.util.ktx.getTypeArguments +import me.rhunk.snapenhance.core.event.events.impl.ActivityResultEvent +import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper +import me.rhunk.snapenhance.core.util.dataBuilder +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import java.io.InputStream +import java.lang.reflect.Method +import kotlin.random.Random + +class MediaFilePicker : Feature("Media File Picker") { + var lastMediaDuration: Long? = null + private set + + @SuppressLint("Recycle") + override fun init() { + if (!context.config.experimental.mediaFilePicker.get()) return + + onNextActivityCreate(defer = true) { + lateinit var chatMediaDrawerActionHandler: Any + lateinit var sendItemsMethod: Method + + findClass("com.snap.composer.memories.ChatMediaDrawer").genericSuperclass?.getTypeArguments()?.getOrNull(1)?.apply { + methods.first { + it.parameterTypes.size == 1 && it.parameterTypes[0].name.endsWith("ChatMediaDrawerActionHandler") + }.also { method -> + sendItemsMethod = method.parameterTypes[0].methods.first { it.name == "sendItems" } + }.hook(HookStage.AFTER) { + chatMediaDrawerActionHandler = it.arg(0) + } + } + + var requestCode: Int? = null + var firstVideoId: Long? = null + var mediaInputStream: InputStream? = null + + ContentResolver::class.java.apply { + hook("query", HookStage.AFTER) { param -> + val uri = param.arg<Uri>(0) + if (!uri.toString().endsWith(firstVideoId.toString())) return@hook + + param.setResult(object: CursorWrapper(param.getResult() as Cursor) { + override fun getLong(columnIndex: Int): Long { + if (getColumnName(columnIndex) == "duration") { + return lastMediaDuration ?: -1 + } + return super.getLong(columnIndex) + } + }) + } + hook("openInputStream", HookStage.BEFORE) { param -> + val uri = param.arg<Uri>(0) + if (uri.toString().endsWith(firstVideoId.toString())) { + param.setResult(mediaInputStream) + mediaInputStream = null + } + } + } + + context.event.subscribe(ActivityResultEvent::class) { event -> + if (event.requestCode != requestCode || event.resultCode != Activity.RESULT_OK) return@subscribe + requestCode = null + + firstVideoId = context.androidContext.contentResolver.query( + MediaStore.Video.Media.EXTERNAL_CONTENT_URI, + arrayOf(MediaStore.Video.Media._ID), + null, + null, + "${MediaStore.Video.Media.DATE_TAKEN} DESC" + )?.use { cursor -> + if (cursor.moveToFirst()) { + cursor.getLongOrNull("_id") + } else { + null + } + } + + if (firstVideoId == null) { + context.inAppOverlay.showStatusToast( + Icons.Default.Upload, + "Must have a video in gallery to upload." + ) + return@subscribe + } + + fun sendMedia() { + sendItemsMethod.invoke(chatMediaDrawerActionHandler, listOf<Any>(), listOf( + sendItemsMethod.genericParameterTypes[1].getTypeArguments().first().dataBuilder { + from("_item") { + set("_cameraRollSource", "Snapchat") + set("_contentUri", "") + set("_durationMs", 0.0) + set("_disabled", false) + set("_imageRotation", 0.0) + set("_width", 1080.0) + set("_height", 1920.0) + set("_timestampMs", System.currentTimeMillis().toDouble()) + from("_itemId") { + set("_itemId", firstVideoId.toString()) + set("_type", "VIDEO") + } + } + set("_order", 0.0) + } + )) + } + + fun startConversion(audioOnly: Boolean) { + context.coroutineScope.launch { + lastMediaDuration = MediaPlayer().run { + setDataSource(context.androidContext, event.intent.data!!) + prepare() + duration.toLong().also { + release() + } + } + + context.inAppOverlay.showStatusToast(Icons.Default.Crop, "Converting media...", durationMs = 3000) + val pfd = context.bridgeClient.convertMedia( + context.androidContext.contentResolver.openFileDescriptor(event.intent.data!!, "r")!!, + "m4a", + "m4a", + "aac", + if (!audioOnly) "libx264" else null + ) + + if (pfd == null) { + context.inAppOverlay.showStatusToast(Icons.Default.Error, "Failed to convert media.") + return@launch + } + + context.inAppOverlay.showStatusToast(Icons.Default.CheckCircleOutline, "Media converted successfully.") + + runCatching { + mediaInputStream = ParcelFileDescriptor.AutoCloseInputStream(pfd) + sendMedia() + }.onFailure { + mediaInputStream = null + context.log.error(it) + context.inAppOverlay.showStatusToast(Icons.Default.Error, "Failed to send media.") + } + } + } + + val isAudio = context.androidContext.contentResolver.getType(event.intent.data!!)!!.startsWith("audio/") + + if (isAudio || context.config.messaging.galleryMediaSendOverride.getNullable() == null) { + startConversion(isAudio) + return@subscribe + } + + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!) + .setTitle("Convert video file") + .setItems(arrayOf("Send as video/audio", "Send as audio only")) { _, which -> + startConversion(which == 1) + } + .setNegativeButton("Cancel") { dialog, _ -> dialog.dismiss() }.show() + } + + val buttonTag = Random.nextInt(0, 65535) + + context.event.subscribe(AddViewEvent::class) { event -> + if (event.parent !is FrameLayout || !event.view::class.java.name.endsWith("ChatMediaDrawer")) return@subscribe + + event.view.addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + if (event.parent.findViewWithTag<View>(buttonTag)?.run { + visibility = View.VISIBLE + bringToFront() + } != null) return + event.parent.addView( + createComposeView(context.mainActivity!!) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + FilledIconButton(onClick = { + requestCode = Random.nextInt(0, 65535) + this@MediaFilePicker.context.mainActivity!!.startActivityForResult( + Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "video/*" + putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("video/*", "audio/*")) + }, + requestCode!! + ) + }) { + Icon(Icons.Default.Upload, "Upload media") + } + } + }.apply { + tag = buttonTag + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + ) + } + override fun onViewDetachedFromWindow(v: View) { + event.parent.findViewWithTag<View>(buttonTag)?.visibility = View.GONE + } + }) + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/MeoPasscodeBypass.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/MeoPasscodeBypass.kt new file mode 100644 index 0000000000..9ea6024e9a --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/MeoPasscodeBypass.kt @@ -0,0 +1,24 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.mapper.impl.BCryptClassMapper + +class MeoPasscodeBypass : Feature("Meo Passcode Bypass") { + override fun init() { + if (!context.config.experimental.meoPasscodeBypass.get()) return + + onNextActivityCreate(defer = true) { + context.mappings.useMapper(BCryptClassMapper::class) { + classReference.get()?.hook( + hashMethod.get()!!, + HookStage.BEFORE, + ) { param -> + //set the hash to the result of the method + param.setResult(param.arg(1)) + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/NoFriendScoreDelay.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/NoFriendScoreDelay.kt new file mode 100644 index 0000000000..26c7315426 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/NoFriendScoreDelay.kt @@ -0,0 +1,27 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hookConstructor +import me.rhunk.snapenhance.mapper.impl.ScoreUpdateMapper +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.minutes + +class NoFriendScoreDelay : Feature("NoFriendScoreDelay") { + override fun init() { + if (!context.config.experimental.noFriendScoreDelay.get()) return + + onNextActivityCreate { + context.mappings.useMapper(ScoreUpdateMapper::class) { + classReference.get()?.hookConstructor(HookStage.BEFORE) { param -> + param.args().indexOfFirst { + val longValue = it.toString().toLongOrNull() ?: return@indexOfFirst false + longValue > 30.minutes.inWholeMilliseconds && longValue < 10.days.inWholeMilliseconds + }.takeIf { it != -1 }?.let { index -> + param.setArg(index, 0) + } + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/PreventForcedLogout.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/PreventForcedLogout.kt new file mode 100644 index 0000000000..213b97863d --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/PreventForcedLogout.kt @@ -0,0 +1,18 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +import android.content.Intent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook + +class PreventForcedLogout : Feature("Prevent Forced Logout") { + override fun init() { + if (!context.config.experimental.preventForcedLogout.get()) return + findClass("com.snap.identity.service.ForcedLogoutBroadcastReceiver").hook("onReceive", HookStage.BEFORE) { param -> + val intent = param.arg<Intent>(1) + if (!intent.getBooleanExtra("forced", false)) return@hook + context.log.verbose("Prevent forced logout, reason=${intent.getStringExtra("reason")}") + param.setResult(null) + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/SnapScoreChanges.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/SnapScoreChanges.kt new file mode 100644 index 0000000000..abe4a94e93 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/SnapScoreChanges.kt @@ -0,0 +1,71 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +import android.view.ViewGroup +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent +import me.rhunk.snapenhance.core.event.events.impl.UnaryCallEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.ui.getComposerContext +import me.rhunk.snapenhance.core.ui.getComposerViewNode +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID + +class SnapScoreChanges: Feature("Snap Score Changes") { + private val scores = mutableMapOf<String, Long>() + private var lastViewedUserId: String? = null + + override fun init() { + if (!context.config.experimental.snapScoreChanges.get()) return + + context.event.subscribe(UnaryCallEvent::class) { event -> + if (event.uri != "/com.snapchat.atlas.gw.AtlasGw/GetFriendsUserScore") return@subscribe + + event.addResponseCallback { + synchronized(scores) { + ProtoReader(buffer).eachBuffer(1) { + val friendUUID = getByteArray(1) ?: return@eachBuffer + val score = getVarInt(2) ?: return@eachBuffer + + scores[SnapUUID(friendUUID).toString()] = score + } + } + } + } + + context.event.subscribe(AddViewEvent::class) { event -> + if (event.viewClassName.endsWith("UnifiedProfileFlatlandProfileViewTopViewFrameLayout")) { + val composerView = (event.view as ViewGroup).getChildAt(0) ?: return@subscribe + val composerContext = composerView.getComposerContext() ?: return@subscribe + + lastViewedUserId = composerContext.viewModel?.getObjectField("_userId")?.toString() + } + + if (event.viewClassName.endsWith("ProfileFlatlandFriendSnapScoreIdentityPillDialogView")) { + event.view.post { + event.view.getComposerContext()!!.enqueueNextRenderCallback { + val composerViewNode = event.view.getComposerViewNode() ?: return@enqueueNextRenderCallback + val surface = composerViewNode.getChildren().getOrNull(1) ?: return@enqueueNextRenderCallback + + val snapTextView = surface.getChildren().lastOrNull { + it.getClassName() == "com.snap.composer.views.ComposerSnapTextView" + } ?: return@enqueueNextRenderCallback + + + val currentFriendScore = scores[lastViewedUserId] ?: (event.view.getComposerContext()?.viewModel?.getObjectField("_friendSnapScore") as? Double)?.toLong() ?: return@enqueueNextRenderCallback + + val oldSnapScore = context.bridgeClient.getTracker().updateFriendScore( + lastViewedUserId ?: return@enqueueNextRenderCallback, + currentFriendScore + ) + + val diff = currentFriendScore - oldSnapScore + + snapTextView.setAttribute("value", "${if (oldSnapScore != -1L && diff > 0) "\uD83D\uDCC8 +$diff !\n\n" else ""}Last Checked Score: ${oldSnapScore.takeIf { it != -1L } ?: "N/A"}") + event.view.postInvalidate() + } + event.view.postInvalidate() + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/BypassVideoLengthRestriction.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/BypassVideoLengthRestriction.kt new file mode 100644 index 0000000000..0c5ec312db --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/BypassVideoLengthRestriction.kt @@ -0,0 +1,94 @@ +package me.rhunk.snapenhance.core.features.impl.global + +import android.os.Build +import android.os.FileObserver +import com.google.gson.JsonParser +import me.rhunk.snapenhance.core.event.events.impl.SendMessageWithContentEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.dataBuilder +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hookConstructor +import me.rhunk.snapenhance.core.util.ktx.setObjectField +import me.rhunk.snapenhance.mapper.impl.DefaultMediaItemMapper +import java.io.File + +class BypassVideoLengthRestriction : + Feature("BypassVideoLengthRestriction") { + private lateinit var fileObserver: FileObserver + + override fun init() { + onNextActivityCreate(defer = true) { + val mode = context.config.global.bypassVideoLengthRestriction.getNullable() + + if (mode == "single") { + //fix black videos when story is posted + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val postedStorySnapFolder = + File(context.androidContext.filesDir, "file_manager/posted_story_snap") + + fileObserver = (object : FileObserver(postedStorySnapFolder, MOVED_TO) { + override fun onEvent(event: Int, path: String?) { + if (event != MOVED_TO || path?.contains("posted_story_snap.") != true) return + + runCatching { + val file = File(postedStorySnapFolder, path) + file.bufferedReader().use { bufferedReader -> + bufferedReader.mark(1) + if (bufferedReader.read() != 123) { + context.log.verbose("Ignoring non-JSON file: $path") + return@use + } + bufferedReader.reset() + val fileContent = JsonParser.parseReader(bufferedReader).asJsonObject + if ((fileContent["timerOrDuration"]?.also { + fileObserver.stopWatching() + }?.takeIf { !it.isJsonNull }?.asLong ?: 1) <= 0) { + context.log.verbose("Deleting $path") + file.delete() + } + } + + }.onFailure { + context.log.error("Failed to read story metadata file", it) + } + } + }) + + context.event.subscribe(SendMessageWithContentEvent::class) { event -> + if (event.destinations.stories!!.isEmpty()) return@subscribe + fileObserver.startWatching() + } + } + + context.mappings.useMapper(DefaultMediaItemMapper::class) { + defaultMediaItemClass.getAsClass()?.hookConstructor(HookStage.AFTER) { param -> + //set the video length argument + param.thisObject<Any>().dataBuilder { + set(defaultMediaItemDurationMsField.getAsString()!!, -1L) + } + } + } + } + + //TODO: allow split from any source + if (mode == "split") { + // memories grid + context.mappings.useMapper(DefaultMediaItemMapper::class) { + cameraRollMediaId.getAsClass()?.hookConstructor(HookStage.AFTER) { param -> + //set the durationMs field + param.thisObject<Any>() + .setObjectField(durationMsField.get()!!, -1L) + } + } + + // chat camera roll grid + findClass("com.snap.composer.memories.MemoriesPickerVideoDurationConfig").hookConstructor(HookStage.AFTER) { param -> + param.thisObject<Any>().apply { + setObjectField("_maxSingleItemDurationMs", null) + setObjectField("_maxTotalDurationMs", null) + } + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/DisableCustomTabs.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/DisableCustomTabs.kt new file mode 100644 index 0000000000..0c78a637e8 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/DisableCustomTabs.kt @@ -0,0 +1,20 @@ +package me.rhunk.snapenhance.core.features.impl.global + +import android.content.Intent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook + +class DisableCustomTabs: Feature("Disable Custom Tabs") { + override fun init() { + if (!context.config.global.disableCustomTabs.get()) return + onNextActivityCreate { activity -> + activity.packageManager.javaClass.hook("resolveService", HookStage.BEFORE) { param -> + val intent = param.arg<Intent>(0) + if (intent.action == "android.support.customtabs.action.CustomTabsService") { + param.setResult(null) + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/DisableMemoriesSnapFeed.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/DisableMemoriesSnapFeed.kt new file mode 100644 index 0000000000..c0d670a6fb --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/DisableMemoriesSnapFeed.kt @@ -0,0 +1,27 @@ +package me.rhunk.snapenhance.core.features.impl.global + +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.mapper.impl.MemoriesPresenterMapper + +class DisableMemoriesSnapFeed : Feature("Disable Memories Snap Feed") { + override fun init() { + if (!context.config.global.disableMemoriesSnapFeed.get()) return + onNextActivityCreate { + context.mappings.useMapper(MemoriesPresenterMapper::class) { + classReference.get()?.apply { + val getNameMethod = getMethod("getName") ?: return@apply + + hook(onNavigationEventMethod.get()!!, HookStage.BEFORE) { param -> + val instance = param.thisObject<Any>() + + if (getNameMethod.invoke(instance) == "MemoriesAsyncPresenterFragmentSubscriber") { + param.setResult(null) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/DisableMetrics.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/DisableMetrics.kt new file mode 100644 index 0000000000..253bba99bf --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/DisableMetrics.kt @@ -0,0 +1,29 @@ +package me.rhunk.snapenhance.core.features.impl.global + +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent +import me.rhunk.snapenhance.core.event.events.impl.UnaryCallEvent +import me.rhunk.snapenhance.core.features.Feature + +class DisableMetrics : Feature("DisableMetrics") { + override fun init() { + if (!context.config.global.disableMetrics.get()) return + + context.event.subscribe(NetworkApiRequestEvent::class) { param -> + val url = param.url + if (url.contains("app-analytics") || url.endsWith("metrics") || url.contains("streaming-collector")) { + param.canceled = true + } + } + + context.event.subscribe(UnaryCallEvent::class) { event -> + if (event.uri.startsWith("/snap.security.IntegritySyncService/")) { + event.canceled = true + } + if (event.uri.startsWith("/snapchat.cdp.cof.CircumstancesService/")) { + if (ProtoReader(event.buffer).getVarInt(21) == 1L) return@subscribe + event.canceled = true + } + } + } +} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/DisableTelecomFramework.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/DisableTelecomFramework.kt new file mode 100644 index 0000000000..aa22e58fa2 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/DisableTelecomFramework.kt @@ -0,0 +1,16 @@ +package me.rhunk.snapenhance.core.features.impl.global + +import android.content.ContextWrapper +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook + +class DisableTelecomFramework: Feature("Disable Telecom Framework") { + override fun init() { + if (!context.config.global.disableTelecomFramework.get()) return + + ContextWrapper::class.java.hook("getSystemService", HookStage.BEFORE) { param -> + if (param.arg<Any>(0).toString() == "telecom") param.setResult(null) + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/GooglePlayServicesDialogs.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/GooglePlayServicesDialogs.kt new file mode 100644 index 0000000000..13890b22cd --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/GooglePlayServicesDialogs.kt @@ -0,0 +1,23 @@ +package me.rhunk.snapenhance.core.features.impl.global + +import android.app.AlertDialog +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import java.lang.reflect.Modifier + +class GooglePlayServicesDialogs : Feature("Disable GMS Dialogs") { + override fun init() { + if (!context.config.global.disableGooglePlayDialogs.get()) return + + onNextActivityCreate(defer = true) { + findClass("com.google.android.gms.common.GoogleApiAvailability").methods + .first { Modifier.isStatic(it.modifiers) && it.returnType == AlertDialog::class.java }.let { method -> + method.hook(HookStage.BEFORE) { param -> + context.log.verbose("GoogleApiAvailability.showErrorDialogFragment() called, returning null") + param.setResult(null) + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/MediaUploadQualityOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/MediaUploadQualityOverride.kt new file mode 100644 index 0000000000..20f58fcd2e --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/MediaUploadQualityOverride.kt @@ -0,0 +1,48 @@ +package me.rhunk.snapenhance.core.features.impl.global + +import android.graphics.Bitmap +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.mapper.impl.MediaQualityLevelProviderMapper +import java.lang.reflect.Method + +class MediaUploadQualityOverride : Feature("Media Upload Quality Override") { + override fun init() { + if (context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get()) { + context.mappings.useMapper(MediaQualityLevelProviderMapper::class) { + mediaQualityLevelProvider.getAsClass()?.hook( + mediaQualityLevelProviderMethod.getAsString()!!, + HookStage.BEFORE + ) { param -> + param.setResult((param.method() as Method).returnType.enumConstants!!.firstOrNull { it.toString() == "LEVEL_MAX" } ) + } + } + } + + val disableImageCompression by context.config.global.mediaUploadQualityConfig.disableImageCompression + val imageUploadFormat = context.config.global.mediaUploadQualityConfig.customUploadImageFormat.getNullable() + + if (imageUploadFormat != null || disableImageCompression) { + Bitmap::class.java.hook("compress", HookStage.BEFORE) { param -> + if (param.arg<Int>(1) == 0) return@hook + if (param.arg<Any>(0) == Bitmap.CompressFormat.JPEG) { + @Suppress("DEPRECATION") + param.setArg(0, when (imageUploadFormat) { + "png" -> Bitmap.CompressFormat.PNG + "webp" -> Bitmap.CompressFormat.WEBP + "jpeg" -> Bitmap.CompressFormat.JPEG + else -> Bitmap.CompressFormat.JPEG + }) + if (disableImageCompression) { + param.setArg(1, 100) + } + } + } + + findClass("com.snap.camera.jni.SnapImageTranscoder").hook("nativeEncodeBitmapToJpeg", HookStage.BEFORE) { + it.setResult(ByteArray(0)) + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/SnapchatPlus.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/SnapchatPlus.kt new file mode 100644 index 0000000000..5d0b1717ee --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/SnapchatPlus.kt @@ -0,0 +1,64 @@ +package me.rhunk.snapenhance.core.features.impl.global + +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.dataBuilder +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.hook.hookConstructor +import me.rhunk.snapenhance.mapper.impl.PlusSubscriptionMapper + +class SnapchatPlus: Feature("SnapchatPlus") { + private val originalSubscriptionTime = (System.currentTimeMillis() - 7776000000L) + private val expirationTimeMillis = (System.currentTimeMillis() + 15552000000L) + + override fun init() { + val snapchatPlusTier = context.config.global.snapchatPlus.getNullable() + + if (snapchatPlusTier != null) { + context.mappings.useMapper(PlusSubscriptionMapper::class) { + classReference.get()?.hookConstructor(HookStage.AFTER) { param -> + param.thisObject<Any>().dataBuilder { + //subscription tier + if (get<Any>(tierField.getAsString()!!)?.javaClass?.isEnum == true) { + set(tierField.getAsString()!!, when (snapchatPlusTier) { + "not_subscribed" -> "NO_ACCESS" + "basic" -> "SNAPCHAT_PLUS" + "ad_free" -> "SNAPCHAT_PLUS_AD_FREE" + else -> "SNAPCHAT_PLUS" + }) + } else { + set(tierField.getAsString()!!, when (snapchatPlusTier) { + "not_subscribed" -> 1 + "basic" -> 2 + "ad_free" -> 3 + else -> 2 + }) + } + + //subscription status + set(statusField.getAsString()!!, 2) + + set(originalSubscriptionTimeMillisField.getAsString()!!, originalSubscriptionTime) + set(expirationTimeMillisField.getAsString()!!, expirationTimeMillis) + } + } + } + } + + if (context.config.experimental.hiddenSnapchatPlusFeatures.get()) { + findClass("com.snap.plus.FeatureCatalog").methods.last { + !it.name.contains("init") && + it.parameterTypes.isNotEmpty() && + it.parameterTypes[0].name != "java.lang.Boolean" + }.hook(HookStage.BEFORE) { param -> + val instance = param.thisObject<Any>() + val firstArg = param.argNullable<Any>(0) ?: return@hook + + instance::class.java.declaredFields.filter { it.type == firstArg::class.java }.forEach { + it.isAccessible = true + it.set(instance, firstArg) + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/AutoMarkAsRead.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/AutoMarkAsRead.kt new file mode 100644 index 0000000000..6845a2e228 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/AutoMarkAsRead.kt @@ -0,0 +1,156 @@ +package me.rhunk.snapenhance.core.features.impl.messaging + +import android.widget.ProgressBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.WarningAmber +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.data.MessageUpdate +import me.rhunk.snapenhance.core.event.events.impl.OnSnapInteractionEvent +import me.rhunk.snapenhance.core.event.events.impl.SendMessageWithContentEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.impl.spying.StealthMode +import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper +import me.rhunk.snapenhance.core.util.CallbackBuilder +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.ktx.getObjectFieldOrNull +import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID +import me.rhunk.snapenhance.mapper.impl.CallbackMapper +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import kotlin.random.Random + +class AutoMarkAsRead : Feature("Auto Mark As Read") { + val canMarkConversationAsRead by lazy { context.config.messaging.autoMarkAsRead.get().contains("conversation_read") } + + fun markConversationsAsRead(conversationIds: List<String>) { + conversationIds.forEach { conversationId -> + val lastClientMessageId = context.database.getMessagesFromConversationId(conversationId, 1)?.firstOrNull()?.clientMessageId?.toLong() ?: Long.MAX_VALUE + context.feature(StealthMode::class).addDisplayedMessageException(lastClientMessageId) + context.feature(Messaging::class).conversationManager?.displayedMessages(conversationId, lastClientMessageId) { + if (it != null) { + context.log.warn("Failed to mark message $lastClientMessageId as read in conversation $conversationId") + } + } + } + } + + suspend fun markSnapAsSeen(conversationId: String, clientMessageId: Long): String? { + return suspendCoroutine { continuation -> + context.feature(Messaging::class).conversationManager?.updateMessage(conversationId, clientMessageId, MessageUpdate.READ) { + continuation.resume(it) + if (it != null && it != "DUPLICATEREQUEST") { + context.log.error("Error marking message as read $it") + } + } + } + } + + fun markSnapsAsSeen(conversationId: String) { + val messaging = context.feature(Messaging::class) + val messageIds = messaging.getFeedCachedMessageIds(conversationId)?.takeIf { it.isNotEmpty() } ?: run { + context.inAppOverlay.showStatusToast( + Icons.Default.WarningAmber, + context.translation["mark_as_seen.no_unseen_snaps_toast"] + ) + return + } + + var job: Job? = null + val dialog = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) + .setTitle("Processing...") + .setView(ProgressBar(context.mainActivity).apply { + setPadding(10, 10, 10, 10) + }) + .setOnDismissListener { job?.cancel() } + .show() + + context.coroutineScope.launch(Dispatchers.IO) { + messageIds.forEach { messageId -> + markSnapAsSeen(conversationId, messageId) + delay(Random.nextLong(20, 60)) + context.runOnUiThread { + dialog.setTitle("Processing... (${messageIds.indexOf(messageId) + 1}/${messageIds.size})") + } + } + }.also { job = it }.invokeOnCompletion { + context.runOnUiThread { + dialog.dismiss() + } + } + } + + override fun init() { + val config by context.config.messaging.autoMarkAsRead + if (config.isEmpty()) return + + if (config.contains("save_snap_in_chat")) { + var lastInteractedSnapClientMessageId = -1L + + context.event.subscribe(OnSnapInteractionEvent::class) { event -> + lastInteractedSnapClientMessageId = event.messageId + } + + context.classCache.conversationManager.hook("updateMessage", HookStage.BEFORE) { param -> + if (param.arg<Any>(2).toString() != "SAVE") return@hook + + val clientMessageId = param.arg<Long>(1) + if (lastInteractedSnapClientMessageId != clientMessageId) return@hook + + val conversationId = SnapUUID(param.arg(0)) + + param.setResult(null) + + val snapManager = context.feature(Messaging::class).snapManager ?: return@hook + val stealthMode = context.feature(StealthMode::class) + + // ignore non-stealth mode conversations + if (!stealthMode.canUseRule(conversationId.toString())) return@hook + + stealthMode.addSnapInteractionException(clientMessageId) + + val onSnapInteraction = snapManager.javaClass.methods.firstOrNull { it.name == "onSnapInteraction" } ?: return@hook + + context.mappings.useMapper(CallbackMapper::class) { + onSnapInteraction.invoke(snapManager, + findClass("com.snapchat.client.messaging.SnapInteractionType").enumConstants!!.first { it.toString() == "VIEWING_INITIATED" }, + conversationId.instanceNonNull(), + clientMessageId, + CallbackBuilder(callbacks.getClass("SnapInteractionCallback")!!) + .override("onSuccess") { + param.invokeOriginal() + stealthMode.addSnapInteractionException(clientMessageId) + } + .override("onError") { + context.log.verbose("error ${it.arg<Any>(0)}") + } + .build() + ) + } + } + } + + context.event.subscribe(SendMessageWithContentEvent::class) { event -> + event.addCallbackResult("onSuccess") { + if (canMarkConversationAsRead) { + markConversationsAsRead(event.destinations.conversations?.map { it.toString() } ?: return@addCallbackResult) + } + + if (config.contains("snap_reply")) { + val quotedMessageId = event.messageContent.instanceNonNull().getObjectFieldOrNull("mQuotedMessageId") as? Long ?: return@addCallbackResult + val message = context.database.getConversationMessageFromId(quotedMessageId) ?: return@addCallbackResult + + if (message.contentType == ContentType.SNAP.id) { + context.coroutineScope.launch { + markSnapAsSeen(event.destinations.conversations?.firstOrNull()?.toString() ?: return@launch, quotedMessageId) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/AutoSave.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/AutoSave.kt new file mode 100644 index 0000000000..9cbfc06c2a --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/AutoSave.kt @@ -0,0 +1,107 @@ +package me.rhunk.snapenhance.core.features.impl.messaging + +import me.rhunk.snapenhance.common.data.MessageState +import me.rhunk.snapenhance.common.data.MessageUpdate +import me.rhunk.snapenhance.common.data.MessagingRuleType +import me.rhunk.snapenhance.core.event.events.impl.ConversationUpdateEvent +import me.rhunk.snapenhance.core.features.MessagingRuleFeature +import me.rhunk.snapenhance.core.features.impl.spying.MessageLogger +import me.rhunk.snapenhance.core.features.impl.spying.StealthMode +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.wrapper.impl.Message +import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID +import me.rhunk.snapenhance.mapper.impl.CallbackMapper +import java.util.concurrent.Executors + +class AutoSave : MessagingRuleFeature("Auto Save", MessagingRuleType.AUTO_SAVE) { + private val asyncSaveExecutorService = Executors.newSingleThreadExecutor() + + private val messageLogger by lazy { context.feature(MessageLogger::class) } + + private val autoSaveFilter by lazy { + context.config.messaging.autoSaveMessagesInConversations.get() + } + + fun saveMessage(conversationId: String, message: Message) { + val messageId = message.messageDescriptor!!.messageId!! + if (messageLogger.takeIf { it.isEnabled }?.isMessageDeleted(conversationId, messageId) == true) return + + runCatching { + context.feature(Messaging::class).conversationManager?.updateMessage( + conversationId, + messageId, + MessageUpdate.SAVE + ) { + if (it != null) { + context.log.warn("Error saving message $messageId: $it") + } + } + }.onFailure { + context.log.error("Error saving message $messageId", it) + } + + //delay between saves + Thread.sleep(100L) + } + + fun canSaveMessage(message: Message, headless: Boolean = false): Boolean { + if (message.messageState != MessageState.COMMITTED || message.messageMetadata?.isSaveable != true) return false + + if (!headless && (context.mainActivity == null || context.isMainActivityPaused)) return false + if (message.messageMetadata!!.savedBy!!.any { uuid -> uuid.toString() == context.database.myUserId }) return false + val contentType = message.messageContent!!.contentType.toString() + + return autoSaveFilter.any { it == contentType } + } + + fun canSaveInConversation(targetConversationId: String, headless: Boolean = false): Boolean { + val messaging = context.feature(Messaging::class) + if (!headless) { + if (messaging.openedConversationUUID?.toString() != targetConversationId) return false + } + + if (context.feature(StealthMode::class).canUseRule(targetConversationId)) return false + + return canUseRule(targetConversationId) + } + + override fun init() { + onNextActivityCreate(defer = true) { + // called when enter in a conversation + context.mappings.useMapper(CallbackMapper::class) { + callbacks.getClass("FetchConversationWithMessagesCallback")?.hook( + "onFetchConversationWithMessagesComplete", + HookStage.BEFORE, + { autoSaveFilter.isNotEmpty() } + ) { param -> + val conversationId = SnapUUID(param.arg<Any>(0).getObjectField("mConversationId")!!) + if (!canSaveInConversation(conversationId.toString())) return@hook + + val messages = param.arg<List<Any>>(1).map { Message(it) } + messages.forEach { + if (!canSaveMessage(it)) return@forEach + asyncSaveExecutorService.submit { + saveMessage(conversationId.toString(), it) + } + } + } + } + + context.event.subscribe( + ConversationUpdateEvent::class, + { autoSaveFilter.isNotEmpty() } + ) { event -> + if (!canSaveInConversation(event.conversationId)) return@subscribe + + event.messages.forEach { message -> + if (!canSaveMessage(message)) return@forEach + asyncSaveExecutorService.submit { + saveMessage(event.conversationId, message) + } + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/BypassMessageActionRestrictions.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/BypassMessageActionRestrictions.kt new file mode 100644 index 0000000000..784c9ed18c --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/BypassMessageActionRestrictions.kt @@ -0,0 +1,18 @@ +package me.rhunk.snapenhance.core.features.impl.messaging + +import me.rhunk.snapenhance.core.event.events.impl.BuildMessageEvent +import me.rhunk.snapenhance.core.features.Feature + +class BypassMessageActionRestrictions : Feature("Bypass Message Action Restrictions") { + override fun init() { + if (!context.config.messaging.bypassMessageActionRestrictions.get()) return + onNextActivityCreate { + context.event.subscribe(BuildMessageEvent::class, priority = 102) { event -> + event.message.messageMetadata?.apply { + isSaveable = true + isReactable = true + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/CallButtonsOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/CallButtonsOverride.kt new file mode 100644 index 0000000000..1238d3eeb6 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/CallButtonsOverride.kt @@ -0,0 +1,90 @@ +package me.rhunk.snapenhance.core.features.impl.messaging + +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper +import me.rhunk.snapenhance.core.ui.children +import me.rhunk.snapenhance.core.ui.hideViewCompletely +import me.rhunk.snapenhance.core.util.hook.HookAdapter +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook + +class CallButtonsOverride : Feature("CallButtonsOverride") { + private fun hookTouchEvent(param: HookAdapter, motionEvent: MotionEvent, onConfirm: () -> Unit) { + if (motionEvent.action != MotionEvent.ACTION_UP) return + param.setResult(true) + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) + .setTitle(context.translation["call_start_confirmation.dialog_title"]) + .setMessage(context.translation["call_start_confirmation.dialog_message"]) + .setPositiveButton(context.translation["button.positive"]) { _, _ -> onConfirm() } + .setNeutralButton(context.translation["button.negative"]) { _, _ -> } + .show() + } + + override fun init() { + val hideUiComponents by context.config.userInterface.hideUiComponents + + val hideProfileCallButtons = hideUiComponents.contains("hide_profile_call_buttons") + val hideChatCallButtons = hideUiComponents.contains("hide_chat_call_buttons") + val callStartConfirmation = context.config.messaging.callStartConfirmation.get() + + if (!hideProfileCallButtons && !hideChatCallButtons && !callStartConfirmation) return + + var actionSheetVideoCallButtonId = -1 + var actionSheetAudioCallButtonId = -1 + + context.event.subscribe(AddViewEvent::class) { event -> + if (event.viewClassName.endsWith("ConstraintLayout")) { + val layout = event.view as? ViewGroup ?: return@subscribe + val children = layout.children() + if (children.any { !it.javaClass.name.endsWith("FriendActionButton") } || children.size != 4) return@subscribe + + actionSheetVideoCallButtonId = children.getOrNull(2)?.id ?: throw IllegalStateException("Video call button not found") + actionSheetAudioCallButtonId = children.getOrNull(3)?.id ?: throw IllegalStateException("Audio call button not found") + + if (hideProfileCallButtons) { + children.getOrNull(2)?.hideViewCompletely() + children.getOrNull(3)?.hideViewCompletely() + } + } + + if (event.viewClassName.endsWith("CallButtonsView") && hideChatCallButtons) { + event.view.hideViewCompletely() + } + } + + onNextActivityCreate { + if (callStartConfirmation) { + findClass("com.snap.composer.views.ComposerRootView").hook("dispatchTouchEvent", HookStage.BEFORE) { param -> + val view = param.thisObject() as? ViewGroup ?: return@hook + if (!view.javaClass.name.endsWith("CallButtonsView")) return@hook + val childComposerView = view.getChildAt(0) as? ViewGroup ?: return@hook + // check if the child composer view contains 2 call buttons + if (childComposerView.children().count { + it::class.java == childComposerView::class.java + } != 2) return@hook + hookTouchEvent(param, param.arg(0)) { + param.invokeOriginal() + } + } + + findClass("com.snap.ui.view.stackdraw.StackDrawLayout").hook("onTouchEvent", HookStage.BEFORE) { param -> + val view = param.thisObject<View>().takeIf { it.id != -1 } ?: return@hook + if (view.id != actionSheetAudioCallButtonId && view.id != actionSheetVideoCallButtonId) return@hook + + hookTouchEvent(param, param.arg(0)) { + arrayOf( + MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 0f, 0f, 0), + MotionEvent.obtain(0, 0, MotionEvent.ACTION_UP, 0f, 0f, 0) + ).forEach { + param.invokeOriginal(arrayOf(it)) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/DisableReplayInFF.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/DisableReplayInFF.kt new file mode 100644 index 0000000000..786e1b113d --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/DisableReplayInFF.kt @@ -0,0 +1,23 @@ +package me.rhunk.snapenhance.core.features.impl.messaging + +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hookConstructor +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.util.ktx.setEnumField + +class DisableReplayInFF : Feature("DisableReplayInFF") { + override fun init() { + val state by context.config.messaging.disableReplayInFF + + onNextActivityCreate(defer = true) { + findClass("com.snapchat.client.messaging.InteractionInfo") + .hookConstructor(HookStage.AFTER, { state }) { param -> + val instance = param.thisObject<Any>() + if (instance.getObjectField("mLongPressActionState").toString() == "REQUEST_SNAP_REPLAY") { + instance.setEnumField("mLongPressActionState", "SHOW_CONVERSATION_ACTION_MENU") + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt new file mode 100644 index 0000000000..4133d99b49 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt @@ -0,0 +1,221 @@ +package me.rhunk.snapenhance.core.features.impl.messaging + +import android.content.ComponentName +import android.content.Intent +import me.rhunk.snapenhance.common.Constants +import me.rhunk.snapenhance.common.ReceiversConfig +import me.rhunk.snapenhance.core.event.events.impl.ConversationUpdateEvent +import me.rhunk.snapenhance.core.event.events.impl.OnSnapInteractionEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.impl.spying.StealthMode +import me.rhunk.snapenhance.core.util.EvictingMap +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.hook.hookConstructor +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.util.ktx.getObjectFieldOrNull +import me.rhunk.snapenhance.core.wrapper.impl.* +import me.rhunk.snapenhance.mapper.impl.CallbackMapper +import me.rhunk.snapenhance.mapper.impl.FriendsFeedEventDispatcherMapper +import java.util.UUID +import java.util.concurrent.Future + +class Messaging : Feature("Messaging") { + var conversationManager: ConversationManager? = null + private set + var snapManager: Any? = null + private set + + private var conversationManagerDelegate: Any? = null + private var identityDelegate: Any? = null + + var openedConversationUUID: SnapUUID? = null + private set + var lastFocusedConversationId: String? = null + private set + var lastFocusedConversationType: Int = -1 + private set + var lastFocusedMessageId: Long = -1 + private set + + private val feedCachedSnapMessages = EvictingMap<String, List<Long>>(100) + private val conversationManagerReadyListeners = mutableListOf<() -> Unit>() + + fun onConversationManagerReady(listener: () -> Unit) { + synchronized(conversationManagerReadyListeners) { + conversationManager?.let { listener() } ?: conversationManagerReadyListeners.add(listener) + } + } + + fun resetLastFocusedConversation() { + lastFocusedConversationId = null + lastFocusedConversationType = -1 + } + + override fun init() { + val stealthMode = context.feature(StealthMode::class) + context.classCache.conversationManager.hookConstructor(HookStage.BEFORE) { param -> + synchronized(conversationManagerReadyListeners) { + conversationManager = ConversationManager(context, param.thisObject()) + context.messagingBridge.triggerSessionStart() + context.mainActivity?.takeIf { it.intent.getBooleanExtra(ReceiversConfig.MESSAGING_PREVIEW_EXTRA, false) }?.run { + startActivity(Intent().apply { + setComponent(ComponentName(Constants.SE_PACKAGE_NAME, "me.rhunk.snapenhance.ui.manager.MainActivity")) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }) + } + conversationManagerReadyListeners.removeIf { it(); true } + } + } + + context.classCache.snapManager.hookConstructor(HookStage.BEFORE) { param -> + snapManager = param.thisObject() + } + + context.mappings.useMapper(CallbackMapper::class) { + callbacks.getClass("ConversationManagerDelegate")?.apply { + hookConstructor(HookStage.AFTER) { param -> + conversationManagerDelegate = param.thisObject() + } + hook("onConversationUpdated", HookStage.BEFORE) { param -> + context.event.post(ConversationUpdateEvent( + conversationId = SnapUUID(param.arg(0)).toString(), + conversation = param.argNullable(1), + messages = param.arg<ArrayList<*>>(2).map { Message(it) }, + ).apply { adapter = param }) { + param.setArg( + 2, + messages.map { it.instanceNonNull() }.toCollection(ArrayList()) + ) + } + } + } + callbacks.getClass("IdentityDelegate")?.apply { + hookConstructor(HookStage.AFTER) { + identityDelegate = it.thisObject() + } + } + } + + defer { + arrayOf("activate", "deactivate", "processTypingActivity").forEach { hook -> + context.classCache.presenceSession.hook(hook, HookStage.BEFORE, { + context.config.messaging.hideBitmojiPresence.get() || stealthMode.canUseRule(openedConversationUUID.toString()) + }) { + it.setResult(null) + } + } + + context.classCache.presenceSession.hook("startPeeking", HookStage.BEFORE, { + context.config.messaging.hidePeekAPeek.get() || stealthMode.canUseRule(openedConversationUUID.toString()) + }) { it.setResult(null) } + + //get last opened snap for media downloader + context.event.subscribe(OnSnapInteractionEvent::class) { event -> + openedConversationUUID = event.conversationId + lastFocusedMessageId = event.messageId + } + + context.classCache.conversationManager.hook("fetchMessage", HookStage.BEFORE) { param -> + val conversationId = SnapUUID(param.arg(0)).toString() + if (openedConversationUUID?.toString() == conversationId) { + lastFocusedMessageId = param.arg(1) + } + } + + context.classCache.conversationManager.hook("sendTypingNotification", HookStage.BEFORE, { + context.config.messaging.hideTypingNotifications.get() || stealthMode.canUseRule(openedConversationUUID.toString()) + }) { + it.setResult(null) + } + } + + onNextActivityCreate { + context.mappings.useMapper(FriendsFeedEventDispatcherMapper::class) { + classReference.getAsClass()?.hook("onItemLongPress", HookStage.BEFORE) { param -> + val viewItemContainer = param.arg<Any>(0) + val viewItem = viewItemContainer.getObjectField(viewModelField.get()!!).toString() + val conversationId = viewItem.substringAfter("conversationId: ").substring(0, 36).also { + if (it.startsWith("null")) return@hook + } + lastFocusedConversationId = conversationId + lastFocusedConversationType = context.database.getConversationType(conversationId) ?: 0 + } + } + + context.classCache.feedEntry.hookConstructor(HookStage.AFTER) { param -> + val instance = param.thisObject<Any>() + val interactionInfo = instance.getObjectFieldOrNull("mInteractionInfo") ?: return@hookConstructor + val messages = (interactionInfo.getObjectFieldOrNull("mMessages") as? List<*>)?.map { Message(it) } ?: return@hookConstructor + val conversationId = SnapUUID(instance.getObjectFieldOrNull("mConversationId") ?: return@hookConstructor).toString() + val myUserId = context.database.myUserId + + feedCachedSnapMessages[conversationId] = messages.filter { msg -> + msg.messageMetadata?.openedBy?.none { it.toString() == myUserId } == true + }.sortedBy { it.orderKey }.mapNotNull { it.messageDescriptor?.messageId } + } + + context.classCache.conversationManager.apply { + hook("enterConversation", HookStage.BEFORE) { param -> + openedConversationUUID = SnapUUID(param.arg(0)) + if (context.config.messaging.bypassMessageRetentionPolicy.get()) { + val callback = param.argNullable<Any>(2) ?: return@hook + callback::class.java.methods.firstOrNull { it.name == "onSuccess" }?.invoke(callback) + param.setResult(null) + } + } + + hook("exitConversation", HookStage.BEFORE) { + openedConversationUUID = null + } + } + } + } + + fun getFeedCachedMessageIds(conversationId: String) = feedCachedSnapMessages[conversationId] + + fun clearConversationFromFeed(conversationId: String, onError : (String) -> Unit = {}, onSuccess : () -> Unit = {}) { + conversationManager?.clearConversation(conversationId, onError = { onError(it) }, onSuccess = { + runCatching { + conversationManagerDelegate!!.let { + it::class.java.methods.first { method -> + method.name == "onConversationRemoved" + }.invoke(conversationManagerDelegate, conversationId.toSnapUUID().instanceNonNull()) + } + onSuccess() + }.onFailure { + context.log.error("Failed to invoke onConversationRemoved: $it") + onError(it.message ?: "Unknown error") + } + }) + } + + fun localUpdateMessage(conversationId: String, message: Message, forceUpdate: Boolean = false) { + if (forceUpdate) { + message.messageMetadata?.screenRecordedBy = ArrayList<SnapUUID>(message.messageMetadata?.screenRecordedBy ?: emptyList()).apply { + add(SnapUUID(UUID.randomUUID().toString())) + } + } + conversationManagerDelegate?.let { + it::class.java.methods.first { method -> + method.name == "onConversationUpdated" + }.invoke(conversationManagerDelegate, conversationId.toSnapUUID().instanceNonNull(), null, mutableListOf(message.instanceNonNull()), mutableListOf<Any>()) + } + } + + fun fetchSnapchatterInfos(userIds: List<String>): List<Snapchatter> { + val identity = identityDelegate ?: return emptyList() + val snapUUIDs = userIds.map { + it.toSnapUUID().instanceNonNull() + } + + val future = identity::class.java.methods.first { + it.name == "fetchSnapchatterInfos" + }.let { method -> + if (method.parameterCount == 2) method.invoke(identity, snapUUIDs, false) + else method.invoke(identity, snapUUIDs) + } as Future<*> + + return (future.get() as? List<*>)?.map { Snapchatter(it) } ?: return emptyList() + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt new file mode 100644 index 0000000000..180de4d89e --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt @@ -0,0 +1,540 @@ +package me.rhunk.snapenhance.core.features.impl.messaging + +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.RemoteInput +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.os.Build +import android.os.Bundle +import android.os.UserHandle +import de.robv.android.xposed.XposedBridge +import kotlinx.coroutines.* +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.data.FileType +import me.rhunk.snapenhance.common.data.MessageUpdate +import me.rhunk.snapenhance.common.data.NotificationType +import me.rhunk.snapenhance.common.data.download.SplitMediaAssetType +import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper +import me.rhunk.snapenhance.common.util.snap.SnapWidgetBroadcastReceiverHelper +import me.rhunk.snapenhance.core.event.events.impl.SnapWidgetBroadcastReceiveEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.impl.FriendMutationObserver +import me.rhunk.snapenhance.core.features.impl.downloader.MediaDownloader +import me.rhunk.snapenhance.core.features.impl.downloader.decoder.AttachmentType +import me.rhunk.snapenhance.core.features.impl.downloader.decoder.MessageDecoder +import me.rhunk.snapenhance.core.features.impl.experiments.BetterTranscript +import me.rhunk.snapenhance.core.features.impl.spying.StealthMode +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.findRestrictedConstructor +import me.rhunk.snapenhance.core.util.hook.findRestrictedMethod +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.ktx.setObjectField +import me.rhunk.snapenhance.core.util.media.PreviewUtils +import me.rhunk.snapenhance.core.wrapper.impl.Message +import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID +import java.nio.ByteBuffer +import kotlin.coroutines.suspendCoroutine + +class Notifications : Feature("Notifications") { + inner class NotificationData( + val tag: String?, + val id: Int, + var notification: Notification, + val userHandle: UserHandle + ) { + fun send() { + XposedBridge.invokeOriginalMethod(notifyAsUserMethod, notificationManager, arrayOf( + tag, id, notification, userHandle + )) + } + + fun copy(tag: String? = this.tag, id: Int = this.id, notification: Notification = this.notification, userHandle: UserHandle = this.userHandle) = + NotificationData(tag, id, notification, userHandle) + } + + companion object{ + const val ACTION_REPLY = "me.rhunk.snapenhance.action.notification.REPLY" + const val ACTION_DOWNLOAD = "me.rhunk.snapenhance.action.notification.DOWNLOAD" + const val ACTION_MARK_AS_READ = "me.rhunk.snapenhance.action.notification.MARK_AS_READ" + const val SNAPCHAT_NOTIFICATION_GROUP = "snapchat_notification_group" + } + + @OptIn(ExperimentalCoroutinesApi::class) + private val coroutineDispatcher = Dispatchers.IO.limitedParallelism(1) + private val cachedMessages = mutableMapOf<String, MutableMap<Long, String>>() // conversationId => orderKey, message + private val sentNotifications = mutableMapOf<Int, String>() // notificationId => conversationId + + private val notifyAsUserMethod by lazy { + NotificationManager::class.java.findRestrictedMethod { it.name == "notifyAsUser" } ?: throw NoSuchMethodException("notifyAsUser") + } + + private val notificationManager by lazy { + context.androidContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + } + + private val translations by lazy { context.translation.getCategory("better_notifications") } + private val config by lazy { context.config.messaging.betterNotifications } + + private fun newNotificationBuilder(notification: Notification) = Notification.Builder::class.java.findRestrictedConstructor { + it.parameterTypes.size == 2 && it.parameterTypes[1] == Notification::class.java + }?.newInstance(context.androidContext, notification) as? Notification.Builder ?: throw NoSuchMethodException("Notification.Builder") + + private fun setNotificationText(notification: Notification, text: String) { + notification.extras.putString("android.text", text) + notification.extras.putString("android.bigText", text) + notification.extras.putParcelableArray("android.messages", text.split("\n").map { + Bundle().apply { + putBundle("extras", Bundle()) + putString("text", it) + putLong("time", System.currentTimeMillis()) + } + }.toTypedArray()) + } + + private fun computeNotificationMessages(notification: Notification, conversationId: String) { + val messageText = StringBuilder().apply { + cachedMessages.computeIfAbsent(conversationId) { sortedMapOf() }.forEach { + if (isNotEmpty()) append("\n") + append(it.value) + } + }.toString() + + setNotificationText(notification, messageText) + } + + private fun setupNotificationActionButtons(contentType: ContentType, conversationId: String, message: Message, notificationData: NotificationData) { + val actions = mutableListOf<Notification.Action>() + actions.addAll(notificationData.notification.actions ?: emptyArray()) + + fun newAction(title: String, remoteAction: String, filter: (() -> Boolean), builder: (Notification.Action.Builder) -> Unit) { + if (!filter()) return + + val intent = SnapWidgetBroadcastReceiverHelper.create(remoteAction) { + putExtra("conversation_id", conversationId) + putExtra("notification_id", notificationData.id) + putExtra("client_message_id", message.messageDescriptor!!.messageId!!) + } + + val action = Notification.Action.Builder(null, title, PendingIntent.getBroadcast( + context.androidContext, + System.nanoTime().toInt(), + intent, + PendingIntent.FLAG_MUTABLE + )).apply(builder).build() + actions.add(action) + } + + newAction(translations["button.reply"], ACTION_REPLY, { + config.replyButton.get() && contentType == ContentType.CHAT + }) { + val chatReplyInput = RemoteInput.Builder("chat_reply_input") + .setLabel(translations["button.reply"]) + .build() + it.addRemoteInput(chatReplyInput) + if (config.smartReplies.get()) { + it.setAllowGeneratedReplies(true) + } + } + + newAction(translations["button.download"], ACTION_DOWNLOAD, { + config.downloadButton.get() && config.mediaPreview.get().contains(contentType.name) + }) {} + + newAction(translations["button.mark_as_read"], ACTION_MARK_AS_READ, { + config.markAsReadButton.get() + }) {} + + val notificationBuilder = newNotificationBuilder(notificationData.notification).apply { + setActions(*actions.toTypedArray()) + } + notificationData.notification = notificationBuilder.build() + } + + private fun setupBroadcastReceiverHook() { + context.event.subscribe(SnapWidgetBroadcastReceiveEvent::class) { event -> + val intent = event.intent ?: return@subscribe + val conversationId = intent.getStringExtra("conversation_id") ?: return@subscribe + val clientMessageId = intent.getLongExtra("client_message_id", -1) + val notificationId = intent.getIntExtra("notification_id", -1) + + val updateNotification: (Int, (Notification) -> Unit) -> Unit = { id, notificationBuilder -> + notificationManager.activeNotifications.firstOrNull { it.id == id }?.let { + notificationBuilder(it.notification) + NotificationData(it.tag, it.id, it.notification, it.user).send() + } + } + + suspend fun appendNotificationText(input: String) { + cachedMessages.computeIfAbsent(conversationId) { sortedMapOf() }.let { + it[(it.keys.lastOrNull() ?: 0) + 1L] = input + } + + withContext(Dispatchers.Main) { + updateNotification(notificationId) { notification -> + notification.flags = notification.flags or Notification.FLAG_ONLY_ALERT_ONCE + computeNotificationMessages(notification, conversationId) + } + } + } + + when (event.action) { + ACTION_REPLY -> { + val input = RemoteInput.getResultsFromIntent(intent).getCharSequence("chat_reply_input") + .toString() + val myUser = context.database.myUserId.let { context.database.getFriendInfo(it) } ?: return@subscribe + + context.messageSender.sendChatMessage(listOf(SnapUUID(conversationId)), input, onError = { + context.longToast("Failed to send message: $it") + context.coroutineScope.launch(coroutineDispatcher) { + appendNotificationText("Failed to send message: $it") + } + }, onSuccess = { + context.coroutineScope.launch(coroutineDispatcher) { + appendNotificationText("${myUser.displayName ?: myUser.mutableUsername}: $input") + context.feature(AutoMarkAsRead::class).takeIf { it.canMarkConversationAsRead }?.markConversationsAsRead(listOf(conversationId)) + } + }) + } + ACTION_DOWNLOAD -> { + runCatching { + context.feature(MediaDownloader::class).downloadMessageId(clientMessageId, isPreview = false) + }.onFailure { + context.longToast(it) + } + } + ACTION_MARK_AS_READ -> { + runCatching { + val conversationManager = context.feature(Messaging::class).conversationManager ?: return@subscribe + + context.feature(StealthMode::class).addDisplayedMessageException(clientMessageId) + conversationManager.displayedMessages( + conversationId, + clientMessageId, + onResult = { + if (it != null) { + context.log.error("Failed to mark conversation as read: $it") + context.shortToast("Failed to mark conversation as read") + } + } + ) + + if (config.markAsReadAndSaveInChat.get()) { + val messaging = context.feature(Messaging::class) + val autoSave = context.feature(AutoSave::class) + + if (autoSave.canSaveInConversation(conversationId, headless = true)) { + messaging.conversationManager?.fetchConversationWithMessagesPaginated( + conversationId, + Long.MAX_VALUE, + 20, + onSuccess = { messages -> + messages.reversed().forEach { message -> + if (!autoSave.canSaveMessage(message, headless = true)) return@forEach + context.coroutineScope.launch(coroutineDispatcher) { + autoSave.saveMessage(conversationId, message) + } + } + }, + onError = { + context.log.error("Failed to fetch conversation: $it") + context.shortToast("Failed to fetch conversation") + } + ) + } + } + + val conversationMessage = context.database.getConversationMessageFromId(clientMessageId) ?: return@subscribe + + if (conversationMessage.contentType == ContentType.SNAP.id) { + conversationManager.updateMessage(conversationId, clientMessageId, MessageUpdate.READ) { + if (it != null) { + context.log.error("Failed to open snap: $it") + context.shortToast("Failed to open snap") + } + } + } + }.onFailure { + context.log.error("Failed to mark message as read", it) + context.shortToast("Failed to mark message as read. Check logs for more details") + } + notificationManager.cancel(notificationId) + } + else -> return@subscribe + } + + event.canceled = true + } + } + + private fun sendNotification(message: Message, notificationData: NotificationData, forceCreate: Boolean) { + val conversationId = message.messageDescriptor?.conversationId.toString() + val notificationId = if (forceCreate) System.nanoTime().toInt() else message.messageDescriptor?.conversationId?.toBytes().contentHashCode() + sentNotifications.computeIfAbsent(notificationId) { conversationId } + + if (config.groupNotifications.get()) { + runCatching { + if (notificationManager.activeNotifications.firstOrNull { + it.notification.flags and Notification.FLAG_GROUP_SUMMARY != 0 + } == null) { + notificationManager.notify( + notificationData.tag, + System.nanoTime().toInt(), + Notification.Builder(context.androidContext, notificationData.notification.channelId) + .setSmallIcon(notificationData.notification.smallIcon) + .setGroup(SNAPCHAT_NOTIFICATION_GROUP) + .setGroupSummary(true) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .build() + ) + } + }.onFailure { + context.log.warn("Failed to set notification group key: ${it.stackTraceToString()}", key) + } + } + + val builder = newNotificationBuilder(notificationData.notification).apply { + setGroup(SNAPCHAT_NOTIFICATION_GROUP) + setGroupAlertBehavior(Notification.GROUP_ALERT_CHILDREN) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && config.smartReplies.get()) { + setAllowSystemGeneratedContextualActions(true) + } + } + + notificationData.copy(id = notificationId, notification = builder.build()).also { + setupNotificationActionButtons(message.messageContent!!.contentType!!, conversationId, message, it) + }.send() + } + + private suspend fun onMessageReceived(data: NotificationData, notificationType: String, message: Message) { + val conversationId = message.messageDescriptor?.conversationId.toString() + val orderKey = message.orderKey ?: return + val senderUsername by lazy { + context.database.getFriendInfo(message.senderId.toString())?.let { + it.displayName ?: it.mutableUsername + } ?: "Unknown" + } + + val contentType = message.messageContent!!.contentType!!.let { contentType -> + when { + notificationType.contains("screenshot") -> ContentType.STATUS_CONVERSATION_CAPTURE_SCREENSHOT + notificationType.contains("save_camera_roll") -> ContentType.STATUS_SAVE_TO_CAMERA_ROLL + else -> contentType + } + } + val computeMessages: () -> Unit = { computeNotificationMessages(data.notification, conversationId)} + + fun setNotificationText(text: String) { + val includeUsername = context.database.getDMOtherParticipant(conversationId) == null + cachedMessages.computeIfAbsent(conversationId) { + sortedMapOf() + }[orderKey] = if (includeUsername) "$senderUsername: $text" else text + } + + if (config.mediaPreview.get().contains(contentType.name)) { + MessageDecoder.decode(message.messageContent!!).firstOrNull()?.also { media -> + runCatching { + media.openStream { mediaStream, length -> + if (mediaStream == null || length > 25 * 1024 * 1024) { + context.log.error("Failed to open media stream or media is too large") + sendNotification(message, data, true) + return@openStream + } + val downloadedMedias = mutableMapOf<SplitMediaAssetType, ByteArray>() + + MediaDownloaderHelper.getSplitElements(mediaStream) { type, inputStream -> + downloadedMedias[type] = inputStream.readBytes() + } + + val originalMedia = downloadedMedias[SplitMediaAssetType.ORIGINAL]!! + var bitmapPreview = PreviewUtils.createPreview(originalMedia, FileType.fromByteArray(originalMedia).isVideo)!! + + downloadedMedias[SplitMediaAssetType.OVERLAY]?.let { + bitmapPreview = PreviewUtils.mergeBitmapOverlay(bitmapPreview, BitmapFactory.decodeByteArray(it, 0, it.size)) + } + + val notificationBuilder = newNotificationBuilder(data.notification).apply { + setLargeIcon(bitmapPreview) + style = Notification.BigPictureStyle().bigPicture(bitmapPreview).bigLargeIcon(null as Bitmap?) + } + if (config.mediaCaption.get()) { + message.serialize()?.let { + notificationBuilder.setContentText(it) + } + } + + sendNotification(message, data.copy(notification = notificationBuilder.build()), true) + } + return + }.onFailure { + context.log.error("Failed to send preview notification", it) + sendNotification(message, data, true) + return + } + } + } + + if (config.chatPreview.get()) { + var isChatMessage = contentType == ContentType.CHAT + var serializedMessage = if (isChatMessage) { + message.serialize() ?: "[Failed to parse message]" + } else { + "[${context.translation.getCategory("content_type")[contentType.name]}]${ + if (config.mediaCaption.get()) { + message.serialize() ?: "" + } else "" + }" + } + + if (contentType == ContentType.NOTE && context.config.experimental.betterTranscript.takeIf { it.globalState == true }?.notificationTranscript?.get() == true) { + MessageDecoder.decode(message.messageContent!!).firstOrNull { it.type == AttachmentType.NOTE }?.also { media -> + runCatching { + media.openStream { mediaStream, length -> + if (mediaStream == null || length > 25 * 1024 * 1024) { + context.log.error("Failed to open media stream or media is too large") + return@openStream + } + + val text = context.feature(BetterTranscript::class).transcribe( + ByteBuffer.allocateDirect(length.toInt()).apply { + put(mediaStream.readBytes()) + rewind() + }) ?: return@openStream + serializedMessage = "\uD83C\uDFA4 $text" + isChatMessage = true + } + }.onFailure { + context.log.error("Failed to transcribe message", it) + } + } + } + + if (isChatMessage || config.stackedMediaMessages.get()) { + setNotificationText(serializedMessage) + } else { + sendNotification(message, data, true) + return + } + computeMessages() + } + + sendNotification(message, data, false) + } + + private fun canSendNotification(type: String): Boolean { + val formattedMessageType = type.replaceFirst("mischief_", "") + .replaceFirst("group_your_", "group_") + .replaceFirst("group_other_", "group_") + + return context.config.messaging.notificationBlacklist.get().mapNotNull { + NotificationType.getByKey(it) + }.none { + it.isMatch(formattedMessageType) + }.also { + if (!it) context.log.debug("prevented notification of type $type") + } + } + + override fun init() { + setupBroadcastReceiverHook() + + notifyAsUserMethod.hook(HookStage.BEFORE) { param -> + val notificationData = NotificationData(param.argNullable(0), param.arg(1), param.arg(2), param.arg(3)) + val extras = notificationData.notification.extras.getBundle("system_notification_extras")?: return@hook + + if (config.groupNotifications.get()) { + notificationData.notification.setObjectField("mGroupKey", SNAPCHAT_NOTIFICATION_GROUP) + } + + val notificationType = extras.getString("notification_type")?.lowercase() ?: return@hook + + if (!canSendNotification(notificationType)) { + param.setResult(null) + return@hook + } + + if (notificationType == "addfriend" && config.friendAddSource.get()) { + val userId = notificationData.notification.shortcutId?.split("|")?.lastOrNull() ?: return@hook + runBlocking { + var addSource: String? = null + withTimeoutOrNull(7000) { + while (true) { + addSource = context.feature(FriendMutationObserver::class).getFriendAddSource(userId) + if (addSource != null) break + delay(500) + } + } + setNotificationText(notificationData.notification, addSource ?: return@runBlocking) + } + return@hook + } + + if (!config.chatPreview.get() && config.mediaPreview.isEmpty()) return@hook + if (notificationType.endsWith("typing")) return@hook + + val serverMessageId = extras.getString("message_id") ?: return@hook + val conversationId = extras.getString("conversation_id").also { id -> + sentNotifications.computeIfAbsent(notificationData.id) { id ?: "" } + } ?: return@hook + + param.setResult(null) + val conversationManager = context.feature(Messaging::class).conversationManager ?: return@hook + + context.coroutineScope.launch(coroutineDispatcher) { + suspendCoroutine { continuation -> + conversationManager.fetchMessageByServerId(conversationId, serverMessageId.toLong(), onSuccess = { + continuation.resumeWith(Result.success(Unit)) + if (it.senderId.toString() == context.database.myUserId) { + param.invokeOriginal() + return@fetchMessageByServerId + } + context.coroutineScope.launch(coroutineDispatcher) { + onMessageReceived(notificationData, notificationType, it) + } + }, onError = { + context.log.error("Failed to fetch message id ${serverMessageId}: $it") + continuation.resumeWith(Result.success(Unit)) + param.invokeOriginal() + }) + } + } + } + + NotificationManager::class.java.findRestrictedMethod { + it.name == "cancelAsUser" + }?.hook(HookStage.AFTER) { param -> + val notificationId = param.arg<Int>(1) + + context.coroutineScope.launch(coroutineDispatcher) { + sentNotifications[notificationId]?.let { + cachedMessages[it]?.clear() + } + sentNotifications.remove(notificationId) + } + + notificationManager.activeNotifications.let { notifications -> + if (notifications.all { it.notification.flags and Notification.FLAG_GROUP_SUMMARY != 0 }) { + notifications.forEach { param.invokeOriginal(arrayOf(it.tag, it.id, it.user)) } + } + } + } + + findClass("com.google.firebase.messaging.FirebaseMessagingService").run { + methods.first { it.declaringClass == this && it.returnType == Void::class.javaPrimitiveType && it.parameterCount == 1 && it.parameterTypes[0] == Intent::class.java } + .hook(HookStage.BEFORE) { param -> + val intent = param.argNullable<Intent>(0) ?: return@hook + val messageType = intent.getStringExtra("type") ?: return@hook + + context.log.debug("received message type: $messageType") + + if (!canSendNotification(messageType)) { + param.setResult(null) + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/PreventMessageSending.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/PreventMessageSending.kt new file mode 100644 index 0000000000..8f2abe890e --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/PreventMessageSending.kt @@ -0,0 +1,49 @@ +package me.rhunk.snapenhance.core.features.impl.messaging + +import me.rhunk.snapenhance.common.data.NotificationType +import me.rhunk.snapenhance.common.util.protobuf.ProtoEditor +import me.rhunk.snapenhance.core.event.events.impl.NativeUnaryCallEvent +import me.rhunk.snapenhance.core.event.events.impl.SendMessageWithContentEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook + +class PreventMessageSending : Feature("Prevent message sending") { + override fun init() { + val preventMessageSending by context.config.messaging.preventMessageSending + + context.event.subscribe(NativeUnaryCallEvent::class, { preventMessageSending.contains("snap_replay") }) { event -> + if (event.uri != "/messagingcoreservice.MessagingCoreService/UpdateContentMessage") return@subscribe + event.buffer = ProtoEditor(event.buffer).apply { + edit(3) { + // replace replayed to read receipt + if (firstOrNull(13) != null) { + remove(13) + addBuffer(4, byteArrayOf()) + } + } + }.toByteArray() + } + + context.classCache.conversationManager.hook("updateMessage", HookStage.BEFORE) { param -> + val messageUpdate = param.arg<Any>(2).toString() + if (messageUpdate == "SCREENSHOT" && preventMessageSending.contains("chat_screenshot")) { + param.setResult(null) + } + + if (messageUpdate == "SCREEN_RECORD" && preventMessageSending.contains("chat_screen_record")) { + param.setResult(null) + } + } + + context.event.subscribe(SendMessageWithContentEvent::class) { event -> + val contentType = event.messageContent.contentType + val associatedType = NotificationType.fromContentType(contentType ?: return@subscribe) ?: return@subscribe + + if (preventMessageSending.contains(associatedType.key)) { + context.log.verbose("Preventing message sending for $associatedType") + event.canceled = true + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/SendOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/SendOverride.kt new file mode 100644 index 0000000000..1ff23c7fb1 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/SendOverride.kt @@ -0,0 +1,379 @@ +package me.rhunk.snapenhance.core.features.impl.messaging + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.filled.Photo +import androidx.compose.material.icons.filled.PhotoCamera +import androidx.compose.material.icons.filled.WarningAmber +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +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 me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.ui.createComposeAlertDialog +import me.rhunk.snapenhance.common.util.protobuf.ProtoEditor +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.common.util.protobuf.ProtoWriter +import me.rhunk.snapenhance.core.event.events.impl.MediaUploadEvent +import me.rhunk.snapenhance.core.event.events.impl.NativeUnaryCallEvent +import me.rhunk.snapenhance.core.event.events.impl.SendMessageWithContentEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.impl.experiments.MediaFilePicker +import me.rhunk.snapenhance.core.messaging.MessageSender +import me.rhunk.snapenhance.core.util.ktx.getObjectFieldOrNull +import java.util.Locale +import kotlin.time.DurationUnit +import kotlin.time.toDuration + + +class SendOverride : Feature("Send Override") { + private var selectedType by mutableStateOf("SNAP") + private var customDuration by mutableFloatStateOf(10f) + + @OptIn(ExperimentalLayoutApi::class) + override fun init() { + val stripMediaMetadata = context.config.messaging.stripMediaMetadata.get() + var postSavePolicy: Int? = null + + val configOverrideType = context.config.messaging.galleryMediaSendOverride.getNullable() + if (configOverrideType == null && stripMediaMetadata.isEmpty()) return + + context.event.subscribe(MediaUploadEvent::class) { event -> + ProtoReader(event.localMessageContent.content!!).followPath(11, 5)?.let { snapDocPlayback -> + event.onMediaUploaded { result -> + result.messageContent.content = ProtoEditor(result.messageContent.content!!).apply { + edit(11, 5) { + edit(1) { + edit(1) { + snapDocPlayback.getVarInt(2, 99)?.let { customDuration -> + remove(15) + addVarInt(15, customDuration) + } + remove(27) + remove(26) + addBuffer(26, byteArrayOf()) + } + } + + // set back the original snap duration + snapDocPlayback.getByteArray(2)?.let { + val originalHasSound = firstOrNull(2)?.toReader()?.getVarInt(5) + remove(2) + addBuffer(2, it) + + originalHasSound?.let { hasSound -> + edit(2) { + remove(5) + addVarInt(5, hasSound) + } + } + } + } + + if (stripMediaMetadata.isNotEmpty()) { + when (result.messageContent.contentType) { + ContentType.SNAP, ContentType.EXTERNAL_MEDIA -> { + edit(*(if (result.messageContent.contentType == ContentType.SNAP) intArrayOf(11) else intArrayOf(3, 3))) { + if (stripMediaMetadata.contains("hide_caption_text")) { + edit(5) { + editEach(1) { + remove(2) + } + } + } + if (stripMediaMetadata.contains("hide_snap_filters")) { + remove(9) + remove(11) + } + if (stripMediaMetadata.contains("hide_extras")) { + remove(13) + edit(5, 1) { + remove(2) + } + } + } + } + ContentType.NOTE -> { + if (stripMediaMetadata.contains("remove_audio_note_duration")) { + edit(6, 1, 1) { + remove(13) + } + } + if (stripMediaMetadata.contains("remove_audio_note_transcript_capability")) { + edit(6, 1) { + remove(3) + } + } + } + else -> {} + } + } + + edit(11, 5, 2) { + remove(99) + } + }.toByteArray() + } + } + } + + if (configOverrideType == null) return + + context.event.subscribe(NativeUnaryCallEvent::class) { event -> + if (event.uri != "/messagingcoreservice.MessagingCoreService/CreateContentMessage") return@subscribe + postSavePolicy?.let { savePolicy -> + context.log.verbose("postSavePolicy=$savePolicy") + event.buffer = ProtoEditor(event.buffer).apply { + edit(4) { + remove(7) + addVarInt(7, savePolicy) + } + + // remove Keep Snaps in Chat ability + if (savePolicy == 1/* PROHIBITED */) { + edit(6, 9) { + remove(1) + } + } + }.toByteArray() + } + } + + context.event.subscribe(SendMessageWithContentEvent::class) { event -> + postSavePolicy = null + if (event.destinations.stories?.isNotEmpty() == true && event.destinations.conversations?.isEmpty() == true) return@subscribe + val localMessageContent = event.messageContent + if (localMessageContent.contentType != ContentType.EXTERNAL_MEDIA && localMessageContent.instanceNonNull().getObjectFieldOrNull("mExternalContentMetadata") == null) return@subscribe + + //prevent story replies + val messageProtoReader = ProtoReader(localMessageContent.content ?: return@subscribe) + if (messageProtoReader.contains(7)) return@subscribe + + event.canceled = true + + fun sendMedia(overrideType: String, snapDurationMs: Int?): Boolean { + if (overrideType != "ORIGINAL" && (messageProtoReader.followPath(3)?.getCount(3) ?: 0) > 1) { + context.inAppOverlay.showStatusToast( + icon = Icons.Default.WarningAmber, + context.translation["gallery_media_send_override.multiple_media_toast"] + ) + return false + } + + when (overrideType) { + "SNAP", "SAVEABLE_SNAP" -> { + postSavePolicy = if (overrideType == "SAVEABLE_SNAP") 3 /* VIEW_SESSION */ else 1 /* PROHIBITED */ + + val extras = messageProtoReader.followPath(3, 3, 13)?.getBuffer() + + if (localMessageContent.contentType != ContentType.SNAP) { + localMessageContent.content = ProtoWriter().apply { + from(11) { + from(5) { + from(1) { + from(1) { + addVarInt(2, 0) + addVarInt(12, 0) + addVarInt(15, 0) + } + addVarInt(6, 1) + } + from(2) {} + } + extras?.let { + addBuffer(13, it) + } + from(22) {} + } + }.toByteArray() + } + + localMessageContent.contentType = ContentType.SNAP + localMessageContent.content = ProtoEditor(localMessageContent.content!!).apply { + edit(11, 5, 2) { + arrayOf(6, 7, 8).forEach { remove(it) } + addVarInt(5, messageProtoReader.getVarInt(3, 3, 5, 2, 5) ?: messageProtoReader.getVarInt(11, 5, 2, 5) ?: 1) + // set snap duration + if (snapDurationMs != null) { + addVarInt(8, snapDurationMs / 1000) + if (snapDurationMs / 1000 <= 0) { + addVarInt(99, snapDurationMs) + } + } else { + addBuffer(6, byteArrayOf()) + } + } + + // set app source + edit(11, 22) { + remove(4) + addVarInt(4, 5) // APP_SOURCE_CAMERA + } + }.toByteArray() + } + "NOTE" -> { + localMessageContent.contentType = ContentType.NOTE + localMessageContent.content = + MessageSender.audioNoteProto( + messageProtoReader.getVarInt(3, 3, 5, 1, 1, 15) ?: context.feature(MediaFilePicker::class).lastMediaDuration ?: 0, + Locale.getDefault().toLanguageTag() + ) + } + } + + return true + } + + if (configOverrideType != "always_ask") { + if (sendMedia(configOverrideType, 10)) { + event.invokeOriginal() + } + return@subscribe + } + + context.runOnUiThread { + createComposeAlertDialog(context.mainActivity!!) { alertDialog -> + val mainTranslation = remember { + context.translation.getCategory("send_override_dialog") + } + + @Composable + fun ActionTile( + modifier: Modifier = Modifier, + selected: Boolean = false, + icon: ImageVector, + title: String, + onClick: () -> Unit + ) { + Card( + modifier = modifier, + onClick = onClick, + elevation = if (selected) CardDefaults.elevatedCardElevation(disabledElevation = 3.dp) else CardDefaults.cardElevation(), + colors = if (selected) CardDefaults.elevatedCardColors() else CardDefaults.cardColors() + ) { + Column( + modifier = Modifier + .padding(16.dp) + .size(75.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon(icon, contentDescription = title, modifier = Modifier + .size(32.dp) + .padding(4.dp)) + Text(title, modifier = Modifier.fillMaxWidth(), fontSize = 12.sp, fontWeight = FontWeight.Light, softWrap = true, lineHeight = 14.sp, textAlign = TextAlign.Center) + } + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + val translation = remember { + context.translation.getCategory("features.options.gallery_media_send_override") + } + + Text(fontSize = 20.sp, fontWeight = FontWeight.Medium, text = "Send as ${ + translation[selectedType]}", modifier = Modifier.padding(5.dp)) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + ActionTile(selected = selectedType == "ORIGINAL", icon = Icons.Filled.Photo, title = + translation["ORIGINAL"]) { + selectedType = "ORIGINAL" + } + ActionTile(selected = selectedType == "SNAP" || selectedType == "SAVEABLE_SNAP", icon = Icons.Filled.PhotoCamera, title = translation["SNAP"]) { + selectedType = "SNAP" + } + ActionTile(selected = selectedType == "NOTE", icon = Icons.Filled.MusicNote, title = translation["NOTE"]) { + selectedType = "NOTE" + } + } + + fun convertDuration(duration: Float): Int? { + return when { + duration in -2f..-1f -> 100 + duration in -1f..-0f -> 250 + duration in -0f..1f -> 500 + duration >= 11f -> null + else -> ((duration * 1000).toInt() / 1000) * 1000 + } + } + + when (selectedType) { + "SNAP", "SAVEABLE_SNAP" -> { + fun toggleSaveable() { + selectedType = if (selectedType == "SAVEABLE_SNAP") "SNAP" else "SAVEABLE_SNAP" + } + Row( + modifier = Modifier.fillMaxWidth().clickable { + toggleSaveable() + }, + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ){ + Checkbox( + checked = selectedType == "SAVEABLE_SNAP", + onCheckedChange = { + toggleSaveable() + } + ) + Text(text = mainTranslation["saveable_snap_hint"], lineHeight = 15.sp) + } + Column( + modifier = Modifier.padding(start = 8.dp) + ) { + Text( + text = mainTranslation.format("duration", + "duration" to (convertDuration(customDuration)?.toDuration(DurationUnit.MILLISECONDS)?.toString(DurationUnit.SECONDS, 2) ?: mainTranslation["unlimited_duration"]) + ) + ) + Slider( + modifier = Modifier.fillMaxWidth(), + enabled = selectedType != "SAVEABLE_SNAP", + value = customDuration, + onValueChange = { + customDuration = it + }, + valueRange = -2f..11f, + ) + } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedButton(onClick = { + alertDialog.dismiss() + }) { + Text(context.translation["button.cancel"]) + } + Button(onClick = { + alertDialog.dismiss() + if (sendMedia(selectedType, if (selectedType != "SAVEABLE_SNAP" ) convertDuration(customDuration) else null)) { + event.invokeOriginal() + } + }) { + Text(context.translation["button.send"]) + } + } + } + }.show() + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/UnlimitedSnapViewTime.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/UnlimitedSnapViewTime.kt new file mode 100644 index 0000000000..c3f2bda15a --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/UnlimitedSnapViewTime.kt @@ -0,0 +1,32 @@ +package me.rhunk.snapenhance.core.features.impl.messaging + +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.data.MessageState +import me.rhunk.snapenhance.common.util.protobuf.ProtoEditor +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.core.event.events.impl.BuildMessageEvent +import me.rhunk.snapenhance.core.features.Feature + +class UnlimitedSnapViewTime : Feature("UnlimitedSnapViewTime") { + override fun init() { + onNextActivityCreate { + val state by context.config.messaging.unlimitedSnapViewTime + + context.event.subscribe(BuildMessageEvent::class, { state }, priority = 101) { event -> + if (event.message.messageState != MessageState.COMMITTED) return@subscribe + if (event.message.messageContent!!.contentType != ContentType.SNAP) return@subscribe + + val messageContent = event.message.messageContent + + val mediaAttributes = ProtoReader(messageContent!!.content!!).followPath(11, 5, 2) ?: return@subscribe + if (mediaAttributes.contains(6)) return@subscribe + messageContent.content = ProtoEditor(messageContent.content!!).apply { + edit(11, 5, 2) { + remove(8) + addBuffer(6, byteArrayOf()) + } + }.toByteArray() + } + } + } +} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/FriendTracker.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/FriendTracker.kt new file mode 100644 index 0000000000..a715581c85 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/FriendTracker.kt @@ -0,0 +1,383 @@ +package me.rhunk.snapenhance.core.features.impl.spying + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import me.rhunk.snapenhance.common.Constants +import me.rhunk.snapenhance.common.data.* +import me.rhunk.snapenhance.common.util.lazyBridge +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.common.util.toParcelable +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.impl.messaging.Messaging +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.hook.hookConstructor +import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID +import me.rhunk.snapenhance.core.wrapper.impl.toSnapUUID +import me.rhunk.snapenhance.nativelib.NativeLib +import java.lang.reflect.Method +import java.nio.ByteBuffer + +class FriendTracker : Feature("Friend Tracker") { + private val conversationPresenceState = mutableMapOf<String, MutableMap<String, FriendPresenceState?>>() // conversationId -> (userId -> state) + private val tracker by lazyBridge { context.bridgeClient.getTracker() } + private val notificationManager by lazy { context.androidContext.getSystemService(NotificationManager::class.java).apply { + createNotificationChannel(NotificationChannel( + "friend_tracker", + "Friend Tracker", + NotificationManager.IMPORTANCE_DEFAULT + )) + } } + + private fun getTrackedEvents(eventType: TrackerEventType): TrackerEventsResult? { + return runCatching { + tracker.getTrackedEvents(eventType.key)?.let { + toParcelable<TrackerEventsResult>(it) + } + }.onFailure { + context.log.error("Failed to get tracked events for $eventType", it) + }.getOrNull() + } + + private fun isInConversation(conversationId: String?) = context.feature(Messaging::class).openedConversationUUID?.toString() == conversationId + + private fun sendInfoNotification(id: Int = System.nanoTime().toInt(), text: String) { + notificationManager.notify( + id, + Notification.Builder( + context.androidContext, + "friend_tracker" + ) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setAutoCancel(true) + .setShowWhen(true) + .setWhen(System.currentTimeMillis()) + .setContentIntent(context.androidContext.packageManager.getLaunchIntentForPackage( + Constants.SNAPCHAT_PACKAGE_NAME + )?.let { + PendingIntent.getActivity( + context.androidContext, + 0, it, PendingIntent.FLAG_IMMUTABLE + ) + }) + .setContentText(text) + .build() + ) + } + + private fun handleVolatileEvent(protoReader: ProtoReader) { + context.log.verbose("volatile event\n$protoReader") + } + + private fun dispatchEvents( + eventType: TrackerEventType, + conversationId: String, + userId: String, + extras: String = "" + ) { + val feedEntry = context.database.getFeedEntryByConversationId(conversationId) + val conversationName = feedEntry?.feedDisplayName ?: "DMs" + val authorName = context.database.getFriendInfo(userId)?.mutableUsername ?: "Unknown" + + context.log.verbose("$authorName $eventType in $conversationName") + + getTrackedEvents(eventType)?.takeIf { it.canTrackOn(conversationId, userId) }?.getActions()?.forEach { (action, params) -> + if ((params.onlyWhenAppActive || action == TrackerRuleAction.IN_APP_NOTIFICATION) && context.isMainActivityPaused) return@forEach + if (params.onlyWhenAppInactive && !context.isMainActivityPaused) return@forEach + if (params.onlyInsideConversation && !isInConversation(conversationId)) return@forEach + if (params.onlyOutsideConversation && isInConversation(conversationId)) return@forEach + + context.log.verbose("dispatching $action for $eventType in $conversationName") + + when (action) { + TrackerRuleAction.PUSH_NOTIFICATION -> { + if (params.noPushNotificationWhenAppActive && !context.isMainActivityPaused) return@forEach + sendInfoNotification(text = "$authorName $eventType in $conversationName") + } + TrackerRuleAction.IN_APP_NOTIFICATION -> context.inAppOverlay.showStatusToast( + icon = Icons.Default.Info, + text = "$authorName $eventType in $conversationName" + ) + TrackerRuleAction.LOG -> context.bridgeClient.getMessageLogger().logTrackerEvent( + conversationId, + conversationName, + context.database.getConversationType(conversationId) == 1, + authorName, + userId, + eventType.key, + extras + ) + else -> {} + } + } + } + + private fun onConversationPresenceUpdate(conversationId: String, userId: String, oldState: FriendPresenceState?, currentState: FriendPresenceState?) { + context.log.verbose("presence state for $userId in conversation $conversationId\n$currentState") + + val eventType = when { + (oldState == null || currentState?.bitmojiPresent == false) && currentState?.bitmojiPresent == true -> TrackerEventType.CONVERSATION_ENTER + (currentState == null || oldState?.bitmojiPresent == false) && oldState?.bitmojiPresent == true -> TrackerEventType.CONVERSATION_EXIT + oldState?.typing == false && currentState?.typing == true -> if (currentState.speaking) TrackerEventType.STARTED_SPEAKING else TrackerEventType.STARTED_TYPING + oldState?.typing == true && (currentState == null || !currentState.typing) -> if (oldState.speaking) TrackerEventType.STOPPED_SPEAKING else TrackerEventType.STOPPED_TYPING + (oldState == null || !oldState.peeking) && currentState?.peeking == true -> TrackerEventType.STARTED_PEEKING + oldState?.peeking == true && (currentState == null || !currentState.peeking) -> TrackerEventType.STOPPED_PEEKING + else -> null + } ?: return + + dispatchEvents(eventType, conversationId, userId) + } + + private fun onConversationMessagingEvent(event: SessionEvent) { + context.log.verbose("conversation messaging event\n${event.type} in ${event.conversationId} from ${event.authorUserId}") + + val eventType = when(event.type) { + SessionEventType.MESSAGE_READ_RECEIPTS -> TrackerEventType.MESSAGE_READ + SessionEventType.MESSAGE_DELETED -> TrackerEventType.MESSAGE_DELETED + SessionEventType.MESSAGE_REACTION_ADD -> TrackerEventType.MESSAGE_REACTION_ADD + SessionEventType.MESSAGE_REACTION_REMOVE -> TrackerEventType.MESSAGE_REACTION_REMOVE + SessionEventType.MESSAGE_SAVED -> TrackerEventType.MESSAGE_SAVED + SessionEventType.MESSAGE_UNSAVED -> TrackerEventType.MESSAGE_UNSAVED + SessionEventType.MESSAGE_EDITED -> TrackerEventType.MESSAGE_EDITED + SessionEventType.SNAP_OPENED -> TrackerEventType.SNAP_OPENED + SessionEventType.SNAP_REPLAYED -> TrackerEventType.SNAP_REPLAYED + SessionEventType.SNAP_REPLAYED_TWICE -> TrackerEventType.SNAP_REPLAYED_TWICE + SessionEventType.SNAP_SCREENSHOT -> TrackerEventType.SNAP_SCREENSHOT + SessionEventType.SNAP_SCREEN_RECORD -> TrackerEventType.SNAP_SCREEN_RECORD + else -> return + } + + val conversationMessage by lazy { + (event as? SessionMessageEvent)?.serverMessageId?.let { context.database.getConversationServerMessage(event.conversationId, it) } + } + + dispatchEvents(eventType, event.conversationId, event.authorUserId, extras = conversationMessage?.takeIf { + eventType == TrackerEventType.MESSAGE_READ || + eventType == TrackerEventType.MESSAGE_REACTION_ADD || + eventType == TrackerEventType.MESSAGE_REACTION_REMOVE || + eventType == TrackerEventType.MESSAGE_DELETED || + eventType == TrackerEventType.MESSAGE_SAVED || + eventType == TrackerEventType.MESSAGE_UNSAVED || + eventType == TrackerEventType.MESSAGE_EDITED + }?.contentType?.let { ContentType.fromId(it).name } ?: "") + } + + private fun handlePresenceEvent(protoReader: ProtoReader) { + val conversationId = protoReader.getString(6) ?: return + + val presenceMap = conversationPresenceState.getOrPut(conversationId) { mutableMapOf() }.toMutableMap() + val userIds = mutableSetOf<String>() + + protoReader.eachBuffer(4) { + val participantUserId = getString(1)?.takeIf { it.contains(":") }?.substringBefore(":") ?: return@eachBuffer + userIds.add(participantUserId) + if (participantUserId == context.database.myUserId) return@eachBuffer + val stateMap = getVarInt(2, 1)?.toString(2)?.padStart(16, '0')?.reversed()?.map { it == '1' } ?: return@eachBuffer + + presenceMap[participantUserId] = FriendPresenceState( + bitmojiPresent = stateMap[0], + typing = stateMap[4], + wasTyping = stateMap[5], + speaking = stateMap[6] && stateMap[4], + peeking = stateMap[8] + ) + } + + presenceMap.keys.filterNot { it in userIds }.forEach { presenceMap[it] = null } + + presenceMap.forEach { (userId, state) -> + val oldState = conversationPresenceState[conversationId]?.get(userId) + if (oldState != state) { + onConversationPresenceUpdate(conversationId, userId, oldState, state) + } + } + + conversationPresenceState[conversationId] = presenceMap + } + + private fun handleMessagingEvent(protoReader: ProtoReader) { + // read receipts + protoReader.followPath(12) { + val conversationId = getByteArray(1, 1)?.toSnapUUID()?.toString() ?: return@followPath + + followPath(7) readReceipts@{ + val senderId = getByteArray(1, 1)?.toSnapUUID()?.toString() ?: return@readReceipts + val serverMessageId = getVarInt(2, 2) ?: return@readReceipts + + onConversationMessagingEvent( + SessionMessageEvent( + SessionEventType.MESSAGE_READ_RECEIPTS, + conversationId, + senderId, + serverMessageId, + ) + ) + } + } + + protoReader.followPath(13, 1, 4) { + val serverMessageId = getVarInt(1) ?: return@followPath + val senderId = getByteArray(2, 1) ?: return@followPath + val conversationId = getByteArray(3, 1, 1, 1) ?: return@followPath + + onConversationMessagingEvent( + SessionMessageEvent( + SessionEventType.MESSAGE_EDITED, + SnapUUID(conversationId).toString(), + SnapUUID(senderId).toString(), + serverMessageId + ) + ) + } + + protoReader.followPath(6, 2) { + val conversationId = getByteArray(3, 1)?.toSnapUUID()?.toString() ?: return@followPath + val senderId = getByteArray(1, 1)?.toSnapUUID()?.toString() ?: return@followPath + val serverMessageId = getVarInt(2) ?: return@followPath + + if (contains(4)) { + onConversationMessagingEvent( + SessionMessageEvent( + SessionEventType.SNAP_OPENED, + conversationId, + senderId, + serverMessageId + ) + ) + } + + if (contains(13)) { + onConversationMessagingEvent( + SessionMessageEvent( + if (getVarInt(13, 1) == 2L) SessionEventType.SNAP_REPLAYED_TWICE else SessionEventType.SNAP_REPLAYED, + conversationId, + senderId, + serverMessageId + ) + ) + } + + if (contains(6) || contains(7)) { + onConversationMessagingEvent( + SessionMessageEvent( + if (contains(6)) SessionEventType.MESSAGE_SAVED else SessionEventType.MESSAGE_UNSAVED, + conversationId, + senderId, + serverMessageId + ) + ) + } + + if (contains(11) || contains(12)) { + onConversationMessagingEvent( + SessionMessageEvent( + if (contains(11)) SessionEventType.SNAP_SCREENSHOT else SessionEventType.SNAP_SCREEN_RECORD, + conversationId, + senderId, + serverMessageId, + ) + ) + } + + followPath(16) { + onConversationMessagingEvent( + SessionMessageEvent( + SessionEventType.MESSAGE_REACTION_ADD, conversationId, senderId, serverMessageId, reactionId = getVarInt(1, 1, 1)?.toInt() ?: -1 + ) + ) + } + + if (contains(17)) { + onConversationMessagingEvent( + SessionMessageEvent(SessionEventType.MESSAGE_REACTION_REMOVE, conversationId, senderId, serverMessageId) + ) + } + + followPath(8) { + onConversationMessagingEvent( + SessionMessageEvent(SessionEventType.MESSAGE_DELETED, conversationId, senderId, serverMessageId, messageData = getByteArray(1)) + ) + } + } + } + + override fun init() { + val sessionEventsConfig = context.config.friendTracker + if (sessionEventsConfig.globalState != true) return + + if (sessionEventsConfig.allowRunningInBackground.get()) { + findClass("com.snapchat.client.duplex.DuplexClient\$CppProxy").apply { + // prevent disabling events when the app is inactive + hook("appStateChanged", HookStage.BEFORE) { param -> + if (param.arg<Any>(0).toString() == "INACTIVE") param.setResult(null) + } + // allow events when a notification is received + hookConstructor(HookStage.AFTER) { param -> + methods.first { it.name == "appStateChanged" }.let { method -> + method.invoke(param.thisObject(), method.parameterTypes[0].enumConstants!!.first { it.toString() == "ACTIVE" }) + } + } + } + } + + if (sessionEventsConfig.recordMessagingEvents.get()) { + val messageHandlerClass = findClass("com.snapchat.client.duplex.MessageHandler\$CppProxy").apply { + hook("onReceive", HookStage.BEFORE) { param -> + param.setResult(null) + + val byteBuffer = param.arg<ByteBuffer>(0) + val content = byteBuffer.let { + val bytes = ByteArray(it.limit()) + it.get(bytes) + bytes + } + val reader = ProtoReader(content) + reader.getString(1, 1)?.let { + val eventData = reader.followPath(1, 2) ?: return@let + if (it == "volatile") { + handleVolatileEvent(eventData) + return@hook + } + + if (it == "presence") { + handlePresenceEvent(eventData) + return@hook + } + } + handleMessagingEvent(reader) + } + hook("nativeDestroy", HookStage.BEFORE) { it.setResult(null) } + } + + + findClass("com.snapchat.client.messaging.Session").hook("create", HookStage.BEFORE) { param -> + if (!NativeLib.initialized) { + context.log.warn("Can't register duplex message handler, native lib not initialized") + return@hook + } + + val method = param.method() as Method + val duplexClient = method.parameterTypes.indexOfFirst { it.name.endsWith("DuplexClient") }.let { + param.arg<Any>(it) + } + val dispatchQueue = method.parameterTypes.indexOfFirst { it.name.endsWith("DispatchQueue") }.let { + param.arg<Any>(it) + } + for (channel in arrayOf("pcs", "mcs")) { + duplexClient::class.java.methods.first { + it.name == "registerHandler" + }.invoke( + duplexClient, + channel, + messageHandlerClass.declaredConstructors.first().also { it.isAccessible = true }.newInstance(-1), + dispatchQueue + ) + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/HalfSwipeNotifier.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/HalfSwipeNotifier.kt new file mode 100644 index 0000000000..37f270fd73 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/HalfSwipeNotifier.kt @@ -0,0 +1,129 @@ +package me.rhunk.snapenhance.core.features.impl.spying + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import me.rhunk.snapenhance.common.Constants +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.hook.hookConstructor +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.mapper.impl.CallbackMapper +import java.util.concurrent.ConcurrentHashMap +import kotlin.time.Duration.Companion.milliseconds + +class HalfSwipeNotifier : Feature("Half Swipe Notifier") { + private val peekingConversations = ConcurrentHashMap<String, List<String>>() + private val startPeekingTimestamps = ConcurrentHashMap<String, Long>() + + private val notificationManager get() = context.androidContext.getSystemService(NotificationManager::class.java) + private val translation by lazy { context.translation.getCategory("half_swipe_notifier")} + private val channelId by lazy { + "peeking".also { + notificationManager.createNotificationChannel( + NotificationChannel( + it, + translation["notification_channel_name"], + NotificationManager.IMPORTANCE_HIGH + ) + ) + } + } + + + override fun init() { + if (context.config.messaging.halfSwipeNotifier.globalState != true) return + lateinit var presenceService: Any + + findClass("com.snapchat.talkcorev3.PresenceService\$CppProxy").hookConstructor(HookStage.AFTER) { + presenceService = it.thisObject() + } + + context.mappings.useMapper(CallbackMapper::class) { + callbacks.getClass("PresenceServiceDelegate")?.hook("notifyActiveConversationsChanged", HookStage.BEFORE) { + val activeConversations = presenceService::class.java.methods.find { it.name == "getActiveConversations" }?.invoke(presenceService) as? Map<*, *> ?: return@hook // conversationId, conversationInfo (this.mPeekingParticipants) + + if (activeConversations.isEmpty()) { + peekingConversations.forEach { + val conversationId = it.key + val peekingParticipantsIds = it.value + peekingParticipantsIds.forEach { userId -> + endPeeking(conversationId, userId) + } + } + peekingConversations.clear() + return@hook + } + + activeConversations.forEach { (conversationId, conversationInfo) -> + val peekingParticipantsIds = (conversationInfo?.getObjectField("mPeekingParticipants") as? List<*>)?.map { it.toString() } ?: return@forEach + val cachedPeekingParticipantsIds = peekingConversations[conversationId] ?: emptyList() + + val newPeekingParticipantsIds = peekingParticipantsIds - cachedPeekingParticipantsIds.toSet() + val exitedPeekingParticipantsIds = cachedPeekingParticipantsIds - peekingParticipantsIds.toSet() + + newPeekingParticipantsIds.forEach { userId -> + startPeeking(conversationId.toString(), userId) + } + + exitedPeekingParticipantsIds.forEach { userId -> + endPeeking(conversationId.toString(), userId) + } + peekingConversations[conversationId.toString()] = peekingParticipantsIds + } + } + } + } + + private fun startPeeking(conversationId: String, userId: String) { + startPeekingTimestamps[conversationId + userId] = System.currentTimeMillis() + } + + private fun endPeeking(conversationId: String, userId: String) { + startPeekingTimestamps[conversationId + userId]?.let { startPeekingTimestamp -> + val peekingDuration = (System.currentTimeMillis() - startPeekingTimestamp).milliseconds.inWholeSeconds + val minDuration = context.config.messaging.halfSwipeNotifier.minDuration.get().toLong() + val maxDuration = context.config.messaging.halfSwipeNotifier.maxDuration.get().toLong() + + if (minDuration > peekingDuration || maxDuration < peekingDuration) return + + val feedEntry = context.database.getFeedEntryByConversationId(conversationId) + val friendInfo = context.database.getFriendInfo(userId) ?: return + + Notification.Builder(context.androidContext, channelId) + .setContentTitle(feedEntry?.feedDisplayName ?: friendInfo.displayName ?: friendInfo.mutableUsername) + .setContentText(if (feedEntry?.conversationType == 1) { + translation.format("notification_content_group", + "friend" to (friendInfo.displayName ?: friendInfo.mutableUsername).toString(), + "group" to (feedEntry.feedDisplayName ?: "Group"), + "duration" to peekingDuration.toString() + ) + } else { + translation.format("notification_content_dm", + "friend" to (friendInfo.displayName ?: friendInfo.mutableUsername).toString(), + "duration" to peekingDuration.toString() + ) + }) + .setContentIntent( + context.androidContext.packageManager.getLaunchIntentForPackage( + Constants.SNAPCHAT_PACKAGE_NAME + )?.let { + PendingIntent.getActivity( + context.androidContext, + 0, it, PendingIntent.FLAG_IMMUTABLE + ) + } + ) + .setWhen(System.currentTimeMillis()) + .setShowWhen(true) + .setAutoCancel(true) + .setSmallIcon(android.R.drawable.presence_invisible) + .build() + .let { notification -> + notificationManager.notify(System.nanoTime().toInt(), notification) + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/MessageLogger.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/MessageLogger.kt new file mode 100644 index 0000000000..a3eec64524 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/MessageLogger.kt @@ -0,0 +1,205 @@ +package me.rhunk.snapenhance.core.features.impl.spying + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.Shape +import android.os.DeadObjectException +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import me.rhunk.snapenhance.bridge.logger.BridgeLoggedMessage +import me.rhunk.snapenhance.bridge.logger.LoggedChatEdit +import me.rhunk.snapenhance.common.config.impl.MessagingTweaks +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.data.MessageState +import me.rhunk.snapenhance.common.data.MessagingRuleType +import me.rhunk.snapenhance.common.data.QuotedMessageContentStatus +import me.rhunk.snapenhance.common.data.RuleState +import me.rhunk.snapenhance.common.util.ktx.longHashCode +import me.rhunk.snapenhance.common.util.lazyBridge +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent +import me.rhunk.snapenhance.core.event.events.impl.BuildMessageEvent +import me.rhunk.snapenhance.core.features.MessagingRuleFeature +import me.rhunk.snapenhance.core.ui.addForegroundDrawable +import me.rhunk.snapenhance.core.ui.removeForegroundDrawable +import me.rhunk.snapenhance.core.util.EvictingMap +import java.util.concurrent.Executors +import kotlin.system.measureTimeMillis + +class MessageLogger : MessagingRuleFeature("MessageLogger", MessagingRuleType.EXCLUDE_MESSAGE_LOGGER) { + companion object { + const val PREFETCH_MESSAGE_COUNT = 20 + const val PREFETCH_FEED_COUNT = 20 + } + + private val loggerInterface by lazyBridge { context.bridgeClient.getMessageLogger() } + + val isEnabled get() = context.config.messaging.messageLogger.globalState == true + + private val threadPool = Executors.newFixedThreadPool(10) + + private val usernameCache = EvictingMap<String, String>(500) // user id -> username + private val groupTitleCache = EvictingMap<String, String?>(500) // conversation id -> group title + + private val cachedIdLinks = EvictingMap<Long, Long>(500) // client id -> server id + private val fetchedMessages = mutableListOf<Long>() // list of unique message ids + private val deletedMessageCache = EvictingMap<Long, JsonObject>(200) // unique message id -> message json object + + fun isMessageDeleted(conversationId: String, clientMessageId: Long) + = makeUniqueIdentifier(conversationId, clientMessageId)?.let { deletedMessageCache.containsKey(it) } ?: false + + fun deleteMessage(conversationId: String, clientMessageId: Long) { + val uniqueMessageId = makeUniqueIdentifier(conversationId, clientMessageId) ?: return + fetchedMessages.remove(uniqueMessageId) + deletedMessageCache.remove(uniqueMessageId) + loggerInterface.deleteMessage(conversationId, uniqueMessageId) + } + + fun getMessageObject(conversationId: String, clientMessageId: Long): JsonObject? { + val uniqueMessageId = makeUniqueIdentifier(conversationId, clientMessageId) ?: return null + if (deletedMessageCache.containsKey(uniqueMessageId)) { + return deletedMessageCache[uniqueMessageId] + } + return loggerInterface.getMessage(conversationId, uniqueMessageId)?.let { + JsonParser.parseString(it.toString(Charsets.UTF_8)).asJsonObject + } + } + + fun getMessageProto(conversationId: String, clientMessageId: Long): ProtoReader? { + return getMessageObject(conversationId, clientMessageId)?.let { message -> + ProtoReader(message.getAsJsonObject("mMessageContent").getAsJsonArray("mContent") + .map { it.asByte } + .toByteArray()) + } + } + + fun getChatEdits(conversationId: String, clientMessageId: Long): List<LoggedChatEdit> { + val uniqueMessageId = makeUniqueIdentifier(conversationId, clientMessageId) ?: return emptyList() + return loggerInterface.getChatEdits(conversationId, uniqueMessageId) + } + + private fun computeMessageIdentifier(conversationId: String, orderKey: Long) = (orderKey.toString() + conversationId).longHashCode() + + private fun makeUniqueIdentifier(conversationId: String, clientMessageId: Long): Long? { + val serverMessageId = cachedIdLinks[clientMessageId] ?: + context.database.getConversationMessageFromId(clientMessageId)?.serverMessageId?.toLong()?.also { + cachedIdLinks[clientMessageId] = it + } + ?: return run { + context.log.error("Failed to get server message id for $conversationId $clientMessageId") + null + } + return computeMessageIdentifier(conversationId, serverMessageId) + } + + override fun init() { + if (!isEnabled) return + val keepMyOwnMessages = context.config.messaging.messageLogger.keepMyOwnMessages.get() + val messageFilter by context.config.messaging.messageLogger.messageFilter + + onNextActivityCreate(defer = true) { + if (!context.database.hasArroyo()) return@onNextActivityCreate + measureTimeMillis { + val conversationIds = context.database.getFeedEntries(PREFETCH_FEED_COUNT).map { it.key!! } + if (conversationIds.isEmpty()) return@measureTimeMillis + fetchedMessages.addAll(loggerInterface.getLoggedIds(conversationIds.toTypedArray(), PREFETCH_MESSAGE_COUNT).toList()) + }.also { context.log.verbose("Loaded ${fetchedMessages.size} cached messages in ${it}ms") } + } + + context.event.subscribe(BuildMessageEvent::class, priority = 1) { event -> + val messageInstance = event.message.instanceNonNull() + if (event.message.messageState != MessageState.COMMITTED) return@subscribe + + cachedIdLinks[event.message.messageDescriptor!!.messageId!!] = event.message.orderKey!! + val conversationId = event.message.messageDescriptor!!.conversationId.toString() + //exclude messages sent by me + if (!keepMyOwnMessages && event.message.senderId.toString() == context.database.myUserId) return@subscribe + + val uniqueMessageIdentifier = computeMessageIdentifier(conversationId, event.message.orderKey!!) + val messageContentType = event.message.messageContent!!.contentType + val isMessageDeleted = messageContentType == ContentType.STATUS || event.message.messageContent!!.quotedMessage?.status?.let { + it == QuotedMessageContentStatus.DELETED || it == QuotedMessageContentStatus.STORYMEDIADELETEDBYPOSTER + } == true + + if (!isMessageDeleted) { + if (messageFilter.isNotEmpty() && !messageFilter.contains(messageContentType?.name)) return@subscribe + if (event.message.messageMetadata?.isEdited != true) { + if (fetchedMessages.contains(uniqueMessageIdentifier)) return@subscribe + fetchedMessages.add(uniqueMessageIdentifier) + } + + threadPool.execute { + // ignore excluded conversations + if (getState(conversationId)) { + return@execute + } + + try { + loggerInterface.addMessage( + BridgeLoggedMessage().also { + it.messageId = uniqueMessageIdentifier + it.conversationId = conversationId + it.userId = event.message.senderId.toString() + it.username = usernameCache.getOrPut(it.userId) { + context.database.getFriendInfo(it.userId)?.mutableUsername ?: it.userId + } + it.sendTimestamp = event.message.messageMetadata?.createdAt ?: System.currentTimeMillis() + it.groupTitle = groupTitleCache.getOrPut(conversationId) { + context.database.getFeedEntryByConversationId(conversationId)?.feedDisplayName ?: conversationId + } + it.messageData = context.gson.toJson(messageInstance).toByteArray(Charsets.UTF_8) + } + ) + } catch (_: DeadObjectException) {} + } + + return@subscribe + } + + //query the deleted message + val deletedMessageObject: JsonObject = if (deletedMessageCache.containsKey(uniqueMessageIdentifier)) + deletedMessageCache[uniqueMessageIdentifier] + else { + loggerInterface.getMessage(conversationId, uniqueMessageIdentifier)?.let { + JsonParser.parseString(it.toString(Charsets.UTF_8)).asJsonObject + } + } ?: return@subscribe + + //if the message is a snap make it playable + if (deletedMessageObject["mMessageContent"]?.asJsonObject?.get("mContentType")?.asString == "SNAP") { + deletedMessageObject["mMetadata"].asJsonObject.addProperty("mPlayableSnapState", "PLAYABLE") + } + + //serialize all properties of messageJsonObject and put mMessageContent & mMetadata in the message object + messageInstance::class.java.declaredFields.forEach { field -> + if (field.name != "mMessageContent" && field.name != "mMetadata") return@forEach + field.isAccessible = true + deletedMessageObject[field.name]?.let { fieldValue -> + field.set(messageInstance, context.gson.fromJson(fieldValue, field.type)) + } + } + + deletedMessageCache[uniqueMessageIdentifier] = deletedMessageObject + } + + context.event.subscribe(BindViewEvent::class) { event -> + event.chatMessage { conversationId, messageId -> + event.view.removeForegroundDrawable("deletedMessage") + makeUniqueIdentifier(conversationId, messageId.toLong())?.let { serverMessageId -> + if (!deletedMessageCache.contains(serverMessageId)) return@chatMessage + } ?: return@chatMessage + + event.view.addForegroundDrawable("deletedMessage", ShapeDrawable(object: Shape() { + override fun draw(canvas: Canvas, paint: Paint) { + canvas.drawRect(0f, 0f, canvas.width.toFloat(), canvas.height.toFloat(), Paint().apply { + color = context.config.messaging.messageLogger.deletedMessageColor.getNullable() ?: MessagingTweaks.DELETED_MESSAGE_COLOR + }) + } + })) + } + } + } + + override fun getRuleState() = RuleState.WHITELIST +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/StealthMode.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/StealthMode.kt new file mode 100644 index 0000000000..079d0f7e4d --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/StealthMode.kt @@ -0,0 +1,43 @@ +package me.rhunk.snapenhance.core.features.impl.spying + +import me.rhunk.snapenhance.common.data.MessagingRuleType +import me.rhunk.snapenhance.core.event.events.impl.OnSnapInteractionEvent +import me.rhunk.snapenhance.core.features.MessagingRuleFeature +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID +import java.util.concurrent.CopyOnWriteArraySet + +class StealthMode : MessagingRuleFeature("StealthMode", MessagingRuleType.STEALTH) { + private val displayedMessageQueue = CopyOnWriteArraySet<Long>() + private val snapInteractionQueue = CopyOnWriteArraySet<Long>() + + fun addDisplayedMessageException(clientMessageId: Long) { + displayedMessageQueue.add(clientMessageId) + } + + fun addSnapInteractionException(messageId: Long) { + snapInteractionQueue.add(messageId) + } + + + override fun init() { + val isConversationInStealthMode: (SnapUUID) -> Boolean = { canUseRule(it.toString()) } + + arrayOf("mediaMessagesDisplayed", "displayedMessages").forEach { methodName: String -> + context.classCache.conversationManager.hook(methodName, HookStage.BEFORE) { param -> + if (displayedMessageQueue.removeIf { param.arg<Long>(1) == it }) return@hook + if (isConversationInStealthMode(SnapUUID(param.arg(0)))) { + param.setResult(null) + } + } + } + + context.event.subscribe(OnSnapInteractionEvent::class) { event -> + if (snapInteractionQueue.removeIf { event.messageId == it }) return@subscribe + if (isConversationInStealthMode(event.conversationId)) { + event.canceled = true + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/BypassScreenshotDetection.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/BypassScreenshotDetection.kt new file mode 100644 index 0000000000..cafef0f827 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/BypassScreenshotDetection.kt @@ -0,0 +1,26 @@ +package me.rhunk.snapenhance.core.features.impl.tweaks + +import android.app.Activity +import android.content.ContentResolver +import android.database.ContentObserver +import android.net.Uri +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook + +class BypassScreenshotDetection : Feature("BypassScreenshotDetection") { + override fun init() { + if (!context.config.messaging.bypassScreenshotDetection.get()) return + Activity::class.java.hook("registerScreenCaptureCallback", HookStage.BEFORE) { param -> + param.setResult(null) + } + ContentResolver::class.java.methods.first { + it.name == "registerContentObserver" && + it.parameterTypes.contentEquals(arrayOf(android.net.Uri::class.java, Boolean::class.javaPrimitiveType, ContentObserver::class.java)) + }.hook(HookStage.BEFORE) { param -> + val uri = param.arg<Uri>(0) + if (uri.host != "media") return@hook + param.setResult(null) + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/CameraTweaks.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/CameraTweaks.kt new file mode 100644 index 0000000000..765da0777d --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/CameraTweaks.kt @@ -0,0 +1,117 @@ +package me.rhunk.snapenhance.core.features.impl.tweaks + +import android.Manifest +import android.annotation.SuppressLint +import android.content.ContextWrapper +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.CameraCharacteristics.Key +import android.hardware.camera2.CameraManager +import android.media.Image +import android.media.ImageReader +import android.util.Range +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.ktx.setObjectField +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer + +class CameraTweaks : Feature("Camera Tweaks") { + private fun parseResolution(resolution: String): IntArray? { + return runCatching { resolution.split("x").map { it.toInt() }.toIntArray() }.getOrNull() + } + + @SuppressLint("MissingPermission", "DiscouragedApi") + override fun init() { + val config = context.config.camera + + config.startupDefaultCamera.getNullable()?.let { defaultCamera -> + context.database.setCameraType(if (defaultCamera == "back") "BACK_FACING" else "FRONT_FACING") + } + + val frontCameraId by lazy { + runCatching { context.androidContext.getSystemService(CameraManager::class.java).run { + cameraIdList.firstOrNull { getCameraCharacteristics(it).get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT } + } }.getOrNull() + } + + if (config.disableCameras.get().isNotEmpty() && frontCameraId != null) { + ContextWrapper::class.java.hook("checkPermission", HookStage.BEFORE) { param -> + val permission = param.arg<String>(0) + if (permission == Manifest.permission.CAMERA) { + param.setResult(PackageManager.PERMISSION_GRANTED) + } + } + } + + var isLastCameraFront = false + + CameraManager::class.java.hook("openCamera", HookStage.BEFORE) { param -> + val cameraManager = param.thisObject() as? CameraManager ?: return@hook + val cameraId = param.arg<String>(0) + val disabledCameras = config.disableCameras.get() + + if (disabledCameras.size >= 2) { + param.setResult(null) + return@hook + } + + isLastCameraFront = cameraId == frontCameraId + + if (disabledCameras.size != 1) return@hook + + // trick to replace unwanted camera with another one + if ((disabledCameras.contains("front") && isLastCameraFront) || (disabledCameras.contains("back") && !isLastCameraFront)) { + param.setArg(0, cameraManager.cameraIdList.filterNot { it == cameraId }.firstOrNull() ?: return@hook) + isLastCameraFront = !isLastCameraFront + } + } + + ImageReader::class.java.hook("newInstance", HookStage.BEFORE) { param -> + val captureResolutionConfig = config.customResolution.getNullable()?.takeIf { it.isNotEmpty() }?.let { parseResolution(it) } + ?: (if (isLastCameraFront) config.overrideFrontResolution.getNullable() else config.overrideBackResolution.getNullable())?.let { parseResolution(it) } ?: return@hook + param.setArg(0, captureResolutionConfig[0]) + param.setArg(1, captureResolutionConfig[1]) + } + + CameraCharacteristics::class.java.hook("get", HookStage.AFTER) { param -> + val key = param.argNullable<Key<*>>(0) ?: return@hook + + if (key == CameraCharacteristics.LENS_FACING) { + val disabledCameras = config.disableCameras.get() + //FIXME: unexpected behavior when app is resumed + if (disabledCameras.size == 1) { + val isFrontCamera = param.getResult() as? Int == CameraCharacteristics.LENS_FACING_FRONT + if ((disabledCameras.contains("front") && isFrontCamera) || (disabledCameras.contains("back") && !isFrontCamera)) { + param.setResult(if (isFrontCamera) CameraCharacteristics.LENS_FACING_BACK else CameraCharacteristics.LENS_FACING_FRONT) + } + } + } + + if (key == CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES) { + val isFrontCamera = param.invokeOriginal( + arrayOf(CameraCharacteristics.LENS_FACING) + ) == CameraCharacteristics.LENS_FACING_FRONT + val customFrameRate = (if (isFrontCamera) config.frontCustomFrameRate.getNullable() else config.backCustomFrameRate.getNullable())?.toIntOrNull() ?: return@hook + param.setResult(arrayOf(Range(customFrameRate, customFrameRate))) + } + } + + if (config.blackPhotos.get()) { + findClass("android.media.ImageReader\$SurfaceImage").hook("getPlanes", HookStage.AFTER) { param -> + val image = param.thisObject() as? Image ?: return@hook + val planes = param.getResult() as? Array<*> ?: return@hook + val output = ByteArrayOutputStream() + Bitmap.createBitmap(image.width, image.height, Bitmap.Config.ARGB_8888).apply { + compress(Bitmap.CompressFormat.JPEG, 100, output) + recycle() + } + planes.filterNotNull().forEach { plane -> + plane.setObjectField("mBuffer", ByteBuffer.wrap(output.toByteArray())) + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/DisablePermissionRequests.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/DisablePermissionRequests.kt new file mode 100644 index 0000000000..077e9410d6 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/DisablePermissionRequests.kt @@ -0,0 +1,23 @@ +package me.rhunk.snapenhance.core.features.impl.tweaks + +import android.content.ContextWrapper +import android.content.pm.PackageManager +import me.rhunk.snapenhance.common.config.impl.Global +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook + +class DisablePermissionRequests : Feature("Disable Permission Requests") { + override fun init() { + val deniedPermissions by context.config.global.disablePermissionRequests + if (deniedPermissions.isEmpty()) return + + ContextWrapper::class.java.hook("checkPermission", HookStage.BEFORE) { param -> + val permission = param.arg<String>(0) + val permissionKey = Global.permissionMap[permission] ?: return@hook + if (deniedPermissions.contains(permissionKey)) { + param.setResult(PackageManager.PERMISSION_GRANTED) + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/DisableSnapModeRestrictions.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/DisableSnapModeRestrictions.kt new file mode 100644 index 0000000000..ba44d2d3a6 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/DisableSnapModeRestrictions.kt @@ -0,0 +1,18 @@ +package me.rhunk.snapenhance.core.features.impl.tweaks + +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.dataBuilder +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hookConstructor + +class DisableSnapModeRestrictions: Feature("Disable Snap Mode Restrictions") { + override fun init() { + if (!context.config.messaging.disableSnapModeRestrictions.get()) return + + findClass("com.snapchat.client.messaging.SnapModeInfo").hookConstructor(HookStage.AFTER) { param -> + param.thisObject<Any>().dataBuilder { + set("mSelfDestructSnapDurationMs", null) + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/DoubleTapChatAction.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/DoubleTapChatAction.kt new file mode 100644 index 0000000000..578bc04f6a --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/DoubleTapChatAction.kt @@ -0,0 +1,66 @@ +package me.rhunk.snapenhance.core.features.impl.tweaks + +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.data.MessageUpdate +import me.rhunk.snapenhance.common.util.ktx.copyToClipboard +import me.rhunk.snapenhance.common.util.ktx.findFieldsToString +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.impl.messaging.Messaging +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.wrapper.impl.getMessageText +import me.rhunk.snapenhance.mapper.impl.ChatEventDispatcherMapper + +class DoubleTapChatAction: Feature("Double Tap Chat Action") { + override fun init() { + var action = context.config.messaging.doubleTapChatAction.getNullable() ?: return + + context.mappings.useMapper(ChatEventDispatcherMapper::class) { + classReference.getAsClass()?.hook("onChatItemDoubleClickEvent", HookStage.BEFORE) { param -> + param.setResult(null) + val event = param.arg<Any>(0) + val viewModel = event.javaClass.findFieldsToString(event, once = true) { field, value -> value.contains("ChatViewModel") }.firstOrNull()?.get(event)?.toString() ?: return@hook + + val (conversationId, _, clientMessageId) = viewModel.substringAfter("messageId=").substringBefore(",").split(":").takeIf { it.size == 3 } ?: return@hook + + val messageId = clientMessageId.toLongOrNull() ?: return@hook + + if (action == "like_message") { + context.feature(Messaging::class).conversationManager?.reactToMessage( + conversationId, + messageId, + intentionType = 1L, + onError = {}, + onSuccess = {} + ) + } + + if (action == "copy_text") { + var messageContent = context.database.getConversationMessageFromId(messageId)?.messageContent ?: return@hook + var proto = ProtoReader(messageContent).followPath(4, 4) ?: return@hook + context.androidContext.copyToClipboard(proto.getBuffer().getMessageText(ContentType.fromMessageContainer(proto) ?: ContentType.CHAT) ?: return@hook, "Chat Message") + } + + if (action == "delete_message" || action == "mark_as_read") { + context.feature(Messaging::class).conversationManager?.updateMessage( + conversationId, + messageId, + if (action == "delete_message") MessageUpdate.ERASE else MessageUpdate.READ, + onResult = {} + ) + } + + if (action == "custom_emoji_reaction") { + context.feature(Messaging::class).conversationManager?.reactToMessage( + conversationId, + messageId, + emoji = context.config.messaging.doubleTapChatActionCustomEmoji.getNullable()?.takeIf { it.isNotEmpty() } ?: "\uD83D\uDC4D", + onError = {}, + onSuccess = {} + ) + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/HideActiveMusic.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/HideActiveMusic.kt new file mode 100644 index 0000000000..c7f95ae824 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/HideActiveMusic.kt @@ -0,0 +1,17 @@ +package me.rhunk.snapenhance.core.features.impl.tweaks + +import android.media.AudioManager +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook + +class HideActiveMusic: Feature("Hide Active Music") { + override fun init() { + if (!context.config.global.hideActiveMusic.get()) return + onNextActivityCreate { + AudioManager::class.java.hook("isMusicActive", HookStage.BEFORE) { + it.setResult(false) + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/PreventForcedKeyboard.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/PreventForcedKeyboard.kt new file mode 100644 index 0000000000..7919b2a36e --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/PreventForcedKeyboard.kt @@ -0,0 +1,19 @@ +package me.rhunk.snapenhance.core.features.impl.tweaks + +import android.view.View +import android.view.inputmethod.InputMethodManager +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook + +class PreventForcedKeyboard : Feature("Prevent Forced Keyboard") { + override fun init() { + if (!context.config.userInterface.preventForcedKeyboard.get()) return + + InputMethodManager::class.java.hook("showSoftInput", HookStage.BEFORE) { param -> + if (param.argNullable<View>(0)?.javaClass?.name?.endsWith("InputBarEditText") == true && param.args().getOrNull(1) is Int) { + param.setResult(false) + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/PreventMessageListAutoScroll.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/PreventMessageListAutoScroll.kt new file mode 100644 index 0000000000..5c6e94256c --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/PreventMessageListAutoScroll.kt @@ -0,0 +1,54 @@ +package me.rhunk.snapenhance.core.features.impl.tweaks + +import android.util.SparseIntArray +import androidx.core.util.size +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.util.ktx.setObjectField +import me.rhunk.snapenhance.mapper.impl.FoldingLayoutMapper + + +class PreventMessageListAutoScroll : Feature("PreventMessageListAutoScroll") { + companion object { + private const val MIN_SCROLL_ITEMS = 4 + } + + override fun init() { + if (!context.config.userInterface.preventMessageListAutoScroll.get()) return + + val foldingLayoutManager = findClass("com.snap.messaging.chat.features.messagelist.FoldingLayoutManager") + val recyclerViewClass = findClass("androidx.recyclerview.widget.RecyclerView") + + val computeVerticalScrollOffsetMethod = recyclerViewClass.getMethod("computeVerticalScrollOffset") + val computeVerticalScrollExtentMethod = recyclerViewClass.getMethod("computeVerticalScrollExtent") + val computeVerticalScrollRangeMethod = recyclerViewClass.getMethod("computeVerticalScrollRange") + + context.mappings.useMapper(FoldingLayoutMapper::class) { + foldingLayoutManager.hook(onLayoutCompletedMethod.getAsString() ?: throw NoSuchMethodError("onLayoutCompleted"), HookStage.BEFORE) { param -> + val instance = param.thisObject<Any>() + val shouldScrollToBottom = instance.getObjectField(shouldScrollToBottomField.getAsString()!!) as Boolean + + if (shouldScrollToBottom) { + val sparseIntArray = param.thisObject<Any>().getObjectField(sizeSparseArrayField.getAsString()!!) as SparseIntArray + val recyclerView = param.thisObject<Any>().getObjectField(recyclerViewField.getAsString()!!) + + val scrollOffset = computeVerticalScrollRangeMethod.invoke(recyclerView) as Int - (computeVerticalScrollOffsetMethod.invoke(recyclerView) as Int + computeVerticalScrollExtentMethod.invoke(recyclerView) as Int) + + var layoutSizeSum = 0 + + for (i in 0 until sparseIntArray.size) { + if (sparseIntArray.keyAt(i) < MIN_SCROLL_ITEMS) { + layoutSizeSum += sparseIntArray.valueAt(i) + } + } + + if (scrollOffset <= 0 || scrollOffset > layoutSizeSum) { + instance.setObjectField(shouldScrollToBottomField.getAsString()!!, false) + } + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/RemoveGroupsLockedStatus.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/RemoveGroupsLockedStatus.kt new file mode 100644 index 0000000000..8fc287cc73 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/RemoveGroupsLockedStatus.kt @@ -0,0 +1,19 @@ +package me.rhunk.snapenhance.core.features.impl.tweaks + +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.dataBuilder +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hookConstructor + +class RemoveGroupsLockedStatus : Feature("Remove Groups Locked Status") { + override fun init() { + if (!context.config.messaging.removeGroupsLockedStatus.get()) return + onNextActivityCreate(defer = true) { + context.classCache.conversation.hookConstructor(HookStage.AFTER) { param -> + param.thisObject<Any>().dataBuilder { + set("mLockedState", "UNLOCKED") + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/RequerySqlite.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/RequerySqlite.kt new file mode 100644 index 0000000000..46662b9f04 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/RequerySqlite.kt @@ -0,0 +1,40 @@ +package me.rhunk.snapenhance.core.features.impl.tweaks + +import me.rhunk.snapenhance.common.data.MessagingRuleType +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook + +class RequerySqlite : Feature("Requery Sqlite") { + override fun init() { + val hideQuickAddSuggestions = context.config.userInterface.hideQuickAddSuggestions.get() + val hideFriendFeedEntry = context.config.userInterface.hideFriendFeedEntry.get() + val hideSuggestedStories = context.config.userInterface.hideStorySuggestions.get().contains("hide_suggested_friend_stories") + + if (!hideQuickAddSuggestions && !hideFriendFeedEntry && !hideSuggestedStories) return + + findClass("io.requery.android.database.sqlite.SQLiteDatabase").hook("rawQueryWithFactory", HookStage.BEFORE) { param -> + var sqlRequest = param.argNullable<String>(1) ?: return@hook + + fun patchRequest(condition: String) { + sqlRequest.lastIndexOf("WHERE").takeIf { it != -1 }?.let { + sqlRequest = sqlRequest.substring(0, it + 5) + " $condition AND " + sqlRequest.substring(it + 5) + param.setArg(1, sqlRequest) + } + } + + if (hideQuickAddSuggestions && sqlRequest.contains("SuggestedFriendPlacement")) { + patchRequest("0 = 1") + } + + if (hideSuggestedStories && sqlRequest.contains("DiscoverFeedFriendStoriesViewV2 AS DFStories")) { + patchRequest("DFStories.isFriendOfFriend = 0") + } + + if (hideFriendFeedEntry && sqlRequest.startsWith("SELECT") && (sqlRequest.contains("FriendWithUsername")) && sqlRequest.contains("userId")) { + val ids = context.bridgeClient.getRuleIds(MessagingRuleType.HIDE_FRIEND_FEED).takeIf { it.isNotEmpty() } ?: return@hook + patchRequest(ids.joinToString(" AND ") { "${if (sqlRequest.contains("Friend.userId")) "Friend.userId" else "userId "} != '$it'" }) + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/UnsaveableMessages.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/UnsaveableMessages.kt new file mode 100644 index 0000000000..bc73dac104 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/UnsaveableMessages.kt @@ -0,0 +1,44 @@ +package me.rhunk.snapenhance.core.features.impl.tweaks + +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.data.MessagingRuleType +import me.rhunk.snapenhance.common.util.protobuf.ProtoEditor +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.core.event.events.impl.NativeUnaryCallEvent +import me.rhunk.snapenhance.core.features.MessagingRuleFeature +import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID + +class UnsaveableMessages : MessagingRuleFeature( + "Unsaveable Messages", + MessagingRuleType.UNSAVEABLE_MESSAGES +) { + override fun init() { + if (context.config.rules.getRuleState(MessagingRuleType.UNSAVEABLE_MESSAGES) == null) return + + context.event.subscribe(NativeUnaryCallEvent::class) { event -> + if (event.uri != "/messagingcoreservice.MessagingCoreService/CreateContentMessage") return@subscribe + + val protoReader = ProtoReader(event.buffer) + val conversationIds = mutableListOf<String>() + + protoReader.eachBuffer(3) { + if (contains(2)) { + return@eachBuffer + } + conversationIds.add(SnapUUID(getByteArray(1, 1, 1) ?: return@eachBuffer).toString()) + } + + if (conversationIds.all { canUseRule(it) }) { + event.buffer = ProtoEditor(event.buffer).apply { + edit(4) { + val contentType = firstOrNull(2)?.value + if (contentType != ContentType.STATUS.id.toLong() && firstOrNull(4)?.toReader()?.contains(11) != true && contentType != null) { + remove(7) + addVarInt(7, 3) // set savePolicy to VIEW_SESSION except for status and snaps + } + } + }.toByteArray() + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/VoiceNoteOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/VoiceNoteOverride.kt new file mode 100644 index 0000000000..430dd80535 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/VoiceNoteOverride.kt @@ -0,0 +1,155 @@ +package me.rhunk.snapenhance.core.features.impl.tweaks + +import android.view.ViewGroup +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.core.SnapEnhance +import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.impl.downloader.MediaDownloader +import me.rhunk.snapenhance.core.features.impl.messaging.Messaging +import me.rhunk.snapenhance.core.ui.getComposerContext +import me.rhunk.snapenhance.core.util.dataBuilder +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.ktx.getId +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.util.makeFunctionProxy + +class VoiceNoteOverride: Feature("Voice Note Override") { + override fun init() { + val voiceNoteAutoPlay = context.config.experimental.voiceNoteAutoPlay.get() + val autoDownloadVoiceNotes = context.config.downloader.autoDownloadVoiceNotes.get() + + if (!autoDownloadVoiceNotes && !voiceNoteAutoPlay) return + + val playbackMap = sortedMapOf<Long, MutableList<Any>>() + + fun setPlaybackState(componentContext: Any, state: String): Boolean { + val seek = componentContext.getObjectField("_seek") ?: return false + seek.javaClass.getMethod("invoke", Any::class.java).invoke(seek, 0) + + val onPlayButtonTapped = componentContext.getObjectField("_onPlayButtonTapped") ?: return false + onPlayButtonTapped.javaClass.getMethod("invoke", Any::class.java).invoke( + onPlayButtonTapped, + findClass("com.snap.voicenotes.PlaybackState").enumConstants?.first { + it.toString() == state + } + ) + return true + } + + fun getCurrentContextMessageId(currentContext: Any): Long? { + return synchronized(playbackMap) { + playbackMap.entries.firstOrNull { entry -> entry.value.any { it.hashCode() == currentContext.hashCode() } }?.key + } + } + + fun playNextVoiceNote(currentContext: Any) { + val currentContextMessageId = getCurrentContextMessageId(currentContext) ?: return + + context.log.verbose("messageId=$currentContextMessageId") + + val nextPlayback = synchronized(playbackMap) { + playbackMap.entries.firstOrNull { it.key > currentContextMessageId } + } + + if (nextPlayback == null) { + context.log.verbose("No more voice notes to play") + return + } + nextPlayback.value.toList().forEach { setPlaybackState(it, "PLAYING") } + } + + context.classCache.conversationManager.apply { + arrayOf("enterConversation", "exitConversation").forEach { + hook(it, HookStage.BEFORE) { + synchronized(playbackMap) { + playbackMap.clear() + } + } + } + } + + SnapEnhance.classCache.nativeBridge.hook("createContext", HookStage.BEFORE) { param -> + val componentPath = param.arg<String>(1) + val componentContext = param.argNullable<Any>(3) + + if (componentPath != "PlaybackView@voice_notes/src/PlaybackView") return@hook + + var lastPlayerState: String? = null + + componentContext.dataBuilder { + interceptFieldInterface("_onPlayButtonTapped") { args, originalCall -> + lastPlayerState = null + context.log.verbose("onPlayButtonTapped ${args.contentToString()}") + originalCall(args) + } + + from("_playbackStateObservable") { + interceptFieldInterface("_subscribe") { subscribeArgs, originalSubscribe -> + originalSubscribe( + arrayOf( + makeFunctionProxy( + subscribeArgs[0]!! + ) { args, originalCall -> + val state = args[2]?.toString() + + if (autoDownloadVoiceNotes && state != lastPlayerState && state == "PLAYING") { + val currentConversationId = context.feature(Messaging::class).openedConversationUUID.toString() + val currentMessageId = getCurrentContextMessageId(componentContext!!) + val mediaDownloader = context.feature(MediaDownloader::class) + + context.coroutineScope.launch { + val databaseMessage = context.database.getConversationServerMessage(currentConversationId, currentMessageId ?: return@launch) ?: throw IllegalStateException("Failed to get database message") + + if (mediaDownloader.canAutoDownloadMessage(databaseMessage)) { + mediaDownloader.downloadMessageId(databaseMessage.clientMessageId.toLong(), forceDownloadFirst = true) + } + } + } + + if (voiceNoteAutoPlay && state == "PAUSED" && lastPlayerState == "PLAYING") { + lastPlayerState = null + context.log.verbose("playback finished. playing next voice note") + runCatching { + context.coroutineScope.launch(Dispatchers.Main) { + playNextVoiceNote(componentContext!!) + } + }.onFailure { + context.log.error("Failed to play next voice note", it) + } + } + + lastPlayerState = state + originalCall(args) + } + ) + ) + } + } + } + } + + onNextActivityCreate { + context.event.subscribe(BindViewEvent::class) { event -> + event.chatMessage { _, _ -> + val messagePluginContentHolder = event.view.findViewById<ViewGroup>(context.resources.getId("plugin_content_holder")) ?: return@subscribe + val composerRootView = messagePluginContentHolder.getChildAt(0) ?: return@subscribe + + val composerContext = composerRootView.getComposerContext() ?: return@subscribe + val playbackViewComponentContext = composerContext.componentContext?.get() ?: return@subscribe + + if (event.databaseMessage?.contentType != ContentType.NOTE.id) return@subscribe + + val serverMessageId = event.databaseMessage?.serverMessageId?.toLong() ?: return@subscribe + + synchronized(playbackMap) { + playbackMap.computeIfAbsent(serverMessageId) { mutableListOf() }.add(playbackViewComponentContext) + } + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/ClientBootstrapOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/ClientBootstrapOverride.kt new file mode 100644 index 0000000000..21ff52ea6f --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/ClientBootstrapOverride.kt @@ -0,0 +1,50 @@ +package me.rhunk.snapenhance.core.features.impl.ui + +import me.rhunk.snapenhance.common.config.impl.UserInterfaceTweaks +import me.rhunk.snapenhance.common.util.protobuf.ProtoEditor +import me.rhunk.snapenhance.common.util.protobuf.ProtoWriter +import me.rhunk.snapenhance.core.features.Feature +import java.io.File + + +class ClientBootstrapOverride: Feature("ClientBootstrapOverride") { + + private val clientBootstrapFolder by lazy { File(context.androidContext.filesDir, "client-bootstrap") } + + private val appearanceStartupConfigFile by lazy { File(clientBootstrapFolder, "appearancestartupconfig") } + private val plusFile by lazy { File(clientBootstrapFolder, "plus") } + + override fun init() { + val bootstrapOverrideConfig = context.config.userInterface.bootstrapOverride + + if (!clientBootstrapFolder.exists() && (bootstrapOverrideConfig.appAppearance.getNullable() != null || bootstrapOverrideConfig.homeTab.getNullable() != null)) { + clientBootstrapFolder.mkdirs() + } + + bootstrapOverrideConfig.appAppearance.getNullable()?.also { appearance -> + val state = when (appearance) { + "always_light" -> 0 + "always_dark" -> 1 + else -> return@also + }.toByte() + appearanceStartupConfigFile.writeBytes(byteArrayOf(0, 0, 0, state)) + } + + val homeTab = bootstrapOverrideConfig.homeTab.getNullable() + + if (homeTab != null) { + val plusFileBytes = plusFile.exists().let { if (it) plusFile.readBytes() else ProtoWriter().toByteArray() } + + plusFile.writeBytes( + ProtoEditor(plusFileBytes).apply { + edit { + homeTab.let { currentTab -> + remove(1) + addVarInt(1, UserInterfaceTweaks.BootstrapOverride.tabs.indexOf(currentTab) + 1) + } + } + }.toByteArray() + ) + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/ConversationToolbox.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/ConversationToolbox.kt new file mode 100644 index 0000000000..62673177fb --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/ConversationToolbox.kt @@ -0,0 +1,172 @@ +package me.rhunk.snapenhance.core.features.impl.ui + +import android.annotation.SuppressLint +import android.app.AlertDialog +import android.view.Gravity +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.times +import me.rhunk.snapenhance.common.scripting.ui.EnumScriptInterface +import me.rhunk.snapenhance.common.scripting.ui.InterfaceManager +import me.rhunk.snapenhance.common.scripting.ui.ScriptInterface +import me.rhunk.snapenhance.common.ui.createComposeAlertDialog +import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.impl.messaging.Messaging + + +data class ComposableMenu( + val title: String, + val filter: (conversationId: String) -> Boolean, + val composable: @Composable (alertDialog: AlertDialog, conversationId: String) -> Unit, +) + +class ConversationToolbox : Feature("Conversation Toolbox") { + private val composableList = mutableListOf<ComposableMenu>() + private val expandedComposableCache = mutableStateMapOf<String, Boolean>() + + fun addComposable(title: String, filter: (conversationId: String) -> Boolean = { true }, composable: @Composable (alertDialog: AlertDialog, conversationId: String) -> Unit) { + composableList.add( + ComposableMenu(title, filter, composable) + ) + } + + @SuppressLint("SetTextI18n") + override fun init() { + onNextActivityCreate { + context.event.subscribe(AddViewEvent::class) { event -> + if (composableList.isEmpty()) return@subscribe + + val chatInputBar by getChatInputBar(event) ?: return@subscribe + + chatInputBar?.addView(FrameLayout(event.view.context).apply { + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + (52 * context.resources.displayMetrics.density).toInt(), + ).apply { + gravity = Gravity.BOTTOM + } + setPadding(25, 0, 25, 0) + + addView(TextView(event.view.context).apply { + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { + gravity = Gravity.CENTER_VERTICAL + } + setOnClickListener { + openToolbox() + } + textSize = 21f + text = "\uD83E\uDDF0" + }) + }) + } + + context.scriptRuntime.eachModule { + val interfaceManager = getBinding(InterfaceManager::class)?.takeIf { + it.hasInterface(EnumScriptInterface.CONVERSATION_TOOLBOX) + } ?: return@eachModule + addComposable("\uD83D\uDCDC ${moduleInfo.displayName}") { alertDialog, conversationId -> + ScriptInterface(remember { + interfaceManager.buildInterface(EnumScriptInterface.CONVERSATION_TOOLBOX, mapOf( + "alertDialog" to alertDialog, + "conversationId" to conversationId, + )) + } ?: return@addComposable) + } + } + } + } + + private fun openToolbox() { + val openedConversationId = context.feature(Messaging::class).openedConversationUUID?.toString() ?: run { + context.shortToast("You must open a conversation first") + return + } + + createComposeAlertDialog(context.mainActivity!!) { alertDialog -> + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn( + min = 100.dp, + max = LocalConfiguration.current.screenHeightDp * 0.8f.dp + ) + .verticalScroll(rememberScrollState()) + ) { + Text("Conversation Toolbox", fontSize = 20.sp, modifier = Modifier + .fillMaxWidth() + .padding(10.dp), textAlign = TextAlign.Center) + Spacer(modifier = Modifier.height(10.dp)) + + composableList.reversed().forEach { (title, filter, composable) -> + if (!filter(openedConversationId)) return@forEach + Card( + modifier = Modifier + .fillMaxWidth() + .padding(5.dp), + shape = MaterialTheme.shapes.medium + ) { + Row( + modifier = Modifier + .clickable { + expandedComposableCache[title] = !(expandedComposableCache[title] ?: false) + } + .fillMaxWidth() + .padding(10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + imageVector = if (expandedComposableCache[title] == true) Icons.Filled.KeyboardArrowDown else Icons.Filled.KeyboardArrowUp, + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), + ) + Text(title, fontSize = 16.sp, fontStyle = FontStyle.Italic) + } + if (expandedComposableCache[title] == true) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp) + ) { + runCatching { + composable(alertDialog, openedConversationId) + }.onFailure { throwable -> + Text("Failed to load composable: ${throwable.message}") + context.log.error("Failed to load composable: ${throwable.message}", throwable) + } + } + } + } + } + } + }.show() + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/CustomStreaksExpirationFormat.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/CustomStreaksExpirationFormat.kt new file mode 100644 index 0000000000..c2fec3be9e --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/CustomStreaksExpirationFormat.kt @@ -0,0 +1,83 @@ +package me.rhunk.snapenhance.core.features.impl.ui + +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.hook.hookConstructor +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID +import me.rhunk.snapenhance.mapper.impl.StreaksExpirationMapper +import java.util.concurrent.ConcurrentHashMap +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds + +class CustomStreaksExpirationFormat: Feature("CustomStreaksExpirationFormat") { + private fun Long.padZero(): String { + return this.toString().padStart(2, '0') + } + + private val streakCache = ConcurrentHashMap<String, Pair<Int, Long>>() + + override fun init() { + context.classCache.feedEntry.hookConstructor(HookStage.AFTER) { param -> + val conversationId = SnapUUID(param.thisObject<Any>().getObjectField("mConversationId")).toString() + + val streakMetadata = param.thisObject<Any>().getObjectField("mStreakMetadata") ?: apply { + if (streakCache.containsKey(conversationId)) streakCache.remove(conversationId) + return@hookConstructor + } + + streakCache.put(conversationId, streakMetadata.getObjectField("mCount") as Int to + streakMetadata.getObjectField("mExpirationTimestampMs") as Long) + } + + onNextActivityCreate { + val expirationFormat by context.config.experimental.customStreaksExpirationFormat + if (expirationFormat.isNotEmpty() || context.config.userInterface.streakExpirationInfo.get()) { + context.mappings.useMapper(StreaksExpirationMapper::class) { + simpleStreaksFormatterClass.getAsClass()?.hook( + formatSimpleStreaksTextMethod.get() ?: return@useMapper, + HookStage.AFTER + ) { param -> + val result = param.getResult() as? String ?: return@hook + + streakCache[param.arg<String>(1)]?.also { (streakCount, expirationTime) -> + if (expirationTime <= 0L) return@also + + if (expirationFormat.isEmpty()) { + val remainingTime = (expirationTime - System.currentTimeMillis()).milliseconds.inWholeHours + var emojiIndex = result.indexOfFirst { it.code > 127 }.takeIf { it != -1 } + ?.let { it + 2 } + + if (emojiIndex == null) { + emojiIndex = result.length + } + + param.setResult( + result.substring(0, emojiIndex) + remainingTime + result.substring(emojiIndex) + ) + return@hook + } + + val delta = (expirationTime - System.currentTimeMillis()).milliseconds + + val hourGlassEmoji = + if (delta.inWholeMilliseconds in 1..(15.hours.inWholeMilliseconds)) if (expirationTime % 2 == 0L) "\u23F3" else "\u231B" else "" + + param.setResult( + expirationFormat + .replace("%c", streakCount.toString()) + .replace("%e", hourGlassEmoji) + .replace("%d", delta.inWholeDays.toString()) + .replace("%h", (delta.inWholeHours % 24).padZero()) + .replace("%m", (delta.inWholeMinutes % 60).padZero()) + .replace("%s", (delta.inWholeSeconds % 60).padZero()) + .replace("%w", delta.toString()) + ) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/DefaultVolumeControls.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/DefaultVolumeControls.kt new file mode 100644 index 0000000000..d2439bc420 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/DefaultVolumeControls.kt @@ -0,0 +1,20 @@ +package me.rhunk.snapenhance.core.features.impl.ui + +import android.view.KeyEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook + +class DefaultVolumeControls : Feature("Default Volume Controls") { + override fun init() { + if (!context.config.global.defaultVolumeControls.get()) return + onNextActivityCreate { activity -> + activity::class.java.hook("onKeyDown", HookStage.BEFORE) { param -> + val keyCode = param.arg<Int>(0) + if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || keyCode == KeyEvent.KEYCODE_VOLUME_UP) { + param.setResult(false) + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/DisableConfirmationDialogs.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/DisableConfirmationDialogs.kt new file mode 100644 index 0000000000..a5be4ac715 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/DisableConfirmationDialogs.kt @@ -0,0 +1,65 @@ +package me.rhunk.snapenhance.core.features.impl.ui + +import android.view.View +import android.widget.TextView +import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.ui.children +import me.rhunk.snapenhance.core.ui.triggerRootCloseTouchEvent +import me.rhunk.snapenhance.core.util.ktx.getId +import me.rhunk.snapenhance.core.util.ktx.getIdentifier +import java.util.regex.Pattern + +class DisableConfirmationDialogs : Feature("Disable Confirmation Dialogs") { + override fun init() { + onNextActivityCreate { + val disableConfirmationDialogs = context.config.global.disableConfirmationDialogs.get().takeIf { it.isNotEmpty() } ?: return@onNextActivityCreate + val dialogContent = context.resources.getId("dialog_content") + val alertDialogTitle = context.resources.getId("alert_dialog_title") + + val questions = listOf( + "erase_message" to "erase_learn_more_dialog_title", + "erase_message" to "erase_dialog_title", + "erase_message" to "snap_erase_dialog_title", + "erase_message" to "snap_erase_learn_more_dialog_title", + "remove_friend" to "action_menu_remove_friend_question", + "block_friend" to "action_menu_block_friend_question", + "ignore_friend" to "action_menu_ignore_friend_question", + "hide_friend" to "action_menu_hide_friend_question", + "hide_conversation" to "hide_or_block_clear_conversation_dialog_title", + "clear_conversation" to "action_menu_clear_conversation_dialog_title" + ).map { pair -> + pair.first to runCatching { + Pattern.compile( + context.resources.getString(context.resources.getIdentifier(pair.second, "string")) + .split("%s").joinToString(".*") { + Pattern.quote(it) + }, Pattern.CASE_INSENSITIVE) + }.onFailure { + context.log.error("Failed to compile regex for ${pair.second}", it) + }.getOrNull() + } + + context.event.subscribe(AddViewEvent::class) { event -> + if (event.parent.id != dialogContent || !event.view::class.java.name.endsWith("SnapButtonView")) return@subscribe + + val dialogTitle = event.parent.findViewById<TextView>(alertDialogTitle)?.text?.toString() ?: return@subscribe + if (event.parent.children().count { it::class.java.name.endsWith("SnapButtonView") } != 0) return@subscribe + + questions.forEach { (key, value) -> + if (!disableConfirmationDialogs.contains(key)) return@forEach + + if (value?.matcher(dialogTitle)?.matches() == true) { + event.parent.visibility = View.INVISIBLE + event.parent.post { + event.view.callOnClick() + } + event.parent.postDelayed({ + context.mainActivity!!.triggerRootCloseTouchEvent() + }, 400) + } + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/EditTextOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/EditTextOverride.kt new file mode 100644 index 0000000000..20630f5095 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/EditTextOverride.kt @@ -0,0 +1,37 @@ +package me.rhunk.snapenhance.core.features.impl.ui + +import android.text.InputFilter +import android.text.InputType +import android.widget.EditText +import android.widget.TextView +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.hook.hookConstructor + +class EditTextOverride : Feature("Edit Text Override") { + override fun init() { + val editTextOverride by context.config.userInterface.editTextOverride + if (editTextOverride.isEmpty()) return + + onNextActivityCreate { + if (editTextOverride.contains("bypass_text_input_limit")) { + TextView::class.java.getMethod("setFilters", Array<InputFilter>::class.java) + .hook(HookStage.BEFORE) { param -> + param.setArg(0, param.arg<Array<InputFilter>>(0).filter { + it !is InputFilter.LengthFilter + }.toTypedArray()) + } + } + + if (editTextOverride.contains("multi_line_chat_input")) { + findClass("com.snap.messaging.chat.features.input.InputBarEditText").apply { + hookConstructor(HookStage.AFTER) { param -> + val editText = param.thisObject<EditText>() + editText.inputType = editText.inputType or InputType.TYPE_TEXT_FLAG_MULTI_LINE + } + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/FriendFeedMessagePreview.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/FriendFeedMessagePreview.kt new file mode 100644 index 0000000000..5899f6a8a8 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/FriendFeedMessagePreview.kt @@ -0,0 +1,148 @@ +package me.rhunk.snapenhance.core.features.impl.ui + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.Shape +import android.text.TextPaint +import android.view.View +import android.view.ViewGroup +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent +import me.rhunk.snapenhance.core.event.events.impl.BuildMessageEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.impl.experiments.EndToEndEncryption +import me.rhunk.snapenhance.core.ui.addForegroundDrawable +import me.rhunk.snapenhance.core.ui.removeForegroundDrawable +import me.rhunk.snapenhance.core.util.EvictingMap +import me.rhunk.snapenhance.core.util.ktx.getId +import me.rhunk.snapenhance.core.wrapper.impl.getMessageText +import java.util.WeakHashMap +import kotlin.math.absoluteValue + +class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview") { + @OptIn(ExperimentalCoroutinesApi::class) + private val coroutineDispatcher = Dispatchers.IO.limitedParallelism(1) + private val setting get() = context.config.userInterface.friendFeedMessagePreview + + private val cachedLayouts = WeakHashMap<String, View>() + private val messageCache = EvictingMap<String, List<String>>(100) + private val friendNameCache = EvictingMap<String, String>(100) + + private suspend fun fetchMessages(conversationId: String, callback: suspend () -> Unit) { + val messages = context.database.getMessagesFromConversationId(conversationId, setting.amount.get().absoluteValue)?.mapNotNull { message -> + val messageContainer = + message.messageContent + ?.let { ProtoReader(it) } + ?.followPath(4, 4)?.let { + if (context.config.experimental.e2eEncryption.globalState == true) context.feature(EndToEndEncryption::class).decryptDatabaseMessage(message) else it + } + ?: return@mapNotNull null + + val contentType = ContentType.fromMessageContainer(messageContainer) ?: ContentType.fromId(message.contentType) + val messageString = messageContainer.getBuffer().getMessageText(contentType) ?: "[${context.translation.getCategory("content_type")[contentType.name]}]" + + val friendName = friendNameCache.getOrPut(message.senderId ?: return@mapNotNull null) { + context.database.getFriendInfo(message.senderId ?: return@mapNotNull null)?.let { it.displayName?: it.mutableUsername } ?: "Unknown" + } + "$friendName: $messageString" + }?.takeIf { it.isNotEmpty() }?.reversed() + + withContext(Dispatchers.Main) { + messages?.also { messageCache[conversationId] = it } ?: run { + messageCache.remove(conversationId) + } + callback() + } + } + + override fun init() { + if (setting.globalState != true) return + + onNextActivityCreate { + val ffItemId = context.resources.getId("ff_item") + + val density = context.resources.displayMetrics.density + + val secondaryTextSize = 10 * density + val ffSdlAvatarMargin = (7 * density).toInt() + val ffSdlAvatarSize = (43 * density).toInt() + val ffSdlPrimaryTextStartMargin = 6 * density + + val feedEntryHeight = ffSdlAvatarSize + ffSdlAvatarMargin * 2 + (4 * density).toInt() + val separatorHeight = (density * 2).toInt() + val textPaint = TextPaint().apply { + textSize = secondaryTextSize + } + + context.event.subscribe(BuildMessageEvent::class) { param -> + val conversationId = param.message.messageDescriptor?.conversationId?.toString() ?: return@subscribe + val cachedView = cachedLayouts[conversationId] ?: return@subscribe + context.coroutineScope.launch { + fetchMessages(conversationId) { + cachedView.postInvalidateDelayed(100L) + } + } + } + + context.event.subscribe(BindViewEvent::class) { param -> + param.friendFeedItem { conversationId -> + val frameLayout = param.view as ViewGroup + val ffItem = frameLayout.findViewById<View>(ffItemId) + + context.coroutineScope.launch(coroutineDispatcher) { + withContext(Dispatchers.Main) { + cachedLayouts.remove(conversationId) + frameLayout.removeForegroundDrawable("ffItem") + } + + fetchMessages(conversationId) { + var maxTextHeight = 0 + val previewContainerHeight = messageCache[conversationId]?.sumOf { msg -> + val rect = Rect() + textPaint.getTextBounds(msg, 0, msg.length, rect) + rect.height().also { + if (it > maxTextHeight) maxTextHeight = it + }.plus(separatorHeight) + } ?: run { + ffItem.layoutParams = ffItem.layoutParams.apply { + height = ViewGroup.LayoutParams.MATCH_PARENT + } + return@fetchMessages + } + + ffItem.layoutParams = ffItem.layoutParams.apply { + height = feedEntryHeight + previewContainerHeight + separatorHeight + } + + cachedLayouts[conversationId] = frameLayout + + frameLayout.addForegroundDrawable("ffItem", ShapeDrawable(object: Shape() { + override fun draw(canvas: Canvas, paint: Paint) { + val offsetY = canvas.height.toFloat() - previewContainerHeight + paint.textSize = secondaryTextSize + paint.color = context.userInterface.colorPrimary + paint.typeface = context.userInterface.avenirNextTypeface + + messageCache[conversationId]?.forEachIndexed { index, messageString -> + canvas.drawText(messageString, + feedEntryHeight + ffSdlPrimaryTextStartMargin, + offsetY + index * maxTextHeight, + paint + ) + } + } + })) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/FriendNotes.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/FriendNotes.kt new file mode 100644 index 0000000000..1c2fdeafff --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/FriendNotes.kt @@ -0,0 +1,83 @@ +package me.rhunk.snapenhance.core.features.impl.ui + +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.DisposableEffect +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.graphics.Color +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.unit.dp +import me.rhunk.snapenhance.common.ui.AutoClearKeyboardFocus +import me.rhunk.snapenhance.common.ui.EditNoteTextField +import me.rhunk.snapenhance.common.ui.createComposeView +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState +import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.ui.getComposerContext +import me.rhunk.snapenhance.core.util.ktx.getObjectFieldOrNull + +class FriendNotes: Feature("Friend Notes") { + override fun init() { + if (!context.config.experimental.friendNotes.get()) return + + context.event.subscribe(AddViewEvent::class) { event -> + if (!event.viewClassName.endsWith("UnifiedProfileFlatlandProfileViewTopViewFrameLayout")) return@subscribe + + val viewGroup = (event.view as? ViewGroup) ?: return@subscribe + viewGroup.post { + val composerRootView = viewGroup.getChildAt(0) ?: return@post + val composerContext = composerRootView.getComposerContext() ?: return@post + val userId = composerContext.viewModel?.getObjectFieldOrNull("_userId")?.toString() ?: return@post + + if (userId == context.database.myUserId) return@post + + viewGroup.removeView(composerRootView) + + val manageNotesView = createComposeView(viewGroup.context, ViewCompositionStrategy.DisposeOnDetachedFromWindow) { + val primaryColor = remember { Color(this@FriendNotes.context.userInterface.colorPrimary) } + var isFetched by remember { mutableStateOf(false) } + var scopeNotes by rememberAsyncMutableState(null) { + this@FriendNotes.context.bridgeClient.getScopeNotes(userId).also { + isFetched = true + } + } + + DisposableEffect(Unit) { + onDispose { + runCatching { + if (!isFetched) return@runCatching + context.bridgeClient.setScopeNotes(userId, scopeNotes) + }.onFailure { + context.log.error("Failed to save notes", it) + } + } + } + + AutoClearKeyboardFocus() + + EditNoteTextField( + modifier = Modifier.padding(top = 8.dp), + primaryColor = primaryColor, + translation = context.translation, + content = scopeNotes, + setContent = { scopeNotes = it } + ) + } + + val linearLayout = LinearLayout(viewGroup.context).apply { + orientation = LinearLayout.VERTICAL + + addView(composerRootView) + addView(manageNotesView) + } + + viewGroup.addView(linearLayout) + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/HideFriendFeedEntry.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/HideFriendFeedEntry.kt new file mode 100644 index 0000000000..2a0d1a2140 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/HideFriendFeedEntry.kt @@ -0,0 +1,71 @@ +package me.rhunk.snapenhance.core.features.impl.ui + +import me.rhunk.snapenhance.common.data.MessagingRuleType +import me.rhunk.snapenhance.common.data.RuleState + +import me.rhunk.snapenhance.core.features.MessagingRuleFeature +import me.rhunk.snapenhance.core.util.dataBuilder +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID +import me.rhunk.snapenhance.mapper.impl.CallbackMapper + +class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType = MessagingRuleType.HIDE_FRIEND_FEED) { + private fun createDeletedFeedEntry(conversationIdInstance: Any) = findClass("com.snapchat.client.messaging.DeletedFeedEntry").dataBuilder { + from("mFeedEntryIdentifier") { + set("mConversationId", conversationIdInstance) + } + set("mReason", "CLEAR_CONVERSATION") + } + + private fun filterFriendFeed(entries: ArrayList<Any>, deletedEntries: ArrayList<Any>? = null) { + entries.removeIf { feedEntry -> + val conversationIdInstance = feedEntry.getObjectField("mConversationId") ?: return@removeIf false + if (canUseRule(SnapUUID(conversationIdInstance).toString())) { + deletedEntries?.add(createDeletedFeedEntry(conversationIdInstance)!!) + true + } else { + false + } + } + } + + override fun init() { + if (!context.config.userInterface.hideFriendFeedEntry.get()) return + + context.mappings.useMapper(CallbackMapper::class) { + arrayOf( + "FetchAndSyncFeedWithConversationIdsCallback" to "onFetchAndSyncFeedComplete", + "FetchFeedCallback" to "onFetchFeedComplete", + "FetchFeedEntriesCallback" to "onFetchFeedEntriesComplete", + "QueryFeedCallback" to "onQueryFeedComplete", + "FeedManagerDelegate" to "onFeedEntriesUpdated", + "FeedManagerDelegate" to "onInternalSyncFeed", + ).forEach { (callbackName, methodName) -> + findClass(callbacks.get()!![callbackName] ?: return@forEach).hook(methodName, HookStage.BEFORE) { param -> + filterFriendFeed(param.arg(0)) + } + } + + callbacks.getClass("FetchAndSyncFeedCallback") + ?.hook("onFetchAndSyncFeedComplete", HookStage.BEFORE) { param -> + val deletedConversations: ArrayList<Any> = param.arg(2) + filterFriendFeed(param.arg(0), deletedConversations) + + if (deletedConversations.any { + val uuid = SnapUUID(it.getObjectField("mFeedEntryIdentifier")?.getObjectField("mConversationId")).toString() + context.database.getFeedEntryByConversationId(uuid) != null + }) { + param.setArg(4, true) + } + } + callbacks.getClass("SyncFeedCallback") + ?.hook("onSyncFeedComplete", HookStage.BEFORE) { param -> + filterFriendFeed(param.arg(0), param.arg(2)) + } + } + } + + override fun getRuleState() = RuleState.WHITELIST +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/HideStreakRestore.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/HideStreakRestore.kt new file mode 100644 index 0000000000..a089bc99aa --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/HideStreakRestore.kt @@ -0,0 +1,45 @@ +package me.rhunk.snapenhance.core.features.impl.ui + +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.impl.messaging.Messaging +import me.rhunk.snapenhance.core.util.dataBuilder +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hookConstructor +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.util.ktx.getObjectFieldOrNull +import me.rhunk.snapenhance.core.util.ktx.setObjectField +import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID + +class HideStreakRestore : Feature("HideStreakRestore") { + override fun init() { + if (!context.config.userInterface.hideStreakRestore.get()) return + + findClass("com.snapchat.client.messaging.FeedEntry").hookConstructor(HookStage.AFTER) { param -> + val instance = param.thisObject<Any>() + if (instance.getObjectFieldOrNull("mDisplayInfo") + ?.getObjectFieldOrNull("mFeedItem") + ?.getObjectFieldOrNull("mConversation") + ?.getObjectFieldOrNull("mState") + ?.toString() == "STREAK_RESTORE") { + instance.getObjectFieldOrNull("mDisplayInfo") + ?.getObjectFieldOrNull("mFeedItem") + ?.setObjectField("mConversation", null) + val conversationId = SnapUUID(instance.getObjectField("mConversationId")).toString() + context.feature(Messaging::class).conversationManager?.dismissStreakRestore( + conversationId, + onError = { + context.log.error("Failed to dismiss streak restore: $it") + }, onSuccess = { + context.log.info("Dismissed streak restore for conversation $conversationId") + } + ) + } + } + + findClass("com.snapchat.client.messaging.StreakMetadata").hookConstructor(HookStage.AFTER) { param -> + param.thisObject<Any>().dataBuilder { + set("mExpiredStreak", null) + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/MessageIndicators.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/MessageIndicators.kt new file mode 100644 index 0000000000..2a63418cf7 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/MessageIndicators.kt @@ -0,0 +1,140 @@ +package me.rhunk.snapenhance.core.features.impl.ui + +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.ui.createComposeView +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.ui.AppleLogo +import kotlin.random.Random + +class MessageIndicators : Feature("Message Indicators") { + override fun init() { + val messageIndicatorsConfig = context.config.userInterface.messageIndicators.getNullable() ?: return + if (messageIndicatorsConfig.isEmpty()) return + + val messageInfoTag = Random.nextLong().toString() + onNextActivityCreate { + val appleLogo = AppleLogo + + context.event.subscribe(BindViewEvent::class) { event -> + event.chatMessage { _, _ -> + val view = event.view as? ViewGroup ?: return@subscribe + view.findViewWithTag<View>(messageInfoTag)?.let { view.removeView(it) } + + val message = event.databaseMessage ?: return@chatMessage + if (message.contentType != ContentType.SNAP.id && message.contentType != ContentType.EXTERNAL_MEDIA.id) return@chatMessage + val reader = ProtoReader(message.messageContent ?: return@chatMessage) + + createComposeView(event.view.context) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(50.dp) + .padding(top = 4.dp, end = 1.dp), + contentAlignment = Alignment.TopEnd + ) { + val hasEncryption by rememberAsyncMutableState(defaultValue = false) { + reader.getByteArray(4, 3, 3) != null || reader.containsPath(3, 99, 3) + } + val sentFromIosDevice by rememberAsyncMutableState(defaultValue = false) { + if (reader.containsPath(4, 4, 3)) !reader.containsPath(4, 4, 3, 3, 17) else reader.getVarInt(4, 4, 11, 17, 7) != null + } + val sentFromWebApp by rememberAsyncMutableState(defaultValue = false) { + reader.getVarInt(4, 4, *(if (reader.containsPath(4, 4, 3)) intArrayOf(3, 3, 22, 1) else intArrayOf(11, 22, 1))) == 7L + } + val sentWithLocation by rememberAsyncMutableState(defaultValue = false) { + reader.getVarInt(4, 4, 11, 17, 5) != null + } + val sentUsingOvfEditor by rememberAsyncMutableState(defaultValue = false) { + (reader.getString(4, 4, 11, 12, 1) ?: reader.getString(4, 4, 11, 13, 4, 1, 2, 12, 20, 1)) == "c13129f7-fe4a-44c4-9b9d-e0b26fee8f82" + } + val sentUsingDirectorMode by rememberAsyncMutableState(defaultValue = false) { + reader.followPath(4, 4, 11, 28)?.let { + (it.getVarInt(1) to it.getVarInt(2)) == (0L to 0L) + } == true || reader.getByteArray(4, 4, 11, 13, 4, 1, 2, 12, 27, 1) != null + } + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + if (sentWithLocation && messageIndicatorsConfig.contains("location_indicator")) { + Image( + imageVector = Icons.Default.LocationOn, + colorFilter = ColorFilter.tint(Color.Green), + contentDescription = null, + modifier = Modifier.size(15.dp) + ) + } + if (messageIndicatorsConfig.contains("platform_indicator")) { + Image( + imageVector = when { + sentFromWebApp -> Icons.Default.Laptop + sentFromIosDevice -> appleLogo + else -> Icons.Default.Android + }, + colorFilter = ColorFilter.tint(Color.Green), + contentDescription = null, + modifier = Modifier.size(15.dp) + ) + } + if (hasEncryption && messageIndicatorsConfig.contains("encryption_indicator")) { + Image( + imageVector = Icons.Default.Lock, + colorFilter = ColorFilter.tint(Color.Green), + contentDescription = null, + modifier = Modifier.size(15.dp) + ) + } + if (sentUsingDirectorMode && messageIndicatorsConfig.contains("director_mode_indicator")) { + Image( + imageVector = Icons.Default.Edit, + colorFilter = ColorFilter.tint(Color.Red), + contentDescription = null, + modifier = Modifier.size(15.dp) + ) + } + if (sentUsingOvfEditor && messageIndicatorsConfig.contains("ovf_editor_indicator")) { + Text( + text = "OVF", + color = Color.Red, + fontWeight = FontWeight.ExtraBold, + fontSize = 10.sp, + ) + } + } + } + }.apply { + tag = messageInfoTag + addOnLayoutChangeListener { _, left, _, right, _, _, _, _, _ -> + layout(left, 0, right, 0) + } + setPadding(0, 0, 0, -(50 * event.view.resources.displayMetrics.density).toInt()) + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + view.addView(this) + } + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/OldBitmojiSelfie.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/OldBitmojiSelfie.kt new file mode 100644 index 0000000000..c8e65d7376 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/OldBitmojiSelfie.kt @@ -0,0 +1,35 @@ +package me.rhunk.snapenhance.core.features.impl.ui + +import android.net.Uri +import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie +import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent +import me.rhunk.snapenhance.core.features.Feature + +class OldBitmojiSelfie : Feature("OldBitmojiSelfie") { + override fun init() { + val urlPrefixes = arrayOf("https://images.bitmoji.com/3d/render/", "https://cf-st.sc-cdn.net/3d/render/") + val oldBitmojiSelfie = context.config.userInterface.oldBitmojiSelfie.getNullable() ?: return + + context.event.subscribe(NetworkApiRequestEvent::class) { event -> + if (urlPrefixes.firstOrNull { event.url.startsWith(it) } == null) return@subscribe + event.url = event.url.replace("ua=1", "") // replace ua=1 with nothing for old 3d selfies/background + + if (oldBitmojiSelfie == "2d" && event.url.contains("ua=")) { + event.url = event.url.replace(Regex("ua=[^&]+"), "ua=0") + } + // replace with old 2d selfies + if (oldBitmojiSelfie == "2d" && event.url.contains("trim=circle")) { + val bitmojiPath = event.url.substringAfterLast("/").substringBeforeLast("?") + event.url = Uri.parse(BitmojiSelfie.BitmojiSelfieType.STANDARD.prefixUrl) + .buildUpon() + .appendPath(bitmojiPath) + .appendQueryParameter("transparent", "1") + .appendQueryParameter("trim", "circle") + .build() + .toString() + } + + if (arrayOf("?", "&").any { event.url.endsWith(it) }) event.url = event.url.dropLast(1) + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/PinConversations.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/PinConversations.kt new file mode 100644 index 0000000000..ef74e93229 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/PinConversations.kt @@ -0,0 +1,53 @@ +package me.rhunk.snapenhance.core.features.impl.ui + +import me.rhunk.snapenhance.common.data.MessagingRuleType +import me.rhunk.snapenhance.common.data.RuleState +import me.rhunk.snapenhance.core.features.MessagingRuleFeature +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.Hooker +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.hook.hookConstructor +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.util.ktx.setObjectField +import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID + +class PinConversations : MessagingRuleFeature("PinConversations", MessagingRuleType.PIN_CONVERSATION) { + override fun init() { + if (!context.config.messaging.unlimitedConversationPinning.get()) return + + context.classCache.feedManager.hook("setPinnedConversationStatus", HookStage.BEFORE) { param -> + val conversationUUID = SnapUUID(param.arg(0)) + val isPinned = param.arg<Any>(1).toString() == "PINNED" + setState(conversationUUID.toString(), isPinned) + val callback = param.arg<Any>(2) + mutableSetOf<() -> Unit>().apply { + addAll(Hooker.ephemeralHookObjectMethod(callback::class.java, callback,"onSuccess", HookStage.BEFORE) { + forEach { it() } + }) + addAll(Hooker.ephemeralHookObjectMethod(callback::class.java, callback,"onError", HookStage.BEFORE) { methodParam -> + methodParam.setResult(null) + callback::class.java.getDeclaredMethod("onSuccess").invoke(callback) + }) + } + } + + context.classCache.conversation.hookConstructor(HookStage.AFTER) { param -> + val instance = param.thisObject<Any>() + val conversationUUID = SnapUUID(instance.getObjectField("mConversationId")) + if (getState(conversationUUID.toString())) { + instance.setObjectField("mPinnedTimestampMs", 1L) + } + } + + context.classCache.feedEntry.hookConstructor(HookStage.AFTER) { param -> + val instance = param.thisObject<Any>() + val conversationUUID = SnapUUID(instance.getObjectField("mConversationId") ?: return@hookConstructor) + val isPinned = getState(conversationUUID.toString()) + if (isPinned) { + instance.setObjectField("mPinnedTimestampMs", 1L) + } + } + } + + override fun getRuleState() = RuleState.WHITELIST +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/SnapPreview.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/SnapPreview.kt new file mode 100644 index 0000000000..b6d64cec6b --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/SnapPreview.kt @@ -0,0 +1,104 @@ +package me.rhunk.snapenhance.core.features.impl.ui + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.Shape +import android.view.ViewGroup +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.ui.addForegroundDrawable +import me.rhunk.snapenhance.core.ui.randomTag +import me.rhunk.snapenhance.core.ui.removeForegroundDrawable +import me.rhunk.snapenhance.core.util.EvictingMap +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.util.media.PreviewUtils +import me.rhunk.snapenhance.mapper.impl.CallbackMapper +import java.io.File + +class SnapPreview : Feature("SnapPreview") { + private val mediaFileCache = EvictingMap<String, File>(500) // mMediaId => mediaFile + private val bitmapCache = EvictingMap<String, Bitmap>(50) // filePath => bitmap + + private val fetchJobTab = randomTag() + + override fun init() { + if (!context.config.userInterface.snapPreview.get()) return + context.mappings.useMapper(CallbackMapper::class) { + callbacks.getClass("ContentCallback")?.hook("handleContentResult", HookStage.BEFORE) { param -> + val contentResult = param.arg<Any>(0) + val classMethods = contentResult::class.java.methods + + val contentKey = classMethods.find { it.name == "getContentKey" }?.invoke(contentResult) ?: return@hook + if (contentKey.getObjectField("mMediaContextType").toString() != "CHAT") return@hook + + val filePath = classMethods.find { it.name == "getFilePath" }?.invoke(contentResult) ?: return@hook + val mediaId = contentKey.getObjectField("mMediaId").toString() + + mediaFileCache[mediaId.substringAfter("-")] = File(filePath.toString()) + } + } + + onNextActivityCreate { + val (chatMediaCardHeight, chatMediaCardSnapMargin, chatMediaCardSnapMarginStartSdl) = context.userInterface.run { + Triple(dpToPx(60), dpToPx(10), dpToPx(15)) + } + + fun decodeMedia(file: File) = runCatching { + bitmapCache.getOrPut(file.absolutePath) { + PreviewUtils.resizeBitmap( + PreviewUtils.createPreviewFromFile(file) ?: return@runCatching null, + chatMediaCardHeight - chatMediaCardSnapMargin, + chatMediaCardHeight - chatMediaCardSnapMargin + ) + } + }.getOrNull() + + context.event.subscribe(BindViewEvent::class) { event -> + event.chatMessage { _, _ -> + val messageLinearLayout = (event.view as ViewGroup).getChildAt(0) as? ViewGroup ?: return@subscribe + messageLinearLayout.removeForegroundDrawable("snapPreview") + + val message = event.databaseMessage ?: return@chatMessage + val messageReader = ProtoReader(message.messageContent ?: return@chatMessage) + val contentType = ContentType.fromMessageContainer(messageReader.followPath(4, 4)) + + if (contentType != ContentType.SNAP || message.isSaved == 1) return@chatMessage + + val mediaIdKey = messageReader.getString(4, 5, 1, 3, 2, 2) ?: return@chatMessage + + var mediaFile = mediaFileCache[mediaIdKey] ?: return@chatMessage + val mediaFilePath = mediaFile.absolutePath + + (messageLinearLayout.getTag(fetchJobTab) as? Job)?.cancel() + + if (bitmapCache[mediaFilePath] == null) { + messageLinearLayout.setTag(fetchJobTab, context.coroutineScope.launch { + bitmapCache[mediaFilePath] = decodeMedia(mediaFile) ?: return@launch + messageLinearLayout.postInvalidate() + }) + } + + messageLinearLayout.addForegroundDrawable("snapPreview", ShapeDrawable(object: Shape() { + override fun draw(canvas: Canvas, paint: Paint) { + val bitmap = bitmapCache[mediaFilePath] ?: return + + canvas.drawBitmap(bitmap, + canvas.width.toFloat() - bitmap.width - chatMediaCardSnapMarginStartSdl.toFloat() - chatMediaCardSnapMargin.toFloat(), + (canvas.height - bitmap.height) / 2f, + null + ) + } + })) + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/SpotlightCommentsUsername.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/SpotlightCommentsUsername.kt new file mode 100644 index 0000000000..ac9209d128 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/SpotlightCommentsUsername.kt @@ -0,0 +1,59 @@ +package me.rhunk.snapenhance.core.features.impl.ui + +import android.annotation.SuppressLint +import android.view.ViewGroup +import android.widget.TextView +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.impl.messaging.Messaging +import me.rhunk.snapenhance.core.ui.children +import me.rhunk.snapenhance.core.util.EvictingMap + +class SpotlightCommentsUsername : Feature("SpotlightCommentsUsername") { + private val usernameCache = EvictingMap<String, String>(150) + + @SuppressLint("SetTextI18n") + override fun init() { + if (!context.config.global.spotlightCommentsUsername.get()) return + + onNextActivityCreate(defer = true) { + val messaging = context.feature(Messaging::class) + context.event.subscribe(BindViewEvent::class) { event -> + val posterUserId = event.prevModel.toString().takeIf { it.startsWith("Comment") } + ?.substringAfter("posterUserId=")?.substringBefore(",")?.substringBefore(")") ?: return@subscribe + + if (posterUserId == "null") return@subscribe + + fun setUsername(username: String) { + usernameCache[posterUserId] = username + val commentsCreatorBadgeTimestamp = (event.view as ViewGroup).children().filterIsInstance<TextView>() + .getOrNull(1) ?: return + if (commentsCreatorBadgeTimestamp.text.contains(username)) return + commentsCreatorBadgeTimestamp.text = " (${username})" + commentsCreatorBadgeTimestamp?.text.toString() + } + + event.view.post { + usernameCache[posterUserId]?.let { + setUsername(it) + return@post + } + + context.coroutineScope.launch { + val username = runCatching { + messaging.fetchSnapchatterInfos(listOf(posterUserId)).firstOrNull() + }.onFailure { + context.log.error("Failed to fetch snapchatter info for user $posterUserId", it) + }.getOrNull()?.username ?: return@launch + + withContext(Dispatchers.Main) { + setUsername(username) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/StealthModeIndicator.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/StealthModeIndicator.kt new file mode 100644 index 0000000000..e6958230f7 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/StealthModeIndicator.kt @@ -0,0 +1,103 @@ +package me.rhunk.snapenhance.core.features.impl.ui + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.Shape +import androidx.core.content.res.use +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.common.data.RuleState +import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.impl.spying.StealthMode +import me.rhunk.snapenhance.core.ui.addForegroundDrawable +import me.rhunk.snapenhance.core.ui.randomTag +import me.rhunk.snapenhance.core.ui.removeForegroundDrawable +import me.rhunk.snapenhance.core.util.EvictingMap +import me.rhunk.snapenhance.core.util.ktx.getDimens +import me.rhunk.snapenhance.core.util.ktx.getIdentifier + +class StealthModeIndicator : Feature("StealthModeIndicator") { + private val stealthMode by lazy { context.feature(StealthMode::class) } + private val listeners = EvictingMap<String, (Boolean) -> Unit>(100) + + inner class UpdateHandler { + private var fetchJob: Job? = null + private var listener = { _: Boolean -> } + + private fun requestUpdate(conversationId: String) { + fetchJob?.cancel() + fetchJob = context.coroutineScope.launch { + val isStealth = stealthMode.canUseRule(conversationId) + withContext(Dispatchers.Main) { + listener(isStealth) + } + } + } + + fun subscribe(conversationId: String, onStateChange: (Boolean) -> Unit) { + listener = onStateChange.also { + listeners[conversationId] = it + } + requestUpdate(conversationId) + } + } + + private val stealthModeIndicatorTag = randomTag() + + override fun init() { + if (!context.config.userInterface.stealthModeIndicator.get()) return + + onNextActivityCreate { + stealthMode.addStateListener { conversationId, state -> + runCatching { + listeners[conversationId]?.invoke(stealthMode.getRuleState()?.let { if (it == RuleState.BLACKLIST) !state else state } ?: state) + }.onFailure { + context.log.error("Failed to update stealth mode indicator", it) + } + } + + context.event.subscribe(BindViewEvent::class) { event -> + fun updateStealthIndicator(isStealth: Boolean = true) { + event.view.removeForegroundDrawable("stealthModeIndicator") + if (!isStealth || !event.view.isAttachedToWindow) return + event.view.addForegroundDrawable("stealthModeIndicator", ShapeDrawable(object : Shape() { + override fun draw(canvas: Canvas, paint: Paint) { + val secondaryTextSize = context.userInterface.dpToPx(10).toFloat() + paint.textSize = secondaryTextSize + paint.color = context.userInterface.colorPrimary + canvas.drawText( + "\uD83D\uDC7B", + 0f, + canvas.height.toFloat() - secondaryTextSize / 2, + paint + ) + } + })) + } + + event.friendFeedItem { conversationId -> + val updateHandler = event.view.getTag(stealthModeIndicatorTag) as? UpdateHandler ?: run { + val handler = UpdateHandler() + event.view.setTag(stealthModeIndicatorTag, handler) + handler + } + + event.view.post { + synchronized(listeners) { + updateHandler.subscribe(conversationId) { isStealth -> + updateStealthIndicator(isStealth) + } + } + } + return@subscribe + } + + event.view.setTag(stealthModeIndicatorTag, null) + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/UITweaks.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/UITweaks.kt new file mode 100644 index 0000000000..0ebf6edf29 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/UITweaks.kt @@ -0,0 +1,209 @@ +package me.rhunk.snapenhance.core.features.impl.ui + +import android.content.res.Resources +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.MarginLayoutParams +import android.widget.FrameLayout +import android.widget.LinearLayout +import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent +import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.ui.children +import me.rhunk.snapenhance.core.ui.getComposerContext +import me.rhunk.snapenhance.core.ui.hideViewCompletely +import me.rhunk.snapenhance.core.ui.onLayoutChange +import me.rhunk.snapenhance.core.util.dataBuilder +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.Hooker +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.ktx.getIdentifier + +fun getChatInputBar(event: AddViewEvent): Lazy<ViewGroup?>? { + if (!event.parent.javaClass.name.endsWith("ChatInputLayout")) return null + val isViewSwitcher = event.viewClassName.endsWith("ViewSwitcher") + + return lazy { + // get the first linear layout in the view switcher + val firstLinearLayout = if (isViewSwitcher) { + (event.view as ViewGroup).children() + .firstOrNull { it is LinearLayout } as? ViewGroup ?: return@lazy null + } else { + event.view as? ViewGroup ?: return@lazy null + } + // get the first linear layout with at least 3 children + firstLinearLayout.children() + .firstOrNull { v -> v is LinearLayout && v.childCount > 2 } as? LinearLayout + ?: return@lazy null + } +} + +class UITweaks : Feature("UITweaks") { + private val identifierCache = mutableMapOf<String, Int>() + + fun getId(name: String, defType: String): Int { + return identifierCache.getOrPut("$name:$defType") { + context.resources.getIdentifier(name, defType) + } + } + + private fun hideStorySection(event: AddViewEvent) { + val parent = event.parent + parent.visibility = View.GONE + val marginLayoutParams = parent.layoutParams as MarginLayoutParams + marginLayoutParams.setMargins(-99999, -99999, -99999, -99999) + event.canceled = true + } + + private fun hideView(view: View) { + view.apply { + visibility = View.GONE + post { + isEnabled = false + visibility = View.GONE + setWillNotDraw(true) + } + addOnLayoutChangeListener { view, _, _, _, _, _, _, _, _ -> + view.post { view.visibility = View.GONE } + } + } + } + + private fun onActivityCreate() { + val blockAds by context.config.global.blockAds + val hiddenElements by context.config.userInterface.hideUiComponents + val hideStorySuggestions by context.config.userInterface.hideStorySuggestions + val isImmersiveCamera by context.config.camera.immersiveCameraPreview + + val displayMetrics = context.resources.displayMetrics + val deviceAspectRatio = displayMetrics.widthPixels.toFloat() / displayMetrics.heightPixels.toFloat() + + val chatNoteRecordButton = getId("chat_note_record_button", "id") + val unreadHintButton = getId("unread_hint_button", "id") + + Resources::class.java.methods.first { it.name == "getDimensionPixelSize"}.hook( + HookStage.AFTER, + { isImmersiveCamera } + ) { param -> + val id = param.arg<Int>(0) + if (id == getId("capri_viewfinder_default_corner_radius", "dimen") || + id == getId("ngs_hova_nav_larger_camera_button_size", "dimen")) { + param.setResult(0) + } + } + + context.event.subscribe(BindViewEvent::class, { hideStorySuggestions.isNotEmpty() }) { event -> + if (event.view is FrameLayout) { + fun removeView() { + event.view.layoutParams = event.view.layoutParams?.apply { + width = 0; height = 0 + } ?: return + } + + val viewModelString = event.prevModel.toString() + val isMyStory by lazy { viewModelString.let { it.startsWith("StoryCarouselItemViewModel") && it.contains("storyId=") } } + + if (hideStorySuggestions.contains("hide_my_stories") && isMyStory) { + removeView() + return@subscribe + } + } + } + + context.event.subscribe(AddViewEvent::class) { event -> + val viewId = event.view.id + val view = event.view + + if (blockAds && viewId == getId("df_promoted_story", "id")) { + hideStorySection(event) + } + + if (isImmersiveCamera) { + if (view.id == getId("edits_container", "id")) { + Hooker.hookObjectMethod(View::class.java, view, "layout", HookStage.BEFORE) { + val width = it.arg(2) as Int + val realHeight = (width / deviceAspectRatio).toInt() + it.setArg(3, realHeight) + } + } + if (view.id == getId("full_screen_surface_view", "id")) { + Hooker.hookObjectMethod(View::class.java, view, "layout", HookStage.BEFORE) { + it.setArg(1, 1) + it.setArg(3, displayMetrics.heightPixels) + } + } + } + + if (hiddenElements.contains("hide_billboard_prompt") && event.parent.javaClass.name.endsWith("BillboardFeedHeaderPromptComponent")) { + hideView(event.parent) + view.getComposerContext()?.componentContext?.get()?.dataBuilder { + val dismissFunction = get<Any>("_onDismiss") ?: return@subscribe + dismissFunction.javaClass.getMethod("invoke").invoke(dismissFunction) + } + } + + if (event.parent.javaClass.name.endsWith("ConstraintLayout") && event.view is LinearLayout && hiddenElements.contains("hide_map_reactions")) { + val viewGroup = event.view as ViewGroup + val children = viewGroup.children() + + // hide image views in the reaction bar + if (children.takeIf { it.count() == 5 }?.all { it.javaClass.name.endsWith("SnapImageView") } == true) { + children.forEach { imageView -> + imageView.hideViewCompletely() + } + } + } + + if (event.parent.javaClass.name.endsWith("PreviewBottomToolbarView") && hiddenElements.contains("hide_post_to_story_buttons")) { + if (event.parent.childCount == 1) { + event.view.hideViewCompletely() + } + } + + if (viewId == getId("send_btn", "id") && hiddenElements.contains("hide_post_to_story_buttons")) { + // hide previous view + if (event.parent.childCount > 0) { + val lastChild = event.parent.getChildAt(event.parent.childCount - 1)?.takeIf { it is LinearLayout } ?: return@subscribe + context.log.verbose("Hiding post to story button") + lastChild.hideViewCompletely() + } + } + + getChatInputBar(event)?.let { lazyChatInputBar -> + val chatInputBar by lazyChatInputBar + + if (hiddenElements.contains("hide_live_location_share_button")) { + chatInputBar?.onLayoutChange { + chatInputBar!!.children().lastOrNull { it.javaClass.name.endsWith("AppCompatImageButton") && runCatching { it.resources.getResourceName(it.id) }.getOrNull() == null } + ?.hideViewCompletely() + } + } + + if (hiddenElements.contains("hide_stickers_button")) { + chatInputBar + ?.children() + ?.lastOrNull { layout -> + layout is FrameLayout && layout.children().all { + it.javaClass.name.endsWith("SnapImageView") + } + } + ?.hideViewCompletely() + } + } + + if (viewId == chatNoteRecordButton && hiddenElements.contains("hide_voice_record_button")) { + view.hideViewCompletely() + } + + if (viewId == unreadHintButton && hiddenElements.contains("hide_unread_chat_hint")) { + event.canceled = true + } + } + } + + override fun init() { + onNextActivityCreate { + onActivityCreate() + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/logger/CoreLogger.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/logger/CoreLogger.kt new file mode 100644 index 0000000000..7fa51edc32 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/logger/CoreLogger.kt @@ -0,0 +1,77 @@ +package me.rhunk.snapenhance.core.logger + +import android.annotation.SuppressLint +import android.util.Log +import de.robv.android.xposed.XposedBridge +import me.rhunk.snapenhance.common.logger.AbstractLogger +import me.rhunk.snapenhance.common.logger.LogChannel +import me.rhunk.snapenhance.common.logger.LogLevel +import me.rhunk.snapenhance.core.bridge.BridgeClient +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook + + +@SuppressLint("PrivateApi") +class CoreLogger( + private val bridgeClient: BridgeClient +): AbstractLogger(LogChannel.CORE) { + companion object { + private const val TAG = "SnapEnhanceCore" + + fun xposedLog(message: Any?, tag: String = TAG) { + Log.println(Log.INFO, tag, message.toString()) + XposedBridge.log("$tag: $message") + } + + fun xposedLog(message: Any?, throwable: Throwable, tag: String = TAG) { + Log.println(Log.INFO, tag, message.toString()) + XposedBridge.log("$tag: $message") + XposedBridge.log(throwable) + } + } + + private var invokeOriginalPrintLog: (Int, String, String) -> Unit + + init { + val printLnMethod = Log::class.java.getDeclaredMethod("println", Int::class.java, String::class.java, String::class.java) + printLnMethod.hook(HookStage.BEFORE) { param -> + val priority = param.arg(0) as Int + val tag = param.arg(1) as String + val message = param.arg(2) as String + internalLog(tag, LogLevel.fromPriority(priority) ?: LogLevel.INFO, message) + } + + invokeOriginalPrintLog = { priority, tag, message -> + XposedBridge.invokeOriginalMethod( + printLnMethod, + null, + arrayOf(priority, tag, message) + ) + } + } + + private fun internalLog(tag: String, logLevel: LogLevel, message: Any?) { + runCatching { + bridgeClient.broadcastLog(tag, logLevel.shortName, message.toString()) + }.onFailure { + invokeOriginalPrintLog(logLevel.priority, tag, message.toString()) + } + } + + override fun debug(message: Any?, tag: String) = internalLog(tag, LogLevel.DEBUG, message) + + override fun error(message: Any?, tag: String) = internalLog(tag, LogLevel.ERROR, message) + + override fun error(message: Any?, throwable: Throwable, tag: String) { + internalLog(tag, LogLevel.ERROR, message) + internalLog(tag, LogLevel.ERROR, throwable.stackTraceToString()) + } + + override fun info(message: Any?, tag: String) = internalLog(tag, LogLevel.INFO, message) + + override fun verbose(message: Any?, tag: String) = internalLog(tag, LogLevel.VERBOSE, message) + + override fun warn(message: Any?, tag: String) = internalLog(tag, LogLevel.WARN, message) + + override fun assert(message: Any?, tag: String) = internalLog(tag, LogLevel.ASSERT, message) +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/ConversationExporter.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/ConversationExporter.kt new file mode 100644 index 0000000000..ef40df4c99 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/ConversationExporter.kt @@ -0,0 +1,336 @@ +package me.rhunk.snapenhance.core.messaging + +import android.util.Base64InputStream +import android.util.Base64OutputStream +import com.google.gson.stream.JsonWriter +import kotlinx.coroutines.runBlocking +import me.rhunk.snapenhance.common.BuildConfig +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.database.impl.FriendFeedEntry +import me.rhunk.snapenhance.common.database.impl.FriendInfo +import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper +import me.rhunk.snapenhance.core.ModContext +import me.rhunk.snapenhance.core.features.impl.downloader.decoder.MessageDecoder +import me.rhunk.snapenhance.core.util.hook.findRestrictedConstructor +import me.rhunk.snapenhance.core.wrapper.impl.Message +import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID +import java.io.BufferedInputStream +import java.io.File +import java.io.InputStream +import java.io.OutputStream +import java.text.DateFormat +import java.util.Date +import java.util.concurrent.CopyOnWriteArraySet +import java.util.concurrent.Executors +import java.util.zip.Deflater +import java.util.zip.DeflaterInputStream +import java.util.zip.DeflaterOutputStream +import java.util.zip.ZipFile +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +@OptIn(ExperimentalEncodingApi::class) +class ConversationExporter( + private val context: ModContext, + private val friendFeedEntry: FriendFeedEntry, + private val conversationParticipants: Map<String, FriendInfo>, + private val exportParams: ExportParams, + private val cacheFolder: File, + private val outputFile: File +) { + lateinit var printLog: (Any?) -> Unit + + private val downloadThreadExecutor = Executors.newFixedThreadPool(4) + private val writeThreadExecutor = Executors.newSingleThreadExecutor() + + private val conversationJsonDataFile by lazy { cacheFolder.resolve("messages_${friendFeedEntry.key}.json") } + private val jsonDataWriter by lazy { JsonWriter(conversationJsonDataFile.writer()) } + private val outputFileStream by lazy { outputFile.outputStream() } + private val participants = mutableMapOf<String, Int>() + + private val newBase64OutputStream by lazy { + Base64OutputStream::class.java.findRestrictedConstructor { + it.parameterTypes.size == 3 && + it.parameterTypes[0] == OutputStream::class.java && + it.parameterTypes[1] == Int::class.javaPrimitiveType && + it.parameterTypes[2] == Boolean::class.javaPrimitiveType + } ?: throw Throwable("Failed to find Base64OutputStream constructor") + } + + private val newBase64InputStream by lazy { + Base64InputStream::class.java.findRestrictedConstructor { + it.parameterTypes.size == 3 && + it.parameterTypes[0] == InputStream::class.java && + it.parameterTypes[1] == Int::class.javaPrimitiveType && + it.parameterTypes[2] == Boolean::class.javaPrimitiveType + } ?: throw Throwable("Failed to find Base64InputStream constructor") + } + + fun init() { + when (exportParams.exportFormat) { + ExportFormat.TEXT -> { + outputFileStream.write("Conversation id: ${friendFeedEntry.key}\n".toByteArray()) + outputFileStream.write("Conversation name: ${friendFeedEntry.feedDisplayName}\n".toByteArray()) + outputFileStream.write("Participants:\n".toByteArray()) + conversationParticipants.forEach { (userId, friendInfo) -> + outputFileStream.write(" $userId: ${friendInfo.displayName}\n".toByteArray()) + } + outputFileStream.write("\n\n".toByteArray()) + } + else -> { + jsonDataWriter.isHtmlSafe = true + jsonDataWriter.serializeNulls = true + + jsonDataWriter.beginObject() + jsonDataWriter.name("conversationId").value(friendFeedEntry.key) + jsonDataWriter.name("conversationName").value(friendFeedEntry.feedDisplayName) + + var index = 0 + + jsonDataWriter.name("participants").apply { + beginObject() + conversationParticipants.forEach { (userId, friendInfo) -> + jsonDataWriter.name(userId).beginObject() + jsonDataWriter.name("id").value(index) + jsonDataWriter.name("displayName").value(friendInfo.displayName) + jsonDataWriter.name("username").value(friendInfo.usernameForSorting) + jsonDataWriter.name("bitmojiSelfieId").value(friendInfo.bitmojiSelfieId) + jsonDataWriter.endObject() + participants[userId] = index++ + } + endObject() + } + + jsonDataWriter.name("messages").beginArray() + + if (exportParams.exportFormat != ExportFormat.HTML) return + outputFileStream.write(""" + <!DOCTYPE html> + <html> + <head> + <meta charset="UTF-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title> + + """.trimIndent().toByteArray()) + + outputFileStream.write("\n".toByteArray()) + + outputFileStream.flush() + } + } + } + + private val downloadedMediaIdCache = CopyOnWriteArraySet() + private val pendingDownloadMediaIdCache = CopyOnWriteArraySet() + + private fun downloadMedia(message: Message) { + downloadThreadExecutor.execute { + MessageDecoder.decode(message.messageContent!!).forEach decode@{ attachment -> + if (attachment.mediaUniqueId in downloadedMediaIdCache || attachment.mediaUniqueId in pendingDownloadMediaIdCache) return@decode + pendingDownloadMediaIdCache.add(attachment.mediaUniqueId!!) + for (i in 0..5) { + printLog("downloading ${attachment.boltKey ?: attachment.directUrl}... (attempt ${i + 1}/5)") + runCatching { + runBlocking { + attachment.openStream { downloadedInputStream, _ -> + MediaDownloaderHelper.getSplitElements(downloadedInputStream!!) { type, splitInputStream -> + val mediaKey = "${type}_${attachment.mediaUniqueId}" + val bufferedInputStream = BufferedInputStream(splitInputStream) + val fileType = MediaDownloaderHelper.getFileType(bufferedInputStream) + val mediaFile = cacheFolder.resolve("$mediaKey.${fileType.fileExtension}") + + mediaFile.outputStream().use { fos -> + bufferedInputStream.copyTo(fos) + } + + writeThreadExecutor.execute { + outputFileStream.write("
\n".toByteArray()) + outputFileStream.flush() + } + } + } + writeThreadExecutor.execute { + downloadedMediaIdCache.add(attachment.mediaUniqueId!!) + } + } + } + return@decode + }.onFailure { + downloadedMediaIdCache.remove(attachment.mediaUniqueId!!) + printLog("failed to download media ${attachment.boltKey}. retrying...") + it.printStackTrace() + } + } + pendingDownloadMediaIdCache.remove(attachment.mediaUniqueId!!) + } + } + } + + fun readMessage(message: Message) { + if (exportParams.exportFormat == ExportFormat.TEXT) { + val (displayName, senderUsername) = conversationParticipants[message.senderId.toString()]?.let { + it.displayName to it.mutableUsername + } ?: ("" to message.senderId.toString()) + + val date = DateFormat.getDateTimeInstance().format(Date(message.messageMetadata!!.createdAt ?: -1)) + outputFileStream.write("[$date] - $displayName ($senderUsername): ${message.serialize() ?: message.messageContent?.contentType?.name}\n".toByteArray(Charsets.UTF_8)) + return + } + val contentType = message.messageContent?.contentType ?: return + + if (exportParams.downloadMedias && (contentType == ContentType.NOTE || + contentType == ContentType.SNAP || + contentType == ContentType.EXTERNAL_MEDIA || + contentType == ContentType.STICKER || + contentType == ContentType.SHARE || + contentType == ContentType.MAP_REACTION) + ) { + downloadMedia(message) + } + + jsonDataWriter.apply { + beginObject() + name("orderKey").value(message.orderKey) + name("senderId").value(participants.getOrDefault(message.senderId.toString(), -1)) + name("type").value(message.messageContent!!.contentType.toString()) + + fun addUUIDList(name: String, list: List) { + name(name).beginArray() + list.map { participants.getOrDefault(it.toString(), -1) }.forEach { value(it) } + endArray() + } + + addUUIDList("savedBy", message.messageMetadata!!.savedBy!!) + addUUIDList("seenBy", message.messageMetadata!!.seenBy!!) + addUUIDList("openedBy", message.messageMetadata!!.openedBy!!) + + name("reactions").beginObject() + message.messageMetadata!!.reactions!!.forEach { reaction -> + name(participants.getOrDefault(reaction.userId.toString(), -1L).toString()).value(reaction.reactionId) + } + endObject() + + name("createdTimestamp").value(message.messageMetadata!!.createdAt) + name("readTimestamp").value(message.messageMetadata!!.readAt) + name("serializedContent").value(message.serialize()) + name("rawContent").value(Base64.UrlSafe.encode(message.messageContent!!.content!!)) + name("attachments").beginArray() + MessageDecoder.decode(message.messageContent!!) + .forEach attachments@{ attachments -> + beginObject() + name("url").value(attachments.boltKey ?: attachments.directUrl) + name("key").value(attachments.mediaUniqueId) + name("type").value(attachments.type.toString()) + name("encryption").apply { + attachments.attachmentInfo?.encryption?.let { encryption -> + beginObject() + name("key").value(encryption.key) + name("iv").value(encryption.iv) + endObject() + } ?: nullValue() + } + endObject() + } + endArray() + endObject() + flush() + } + } + + fun awaitDownload() { + downloadThreadExecutor.shutdown() + downloadThreadExecutor.awaitTermination(Long.MAX_VALUE, java.util.concurrent.TimeUnit.NANOSECONDS) + writeThreadExecutor.shutdown() + writeThreadExecutor.awaitTermination(Long.MAX_VALUE, java.util.concurrent.TimeUnit.NANOSECONDS) + } + + fun close() { + if (exportParams.exportFormat != ExportFormat.TEXT) { + jsonDataWriter.endArray() + jsonDataWriter.endObject() + jsonDataWriter.flush() + jsonDataWriter.close() + } + + if (exportParams.exportFormat == ExportFormat.JSON) { + conversationJsonDataFile.inputStream().use { + it.copyTo(outputFileStream) + } + } + + if (exportParams.exportFormat == ExportFormat.HTML) { + //write the json file + outputFileStream.write("\n".toByteArray()) + printLog("writing template...") + + runCatching { + ZipFile(context.bridgeClient.getApplicationApkPath()).use { apkFile -> + //export rawinflate.js + apkFile.getEntry("assets/web/rawinflate.js")?.let { entry -> + outputFileStream.write("\n".toByteArray()) + } + + //export avenir next font + apkFile.getEntry("assets/web/avenir_next_medium.ttf")?.let { entry -> + val encodedFontData = Base64.Default.encode(apkFile.getInputStream(entry).readBytes()) + outputFileStream.write(""" + + """.trimIndent().toByteArray()) + } + + apkFile.getEntry("assets/web/export_template.html")?.let { entry -> + apkFile.getInputStream(entry).copyTo(outputFileStream) + } + + apkFile.close() + } + }.onFailure { + throw Throwable("Failed to read template from apk", it) + } + + outputFileStream.write("".toByteArray()) + } + + outputFileStream.flush() + outputFileStream.close() + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/CoreMessagingBridge.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/CoreMessagingBridge.kt new file mode 100644 index 0000000000..e8d13ce52d --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/CoreMessagingBridge.kt @@ -0,0 +1,120 @@ +package me.rhunk.snapenhance.core.messaging + +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine +import me.rhunk.snapenhance.bridge.snapclient.MessagingBridge +import me.rhunk.snapenhance.bridge.snapclient.SessionStartListener +import me.rhunk.snapenhance.bridge.snapclient.types.Message +import me.rhunk.snapenhance.common.data.MessageUpdate +import me.rhunk.snapenhance.core.ModContext +import me.rhunk.snapenhance.core.features.impl.downloader.decoder.MessageDecoder +import me.rhunk.snapenhance.core.features.impl.messaging.Messaging + + +fun me.rhunk.snapenhance.core.wrapper.impl.Message.toBridge(): Message { + return Message().also { output -> + output.conversationId = this.messageDescriptor!!.conversationId.toString() + output.senderId = this.senderId.toString() + output.clientMessageId = this.messageDescriptor!!.messageId!! + output.serverMessageId = this.orderKey!! + output.contentType = this.messageContent?.contentType?.id ?: -1 + output.content = this.messageContent?.content + output.mediaReferences = MessageDecoder.getEncodedMediaReferences(this.messageContent!!) + } +} + + +class CoreMessagingBridge( + private val context: ModContext +) : MessagingBridge.Stub() { + private val conversationManager get() = context.feature(Messaging::class).conversationManager + private var sessionStartListener: SessionStartListener? = null + + fun triggerSessionStart() { + sessionStartListener?.onConnected() + sessionStartListener = null + } + + override fun isSessionStarted() = conversationManager != null + override fun registerSessionStartListener(listener: SessionStartListener) { + sessionStartListener = listener + } + + override fun getMyUserId() = context.database.myUserId + + override fun fetchMessage(conversationId: String, clientMessageId: String): Message? { + return runBlocking { + suspendCancellableCoroutine { continuation -> + conversationManager?.fetchMessage( + conversationId, + clientMessageId.toLong(), + onSuccess = { + continuation.resumeWith(Result.success(it.toBridge())) + }, + onError = { continuation.resumeWith(Result.success(null)) } + ) ?: continuation.resumeWith(Result.success(null)) + } + } + } + + override fun fetchMessageByServerId( + conversationId: String, + serverMessageId: String + ): Message? { + return runBlocking { + suspendCancellableCoroutine { continuation -> + conversationManager?.fetchMessageByServerId( + conversationId, + serverMessageId.toLong(), + onSuccess = { + continuation.resumeWith(Result.success(it.toBridge())) + }, + onError = { continuation.resumeWith(Result.success(null)) } + ) ?: continuation.resumeWith(Result.success(null)) + } + } + } + + override fun fetchConversationWithMessagesPaginated( + conversationId: String, + limit: Int, + beforeMessageId: Long + ): List? { + return runBlocking { + suspendCancellableCoroutine { continuation -> + conversationManager?.fetchConversationWithMessagesPaginated( + conversationId, + beforeMessageId, + limit, + onSuccess = { messages -> + continuation.resumeWith(Result.success(messages.map { it.toBridge() })) + }, + onError = { + continuation.resumeWith(Result.success(null)) + } + ) ?: continuation.resumeWith(Result.success(null)) + } + } + } + + override fun updateMessage( + conversationId: String, + clientMessageId: Long, + messageUpdate: String + ): String? { + return runBlocking { + suspendCancellableCoroutine { continuation -> + conversationManager?.updateMessage( + conversationId, + clientMessageId, + MessageUpdate.valueOf(messageUpdate), + onResult = { + continuation.resumeWith(Result.success(it)) + } + ) ?: continuation.resumeWith(Result.success("ConversationManager is null")) + } + } + } + + override fun getOneToOneConversationId(userId: String) = context.database.getDMConversationId(userId) +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/EnumBulkAction.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/EnumBulkAction.kt new file mode 100644 index 0000000000..66a317fdfb --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/EnumBulkAction.kt @@ -0,0 +1,8 @@ +package me.rhunk.snapenhance.core.messaging + +enum class EnumBulkAction( + val key: String, +) { + REMOVE_FRIENDS("remove_friends"), + CLEAR_CONVERSATIONS("clear_conversations"), +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/ExportFormat.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/ExportFormat.kt new file mode 100644 index 0000000000..d84197e485 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/ExportFormat.kt @@ -0,0 +1,9 @@ +package me.rhunk.snapenhance.core.messaging; + +enum class ExportFormat( + val extension: String, +){ + JSON("json"), + TEXT("txt"), + HTML("html"); +} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/ExportParams.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/ExportParams.kt new file mode 100644 index 0000000000..e12ae1eafc --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/ExportParams.kt @@ -0,0 +1,10 @@ +package me.rhunk.snapenhance.core.messaging + +import me.rhunk.snapenhance.common.data.ContentType + +class ExportParams( + val exportFormat: ExportFormat = ExportFormat.HTML, + val messageTypeFilter: List? = null, + val amountOfMessages: Int? = null, + val downloadMedias: Boolean = false, +) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessageSender.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessageSender.kt new file mode 100644 index 0000000000..7376355705 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessageSender.kt @@ -0,0 +1,110 @@ +package me.rhunk.snapenhance.core.messaging + +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.util.protobuf.ProtoWriter +import me.rhunk.snapenhance.core.ModContext +import me.rhunk.snapenhance.core.features.impl.messaging.Messaging +import me.rhunk.snapenhance.core.util.CallbackBuilder +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper +import me.rhunk.snapenhance.core.wrapper.impl.MessageDestinations +import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID +import me.rhunk.snapenhance.mapper.impl.CallbackMapper + +class MessageSender( + private val context: ModContext, +) { + companion object { + val audioNoteProto: (Long, String?) -> ByteArray = { duration, userLocale -> + ProtoWriter().apply { + from(6, 1) { + from(1) { + addVarInt(2, 4) + from(5) { + addVarInt(1, 0) + addVarInt(2, 0) + } + addVarInt(7, 0) + addVarInt(13, duration) + } + if (userLocale != null) { + addString(3, userLocale) + } + } + }.toByteArray() + } + + } + + private val sendMessageCallback by lazy { + lateinit var result: Class<*> + context.mappings.useMapper(CallbackMapper::class) { + result = callbacks.getClass("SendMessageCallback") ?: return@useMapper + } + result + } + + private fun createLocalMessageContentTemplate( + contentType: ContentType, + messageContent: ByteArray, + localMediaReference: ByteArray? = null, + savePolicy: String = "PROHIBITED", + ): String { + return """ + { + "mAllowsTranscription": false, + "mBotMention": false, + "mContent": [${messageContent.joinToString(",")}], + "mContentType": "${contentType.name}", + "mIncidentalAttachments": [], + "mLocalMediaReferences": [${ + if (localMediaReference != null) { + "{\"mId\": [${localMediaReference.joinToString(",")}]}" + } else { + "" + } + }], + "mPlatformAnalytics": { + "mAttemptId": null, + "mContent": null, + "mMetricsMessageMediaType": "NO_MEDIA", + "mMetricsMessageType": "TEXT", + "mReactionSource": "NONE" + }, + "mSavePolicy": "$savePolicy" + } + """.trimIndent() + } + + private fun internalSendMessage(conversations: List, localMessageContentTemplate: String, callback: Any) { + val sendMessageWithContentMethod = context.classCache.conversationManager.declaredMethods.first { it.name == "sendMessageWithContent" } + + val localMessageContent = context.gson.fromJson(localMessageContentTemplate, context.classCache.localMessageContent) + val messageDestinations = MessageDestinations(AbstractWrapper.newEmptyInstance(context.classCache.messageDestinations)).also { + it.conversations = conversations.toCollection(ArrayList()) + it.mPhoneNumbers = arrayListOf() + it.stories = arrayListOf() + } + + sendMessageWithContentMethod.invoke(context.feature(Messaging::class).conversationManager?.instanceNonNull(), messageDestinations.instanceNonNull(), localMessageContent, callback) + } + + fun sendChatMessage(conversations: List, message: String, onError: (Any) -> Unit = {}, onSuccess: () -> Unit = {}) { + internalSendMessage(conversations, createLocalMessageContentTemplate(ContentType.CHAT, ProtoWriter().apply { + from(2) { + addString(1, message) + } + }.toByteArray(), savePolicy = "LIFETIME"), CallbackBuilder(sendMessageCallback) + .override("onSuccess", callback = { onSuccess() }) + .override("onError", callback = { onError(it.arg(0)) }) + .build()) + } + + fun sendCustomChatMessage(conversations: List, contentType: ContentType, message: ProtoWriter.() -> Unit, onError: (Any) -> Unit = {}, onSuccess: () -> Unit = {}) { + internalSendMessage(conversations, createLocalMessageContentTemplate(contentType, ProtoWriter().apply { + message() + }.toByteArray(), savePolicy = "LIFETIME"), CallbackBuilder(sendMessageCallback) + .override("onSuccess", callback = { onSuccess() }) + .override("onError", callback = { onError(it.arg(0)) }) + .build()) + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/scripting/CoreScriptRuntime.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/scripting/CoreScriptRuntime.kt new file mode 100644 index 0000000000..dc0f592178 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/scripting/CoreScriptRuntime.kt @@ -0,0 +1,61 @@ +package me.rhunk.snapenhance.core.scripting + +import me.rhunk.snapenhance.bridge.scripting.AutoReloadListener +import me.rhunk.snapenhance.common.logger.AbstractLogger +import me.rhunk.snapenhance.common.scripting.ScriptRuntime +import me.rhunk.snapenhance.common.scripting.bindings.BindingSide +import me.rhunk.snapenhance.core.ModContext +import me.rhunk.snapenhance.core.scripting.impl.* + +class CoreScriptRuntime( + private val modContext: ModContext, + logger: AbstractLogger, +): ScriptRuntime( + config = { modContext.config }, + androidContext = modContext.androidContext, + logger = logger +) { + // we assume that the bridge is reloaded the next time we connect to it + private var isBridgeReloaded = false + + fun init() { + buildModuleObject = { module -> + putConst("currentSide", this, BindingSide.CORE.key) + module.registerBindings( + CoreScriptConfig(), + CoreIPC(), + CoreScriptHooker(), + CoreMessaging(modContext), + CoreEvents(modContext), + ) + } + + modContext.bridgeClient.addOnConnectedCallback(initNow = true) { + scripting = modContext.bridgeClient.getScriptingInterface() + + if (!isBridgeReloaded) { + scripting.enabledScripts.forEach { path -> + runCatching { + load(path, scripting.getScriptContent(path)) + }.onFailure { + logger.error("Failed to load script $path", it) + } + } + } + + scripting.registerAutoReloadListener(object : AutoReloadListener.Stub() { + override fun restartApp() { + modContext.softRestartApp() + } + }) + + eachModule { + onBridgeConnected(reloaded = isBridgeReloaded) + } + + if (!isBridgeReloaded) { + isBridgeReloaded = true + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/scripting/impl/CoreEvents.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/scripting/impl/CoreEvents.kt new file mode 100644 index 0000000000..57dacadf9a --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/scripting/impl/CoreEvents.kt @@ -0,0 +1,75 @@ +package me.rhunk.snapenhance.core.scripting.impl + +import me.rhunk.snapenhance.common.scripting.bindings.AbstractBinding +import me.rhunk.snapenhance.common.scripting.bindings.BindingSide +import me.rhunk.snapenhance.common.scripting.ktx.scriptableObject +import me.rhunk.snapenhance.core.ModContext +import me.rhunk.snapenhance.core.event.Event +import me.rhunk.snapenhance.core.event.events.impl.* +import org.mozilla.javascript.ScriptableObject + +class CoreEvents( + private val modContext: ModContext +) : AbstractBinding("events", BindingSide.CORE) { + private fun ScriptableObject.cancelableEvent(event: Event): ScriptableObject { + defineProperty("canceled", { event.canceled }, { value -> event.canceled = value as Boolean },0) + return this + } + + fun onConversationUpdated(callback: (event: ScriptableObject) -> Unit) { + modContext.event.subscribe(ConversationUpdateEvent::class) { event -> + callback(scriptableObject("ConversationUpdateEvent") { + putConst("conversationId", this, event.conversationId) + putConst("conversation",this, event.conversation) + putConst("messages", this, event.messages) + }.cancelableEvent(event)) + } + } + + fun onMessageBuild(callback: (event: ScriptableObject) -> Unit) { + modContext.event.subscribe(BuildMessageEvent::class) { event -> + callback(scriptableObject("BuildMessageEvent") { + putConst("message", this, event.message) + }.cancelableEvent(event)) + } + } + + fun onViewBind(callback: (event: ScriptableObject) -> Unit) { + modContext.event.subscribe(BindViewEvent::class) { + callback(scriptableObject("BindViewEvent") { + putConst("view", this, it.view) + putConst("model", this, it.prevModel) + }.cancelableEvent(it)) + } + } + + fun onSnapInteraction(callback: (event: ScriptableObject) -> Unit) { + modContext.event.subscribe(OnSnapInteractionEvent::class) { event -> + callback(scriptableObject("OnSnapInteractionEvent") { + putConst("interactionType", this, event.interactionType) + putConst("conversationId", this, event.conversationId.toString()) + putConst("messageId", this, event.messageId) + }.cancelableEvent(event)) + } + } + + fun onPreMessageSend(callback: (event: ScriptableObject) -> Unit) { + modContext.event.subscribe(SendMessageWithContentEvent::class) { event -> + callback(scriptableObject("SendMessageWithContentEvent") { + putConst("destinations", this, event.destinations) + putConst("messageContent", this, event.messageContent) + }.cancelableEvent(event)) + } + } + + fun onAddView(callback: (event: ScriptableObject) -> Unit) { + modContext.event.subscribe(AddViewEvent::class) { event -> + callback(scriptableObject("AddViewEvent") { + putConst("parent", this, event.parent) + defineProperty("view", { event.view }, { value -> event.view = value as android.view.View },0) + }.cancelableEvent(event)) + } + } + + override fun getObject() = this +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/scripting/impl/CoreIPC.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/scripting/impl/CoreIPC.kt new file mode 100644 index 0000000000..f3eaee388b --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/scripting/impl/CoreIPC.kt @@ -0,0 +1,29 @@ +package me.rhunk.snapenhance.core.scripting.impl + +import me.rhunk.snapenhance.bridge.scripting.IPCListener +import me.rhunk.snapenhance.common.scripting.impl.IPCInterface +import me.rhunk.snapenhance.common.scripting.impl.Listener + +class CoreIPC : IPCInterface() { + override fun onBroadcast(channel: String, eventName: String, listener: Listener) { + bridgeAutoReload { + context.runtime.scripting.registerIPCListener(channel, eventName, object: IPCListener.Stub() { + override fun onMessage(args: Array) { + listener(args.toList()) + } + }) + } + } + + override fun on(eventName: String, listener: Listener) { + onBroadcast(context.moduleInfo.name, eventName, listener) + } + + override fun emit(eventName: String, vararg args: String?): Int { + return broadcast(context.moduleInfo.name, eventName, *args) + } + + override fun broadcast(channel: String, eventName: String, vararg args: String?): Int { + return runCatching { context.runtime.scripting.sendIPCMessage(channel, eventName, args) }.getOrNull() ?: 0 + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/scripting/impl/CoreMessaging.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/scripting/impl/CoreMessaging.kt new file mode 100644 index 0000000000..98a26b7e9b --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/scripting/impl/CoreMessaging.kt @@ -0,0 +1,164 @@ +package me.rhunk.snapenhance.core.scripting.impl + +import me.rhunk.snapenhance.common.data.MessageUpdate +import me.rhunk.snapenhance.common.scripting.bindings.AbstractBinding +import me.rhunk.snapenhance.common.scripting.bindings.BindingSide +import me.rhunk.snapenhance.common.scripting.ktx.scriptableObject +import me.rhunk.snapenhance.core.ModContext +import me.rhunk.snapenhance.core.features.impl.messaging.Messaging +import me.rhunk.snapenhance.core.wrapper.impl.Message +import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID +import me.rhunk.snapenhance.core.wrapper.impl.Snapchatter +import org.mozilla.javascript.Scriptable +import org.mozilla.javascript.annotations.JSFunction + +@Suppress("unused") +class CoreMessaging( + private val modContext: ModContext +) : AbstractBinding("messaging", BindingSide.CORE) { + private val messaging by lazy { modContext.feature(Messaging::class) } + private val conversationManager get() = messaging.conversationManager + + @JSFunction + fun onConversationManagerReady(callback: () -> Unit) { + messaging.onConversationManagerReady { + callback() + } + } + + @JSFunction + fun isPresent() = conversationManager != null + + @JSFunction + fun newSnapUUID(uuid: String) = SnapUUID(uuid) + + @JSFunction + fun updateMessage( + conversationId: String, + messageId: Number, + action: String, + callback: (error: String?) -> Unit + ) { + conversationManager?.updateMessage(conversationId, messageId.toLong(), MessageUpdate.entries.find { it.key == action } + ?: throw RuntimeException("Could not find message update $action"), + callback) + } + + @JSFunction + fun fetchConversationWithMessagesPaginated( + conversationId: String, + lastMessageId: Long, + amount: Int, + callback: (error: String?, message: List) -> Unit, + ) { + conversationManager?.fetchConversationWithMessagesPaginated(conversationId, lastMessageId, amount, onSuccess = { + callback(null, it) + }, onError = { + callback(it, emptyList()) + }) + } + + @JSFunction + fun fetchConversationWithMessages( + conversationId: String, + callback: (error: String?, List) -> Unit + ) { + conversationManager?.fetchConversationWithMessages(conversationId, onSuccess = { + callback(null, it) + }, onError = { + callback(it, emptyList()) + }) + } + + @JSFunction + fun fetchMessageByServerId( + conversationId: String, + serverId: Long, + callback: (error: String?, message: Message?) -> Unit, + ) { + conversationManager?.fetchMessageByServerId(conversationId, serverId, onSuccess = { + callback(null, it) + }, onError = { + callback(it, null) + }) + } + + @JSFunction + fun fetchMessagesByServerIds( + conversationId: String, + serverIds: List, + callback: (error: String?, List) -> Unit + ) { + conversationManager?.fetchMessagesByServerIds(conversationId, serverIds.map { + it.toLong() + }, onSuccess = { + callback(null, it) + }, onError = { + callback(it, emptyList()) + }) + } + + @JSFunction + fun displayedMessages( + conversationId: String, + lastMessageId: Number, + callback: (error: String?) -> Unit + ) { + conversationManager?.displayedMessages(conversationId, lastMessageId.toLong(), callback) + } + + @JSFunction + fun fetchMessage( + conversationId: String, + messageId: Number, + callback: (error: String?, message: Message?) -> Unit + ) { + conversationManager?.fetchMessage(conversationId, messageId.toLong(), onSuccess = { + callback(null, it) + }, onError = { callback(it, null) }) + } + + @JSFunction + fun clearConversation( + conversationId: String, + callback: (error: String?) -> Unit + ) { + conversationManager?.clearConversation(conversationId, onSuccess = { + callback(null) + }, onError = { + callback(it) + }) + } + + @JSFunction + fun getOneOnOneConversationIds(userIds: List, callback: (error: String?, List) -> Unit) { + conversationManager?.getOneOnOneConversationIds(userIds, onSuccess = { + callback(null, it.map { (userId, conversationId) -> + scriptableObject { + putConst("conversationId", this, conversationId) + putConst("userId", this, userId) + } + }) + }, onError = { + callback(it, emptyList()) + }) + } + + @JSFunction + fun sendChatMessage( + conversationId: String, + message: String, + result: (error: String?) -> Unit + ) { + modContext.messageSender.sendChatMessage(listOf(SnapUUID(conversationId)), message, onSuccess = { result(null) }, onError = { result(it.toString()) }) + } + + @JSFunction + fun fetchSnapchatterInfos( + userIds: List + ): List { + return messaging.fetchSnapchatterInfos(userIds = userIds) + } + + override fun getObject() = this +} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/scripting/impl/CoreScriptConfig.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/scripting/impl/CoreScriptConfig.kt new file mode 100644 index 0000000000..4baba70e62 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/scripting/impl/CoreScriptConfig.kt @@ -0,0 +1,26 @@ +package me.rhunk.snapenhance.core.scripting.impl + +import me.rhunk.snapenhance.common.scripting.impl.ConfigInterface +import me.rhunk.snapenhance.common.scripting.impl.ConfigTransactionType + +class CoreScriptConfig: ConfigInterface() { + override fun get(key: String, defaultValue: Any?): String? { + return context.runtime.scripting.configTransaction(context.moduleInfo.name, ConfigTransactionType.GET.key, key, defaultValue.toString(), false) + } + + override fun set(key: String, value: Any?, save: Boolean) { + context.runtime.scripting.configTransaction(context.moduleInfo.name, ConfigTransactionType.SET.key, key, value.toString(), save) + } + + override fun save() { + context.runtime.scripting.configTransaction(context.moduleInfo.name, ConfigTransactionType.SAVE.key, null, null, false) + } + + override fun load() { + context.runtime.scripting.configTransaction(context.moduleInfo.name, ConfigTransactionType.LOAD.key, null, null, false) + } + + override fun deleteConfig() { + context.runtime.scripting.configTransaction(context.moduleInfo.name, ConfigTransactionType.DELETE.key, null, null, false) + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/scripting/impl/CoreScriptHooker.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/scripting/impl/CoreScriptHooker.kt new file mode 100644 index 0000000000..ca127a950e --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/scripting/impl/CoreScriptHooker.kt @@ -0,0 +1,168 @@ +package me.rhunk.snapenhance.core.scripting.impl + +import me.rhunk.snapenhance.common.scripting.bindings.AbstractBinding +import me.rhunk.snapenhance.common.scripting.bindings.BindingSide +import me.rhunk.snapenhance.common.scripting.ktx.scriptableObject +import me.rhunk.snapenhance.common.scripting.toPrimitiveValue +import me.rhunk.snapenhance.core.util.hook.HookAdapter +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.Hooker +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.hook.hookConstructor +import org.mozilla.javascript.annotations.JSGetter +import org.mozilla.javascript.annotations.JSSetter +import java.lang.reflect.Constructor +import java.lang.reflect.Member +import java.lang.reflect.Method + + +class ScriptHookCallback( + private val hookAdapter: HookAdapter +) { + var result + @JSGetter("result") get() = hookAdapter.getResult() + @JSSetter("result") set(result) = hookAdapter.setResult(result.toPrimitiveValue(lazy { + when (val member = hookAdapter.method()) { + is Method -> member.returnType.name + else -> "void" + } + })) + + val thisObject + @JSGetter("thisObject") get() = hookAdapter.nullableThisObject() + + val method + @JSGetter("method") get() = hookAdapter.method() + + val args + @JSGetter("args") get() = hookAdapter.args().toList() + + private val parameterTypes by lazy { + when (val member = hookAdapter.method()) { + is Method -> member.parameterTypes + is Constructor<*> -> member.parameterTypes + else -> emptyArray() + }.toList() + } + + fun cancel() = hookAdapter.setResult(null) + + fun arg(index: Int) = hookAdapter.argNullable(index) + + fun setArg(index: Int, value: Any?) { + hookAdapter.setArg(index, value.toPrimitiveValue(lazy { parameterTypes[index].name })) + } + + fun invokeOriginal() = hookAdapter.invokeOriginal() + + fun invokeOriginal(args: Array) = hookAdapter.invokeOriginal(args.map { + it.toPrimitiveValue(lazy { parameterTypes[args.indexOf(it)].name }) ?: it + }.toTypedArray()) + + override fun toString(): String { + return "ScriptHookCallback(\n" + + " thisObject=${ runCatching { thisObject.toString() }.getOrNull() },\n" + + " args=${ runCatching { args.toString() }.getOrNull() }\n" + + " result=${ runCatching { result.toString() }.getOrNull() },\n" + + ")" + } +} + + +typealias HookCallback = (ScriptHookCallback) -> Unit +typealias HookUnhook = () -> Unit + +@Suppress("unused") +class CoreScriptHooker: AbstractBinding("hooker", BindingSide.CORE) { + private val hooks = mutableListOf() + + val stage = scriptableObject { + putConst("BEFORE", this, "before") + putConst("AFTER", this, "after") + } + + private fun findClassSafe(className: String): Class<*>? { + return runCatching { + context.runtime.androidContext.classLoader.loadClass(className) + }.onFailure { + context.runtime.logger.warn("Failed to load class $className") + }.getOrNull() + } + + private fun getHookStageFromString(stage: String): HookStage { + return when (stage) { + "before" -> HookStage.BEFORE + "after" -> HookStage.AFTER + else -> throw IllegalArgumentException("Invalid stage: $stage") + } + } + + fun findMethod(clazz: Class<*>, methodName: String): Member? { + return clazz.declaredMethods.find { it.name == methodName } + } + + fun findMethodWithParameters(clazz: Class<*>, methodName: String, vararg types: String): Member? { + return clazz.declaredMethods.find { method -> method.name == methodName && method.parameterTypes.map { it.name }.toTypedArray() contentEquals types } + } + + fun findMethod(className: String, methodName: String): Member? { + return findClassSafe(className)?.let { findMethod(it, methodName) } + } + + fun findMethodWithParameters(className: String, methodName: String, vararg types: String): Member? { + return findClassSafe(className)?.let { findMethodWithParameters(it, methodName, *types) } + } + + fun findConstructor(clazz: Class<*>, vararg types: String): Member? { + return clazz.declaredConstructors.find { constructor -> constructor.parameterTypes.map { it.name }.toTypedArray() contentEquals types } + } + + fun findConstructorParameters(className: String, vararg types: String): Member? { + return findClassSafe(className)?.let { findConstructor(it, *types) } + } + + // -- hooking + + fun hook(method: Member, stage: String, callback: HookCallback): HookUnhook { + val hookAdapter = Hooker.hook(method, getHookStageFromString(stage)) { + callback(ScriptHookCallback(it)) + } + + return { + hookAdapter.unhook() + }.also { hooks.add(it) } + } + + fun hookAllMethods(clazz: Class<*>, methodName: String, stage: String, callback: HookCallback): HookUnhook { + val hookAdapter = clazz.hook(methodName, getHookStageFromString(stage)) { + callback(ScriptHookCallback(it)) + } + + return { + hookAdapter.forEach { it.unhook() } + }.also { hooks.add(it) } + } + + fun hookAllConstructors(clazz: Class<*>, stage: String, callback: HookCallback): HookUnhook { + val hookAdapter = clazz.hookConstructor(getHookStageFromString(stage)) { + callback(ScriptHookCallback(it)) + } + + return { + hookAdapter.forEach { it.unhook() } + }.also { hooks.add(it) } + } + + fun hookAllMethods(className: String, methodName: String, stage: String, callback: HookCallback) + = findClassSafe(className)?.let { hookAllMethods(it, methodName, stage, callback) } + + fun hookAllConstructors(className: String, stage: String, callback: HookCallback) + = findClassSafe(className)?.let { hookAllConstructors(it, stage, callback) } + + override fun onDispose() { + hooks.forEach { it() } + hooks.clear() + } + + override fun getObject() = this +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/ComposeIcons.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/ComposeIcons.kt new file mode 100644 index 0000000000..2b02535bb3 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/ComposeIcons.kt @@ -0,0 +1,601 @@ +package me.rhunk.snapenhance.core.ui + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + + +val AppleLogo by lazy { + ImageVector.Builder( + defaultWidth = 800.dp, + defaultHeight = 800.dp, + viewportWidth = 27f, + viewportHeight = 27f, + ).apply { + group { + path( + fill = SolidColor(Color(0xFF000000)), + fillAlpha = 1.0f, + stroke = null, + strokeAlpha = 1.0f, + strokeLineWidth = 1.0f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 1.0f, + pathFillType = PathFillType.NonZero + ) { + moveTo(15.769f, 0f) + curveToRelative(0.053f, 0f, 0.106f, 0f, 0.162f, 0f) + curveToRelative(0.13f, 1.606f, -0.483f, 2.806f, -1.228f, 3.675f) + curveToRelative(-0.731f, 0.863f, -1.732f, 1.7f, -3.351f, 1.573f) + curveToRelative(-0.108f, -1.583f, 0.506f, -2.694f, 1.25f, -3.561f) + curveTo(13.292f, 0.879f, 14.557f, 0.16f, 15.769f, 0f) + close() + } + path( + fill = SolidColor(Color(0xFF000000)), + fillAlpha = 1.0f, + stroke = null, + strokeAlpha = 1.0f, + strokeLineWidth = 1.0f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 1.0f, + pathFillType = PathFillType.NonZero + ) { + moveTo(20.67f, 16.716f) + curveToRelative(0f, 0.016f, 0f, 0.03f, 0f, 0.045f) + curveToRelative(-0.455f, 1.378f, -1.104f, 2.559f, -1.896f, 3.655f) + curveToRelative(-0.723f, 0.995f, -1.609f, 2.334f, -3.191f, 2.334f) + curveToRelative(-1.367f, 0f, -2.275f, -0.879f, -3.676f, -0.903f) + curveToRelative(-1.482f, -0.024f, -2.297f, 0.735f, -3.652f, 0.926f) + curveToRelative(-0.155f, 0f, -0.31f, 0f, -0.462f, 0f) + curveToRelative(-0.995f, -0.144f, -1.798f, -0.932f, -2.383f, -1.642f) + curveToRelative(-1.725f, -2.098f, -3.058f, -4.808f, -3.306f, -8.276f) + curveToRelative(0f, -0.34f, 0f, -0.679f, 0f, -1.019f) + curveToRelative(0.105f, -2.482f, 1.311f, -4.5f, 2.914f, -5.478f) + curveToRelative(0.846f, -0.52f, 2.009f, -0.963f, 3.304f, -0.765f) + curveToRelative(0.555f, 0.086f, 1.122f, 0.276f, 1.619f, 0.464f) + curveToRelative(0.471f, 0.181f, 1.06f, 0.502f, 1.618f, 0.485f) + curveToRelative(0.378f, -0.011f, 0.754f, -0.208f, 1.135f, -0.347f) + curveToRelative(1.116f, -0.403f, 2.21f, -0.865f, 3.652f, -0.648f) + curveToRelative(1.733f, 0.262f, 2.963f, 1.032f, 3.723f, 2.22f) + curveToRelative(-1.466f, 0.933f, -2.625f, 2.339f, -2.427f, 4.74f) + curveTo(17.818f, 14.688f, 19.086f, 15.964f, 20.67f, 16.716f) + close() + } + } + }.build() +} + + +val Snapenhance by lazy { + ImageVector.Builder( + name = "SnapEnhance", + defaultWidth = 247.92.dp, + defaultHeight = 39.84.dp, + viewportWidth = 247.92f, + viewportHeight = 39.84f + ).apply { + group { + path( + fill = SolidColor(Color(0xFF000000)), + fillAlpha = 1.0f, + stroke = SolidColor(Color(0xFF000000)), + strokeAlpha = 1.0f, + strokeLineWidth = 0.25f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 1.0f, + pathFillType = PathFillType.EvenOdd + ) { + moveTo(99f, 17.28f) + lineTo(99f, 26.72f) + lineTo(113.8f, 26.72f) + lineTo(113.8f, 30.24f) + lineTo(95.04f, 30.24f) + lineTo(95.04f, 1.92f) + lineTo(113.2f, 1.92f) + lineTo(113.2f, 5.4f) + lineTo(99f, 5.4f) + lineTo(99f, 13.88f) + lineTo(112.36f, 13.88f) + lineTo(112.36f, 17.28f) + lineTo(99f, 17.28f) + close() + moveTo(18.84f, 4.44f) + lineTo(16.04f, 7.16f) + arcTo(5.999f, 5.999f, 0f, isMoreThanHalf = false, isPositiveArc = false, 14.483f, 5.683f) + arcTo(7.469f, 7.469f, 0f, isMoreThanHalf = false, isPositiveArc = false, 13.76f, 5.26f) + quadTo(12.32f, 4.52f, 10.52f, 4.52f) + arcTo(7.531f, 7.531f, 0f, isMoreThanHalf = false, isPositiveArc = false, 8.611f, 4.772f) + arcTo(8.316f, 8.316f, 0f, isMoreThanHalf = false, isPositiveArc = false, 8.58f, 4.78f) + quadTo(7.6f, 5.04f, 6.8f, 5.62f) + quadTo(6f, 6.2f, 5.5f, 7.06f) + arcTo(3.609f, 3.609f, 0f, isMoreThanHalf = false, isPositiveArc = false, 5.076f, 8.234f) + arcTo(4.917f, 4.917f, 0f, isMoreThanHalf = false, isPositiveArc = false, 5f, 9.12f) + quadTo(5f, 10.28f, 5.46f, 11.06f) + quadTo(5.92f, 11.84f, 6.7f, 12.38f) + arcTo(7.064f, 7.064f, 0f, isMoreThanHalf = false, isPositiveArc = false, 7.666f, 12.937f) + arcTo(9.17f, 9.17f, 0f, isMoreThanHalf = false, isPositiveArc = false, 8.54f, 13.3f) + quadTo(9.6f, 13.68f, 10.8f, 14.08f) + quadTo(12.24f, 14.52f, 13.72f, 15.08f) + quadTo(15.2f, 15.64f, 16.4f, 16.56f) + quadTo(17.6f, 17.48f, 18.36f, 18.86f) + arcTo(5.688f, 5.688f, 0f, isMoreThanHalf = false, isPositiveArc = true, 18.906f, 20.316f) + quadTo(19.063f, 20.99f, 19.105f, 21.776f) + arcTo(10.986f, 10.986f, 0f, isMoreThanHalf = false, isPositiveArc = true, 19.12f, 22.36f) + arcTo(9.947f, 9.947f, 0f, isMoreThanHalf = false, isPositiveArc = true, 18.952f, 24.235f) + arcTo(7.349f, 7.349f, 0f, isMoreThanHalf = false, isPositiveArc = true, 18.3f, 26.18f) + quadTo(17.48f, 27.8f, 16.12f, 28.86f) + arcTo(9.162f, 9.162f, 0f, isMoreThanHalf = false, isPositiveArc = true, 13.364f, 30.32f) + arcTo(10.579f, 10.579f, 0f, isMoreThanHalf = false, isPositiveArc = true, 12.98f, 30.44f) + quadTo(11.2f, 30.96f, 9.32f, 30.96f) + quadTo(6.64f, 30.96f, 4.14f, 29.92f) + quadTo(1.64f, 28.88f, 0f, 26.84f) + lineTo(3.08f, 24.24f) + arcTo(7.448f, 7.448f, 0f, isMoreThanHalf = false, isPositiveArc = false, 5.451f, 26.463f) + arcTo(8.765f, 8.765f, 0f, isMoreThanHalf = false, isPositiveArc = false, 5.76f, 26.64f) + quadTo(7.44f, 27.56f, 9.4f, 27.56f) + quadTo(10.4f, 27.56f, 11.4f, 27.28f) + quadTo(12.4f, 27f, 13.22f, 26.4f) + quadTo(14.04f, 25.8f, 14.56f, 24.9f) + arcTo(3.761f, 3.761f, 0f, isMoreThanHalf = false, isPositiveArc = false, 14.987f, 23.732f) + arcTo(5.263f, 5.263f, 0f, isMoreThanHalf = false, isPositiveArc = false, 15.08f, 22.72f) + arcTo(5.021f, 5.021f, 0f, isMoreThanHalf = false, isPositiveArc = false, 14.994f, 21.767f) + quadTo(14.881f, 21.181f, 14.618f, 20.712f) + arcTo(3.056f, 3.056f, 0f, isMoreThanHalf = false, isPositiveArc = false, 14.54f, 20.58f) + arcTo(4.686f, 4.686f, 0f, isMoreThanHalf = false, isPositiveArc = false, 13.304f, 19.274f) + arcTo(5.509f, 5.509f, 0f, isMoreThanHalf = false, isPositiveArc = false, 13.08f, 19.12f) + arcTo(8.755f, 8.755f, 0f, isMoreThanHalf = false, isPositiveArc = false, 11.882f, 18.471f) + arcTo(11.19f, 11.19f, 0f, isMoreThanHalf = false, isPositiveArc = false, 10.94f, 18.1f) + quadTo(9.72f, 17.68f, 8.36f, 17.24f) + quadTo(7f, 16.84f, 5.68f, 16.26f) + quadTo(4.36f, 15.68f, 3.32f, 14.78f) + arcTo(6.788f, 6.788f, 0f, isMoreThanHalf = false, isPositiveArc = true, 1.727f, 12.733f) + arcTo(7.817f, 7.817f, 0f, isMoreThanHalf = false, isPositiveArc = true, 1.64f, 12.56f) + arcTo(5.948f, 5.948f, 0f, isMoreThanHalf = false, isPositiveArc = true, 1.181f, 11.182f) + quadTo(1.045f, 10.536f, 1.011f, 9.789f) + arcTo(11.249f, 11.249f, 0f, isMoreThanHalf = false, isPositiveArc = true, 1f, 9.28f) + quadTo(1f, 7.16f, 1.86f, 5.64f) + quadTo(2.72f, 4.12f, 4.1f, 3.12f) + quadTo(5.48f, 2.12f, 7.22f, 1.66f) + quadTo(8.96f, 1.2f, 10.72f, 1.2f) + arcTo(12.346f, 12.346f, 0f, isMoreThanHalf = false, isPositiveArc = true, 14.669f, 1.824f) + arcTo(11.606f, 11.606f, 0f, isMoreThanHalf = false, isPositiveArc = true, 15.36f, 2.08f) + arcTo(10.587f, 10.587f, 0f, isMoreThanHalf = false, isPositiveArc = true, 17.283f, 3.092f) + arcTo(8.229f, 8.229f, 0f, isMoreThanHalf = false, isPositiveArc = true, 18.84f, 4.44f) + close() + moveTo(146f, 14.16f) + lineTo(146.08f, 14.16f) + arcTo(5.146f, 5.146f, 0f, isMoreThanHalf = false, isPositiveArc = true, 147.221f, 12.643f) + arcTo(7.281f, 7.281f, 0f, isMoreThanHalf = false, isPositiveArc = true, 148.4f, 11.76f) + arcTo(6.887f, 6.887f, 0f, isMoreThanHalf = false, isPositiveArc = true, 151.89f, 10.762f) + arcTo(8.211f, 8.211f, 0f, isMoreThanHalf = false, isPositiveArc = true, 152.08f, 10.76f) + arcTo(9.167f, 9.167f, 0f, isMoreThanHalf = false, isPositiveArc = true, 153.54f, 10.87f) + quadTo(154.477f, 11.022f, 155.24f, 11.38f) + arcTo(6.566f, 6.566f, 0f, isMoreThanHalf = false, isPositiveArc = true, 156.973f, 12.546f) + arcTo(6.086f, 6.086f, 0f, isMoreThanHalf = false, isPositiveArc = true, 157.44f, 13.04f) + quadTo(158.32f, 14.08f, 158.74f, 15.48f) + arcTo(10.1f, 10.1f, 0f, isMoreThanHalf = false, isPositiveArc = true, 159.143f, 17.838f) + arcTo(11.747f, 11.747f, 0f, isMoreThanHalf = false, isPositiveArc = true, 159.16f, 18.48f) + lineTo(159.16f, 30.24f) + lineTo(155.4f, 30.24f) + lineTo(155.4f, 19.76f) + quadTo(155.4f, 18.6f, 155.22f, 17.56f) + arcTo(5.745f, 5.745f, 0f, isMoreThanHalf = false, isPositiveArc = false, 154.906f, 16.424f) + arcTo(4.788f, 4.788f, 0f, isMoreThanHalf = false, isPositiveArc = false, 154.56f, 15.72f) + quadTo(154.08f, 14.92f, 153.26f, 14.44f) + quadTo(152.582f, 14.043f, 151.562f, 13.974f) + arcTo(6.58f, 6.58f, 0f, isMoreThanHalf = false, isPositiveArc = false, 151.12f, 13.96f) + arcTo(4.792f, 4.792f, 0f, isMoreThanHalf = false, isPositiveArc = false, 149.037f, 14.405f) + arcTo(4.776f, 4.776f, 0f, isMoreThanHalf = false, isPositiveArc = false, 147.44f, 15.66f) + arcTo(5.829f, 5.829f, 0f, isMoreThanHalf = false, isPositiveArc = false, 146.247f, 18.044f) + quadTo(146.031f, 18.902f, 146.004f, 19.909f) + arcTo(10.86f, 10.86f, 0f, isMoreThanHalf = false, isPositiveArc = false, 146f, 20.2f) + lineTo(146f, 30.24f) + lineTo(142.24f, 30.24f) + lineTo(142.24f, 0f) + lineTo(146f, 0f) + lineTo(146f, 14.16f) + close() + moveTo(24.36f, 11.28f) + lineTo(27.92f, 11.28f) + arcTo(20.944f, 20.944f, 0f, isMoreThanHalf = false, isPositiveArc = true, 27.99f, 12.039f) + quadTo(28.019f, 12.434f, 28.039f, 12.872f) + arcTo(33.443f, 33.443f, 0f, isMoreThanHalf = false, isPositiveArc = true, 28.04f, 12.9f) + quadTo(28.08f, 13.8f, 28.08f, 14.4f) + lineTo(28.2f, 14.4f) + arcTo(5.447f, 5.447f, 0f, isMoreThanHalf = false, isPositiveArc = true, 29.029f, 13.147f) + arcTo(6.224f, 6.224f, 0f, isMoreThanHalf = false, isPositiveArc = true, 29.18f, 12.98f) + quadTo(29.8f, 12.32f, 30.6f, 11.82f) + arcTo(6.957f, 6.957f, 0f, isMoreThanHalf = false, isPositiveArc = true, 32.241f, 11.076f) + arcTo(7.744f, 7.744f, 0f, isMoreThanHalf = false, isPositiveArc = true, 32.36f, 11.04f) + quadTo(33.32f, 10.76f, 34.36f, 10.76f) + arcTo(9.167f, 9.167f, 0f, isMoreThanHalf = false, isPositiveArc = true, 35.82f, 10.87f) + quadTo(36.757f, 11.022f, 37.52f, 11.38f) + arcTo(6.566f, 6.566f, 0f, isMoreThanHalf = false, isPositiveArc = true, 39.253f, 12.546f) + arcTo(6.086f, 6.086f, 0f, isMoreThanHalf = false, isPositiveArc = true, 39.72f, 13.04f) + quadTo(40.6f, 14.08f, 41.02f, 15.48f) + arcTo(10.1f, 10.1f, 0f, isMoreThanHalf = false, isPositiveArc = true, 41.423f, 17.838f) + arcTo(11.747f, 11.747f, 0f, isMoreThanHalf = false, isPositiveArc = true, 41.44f, 18.48f) + lineTo(41.44f, 30.24f) + lineTo(37.68f, 30.24f) + lineTo(37.68f, 19.72f) + quadTo(37.68f, 18.56f, 37.5f, 17.52f) + arcTo(5.745f, 5.745f, 0f, isMoreThanHalf = false, isPositiveArc = false, 37.186f, 16.384f) + arcTo(4.788f, 4.788f, 0f, isMoreThanHalf = false, isPositiveArc = false, 36.84f, 15.68f) + quadTo(36.36f, 14.88f, 35.52f, 14.4f) + arcTo(3.367f, 3.367f, 0f, isMoreThanHalf = false, isPositiveArc = false, 34.635f, 14.057f) + quadTo(34.244f, 13.963f, 33.788f, 13.933f) + arcTo(6.628f, 6.628f, 0f, isMoreThanHalf = false, isPositiveArc = false, 33.36f, 13.92f) + arcTo(4.794f, 4.794f, 0f, isMoreThanHalf = false, isPositiveArc = false, 31.382f, 14.319f) + arcTo(4.666f, 4.666f, 0f, isMoreThanHalf = false, isPositiveArc = false, 29.7f, 15.62f) + quadTo(28.355f, 17.23f, 28.284f, 19.863f) + arcTo(11.006f, 11.006f, 0f, isMoreThanHalf = false, isPositiveArc = false, 28.28f, 20.16f) + lineTo(28.28f, 30.24f) + lineTo(24.52f, 30.24f) + lineTo(24.52f, 15.36f) + quadTo(24.52f, 14.6f, 24.48f, 13.4f) + arcTo(49.99f, 49.99f, 0f, isMoreThanHalf = false, isPositiveArc = false, 24.437f, 12.404f) + quadTo(24.405f, 11.798f, 24.36f, 11.28f) + close() + moveTo(118.72f, 11.28f) + lineTo(122.28f, 11.28f) + arcTo(20.944f, 20.944f, 0f, isMoreThanHalf = false, isPositiveArc = true, 122.35f, 12.039f) + quadTo(122.379f, 12.434f, 122.399f, 12.872f) + arcTo(33.443f, 33.443f, 0f, isMoreThanHalf = false, isPositiveArc = true, 122.4f, 12.9f) + quadTo(122.44f, 13.8f, 122.44f, 14.4f) + lineTo(122.56f, 14.4f) + arcTo(5.447f, 5.447f, 0f, isMoreThanHalf = false, isPositiveArc = true, 123.389f, 13.147f) + arcTo(6.224f, 6.224f, 0f, isMoreThanHalf = false, isPositiveArc = true, 123.54f, 12.98f) + quadTo(124.16f, 12.32f, 124.96f, 11.82f) + arcTo(6.957f, 6.957f, 0f, isMoreThanHalf = false, isPositiveArc = true, 126.601f, 11.076f) + arcTo(7.744f, 7.744f, 0f, isMoreThanHalf = false, isPositiveArc = true, 126.72f, 11.04f) + quadTo(127.68f, 10.76f, 128.72f, 10.76f) + arcTo(9.167f, 9.167f, 0f, isMoreThanHalf = false, isPositiveArc = true, 130.18f, 10.87f) + quadTo(131.117f, 11.022f, 131.88f, 11.38f) + arcTo(6.566f, 6.566f, 0f, isMoreThanHalf = false, isPositiveArc = true, 133.613f, 12.546f) + arcTo(6.086f, 6.086f, 0f, isMoreThanHalf = false, isPositiveArc = true, 134.08f, 13.04f) + quadTo(134.96f, 14.08f, 135.38f, 15.48f) + arcTo(10.1f, 10.1f, 0f, isMoreThanHalf = false, isPositiveArc = true, 135.783f, 17.838f) + arcTo(11.747f, 11.747f, 0f, isMoreThanHalf = false, isPositiveArc = true, 135.8f, 18.48f) + lineTo(135.8f, 30.24f) + lineTo(132.04f, 30.24f) + lineTo(132.04f, 19.72f) + quadTo(132.04f, 18.56f, 131.86f, 17.52f) + arcTo(5.745f, 5.745f, 0f, isMoreThanHalf = false, isPositiveArc = false, 131.546f, 16.384f) + arcTo(4.788f, 4.788f, 0f, isMoreThanHalf = false, isPositiveArc = false, 131.2f, 15.68f) + quadTo(130.72f, 14.88f, 129.88f, 14.4f) + arcTo(3.367f, 3.367f, 0f, isMoreThanHalf = false, isPositiveArc = false, 128.995f, 14.057f) + quadTo(128.604f, 13.963f, 128.148f, 13.933f) + arcTo(6.628f, 6.628f, 0f, isMoreThanHalf = false, isPositiveArc = false, 127.72f, 13.92f) + arcTo(4.794f, 4.794f, 0f, isMoreThanHalf = false, isPositiveArc = false, 125.742f, 14.319f) + arcTo(4.666f, 4.666f, 0f, isMoreThanHalf = false, isPositiveArc = false, 124.06f, 15.62f) + quadTo(122.715f, 17.23f, 122.644f, 19.863f) + arcTo(11.006f, 11.006f, 0f, isMoreThanHalf = false, isPositiveArc = false, 122.64f, 20.16f) + lineTo(122.64f, 30.24f) + lineTo(118.88f, 30.24f) + lineTo(118.88f, 15.36f) + quadTo(118.88f, 14.6f, 118.84f, 13.4f) + arcTo(49.99f, 49.99f, 0f, isMoreThanHalf = false, isPositiveArc = false, 118.797f, 12.404f) + quadTo(118.765f, 11.798f, 118.72f, 11.28f) + close() + moveTo(187.04f, 11.28f) + lineTo(190.6f, 11.28f) + arcTo(20.944f, 20.944f, 0f, isMoreThanHalf = false, isPositiveArc = true, 190.67f, 12.039f) + quadTo(190.699f, 12.434f, 190.719f, 12.872f) + arcTo(33.443f, 33.443f, 0f, isMoreThanHalf = false, isPositiveArc = true, 190.72f, 12.9f) + quadTo(190.76f, 13.8f, 190.76f, 14.4f) + lineTo(190.88f, 14.4f) + arcTo(5.447f, 5.447f, 0f, isMoreThanHalf = false, isPositiveArc = true, 191.709f, 13.147f) + arcTo(6.224f, 6.224f, 0f, isMoreThanHalf = false, isPositiveArc = true, 191.86f, 12.98f) + quadTo(192.48f, 12.32f, 193.28f, 11.82f) + arcTo(6.957f, 6.957f, 0f, isMoreThanHalf = false, isPositiveArc = true, 194.921f, 11.076f) + arcTo(7.744f, 7.744f, 0f, isMoreThanHalf = false, isPositiveArc = true, 195.04f, 11.04f) + quadTo(196f, 10.76f, 197.04f, 10.76f) + arcTo(9.167f, 9.167f, 0f, isMoreThanHalf = false, isPositiveArc = true, 198.5f, 10.87f) + quadTo(199.437f, 11.022f, 200.2f, 11.38f) + arcTo(6.566f, 6.566f, 0f, isMoreThanHalf = false, isPositiveArc = true, 201.933f, 12.546f) + arcTo(6.086f, 6.086f, 0f, isMoreThanHalf = false, isPositiveArc = true, 202.4f, 13.04f) + quadTo(203.28f, 14.08f, 203.7f, 15.48f) + arcTo(10.1f, 10.1f, 0f, isMoreThanHalf = false, isPositiveArc = true, 204.103f, 17.838f) + arcTo(11.747f, 11.747f, 0f, isMoreThanHalf = false, isPositiveArc = true, 204.12f, 18.48f) + lineTo(204.12f, 30.24f) + lineTo(200.36f, 30.24f) + lineTo(200.36f, 19.72f) + quadTo(200.36f, 18.56f, 200.18f, 17.52f) + arcTo(5.745f, 5.745f, 0f, isMoreThanHalf = false, isPositiveArc = false, 199.866f, 16.384f) + arcTo(4.788f, 4.788f, 0f, isMoreThanHalf = false, isPositiveArc = false, 199.52f, 15.68f) + quadTo(199.04f, 14.88f, 198.2f, 14.4f) + arcTo(3.367f, 3.367f, 0f, isMoreThanHalf = false, isPositiveArc = false, 197.315f, 14.057f) + quadTo(196.924f, 13.963f, 196.468f, 13.933f) + arcTo(6.628f, 6.628f, 0f, isMoreThanHalf = false, isPositiveArc = false, 196.04f, 13.92f) + arcTo(4.794f, 4.794f, 0f, isMoreThanHalf = false, isPositiveArc = false, 194.062f, 14.319f) + arcTo(4.666f, 4.666f, 0f, isMoreThanHalf = false, isPositiveArc = false, 192.38f, 15.62f) + quadTo(191.035f, 17.23f, 190.964f, 19.863f) + arcTo(11.006f, 11.006f, 0f, isMoreThanHalf = false, isPositiveArc = false, 190.96f, 20.16f) + lineTo(190.96f, 30.24f) + lineTo(187.2f, 30.24f) + lineTo(187.2f, 15.36f) + quadTo(187.2f, 14.6f, 187.16f, 13.4f) + arcTo(49.99f, 49.99f, 0f, isMoreThanHalf = false, isPositiveArc = false, 187.117f, 12.404f) + quadTo(187.085f, 11.798f, 187.04f, 11.28f) + close() + moveTo(73.24f, 27.4f) + lineTo(73.24f, 39.84f) + lineTo(69.48f, 39.84f) + lineTo(69.48f, 11.28f) + lineTo(73.16f, 11.28f) + lineTo(73.16f, 14.24f) + lineTo(73.28f, 14.24f) + arcTo(6.614f, 6.614f, 0f, isMoreThanHalf = false, isPositiveArc = true, 75.047f, 12.334f) + arcTo(8.439f, 8.439f, 0f, isMoreThanHalf = false, isPositiveArc = true, 75.98f, 11.74f) + arcTo(7.846f, 7.846f, 0f, isMoreThanHalf = false, isPositiveArc = true, 78.668f, 10.861f) + arcTo(10.159f, 10.159f, 0f, isMoreThanHalf = false, isPositiveArc = true, 80.12f, 10.76f) + arcTo(10.259f, 10.259f, 0f, isMoreThanHalf = false, isPositiveArc = true, 82.504f, 11.028f) + arcTo(8.741f, 8.741f, 0f, isMoreThanHalf = false, isPositiveArc = true, 83.98f, 11.52f) + quadTo(85.72f, 12.28f, 86.98f, 13.64f) + quadTo(88.24f, 15f, 88.92f, 16.82f) + arcTo(10.934f, 10.934f, 0f, isMoreThanHalf = false, isPositiveArc = true, 89.594f, 20.332f) + arcTo(12.508f, 12.508f, 0f, isMoreThanHalf = false, isPositiveArc = true, 89.6f, 20.72f) + arcTo(11.316f, 11.316f, 0f, isMoreThanHalf = false, isPositiveArc = true, 89.061f, 24.235f) + arcTo(10.672f, 10.672f, 0f, isMoreThanHalf = false, isPositiveArc = true, 88.92f, 24.64f) + arcTo(9.832f, 9.832f, 0f, isMoreThanHalf = false, isPositiveArc = true, 87.445f, 27.313f) + arcTo(9.18f, 9.18f, 0f, isMoreThanHalf = false, isPositiveArc = true, 87f, 27.84f) + quadTo(85.76f, 29.2f, 84f, 29.98f) + arcTo(9.076f, 9.076f, 0f, isMoreThanHalf = false, isPositiveArc = true, 81.372f, 30.684f) + arcTo(11.346f, 11.346f, 0f, isMoreThanHalf = false, isPositiveArc = true, 80.04f, 30.76f) + quadTo(78f, 30.76f, 76.2f, 29.88f) + quadTo(74.4f, 29f, 73.36f, 27.4f) + lineTo(73.24f, 27.4f) + close() + moveTo(247.88f, 21.88f) + lineTo(232.84f, 21.88f) + quadTo(232.88f, 23.08f, 233.38f, 24.14f) + quadTo(233.88f, 25.2f, 234.72f, 25.98f) + quadTo(235.56f, 26.76f, 236.66f, 27.2f) + quadTo(237.76f, 27.64f, 239f, 27.64f) + arcTo(7.368f, 7.368f, 0f, isMoreThanHalf = false, isPositiveArc = false, 240.673f, 27.458f) + arcTo(5.681f, 5.681f, 0f, isMoreThanHalf = false, isPositiveArc = false, 242.32f, 26.8f) + arcTo(7.422f, 7.422f, 0f, isMoreThanHalf = false, isPositiveArc = false, 243.689f, 25.757f) + arcTo(6.166f, 6.166f, 0f, isMoreThanHalf = false, isPositiveArc = false, 244.52f, 24.76f) + lineTo(247.16f, 26.88f) + quadTo(245.64f, 28.88f, 243.58f, 29.82f) + quadTo(241.52f, 30.76f, 239f, 30.76f) + arcTo(11.366f, 11.366f, 0f, isMoreThanHalf = false, isPositiveArc = true, 236.189f, 30.422f) + arcTo(10.073f, 10.073f, 0f, isMoreThanHalf = false, isPositiveArc = true, 235f, 30.04f) + arcTo(9.419f, 9.419f, 0f, isMoreThanHalf = false, isPositiveArc = true, 232.398f, 28.524f) + arcTo(8.798f, 8.798f, 0f, isMoreThanHalf = false, isPositiveArc = true, 231.84f, 28.02f) + arcTo(9.247f, 9.247f, 0f, isMoreThanHalf = false, isPositiveArc = true, 229.92f, 25.248f) + arcTo(10.721f, 10.721f, 0f, isMoreThanHalf = false, isPositiveArc = true, 229.76f, 24.88f) + arcTo(10.063f, 10.063f, 0f, isMoreThanHalf = false, isPositiveArc = true, 229.06f, 22.028f) + arcTo(12.284f, 12.284f, 0f, isMoreThanHalf = false, isPositiveArc = true, 229f, 20.8f) + quadTo(229f, 18.6f, 229.74f, 16.74f) + arcTo(9.54f, 9.54f, 0f, isMoreThanHalf = false, isPositiveArc = true, 231.176f, 14.254f) + arcTo(8.794f, 8.794f, 0f, isMoreThanHalf = false, isPositiveArc = true, 231.8f, 13.56f) + quadTo(233.12f, 12.24f, 234.92f, 11.5f) + quadTo(236.72f, 10.76f, 238.8f, 10.76f) + arcTo(11.039f, 11.039f, 0f, isMoreThanHalf = false, isPositiveArc = true, 241.02f, 10.975f) + arcTo(9.125f, 9.125f, 0f, isMoreThanHalf = false, isPositiveArc = true, 242.58f, 11.44f) + quadTo(244.28f, 12.12f, 245.46f, 13.4f) + arcTo(8.518f, 8.518f, 0f, isMoreThanHalf = false, isPositiveArc = true, 246.973f, 15.726f) + arcTo(10.24f, 10.24f, 0f, isMoreThanHalf = false, isPositiveArc = true, 247.28f, 16.5f) + quadTo(247.92f, 18.32f, 247.92f, 20.6f) + lineTo(247.92f, 21.24f) + quadTo(247.92f, 21.56f, 247.88f, 21.88f) + close() + moveTo(59.6f, 18.48f) + lineTo(59.6f, 17.96f) + arcTo(5.548f, 5.548f, 0f, isMoreThanHalf = false, isPositiveArc = false, 59.476f, 16.746f) + quadTo(59.302f, 15.972f, 58.885f, 15.406f) + arcTo(3.092f, 3.092f, 0f, isMoreThanHalf = false, isPositiveArc = false, 58.32f, 14.82f) + arcTo(4.455f, 4.455f, 0f, isMoreThanHalf = false, isPositiveArc = false, 56.642f, 14.002f) + quadTo(55.864f, 13.8f, 54.92f, 13.8f) + quadTo(53.32f, 13.8f, 51.9f, 14.42f) + arcTo(8.386f, 8.386f, 0f, isMoreThanHalf = false, isPositiveArc = false, 50.448f, 15.225f) + arcTo(6.883f, 6.883f, 0f, isMoreThanHalf = false, isPositiveArc = false, 49.52f, 16f) + lineTo(47.52f, 13.6f) + arcTo(9.632f, 9.632f, 0f, isMoreThanHalf = false, isPositiveArc = true, 49.911f, 11.963f) + arcTo(11.725f, 11.725f, 0f, isMoreThanHalf = false, isPositiveArc = true, 50.94f, 11.52f) + arcTo(12.064f, 12.064f, 0f, isMoreThanHalf = false, isPositiveArc = true, 54.776f, 10.769f) + arcTo(13.874f, 13.874f, 0f, isMoreThanHalf = false, isPositiveArc = true, 55.28f, 10.76f) + arcTo(12.083f, 12.083f, 0f, isMoreThanHalf = false, isPositiveArc = true, 56.962f, 10.871f) + quadTo(57.849f, 10.996f, 58.606f, 11.261f) + arcTo(7.17f, 7.17f, 0f, isMoreThanHalf = false, isPositiveArc = true, 58.82f, 11.34f) + quadTo(60.32f, 11.92f, 61.28f, 12.92f) + quadTo(62.24f, 13.92f, 62.72f, 15.28f) + quadTo(63.2f, 16.64f, 63.2f, 18.2f) + lineTo(63.2f, 26.48f) + arcTo(34.317f, 34.317f, 0f, isMoreThanHalf = false, isPositiveArc = false, 63.231f, 27.912f) + arcTo(39.875f, 39.875f, 0f, isMoreThanHalf = false, isPositiveArc = false, 63.26f, 28.5f) + arcTo(15.406f, 15.406f, 0f, isMoreThanHalf = false, isPositiveArc = false, 63.313f, 29.184f) + quadTo(63.375f, 29.794f, 63.48f, 30.24f) + lineTo(60.08f, 30.24f) + quadTo(59.76f, 28.88f, 59.76f, 27.52f) + lineTo(59.64f, 27.52f) + quadTo(58.6f, 29.04f, 57.02f, 29.88f) + quadTo(55.44f, 30.72f, 53.28f, 30.72f) + quadTo(52.16f, 30.72f, 50.94f, 30.42f) + quadTo(49.72f, 30.12f, 48.72f, 29.44f) + quadTo(47.72f, 28.76f, 47.06f, 27.64f) + quadTo(46.4f, 26.52f, 46.4f, 24.88f) + arcTo(6.5f, 6.5f, 0f, isMoreThanHalf = false, isPositiveArc = true, 46.547f, 23.458f) + quadTo(46.767f, 22.476f, 47.317f, 21.75f) + arcTo(3.978f, 3.978f, 0f, isMoreThanHalf = false, isPositiveArc = true, 47.56f, 21.46f) + quadTo(48.72f, 20.2f, 50.6f, 19.54f) + arcTo(15.327f, 15.327f, 0f, isMoreThanHalf = false, isPositiveArc = true, 52.867f, 18.943f) + arcTo(19.933f, 19.933f, 0f, isMoreThanHalf = false, isPositiveArc = true, 54.84f, 18.68f) + quadTo(57.2f, 18.48f, 59.6f, 18.48f) + close() + moveTo(177.32f, 18.48f) + lineTo(177.32f, 17.96f) + arcTo(5.548f, 5.548f, 0f, isMoreThanHalf = false, isPositiveArc = false, 177.196f, 16.746f) + quadTo(177.022f, 15.972f, 176.605f, 15.406f) + arcTo(3.092f, 3.092f, 0f, isMoreThanHalf = false, isPositiveArc = false, 176.04f, 14.82f) + arcTo(4.455f, 4.455f, 0f, isMoreThanHalf = false, isPositiveArc = false, 174.362f, 14.002f) + quadTo(173.584f, 13.8f, 172.64f, 13.8f) + quadTo(171.04f, 13.8f, 169.62f, 14.42f) + arcTo(8.386f, 8.386f, 0f, isMoreThanHalf = false, isPositiveArc = false, 168.168f, 15.225f) + arcTo(6.883f, 6.883f, 0f, isMoreThanHalf = false, isPositiveArc = false, 167.24f, 16f) + lineTo(165.24f, 13.6f) + arcTo(9.632f, 9.632f, 0f, isMoreThanHalf = false, isPositiveArc = true, 167.631f, 11.963f) + arcTo(11.725f, 11.725f, 0f, isMoreThanHalf = false, isPositiveArc = true, 168.66f, 11.52f) + arcTo(12.064f, 12.064f, 0f, isMoreThanHalf = false, isPositiveArc = true, 172.496f, 10.769f) + arcTo(13.874f, 13.874f, 0f, isMoreThanHalf = false, isPositiveArc = true, 173f, 10.76f) + arcTo(12.083f, 12.083f, 0f, isMoreThanHalf = false, isPositiveArc = true, 174.682f, 10.871f) + quadTo(175.569f, 10.996f, 176.326f, 11.261f) + arcTo(7.17f, 7.17f, 0f, isMoreThanHalf = false, isPositiveArc = true, 176.54f, 11.34f) + quadTo(178.04f, 11.92f, 179f, 12.92f) + quadTo(179.96f, 13.92f, 180.44f, 15.28f) + quadTo(180.92f, 16.64f, 180.92f, 18.2f) + lineTo(180.92f, 26.48f) + arcTo(34.317f, 34.317f, 0f, isMoreThanHalf = false, isPositiveArc = false, 180.951f, 27.912f) + arcTo(39.875f, 39.875f, 0f, isMoreThanHalf = false, isPositiveArc = false, 180.98f, 28.5f) + arcTo(15.406f, 15.406f, 0f, isMoreThanHalf = false, isPositiveArc = false, 181.033f, 29.184f) + quadTo(181.095f, 29.794f, 181.2f, 30.24f) + lineTo(177.8f, 30.24f) + quadTo(177.48f, 28.88f, 177.48f, 27.52f) + lineTo(177.36f, 27.52f) + quadTo(176.32f, 29.04f, 174.74f, 29.88f) + quadTo(173.16f, 30.72f, 171f, 30.72f) + quadTo(169.88f, 30.72f, 168.66f, 30.42f) + quadTo(167.44f, 30.12f, 166.44f, 29.44f) + quadTo(165.44f, 28.76f, 164.78f, 27.64f) + quadTo(164.12f, 26.52f, 164.12f, 24.88f) + arcTo(6.5f, 6.5f, 0f, isMoreThanHalf = false, isPositiveArc = true, 164.267f, 23.458f) + quadTo(164.487f, 22.476f, 165.037f, 21.75f) + arcTo(3.978f, 3.978f, 0f, isMoreThanHalf = false, isPositiveArc = true, 165.28f, 21.46f) + quadTo(166.44f, 20.2f, 168.32f, 19.54f) + arcTo(15.327f, 15.327f, 0f, isMoreThanHalf = false, isPositiveArc = true, 170.587f, 18.943f) + arcTo(19.933f, 19.933f, 0f, isMoreThanHalf = false, isPositiveArc = true, 172.56f, 18.68f) + quadTo(174.92f, 18.48f, 177.32f, 18.48f) + close() + moveTo(226.48f, 13.88f) + lineTo(223.76f, 16.08f) + arcTo(4.04f, 4.04f, 0f, isMoreThanHalf = false, isPositiveArc = false, 222.866f, 15.117f) + arcTo(5.637f, 5.637f, 0f, isMoreThanHalf = false, isPositiveArc = false, 221.96f, 14.54f) + quadTo(220.76f, 13.92f, 219.4f, 13.92f) + quadTo(217.92f, 13.92f, 216.78f, 14.48f) + quadTo(215.64f, 15.04f, 214.84f, 15.98f) + arcTo(6.552f, 6.552f, 0f, isMoreThanHalf = false, isPositiveArc = false, 213.66f, 18.044f) + arcTo(7.449f, 7.449f, 0f, isMoreThanHalf = false, isPositiveArc = false, 213.62f, 18.16f) + quadTo(213.2f, 19.4f, 213.2f, 20.76f) + quadTo(213.2f, 22.12f, 213.62f, 23.36f) + arcTo(6.651f, 6.651f, 0f, isMoreThanHalf = false, isPositiveArc = false, 214.645f, 25.299f) + arcTo(6.288f, 6.288f, 0f, isMoreThanHalf = false, isPositiveArc = false, 214.84f, 25.54f) + quadTo(215.64f, 26.48f, 216.8f, 27.04f) + arcTo(5.612f, 5.612f, 0f, isMoreThanHalf = false, isPositiveArc = false, 218.546f, 27.545f) + arcTo(7.101f, 7.101f, 0f, isMoreThanHalf = false, isPositiveArc = false, 219.44f, 27.6f) + quadTo(220.88f, 27.6f, 222.06f, 27.04f) + quadTo(223.24f, 26.48f, 224f, 25.52f) + lineTo(226.52f, 27.8f) + quadTo(225.32f, 29.2f, 223.52f, 29.98f) + quadTo(221.72f, 30.76f, 219.44f, 30.76f) + arcTo(11.759f, 11.759f, 0f, isMoreThanHalf = false, isPositiveArc = true, 216.7f, 30.45f) + arcTo(10.249f, 10.249f, 0f, isMoreThanHalf = false, isPositiveArc = true, 215.38f, 30.04f) + arcTo(9.738f, 9.738f, 0f, isMoreThanHalf = false, isPositiveArc = true, 212.53f, 28.355f) + arcTo(9.23f, 9.23f, 0f, isMoreThanHalf = false, isPositiveArc = true, 212.16f, 28.02f) + arcTo(9.264f, 9.264f, 0f, isMoreThanHalf = false, isPositiveArc = true, 210.094f, 25.051f) + arcTo(10.626f, 10.626f, 0f, isMoreThanHalf = false, isPositiveArc = true, 210.02f, 24.88f) + arcTo(9.821f, 9.821f, 0f, isMoreThanHalf = false, isPositiveArc = true, 209.316f, 22.142f) + arcTo(12.222f, 12.222f, 0f, isMoreThanHalf = false, isPositiveArc = true, 209.24f, 20.76f) + arcTo(11.439f, 11.439f, 0f, isMoreThanHalf = false, isPositiveArc = true, 209.506f, 18.249f) + arcTo(9.623f, 9.623f, 0f, isMoreThanHalf = false, isPositiveArc = true, 210f, 16.68f) + quadTo(210.76f, 14.84f, 212.12f, 13.52f) + quadTo(213.48f, 12.2f, 215.34f, 11.48f) + arcTo(10.84f, 10.84f, 0f, isMoreThanHalf = false, isPositiveArc = true, 218.758f, 10.774f) + arcTo(12.596f, 12.596f, 0f, isMoreThanHalf = false, isPositiveArc = true, 219.36f, 10.76f) + arcTo(10.456f, 10.456f, 0f, isMoreThanHalf = false, isPositiveArc = true, 223.031f, 11.44f) + arcTo(11.991f, 11.991f, 0f, isMoreThanHalf = false, isPositiveArc = true, 223.34f, 11.56f) + arcTo(7.973f, 7.973f, 0f, isMoreThanHalf = false, isPositiveArc = true, 225.212f, 12.613f) + arcTo(6.742f, 6.742f, 0f, isMoreThanHalf = false, isPositiveArc = true, 226.48f, 13.88f) + close() + moveTo(85.72f, 20.72f) + quadTo(85.72f, 19.4f, 85.32f, 18.16f) + quadTo(84.92f, 16.92f, 84.12f, 15.96f) + quadTo(83.32f, 15f, 82.14f, 14.44f) + arcTo(5.677f, 5.677f, 0f, isMoreThanHalf = false, isPositiveArc = false, 80.605f, 13.972f) + arcTo(7.538f, 7.538f, 0f, isMoreThanHalf = false, isPositiveArc = false, 79.4f, 13.88f) + quadTo(77.96f, 13.88f, 76.78f, 14.44f) + quadTo(75.6f, 15f, 74.76f, 15.96f) + quadTo(73.92f, 16.92f, 73.46f, 18.16f) + quadTo(73f, 19.4f, 73f, 20.76f) + quadTo(73f, 22.12f, 73.46f, 23.36f) + quadTo(73.92f, 24.6f, 74.76f, 25.54f) + quadTo(75.6f, 26.48f, 76.78f, 27.04f) + arcTo(5.85f, 5.85f, 0f, isMoreThanHalf = false, isPositiveArc = false, 78.847f, 27.579f) + arcTo(7.039f, 7.039f, 0f, isMoreThanHalf = false, isPositiveArc = false, 79.4f, 27.6f) + arcTo(7.137f, 7.137f, 0f, isMoreThanHalf = false, isPositiveArc = false, 80.779f, 27.473f) + arcTo(5.459f, 5.459f, 0f, isMoreThanHalf = false, isPositiveArc = false, 82.14f, 27.02f) + quadTo(83.32f, 26.44f, 84.12f, 25.48f) + quadTo(84.92f, 24.52f, 85.32f, 23.28f) + quadTo(85.72f, 22.04f, 85.72f, 20.72f) + close() + moveTo(232.84f, 19.08f) + lineTo(244.12f, 19.08f) + arcTo(7.34f, 7.34f, 0f, isMoreThanHalf = false, isPositiveArc = false, 243.879f, 17.431f) + arcTo(6.622f, 6.622f, 0f, isMoreThanHalf = false, isPositiveArc = false, 243.74f, 16.98f) + arcTo(4.888f, 4.888f, 0f, isMoreThanHalf = false, isPositiveArc = false, 242.948f, 15.523f) + arcTo(4.617f, 4.617f, 0f, isMoreThanHalf = false, isPositiveArc = false, 242.74f, 15.28f) + arcTo(4.591f, 4.591f, 0f, isMoreThanHalf = false, isPositiveArc = false, 241.413f, 14.286f) + arcTo(5.441f, 5.441f, 0f, isMoreThanHalf = false, isPositiveArc = false, 241.1f, 14.14f) + quadTo(240.149f, 13.732f, 238.877f, 13.72f) + arcTo(8.206f, 8.206f, 0f, isMoreThanHalf = false, isPositiveArc = false, 238.8f, 13.72f) + quadTo(237.6f, 13.72f, 236.54f, 14.14f) + quadTo(235.48f, 14.56f, 234.7f, 15.28f) + quadTo(233.92f, 16f, 233.42f, 16.98f) + quadTo(232.92f, 17.96f, 232.84f, 19.08f) + close() + moveTo(59.6f, 21.2f) + lineTo(58.68f, 21.2f) + arcTo(35.838f, 35.838f, 0f, isMoreThanHalf = false, isPositiveArc = false, 56.2f, 21.289f) + arcTo(39.946f, 39.946f, 0f, isMoreThanHalf = false, isPositiveArc = false, 55.78f, 21.32f) + quadTo(54.28f, 21.44f, 53.06f, 21.8f) + arcTo(5.928f, 5.928f, 0f, isMoreThanHalf = false, isPositiveArc = false, 51.977f, 22.233f) + arcTo(4.572f, 4.572f, 0f, isMoreThanHalf = false, isPositiveArc = false, 51.04f, 22.88f) + arcTo(2.379f, 2.379f, 0f, isMoreThanHalf = false, isPositiveArc = false, 50.256f, 24.472f) + arcTo(3.318f, 3.318f, 0f, isMoreThanHalf = false, isPositiveArc = false, 50.24f, 24.8f) + arcTo(3.432f, 3.432f, 0f, isMoreThanHalf = false, isPositiveArc = false, 50.292f, 25.414f) + quadTo(50.357f, 25.772f, 50.504f, 26.063f) + arcTo(2.008f, 2.008f, 0f, isMoreThanHalf = false, isPositiveArc = false, 50.58f, 26.2f) + quadTo(50.92f, 26.76f, 51.48f, 27.12f) + quadTo(52.04f, 27.48f, 52.72f, 27.62f) + quadTo(53.4f, 27.76f, 54.12f, 27.76f) + arcTo(7.062f, 7.062f, 0f, isMoreThanHalf = false, isPositiveArc = false, 55.756f, 27.582f) + arcTo(4.6f, 4.6f, 0f, isMoreThanHalf = false, isPositiveArc = false, 58.18f, 26.18f) + arcTo(5.492f, 5.492f, 0f, isMoreThanHalf = false, isPositiveArc = false, 59.548f, 23.089f) + arcTo(7.385f, 7.385f, 0f, isMoreThanHalf = false, isPositiveArc = false, 59.6f, 22.2f) + lineTo(59.6f, 21.2f) + close() + moveTo(177.32f, 21.2f) + lineTo(176.4f, 21.2f) + arcTo(35.838f, 35.838f, 0f, isMoreThanHalf = false, isPositiveArc = false, 173.92f, 21.289f) + arcTo(39.946f, 39.946f, 0f, isMoreThanHalf = false, isPositiveArc = false, 173.5f, 21.32f) + quadTo(172f, 21.44f, 170.78f, 21.8f) + arcTo(5.928f, 5.928f, 0f, isMoreThanHalf = false, isPositiveArc = false, 169.697f, 22.233f) + arcTo(4.572f, 4.572f, 0f, isMoreThanHalf = false, isPositiveArc = false, 168.76f, 22.88f) + arcTo(2.379f, 2.379f, 0f, isMoreThanHalf = false, isPositiveArc = false, 167.976f, 24.472f) + arcTo(3.318f, 3.318f, 0f, isMoreThanHalf = false, isPositiveArc = false, 167.96f, 24.8f) + arcTo(3.432f, 3.432f, 0f, isMoreThanHalf = false, isPositiveArc = false, 168.012f, 25.414f) + quadTo(168.077f, 25.772f, 168.224f, 26.063f) + arcTo(2.008f, 2.008f, 0f, isMoreThanHalf = false, isPositiveArc = false, 168.3f, 26.2f) + quadTo(168.64f, 26.76f, 169.2f, 27.12f) + quadTo(169.76f, 27.48f, 170.44f, 27.62f) + quadTo(171.12f, 27.76f, 171.84f, 27.76f) + arcTo(7.062f, 7.062f, 0f, isMoreThanHalf = false, isPositiveArc = false, 173.476f, 27.582f) + arcTo(4.6f, 4.6f, 0f, isMoreThanHalf = false, isPositiveArc = false, 175.9f, 26.18f) + arcTo(5.492f, 5.492f, 0f, isMoreThanHalf = false, isPositiveArc = false, 177.268f, 23.089f) + arcTo(7.385f, 7.385f, 0f, isMoreThanHalf = false, isPositiveArc = false, 177.32f, 22.2f) + lineTo(177.32f, 21.2f) + close() + } + } + }.build() +} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/DebugComponents.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/DebugComponents.kt new file mode 100644 index 0000000000..8b5c93aac2 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/DebugComponents.kt @@ -0,0 +1,22 @@ +package me.rhunk.snapenhance.core.ui + +import android.content.Context +import android.text.InputType +import android.view.View +import android.widget.EditText +import android.widget.ScrollView + +fun debugEditText(context: Context, initialText: String): View { + return ScrollView(context).apply { + isSmoothScrollingEnabled = true + addView(EditText(context).apply { + inputType = InputType.TYPE_NULL + isSingleLine = false + setTextColor(resources.getColor(android.R.color.white, context.theme)) + setTextIsSelectable(true) + textSize = 12f + setPadding(20, 20, 20, 20) + setText(initialText) + }) + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/InAppOverlay.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/InAppOverlay.kt new file mode 100644 index 0000000000..09d95b36ab --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/InAppOverlay.kt @@ -0,0 +1,306 @@ +package me.rhunk.snapenhance.core.ui + +import android.app.Activity +import android.view.View +import android.widget.FrameLayout +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.rememberSplineBasedDecay +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Warning +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay +import me.rhunk.snapenhance.common.ui.AppMaterialTheme +import me.rhunk.snapenhance.common.ui.createComposeView +import me.rhunk.snapenhance.common.util.ktx.copyToClipboard +import me.rhunk.snapenhance.core.ModContext +import me.rhunk.snapenhance.core.SnapEnhance +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.Hooker +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.ktx.isDarkTheme +import kotlin.math.roundToInt +import kotlin.random.Random +import kotlin.system.exitProcess + +typealias CustomComposable = @Composable BoxScope.() -> Unit + +class InAppOverlay( + private val context: ModContext +) { + companion object { + fun showCrashOverlay(content: String, throwable: Throwable? = null) { + // deny network requests + SnapEnhance.classCache.apply { + unifiedGrpcService.hook("unaryCall", HookStage.BEFORE) { param -> + param.setResult(null) + } + networkApi.hook("submit", HookStage.BEFORE) { param -> + param.setResult(null) + } + } + + Hooker.ephemeralHook(Activity::class.java, "onPostCreate", HookStage.AFTER) { param -> + val contentView = param.thisObject().findViewById(android.R.id.content) + contentView.children().forEach { it.visibility = View.GONE } + val screenView = createComposeView(param.thisObject()) { + AppMaterialTheme(isDarkTheme = true) { + Surface( + color = MaterialTheme.colorScheme.surface + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "SnapEnhance", + fontSize = 28.sp + ) + Spacer(modifier = Modifier.height(40.dp)) + Text( + text = content, + fontSize = 16.sp + ) + Spacer(modifier = Modifier.height(40.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + throwable?.let { + Button(onClick = { + contentView.context.copyToClipboard(it.stackTraceToString()) + }) { + Text("Copy error to clipboard") + } + } + Button(onClick = { + exitProcess(1) + }) { + Text("Exit") + } + } + } + } + } + } + }.apply { + layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) + } + contentView.addView(screenView) + } + } + } + + inner class Toast( + val composable: @Composable Toast.() -> Unit, + val durationMs: Int + ) { + var shown by mutableStateOf(false) + var visible by mutableStateOf(false) + } + + private val toasts = mutableStateListOf() + private val customComposables = mutableStateListOf() + + @OptIn(ExperimentalFoundationApi::class) + @Composable + private fun OverlayContent() { + Box( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + .navigationBarsPadding(), + ) { + toasts.forEach { toast -> + val animation by animateFloatAsState( + targetValue = if (toast.visible) 1f else 0f, + animationSpec = if (toast.visible) tween(durationMillis = 150) else tween(durationMillis = 300), + label = "toast" + ) + + LaunchedEffect(toast) { + toast.visible = true + if (toast.durationMs < 0) return@LaunchedEffect + delay(toast.durationMs.toLong()) + toast.visible = false + delay(1000) + toast.shown = true + synchronized(toasts) { + if (toasts.isNotEmpty() && toasts.all { it.shown }) toasts.clear() + } + } + + val deviceWidth = LocalContext.current.resources.displayMetrics.widthPixels + val delayAnimationSpec = rememberSplineBasedDecay() + val draggableState = remember { + AnchoredDraggableState( + initialValue = 0, + anchors = DraggableAnchors { + -1 at -deviceWidth.toFloat() + 0 at 0f + 1 at deviceWidth.toFloat() + }, + positionalThreshold = { distance: Float -> distance * 0.5f }, + velocityThreshold = { deviceWidth / 2f }, + snapAnimationSpec = tween(), + decayAnimationSpec = delayAnimationSpec, + confirmValueChange = { + if (it == 0) return@AnchoredDraggableState true + toast.visible = false + true + } + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .anchoredDraggable(draggableState, Orientation.Horizontal) + .offset { IntOffset(draggableState.offset.roundToInt(), 0) } + .graphicsLayer { + alpha = animation + translationY = -100.dp.toPx() * (1 - animation) + } + ) { + if (animation > 0.01f) { + toast.composable(toast) + } + } + } + + customComposables.forEach { + it() + } + } + } + + private val overlayTag = Random.nextLong() + + private fun injectOverlay(activity: Activity) { + val root = activity.findViewById(android.R.id.content) + activity.runOnUiThread { + if (root.findViewWithTag(overlayTag) != null) return@runOnUiThread + root.addView(createComposeView(activity) { + AppMaterialTheme(isDarkTheme = remember { activity.isDarkTheme() }) { + OverlayContent() + } + }.apply { + tag = overlayTag + layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) + }) + } + } + + fun onActivityCreate(activity: Activity) { + injectOverlay(activity) + } + + fun addCustomComposable(composable: CustomComposable) { + customComposables.add(composable) + } + + fun removeCustomComposable(composable: CustomComposable) { + customComposables.remove(composable) + } + + @Composable + private fun DurationProgress( + duration: Int, + modifier: Modifier = Modifier + ) { + val progress = remember { Animatable(1f) } + + LaunchedEffect(Unit) { + progress.animateTo( + targetValue = 0f, + animationSpec = tween(durationMillis = duration, easing = LinearEasing) + ) + } + + LinearProgressIndicator( + progress = { progress.value }, + modifier = modifier + ) + } + + fun showStatusToast( + icon: ImageVector, + text: String, + durationMs: Int = 2000, + showDuration: Boolean = true, + maxLines: Int = 3 + ) { + showToast( + icon = { Icon(icon, contentDescription = "icon", modifier = Modifier.size(32.dp)) }, + text = { + Text(text, modifier = Modifier.fillMaxWidth(), maxLines = maxLines, overflow = TextOverflow.Ellipsis, lineHeight = 15.sp, fontSize = 13.sp) + }, + durationMs = durationMs, + showDuration = showDuration + ) + } + + private fun showToast( + icon: @Composable () -> Unit = { + Icon(Icons.Outlined.Warning, contentDescription = "icon", modifier = Modifier.size(32.dp)) + }, + text: @Composable () -> Unit = {}, + durationMs: Int = 3000, + showDuration: Boolean = true, + ) { + injectOverlay(context.mainActivity!!) + toasts.add(Toast( + composable = { + ElevatedCard( + modifier = Modifier + .padding(12.dp) + .shadow(8.dp, RoundedCornerShape(8.dp)) + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + ) { + icon() + text() + } + if (showDuration && durationMs > 0) { + DurationProgress(duration = durationMs, modifier = Modifier.fillMaxWidth()) + } + } + }, + durationMs = durationMs + )) + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/UserInterface.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/UserInterface.kt new file mode 100644 index 0000000000..8b917ee599 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/UserInterface.kt @@ -0,0 +1,83 @@ +package me.rhunk.snapenhance.core.ui + +import android.content.res.Resources +import android.graphics.Typeface +import android.util.TypedValue +import android.view.Gravity +import android.widget.TextView +import androidx.core.content.res.ResourcesCompat +import me.rhunk.snapenhance.core.ModContext +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.ktx.isDarkTheme + +class UserInterface( + private val context: ModContext +) { + private val fontMap = mutableMapOf() + + val colorPrimary get() = if (context.androidContext.isDarkTheme()) 0xfff5f5f5.toInt() else 0xff212121.toInt() + val actionSheetBackground get() = if (context.androidContext.isDarkTheme()) 0xff1e1e1e.toInt() else 0xffffffff.toInt() + + val avenirNextFontId = 500 + val avenirNextTypeface get() = fontMap[avenirNextFontId] ?: fontMap.entries.minByOrNull { it.key }?.value ?: Typeface.DEFAULT + + fun dpToPx(dp: Int): Int { + return (dp * context.resources.displayMetrics.density).toInt() + } + + @Suppress("unused") + fun pxToDp(px: Int): Int { + return (px / context.resources.displayMetrics.density).toInt() + } + + fun applyActionButtonTheme(view: TextView) { + view.apply { + setTextColor(colorPrimary) + typeface = avenirNextTypeface + setShadowLayer(0F, 0F, 0F, 0) + gravity = Gravity.CENTER_VERTICAL + isAllCaps = false + textSize = 16f + outlineProvider = null + setPadding(dpToPx(12), dpToPx(15), 0, dpToPx(15)) + setBackgroundColor(0) + } + } + + fun init() { + ResourcesCompat::class.java.hook("getFont", HookStage.BEFORE) { param -> + val id = param.arg(1) + if (id == avenirNextFontId) { + param.setResult(avenirNextTypeface) + } else if (fontMap.containsKey(id)) { + param.setResult(fontMap[id]) + } + } + + lateinit var unhook: () -> Unit + + unhook = Resources::class.java.hook("getValue", HookStage.AFTER) { param -> + val typedValue = param.argNullable(1)?.takeIf { + it.resourceId != 0 && + it.type == TypedValue.TYPE_STRING && it.string?.endsWith(".ttf") == true + } ?: return@hook + + unhook() + + var offset = typedValue.resourceId.shr(8).shl(8) + + while (true) { + try { + if (context.resources.getResourceTypeName(++offset) != "font") break + val font = runCatching { context.resources.getFont(offset) }.getOrNull() ?: break + fontMap[font.weight] = font + } catch (_: Throwable) { + break + } + } + }.let { unhooks -> + { unhooks.forEach { it.unhook() } } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/ViewAppearanceHelper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/ViewAppearanceHelper.kt new file mode 100644 index 0000000000..c2091ce3b4 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/ViewAppearanceHelper.kt @@ -0,0 +1,159 @@ +package me.rhunk.snapenhance.core.ui + +import android.app.Activity +import android.app.AlertDialog +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.drawable.Drawable +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.Shape +import android.os.SystemClock +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import me.rhunk.snapenhance.core.SnapEnhance +import me.rhunk.snapenhance.core.wrapper.impl.composer.ComposerContext +import me.rhunk.snapenhance.core.wrapper.impl.composer.ComposerViewNode + +private val foregroundDrawableListTag = randomTag() + +@Suppress("UNCHECKED_CAST") +private fun View.getForegroundDrawables(): MutableMap { + return getTag(foregroundDrawableListTag) as? MutableMap + ?: mutableMapOf().also { + setTag(foregroundDrawableListTag, it) + } +} + +private fun View.updateForegroundDrawable() { + foreground = ShapeDrawable(object: Shape() { + override fun draw(canvas: Canvas, paint: Paint) { + getForegroundDrawables().forEach { (_, drawable) -> + drawable.draw(canvas) + } + } + }) +} + +fun View.removeForegroundDrawable(tag: String) { + getForegroundDrawables().remove(tag)?.let { + updateForegroundDrawable() + } +} + +fun View.addForegroundDrawable(tag: String, drawable: Drawable) { + getForegroundDrawables()[tag] = drawable + updateForegroundDrawable() +} + +fun View.triggerCloseTouchEvent() { + arrayOf(MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP).forEach { + this.dispatchTouchEvent( + MotionEvent.obtain( + SystemClock.uptimeMillis(), + SystemClock.uptimeMillis(), + it, 0f, 0f, 0 + ) + ) + } +} + +fun Activity.triggerRootCloseTouchEvent() { + findViewById(android.R.id.content).triggerCloseTouchEvent() +} + +fun ViewGroup.children(): List { + val children = mutableListOf() + for (i in 0 until childCount) { + children.add(getChildAt(i)) + } + return children +} + +fun View.iterateParent(predicate: (View) -> Boolean) { + var parent = this.parent as? View ?: return + while (true) { + if (predicate(parent)) return + parent = parent.parent as? View ?: return + } +} + +fun View.findParent(maxIteration: Int = Int.MAX_VALUE, predicate: (View) -> Boolean): View? { + var parent = this.parent as? View ?: return null + var iteration = 0 + while (iteration < maxIteration) { + if (predicate(parent)) return parent + parent = parent.parent as? View ?: return null + iteration++ + } + return null +} + + +data class LayoutChangeParams( + val view: View, + val left: Int, + val top: Int, + val right: Int, + val bottom: Int, + val oldLeft: Int, + val oldTop: Int, + val oldRight: Int, + val oldBottom: Int +) + +fun View.onLayoutChange(block: (LayoutChangeParams) -> Unit): View.OnLayoutChangeListener { + return View.OnLayoutChangeListener { view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom -> + block(LayoutChangeParams(view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom)) + }.also { addOnLayoutChangeListener (it) } +} + +fun View.onAttachChange(onAttach: (View.OnAttachStateChangeListener) -> Unit = {}, onDetach: (View.OnAttachStateChangeListener) -> Unit = {}): View.OnAttachStateChangeListener { + return object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + onAttach(this) + } + override fun onViewDetachedFromWindow(v: View) { + onDetach(this) + } + }.also { addOnAttachStateChangeListener(it) } +} + +fun View.hideViewCompletely() { + fun hide() { + isEnabled = false + visibility = View.GONE + setWillNotDraw(true) + + layoutParams = layoutParams?.apply { + width = 0 + height = 0 + } ?: return + } + hide() + post { hide() } + onLayoutChange { hide() } +} + +fun View.getComposerViewNode(): ComposerViewNode? { + if (!SnapEnhance.classCache.composerView.isInstance(this)) return null + + val composerViewNode = this::class.java.methods.firstOrNull { + it.name == "getComposerViewNode" + }?.invoke(this) ?: return null + + return ComposerViewNode.fromNode(composerViewNode) +} + +fun View.getComposerContext(): ComposerContext? { + if (!SnapEnhance.classCache.composerView.isInstance(this)) return null + + return ComposerContext(this::class.java.methods.firstOrNull { + it.name == "getComposerContext" + }?.invoke(this) ?: return null) +} + +object ViewAppearanceHelper { + fun newAlertDialogBuilder(context: Context?) = AlertDialog.Builder(context, android.R.style.Theme_DeviceDefault_Dialog_Alert) +} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/ViewTagState.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/ViewTagState.kt new file mode 100644 index 0000000000..64b8348e37 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/ViewTagState.kt @@ -0,0 +1,22 @@ +package me.rhunk.snapenhance.core.ui + +import android.view.View +import kotlin.random.Random + +fun randomTag() = Random.nextInt(0x7000000, 0x7FFFFFFF) + +class ViewTagState { + private val tag = randomTag() + + operator fun get(view: View) = hasState(view) + + private fun hasState(view: View): Boolean { + if (view.getTag(tag) != null) return true + view.setTag(tag, true) + return false + } + + fun removeState(view: View) { + view.setTag(tag, null) + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/AbstractMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/AbstractMenu.kt new file mode 100644 index 0000000000..89b4dddb00 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/AbstractMenu.kt @@ -0,0 +1,16 @@ +package me.rhunk.snapenhance.core.ui.menu + +import android.view.View +import android.view.ViewGroup +import me.rhunk.snapenhance.core.ModContext +import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent + +abstract class AbstractMenu { + lateinit var menuViewInjector: MenuViewInjector + lateinit var context: ModContext + + open fun inject(parent: ViewGroup, view: View, viewConsumer: (View) -> Unit) {} + open fun onViewAdded(event: AddViewEvent) {} + + open fun init() {} +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/MenuViewInjector.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/MenuViewInjector.kt new file mode 100644 index 0000000000..e94b971a1f --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/MenuViewInjector.kt @@ -0,0 +1,87 @@ +package me.rhunk.snapenhance.core.ui.menu + +import android.annotation.SuppressLint +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.impl.COFOverride +import me.rhunk.snapenhance.core.ui.menu.impl.* +import me.rhunk.snapenhance.core.util.ktx.getIdentifier +import kotlin.reflect.KClass + +@SuppressLint("DiscouragedApi") +class MenuViewInjector : Feature("MenuViewInjector") { + private val menuMap by lazy { + arrayOf( + SettingsMenu(), + NewChatActionMenu(), + OperaContextActionMenu(), + OperaViewerIcons(), + FriendFeedInfoMenu(), + ChatActionMenu(), + ).associateBy { + it.context = context + it.menuViewInjector = this + it::class + } + } + + @Suppress("UNCHECKED_CAST") + fun menu(menuClass: KClass): T? { + return menuMap[menuClass] as? T + } + + override fun init() { + onNextActivityCreate(defer = true) { + menuMap.forEach { it.value.init() } + + val hasV2ActionMenu = { true } + + context.event.subscribe(AddViewEvent::class) { event -> + menuMap.forEach { it.value.onViewAdded(event) } + } + + context.event.subscribe(AddViewEvent::class) { event -> + val originalAddView: (View) -> Unit = { + event.adapter.invokeOriginal(arrayOf(it, -1, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )) + ) + } + + val viewGroup: ViewGroup = event.parent + val childView: View = event.view + + if (childView.javaClass.name.endsWith("ActionMenuChatItemContainer") && context.isDeveloper) { + childView.post { + (event.parent.parent as ViewGroup).addView( + (menuMap[NewChatActionMenu::class] as NewChatActionMenu).createDebugInfoView(context.mainActivity!!).apply { + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { + gravity = Gravity.TOP or Gravity.START + } + }, 0) + } + } + + if (childView.javaClass.name.endsWith("ChatActionMenuComponent") && hasV2ActionMenu()) { + (menuMap[NewChatActionMenu::class]!! as NewChatActionMenu).handle(event) + return@subscribe + } + + if (viewGroup.javaClass.name.endsWith("ActionMenuChatItemContainer") && !hasV2ActionMenu()) { + if (viewGroup.parent == null || viewGroup.parent.parent == null) return@subscribe + menuMap[ChatActionMenu::class]!!.inject(viewGroup, childView, originalAddView) + return@subscribe + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/ChatActionMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/ChatActionMenu.kt new file mode 100644 index 0000000000..2657ec951f --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/ChatActionMenu.kt @@ -0,0 +1,218 @@ +package me.rhunk.snapenhance.core.ui.menu.impl + +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.ShapeDrawable +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.MarginLayoutParams +import android.widget.Button +import android.widget.LinearLayout +import me.rhunk.snapenhance.bridge.logger.LoggedChatEdit +import me.rhunk.snapenhance.core.features.impl.downloader.MediaDownloader +import me.rhunk.snapenhance.core.features.impl.experiments.ConvertMessageLocally +import me.rhunk.snapenhance.core.features.impl.messaging.Messaging +import me.rhunk.snapenhance.core.features.impl.spying.MessageLogger +import me.rhunk.snapenhance.core.ui.ViewTagState +import me.rhunk.snapenhance.core.ui.menu.AbstractMenu +import me.rhunk.snapenhance.core.ui.triggerCloseTouchEvent +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.ktx.vibrateLongPress + + +class ChatActionMenu : AbstractMenu() { + private val viewTagState = ViewTagState() + private val defaultGap by lazy { context.userInterface.dpToPx(8) } + private val chatActionMenuItemMargin by lazy { context.userInterface.dpToPx(15) } + private val actionMenuItemHeight by lazy { context.userInterface.dpToPx(45) } + + private fun createRoundedBackground(color: Int, radius: Float, hasRadius: Boolean): Drawable { + if (!hasRadius) return ColorDrawable(color) + return ShapeDrawable().apply { + paint.color = color + shape = android.graphics.drawable.shapes.RoundRectShape( + floatArrayOf(radius, radius, radius, radius, radius, radius, radius, radius), + null, + null + ) + } + } + + private fun createContainer(viewGroup: ViewGroup): LinearLayout { + return LinearLayout(viewGroup.context).apply layout@{ + orientation = LinearLayout.VERTICAL + layoutParams = MarginLayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { + this@ChatActionMenu.context.userInterface.apply { + background = createRoundedBackground(actionSheetBackground, 16F, true) + } + setMargins(chatActionMenuItemMargin, 0, chatActionMenuItemMargin, defaultGap) + } + } + } + + override fun init() { + runCatching { + if (!context.config.downloader.downloadContextMenu.get() && context.config.messaging.messageLogger.globalState != true && !context.isDeveloper) return + context.androidContext.classLoader.loadClass("com.snap.messaging.chat.features.actionmenu.ActionMenuChatItemContainer") + .hook("onMeasure", HookStage.BEFORE) { param -> + param.setArg(1, + View.MeasureSpec.makeMeasureSpec((context.resources.displayMetrics.heightPixels * 0.25).toInt(), View.MeasureSpec.AT_MOST) + ) + } + }.onFailure { + context.log.error("Failed to hook ActionMenuChatItemContainer: $it") + } + } + + override fun inject(parent: ViewGroup, view: View, viewConsumer: (View) -> Unit) { + val viewGroup = parent.parent.parent as? ViewGroup ?: return + if (viewTagState[viewGroup]) return + //close the action menu using a touch event + val closeActionMenu = { + context.runOnUiThread { + parent.triggerCloseTouchEvent() + } + } + + val messaging = context.feature(Messaging::class) + val messageLogger = context.feature(MessageLogger::class) + + val buttonContainer = createContainer(viewGroup) + + val injectButton = { button: Button -> + if (buttonContainer.childCount > 0) { + buttonContainer.addView(View(viewGroup.context).apply { + layoutParams = MarginLayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { + height = 1 + } + setBackgroundColor(0x1A000000) + }) + } + + with(button) { + this@ChatActionMenu.context.userInterface.apply { + background = createRoundedBackground(actionSheetBackground, 16F, true) + setTextColor(colorPrimary) + typeface = this@ChatActionMenu.context.userInterface.avenirNextTypeface + } + isAllCaps = false + setShadowLayer(0F, 0F, 0F, 0) + setPadding(chatActionMenuItemMargin, 0, 0, 0) + + gravity = Gravity.CENTER_VERTICAL + + layoutParams = MarginLayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { + post { + width = viewGroup.width + } + height = actionMenuItemHeight + defaultGap + } + buttonContainer.addView(this) + } + } + + if (context.config.downloader.downloadContextMenu.get()) { + val mediaDownloader = context.feature(MediaDownloader::class) + + injectButton(Button(viewGroup.context).apply { + text = this@ChatActionMenu.context.translation["chat_action_menu.preview_button"] + setOnClickListener { + closeActionMenu() + mediaDownloader.onMessageActionMenu(true) + } + }) + + injectButton(Button(viewGroup.context).apply { + text = this@ChatActionMenu.context.translation["chat_action_menu.download_button"] + setOnClickListener { + closeActionMenu() + mediaDownloader.onMessageActionMenu(false) + } + setOnLongClickListener { + closeActionMenu() + context.vibrateLongPress() + mediaDownloader.onMessageActionMenu(isPreviewMode = false, forceAllowDuplicate = true) + true + } + }) + } + + //delete logged message button + if (context.config.messaging.messageLogger.globalState == true) { + injectButton(Button(viewGroup.context).apply { + text = this@ChatActionMenu.context.translation["chat_action_menu.delete_logged_message_button"] + setOnClickListener { + closeActionMenu() + this@ChatActionMenu.context.executeAsync { + messageLogger.deleteMessage(messaging.openedConversationUUID.toString(), messaging.lastFocusedMessageId) + } + } + }) + + injectButton(Button(viewGroup.context).apply { + var chatEdits = emptyList() + text = this@ChatActionMenu.context.translation["chat_action_menu.show_chat_edit_history"] + setOnClickListener { + menuViewInjector.menu(NewChatActionMenu::class)?.showChatEditHistory(chatEdits) + } + addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + visibility = View.GONE + chatEdits = this@ChatActionMenu.context.feature(MessageLogger::class).getChatEdits( + messaging.openedConversationUUID.toString(), + messaging.lastFocusedMessageId, + ) + if (chatEdits.isEmpty()) return + visibility = View.VISIBLE + } + + override fun onViewDetachedFromWindow(v: View) { + visibility = View.GONE + chatEdits = emptyList() + } + }) + }) + } + + if (context.config.experimental.convertMessageLocally.get()) { + injectButton(Button(viewGroup.context).apply { + text = this@ChatActionMenu.context.translation["chat_action_menu.convert_message"] + setOnClickListener { + closeActionMenu() + messaging.conversationManager?.fetchMessage( + messaging.openedConversationUUID.toString(), + messaging.lastFocusedMessageId, + onSuccess = { + this@ChatActionMenu.context.runOnUiThread { + runCatching { + this@ChatActionMenu.context.feature(ConvertMessageLocally::class) + .convertMessageInterface(it) + }.onFailure { + this@ChatActionMenu.context.log.verbose("Failed to convert message: $it") + this@ChatActionMenu.context.shortToast("Failed to edit message: $it") + } + } + }, + onError = { + this@ChatActionMenu.context.shortToast("Failed to fetch message: $it") + } + ) + } + }) + } + + + viewGroup.addView(buttonContainer) + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt new file mode 100644 index 0000000000..7ba71d4136 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt @@ -0,0 +1,595 @@ +package me.rhunk.snapenhance.core.ui.menu.impl + +import android.content.DialogInterface +import android.content.res.Resources +import android.graphics.BitmapFactory +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.ScrollView +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Message +import androidx.compose.material.icons.filled.CheckCircleOutline +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.NotInterested +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.data.FriendLinkType +import me.rhunk.snapenhance.common.database.impl.ConversationMessage +import me.rhunk.snapenhance.common.database.impl.FriendInfo +import me.rhunk.snapenhance.common.scripting.ui.EnumScriptInterface +import me.rhunk.snapenhance.common.scripting.ui.InterfaceManager +import me.rhunk.snapenhance.common.scripting.ui.ScriptInterface +import me.rhunk.snapenhance.common.ui.createComposeAlertDialog +import me.rhunk.snapenhance.common.ui.createComposeView +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie +import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent +import me.rhunk.snapenhance.core.features.impl.experiments.EndToEndEncryption +import me.rhunk.snapenhance.core.features.impl.messaging.AutoMarkAsRead +import me.rhunk.snapenhance.core.features.impl.messaging.Messaging +import me.rhunk.snapenhance.core.features.impl.spying.MessageLogger +import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper +import me.rhunk.snapenhance.core.ui.children +import me.rhunk.snapenhance.core.ui.menu.AbstractMenu +import me.rhunk.snapenhance.core.ui.randomTag +import me.rhunk.snapenhance.core.ui.triggerRootCloseTouchEvent +import me.rhunk.snapenhance.core.util.ktx.isDarkTheme +import java.net.HttpURLConnection +import java.net.URL +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale + +class FriendFeedInfoMenu : AbstractMenu() { + private fun getImageDrawable(url: String): Drawable { + val connection = URL(url).openConnection() as HttpURLConnection + connection.connect() + val input = connection.inputStream + return BitmapDrawable(Resources.getSystem(), BitmapFactory.decodeStream(input)) + } + + private fun formatDate(timestamp: Long): String? { + return SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH).format(Date(timestamp)) + } + + private fun showProfileInfo(profile: FriendInfo) { + var icon: Drawable? = null + try { + if (profile.bitmojiSelfieId != null && profile.bitmojiAvatarId != null) { + icon = getImageDrawable( + BitmojiSelfie.getBitmojiSelfie( + profile.bitmojiSelfieId.toString(), + profile.bitmojiAvatarId.toString(), + BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D + )!! + ) + } + } catch (e: Throwable) { + context.log.error("Error loading bitmoji selfie", e) + } + val finalIcon = icon + val translation = context.translation.getCategory("profile_info") + val firstCreatedUsername = context.database.getFriendOriginalUsername(profile.mutableUsername.toString()) ?: profile.firstCreatedUsername + + context.runOnUiThread { + val addedTimestamp: Long = profile.addedTimestamp.coerceAtLeast(profile.reverseAddedTimestamp) + val builder = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) + builder.setIcon(finalIcon) + builder.setTitle(profile.displayName ?: profile.username) + + val birthday = Calendar.getInstance() + birthday[Calendar.MONTH] = (profile.birthday shr 32).toInt() - 1 + + builder.setMessage(mapOf( + translation["first_created_username"] to firstCreatedUsername, + translation["mutable_username"] to profile.mutableUsername, + translation["display_name"] to profile.displayName, + translation["added_date"] to formatDate(addedTimestamp).takeIf { addedTimestamp > 0 }, + null to birthday.getDisplayName( + Calendar.MONTH, + Calendar.LONG, + context.translation.loadedLocale + )?.let { + if (profile.birthday == 0L) context.translation["profile_info.hidden_birthday"] + else context.translation.format("profile_info.birthday", + "month" to it, + "day" to profile.birthday.toInt().toString()) + }, + translation["friendship"] to run { + context.translation["friendship_link_type.${FriendLinkType.fromValue(profile.friendLinkType).shortName}"] + }.takeIf { + if (profile.friendLinkType == FriendLinkType.MUTUAL.value) addedTimestamp.toInt() > 0 else true + }, + translation["add_source"] to context.database.getAddSource(profile.userId!!)?.takeIf { it.isNotEmpty() }, + translation["snapchat_plus"] to run { + translation.getCategory("snapchat_plus_state")[if (profile.postViewEmoji != null) "subscribed" else "not_subscribed"] + } + ).filterValues { it != null }.map { + line -> "${line.key?.let { "$it: " } ?: ""}${line.value}" + }.joinToString("\n")) + + builder.setPositiveButton( + "OK" + ) { dialog: DialogInterface, _: Int -> dialog.dismiss() } + builder.show() + } + } + + private suspend fun showConversationPreview( + targetUser: String?, + conversationId: String + ) { + val friendInfo = targetUser?.let { context.database.getFriendInfo(it) } + val conversationInfo = conversationId.takeIf { targetUser == null }?.let { context.database.getFeedEntryByConversationId(it) } + val participants by lazy { + context.database.getConversationParticipants(conversationId)!! + .map { context.database.getFriendInfo(it)!! } + .associateBy { it.userId!! } + } + + withContext(Dispatchers.Main) { + createComposeAlertDialog( + context.mainActivity!!, + ) { + var pageIndex by remember { mutableIntStateOf(0) } + val messages = remember { mutableStateListOf<@Composable () -> Unit>() } + var totalMessages by remember { mutableIntStateOf(-1) } + val coroutineScope = rememberCoroutineScope() + + suspend fun loadMore() { + val conversationMessages = context.database.getMessagesFromConversationId( + conversationId, + 50, + page = pageIndex++ + ) ?: emptyList() + + if (totalMessages == -1) { + totalMessages = conversationMessages.firstOrNull()?.serverMessageId ?: 0 + } + + val messageLogger = context.feature(MessageLogger::class) + val endToEndEncryption = context.feature(EndToEndEncryption::class) + + val parsedMessages = conversationMessages.mapNotNull Unit> { message -> + val sender = participants[message.senderId] + val messageProtoReader = + (messageLogger.takeIf { it.isEnabled && message.contentType == ContentType.STATUS.id }?.getMessageProto(conversationId, message.clientMessageId.toLong()) // process deleted messages if message logger is enabled + ?: ProtoReader(message.messageContent!!).followPath(4, 4) // database message + )?.let { + if (endToEndEncryption.isEnabled) endToEndEncryption.decryptDatabaseMessage(message) else it // try to decrypt message if e2ee is enabled + } ?: return@mapNotNull null + + val contentType = ContentType.fromMessageContainer(messageProtoReader) ?: ContentType.fromId(message.contentType) + var messageString = if (contentType == ContentType.CHAT) { + messageProtoReader.getString(2, 1) ?: return@mapNotNull null + } else "[${context.translation.getOrNull("content_type.${contentType.name}") ?: contentType.name}]" + + if (contentType == ContentType.SNAP) { + messageString = "\uD83D\uDFE5" //red square + if (message.readTimestamp > 0) { + messageString += " \uD83D\uDC40 " //eyes + messageString += DateFormat.getDateTimeInstance( + DateFormat.SHORT, + DateFormat.SHORT + ).format(Date(message.readTimestamp)) + } + } + + var displayUsername = sender?.displayName ?: sender?.usernameForSorting?: context.translation["conversation_preview.unknown_user"] + + if (displayUsername.length > 12) { + displayUsername = displayUsername.substring(0, 13) + "... " + } + + { + Text( + text = "$displayUsername: $messageString", + modifier = Modifier.padding(4.dp) + ) + } + } + + withContext(Dispatchers.Main) { + messages.addAll(parsedMessages) + } + } + + Column( + modifier = Modifier.fillMaxHeight(fraction = 0.85f) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + @Composable + fun Entry(icon: ImageVector, text: String?, title: Boolean) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp) + ) { + Icon(icon, contentDescription = null) + Text( + text = text ?: "", + fontWeight = if (title) FontWeight.Bold else FontWeight.Light, + fontSize = if (title) 16.sp else 14.sp + ) + } + } + + Column( + modifier = Modifier.weight(1f), + ) { + friendInfo?.let { friendInfo -> + Entry(Icons.Outlined.Person, friendInfo.displayName?.let { "$it (${friendInfo.usernameForSorting})" } ?: friendInfo.usernameForSorting, true) + friendInfo.streakExpirationTimestamp.takeIf { it > 0L && friendInfo.streakLength > 0 && System.currentTimeMillis() < it }?.let { timestamp -> + Entry(Icons.Outlined.LocalFireDepartment, context.translation.format("conversation_preview.streak_expiration", + "day" to ((timestamp - System.currentTimeMillis()) / 1000 / 60 / 60 / 24).toString(), + "hour" to ((timestamp - System.currentTimeMillis()) / 1000 / 60 / 60 % 24).toString(), + "minute" to ((timestamp - System.currentTimeMillis()) / 1000 / 60 % 60).toString() + ), false) + } + } + conversationInfo?.let { + Entry(Icons.Outlined.Group, (it.feedDisplayName ?: it.key).toString(), true) + } + Entry(Icons.AutoMirrored.Outlined.Message, context.translation.format("conversation_preview.total_messages", "count" to totalMessages.toString()), false) + } + friendInfo?.let { + IconButton( + onClick = { + coroutineScope.launch(Dispatchers.IO) { showProfileInfo(it) } + } + ) { + Icon(Icons.Outlined.MoreVert, contentDescription = null) + } + } + } + Spacer(modifier = Modifier.height(1.dp).fillMaxWidth().background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f))) + LazyColumn( + contentPadding = PaddingValues(8.dp), + reverseLayout = true + ) { + items(messages) { message -> + Row( + modifier = Modifier.fillMaxWidth(), + ) { + message() + } + } + item { + Spacer(modifier = Modifier.height(10.dp)) + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + loadMore() + } + } + if (messages.isEmpty()) { + Text( + text = context.translation["conversation_preview.no_messages"], + modifier = Modifier + .padding(4.dp) + .fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + } + } + } + }.show() + } + } + + @Composable + private fun MenuElement( + index: Int, + icon: ImageVector, + text: String, + onClick: () -> Unit, + onLongClick: (() -> Unit)? = null, + content: @Composable RowScope.() -> Unit = {} + ) { + if (index > 0) { + Spacer(modifier = Modifier + .height(1.dp) + .background(remember { + if (context.androidContext.isDarkTheme()) Color(0x1affffff) else Color( + 0xffeeeeee + ) + }) + .fillMaxWidth()) + } + Surface( + color = Color(context.userInterface.actionSheetBackground), + contentColor = Color(context.userInterface.colorPrimary), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .pointerInput(Unit) { + detectTapGestures( + onLongPress = { + onLongClick?.invoke() + }, + onTap = { + onClick() + } + ) + } + .heightIn(min = 55.dp) + .padding(start = 16.dp, end = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(icon, contentDescription = null, modifier = Modifier + .size(32.dp) + .padding(end = 8.dp)) + Text( + text = text, + modifier = Modifier.weight(1f), + lineHeight = 18.sp, + fontSize = 16.sp, + ) + content() + } + } + } + + private val recyclerViewTag = randomTag() + private val messaging by lazy { context.feature(Messaging::class)} + + override fun onViewAdded(event: AddViewEvent) { + fun hasAvatarHeader(viewGroup: ViewGroup): Boolean { + val constraintLayout = viewGroup.getChildAt(0)?.takeIf { it.javaClass.name.endsWith("ConstraintLayout") } as? ViewGroup ?: return false + return constraintLayout.children().firstOrNull { it.javaClass.name.endsWith("AvatarView") } != null + } + + if (event.parent is FrameLayout && messaging.lastFocusedConversationType == 1 && event.view.javaClass.name.endsWith("RecyclerView")) { + event.view.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + if (event.view.tag == recyclerViewTag || !hasAvatarHeader(event.view as ViewGroup)) return@addOnLayoutChangeListener + event.view.tag = recyclerViewTag + + // remove recycler view + event.parent.removeView(event.view) + + val newLayout = LinearLayout(event.view.context).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.BOTTOM + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + addView(event.view) + } + + newLayout.addView(ScrollView(newLayout.context).apply { + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { + weight = 1f; + setMargins(0, 100, 0, 0) + } + + addView(LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + injectIntoActionSheetItems(newLayout) { + it.layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { + setMargins(0, 5, 0, 5) + } + addView(it) + } + }) + }, 0) + + event.parent.addView(newLayout) + } + } + + if (event.parent is LinearLayout && event.viewClassName.endsWith("SnapCardView") && hasAvatarHeader(event.parent)) { + val actionSheetItemsContainerLayout = (event.view as ViewGroup).getChildAt(0) as? ViewGroup ?: throw IllegalStateException("ActionSheetItemsContainerLayout not found") + injectIntoActionSheetItems(actionSheetItemsContainerLayout) { + actionSheetItemsContainerLayout.addView(it, 0) + } + } + } + + private fun injectIntoActionSheetItems(actionSheetItemsContainer: View, viewConsumer: ((View) -> Unit)) { + val friendFeedMenuOptions by context.config.userInterface.friendFeedMenuButtons + if (friendFeedMenuOptions.isEmpty()) return + + val messaging = context.feature(Messaging::class) + val conversationId = messaging.lastFocusedConversationId ?: return + val targetUser by lazy { context.database.getDMOtherParticipant(conversationId) } + messaging.resetLastFocusedConversation() + + val translation = context.translation.getCategory("friend_menu_option") + + fun closeMenu() { + if (!context.config.userInterface.autoCloseFriendFeedMenu.get()) return + context.mainActivity?.triggerRootCloseTouchEvent() + } + + @Composable + fun ComposeFriendFeedMenu() { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + var elementIndex by remember { mutableIntStateOf(0) } + + if (friendFeedMenuOptions.contains("conversation_info")) { + MenuElement( + remember { elementIndex++ }, + Icons.Outlined.RemoveRedEye, + translation["preview"], + onClick = { + context.coroutineScope.launch { + showConversationPreview(targetUser, conversationId) + } + } + ) + } + + context.features.getRuleFeatures().forEach { ruleFeature -> + if (!friendFeedMenuOptions.contains(ruleFeature.ruleType.key)) return@forEach + + val ruleState = ruleFeature.getRuleState() ?: return@forEach + var state by remember { mutableStateOf(ruleFeature.getState(conversationId)) } + + fun toggle() { + state = !ruleFeature.getState(conversationId) + ruleFeature.setState(conversationId, state) + context.inAppOverlay.showStatusToast( + if (state) Icons.Default.CheckCircleOutline else Icons.Default.NotInterested, + context.translation.format("rules.toasts.${if (state) "enabled" else "disabled"}", "ruleName" to context.translation[ruleFeature.ruleType.translateOptionKey(ruleState.key)]), + durationMs = 1500 + ) + closeMenu() + } + + MenuElement( + remember { elementIndex++ }, + icon = ruleFeature.ruleType.icon, + text = context.translation[ruleFeature.ruleType.translateOptionKey(ruleState.key)], + onClick = { + toggle() + } + ) { + Switch( + checked = state, + onCheckedChange = { + state = it + toggle() + } + ) + } + } + + if (friendFeedMenuOptions.contains("mark_snaps_as_seen")) { + MenuElement( + remember { elementIndex++ }, + Icons.Outlined.EditNote, + translation["mark_snaps_as_seen"], + onClick = { + context.apply { + closeMenu() + feature(AutoMarkAsRead::class).markSnapsAsSeen(conversationId) + } + } + ) + } + + if (targetUser != null && friendFeedMenuOptions.contains("mark_stories_as_seen_locally")) { + val markAsSeenTranslation = remember { context.translation.getCategory("mark_as_seen") } + + MenuElement( + remember { elementIndex++ }, + Icons.Outlined.RemoveRedEye, + translation["mark_stories_as_seen_locally"], + onClick = { + context.apply { + closeMenu() + inAppOverlay.showStatusToast( + Icons.Default.Info, + if (database.setStoriesViewedState(targetUser!!, true)) markAsSeenTranslation["seen_toast"] + else markAsSeenTranslation["already_seen_toast"], + durationMs = 2500 + ) + } + }, + onLongClick = { + actionSheetItemsContainer.post { + context.apply { + closeMenu() + inAppOverlay.showStatusToast( + Icons.Default.Info, + if (database.setStoriesViewedState(targetUser!!, false)) markAsSeenTranslation["unseen_toast"] + else markAsSeenTranslation["already_unseen_toast"], + durationMs = 2500 + ) + } + } + } + ) + } + } + } + + viewConsumer( + createComposeView(actionSheetItemsContainer.context) { + CompositionLocalProvider( + LocalTextStyle provides LocalTextStyle.current.merge(TextStyle(fontFamily = FontFamily( + Font(context.userInterface.avenirNextFontId, FontWeight.Medium) + ))) + ) { + ComposeFriendFeedMenu() + } + }.apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + ) + + if (context.config.scripting.integratedUI.get()) { + context.scriptRuntime.eachModule { + val interfaceManager = getBinding(InterfaceManager::class) + ?.takeIf { + it.hasInterface(EnumScriptInterface.FRIEND_FEED_CONTEXT_MENU) + } ?: return@eachModule + + viewConsumer(LinearLayout(actionSheetItemsContainer.context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + + orientation = LinearLayout.VERTICAL + addView(createComposeView(actionSheetItemsContainer.context) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface + ) { + ScriptInterface(interfaceBuilder = remember { + interfaceManager.buildInterface(EnumScriptInterface.FRIEND_FEED_CONTEXT_MENU, mapOf( + "conversationId" to conversationId, + "userId" to targetUser + )) + } ?: return@Surface) + } + }) + }) + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/NewChatActionMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/NewChatActionMenu.kt new file mode 100644 index 0000000000..d4a8110dca --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/NewChatActionMenu.kt @@ -0,0 +1,362 @@ +package me.rhunk.snapenhance.core.ui.menu.impl + +import android.content.Context +import android.text.format.Formatter +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.ScrollView +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +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.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import me.rhunk.snapenhance.bridge.logger.LoggedChatEdit +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.ui.createComposeAlertDialog +import me.rhunk.snapenhance.common.ui.createComposeView +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState +import me.rhunk.snapenhance.common.util.ktx.copyToClipboard +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.common.util.snap.RemoteMediaResolver +import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent +import me.rhunk.snapenhance.core.features.impl.downloader.MediaDownloader +import me.rhunk.snapenhance.core.features.impl.downloader.decoder.MessageDecoder +import me.rhunk.snapenhance.core.features.impl.experiments.ConvertMessageLocally +import me.rhunk.snapenhance.core.features.impl.messaging.Messaging +import me.rhunk.snapenhance.core.features.impl.spying.MessageLogger +import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper +import me.rhunk.snapenhance.core.ui.debugEditText +import me.rhunk.snapenhance.core.ui.iterateParent +import me.rhunk.snapenhance.core.ui.menu.AbstractMenu +import me.rhunk.snapenhance.core.ui.triggerCloseTouchEvent +import me.rhunk.snapenhance.core.util.ktx.isDarkTheme +import me.rhunk.snapenhance.core.util.ktx.setObjectField +import me.rhunk.snapenhance.core.util.ktx.vibrateLongPress +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Date +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +class NewChatActionMenu : AbstractMenu() { + private fun debugAlertDialog(context: Context, title: String, text: String) { + this@NewChatActionMenu.context.runOnUiThread { + ViewAppearanceHelper.newAlertDialogBuilder(context).apply { + setTitle(title) + setView(debugEditText(context, text)) + setPositiveButton("OK") { dialog, _ -> dialog.dismiss() } + setNegativeButton("Copy") { _, _ -> + context.copyToClipboard(text, title) + } + }.show() + } + } + + fun showChatEditHistory( + edits: List, + ) { + createComposeAlertDialog(context.mainActivity!!) { + LazyColumn( + modifier = Modifier.padding(16.dp), + ) { + itemsIndexed(edits) { index, edit -> + Column( + modifier = Modifier.padding(8.dp).fillMaxWidth().pointerInput(Unit) { + detectTapGestures( + onLongPress = { + context.androidContext.copyToClipboard(edit.message) + } + ) + }, + horizontalAlignment = Alignment.Start, + ) { + Text(edit.message) + Text(text = DateFormat.getDateTimeInstance().format(edit.timestamp) + " (${index + 1})", fontSize = 12.sp, fontWeight = FontWeight.Light) + } + } + } + }.show() + } + + private val lastFocusedMessage + get() = context.database.getConversationMessageFromId(context.feature(Messaging::class).lastFocusedMessageId) + + @OptIn(ExperimentalLayoutApi::class, ExperimentalEncodingApi::class) + fun createDebugInfoView(context: Context): ComposeView { + val messageLogger = this@NewChatActionMenu.context.feature(MessageLogger::class) + val messaging = this@NewChatActionMenu.context.feature(Messaging::class) + + return createComposeView(context) { + Card( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 0.dp, bottom = 0.dp) + ) { + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(2.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Button(onClick = { + val arroyoMessage = lastFocusedMessage ?: return@Button + debugAlertDialog(context, + "Message Info", + StringBuilder().apply { + runCatching { + append("conversation_id: ${arroyoMessage.clientConversationId}\n") + append("sender_id: ${arroyoMessage.senderId}\n") + append("client_id: ${arroyoMessage.clientMessageId}, server_id: ${arroyoMessage.serverMessageId}\n") + append("content_type: ${ContentType.fromId(arroyoMessage.contentType)} (${arroyoMessage.contentType})\n") + append("parsed_content_type: ${ + ContentType.fromMessageContainer( + ProtoReader(arroyoMessage.messageContent!!).followPath(4, 4) + ).let { "$it (${it?.id})" }}\n") + append("creation_timestamp: ${ + SimpleDateFormat.getDateTimeInstance().format( + Date(arroyoMessage.creationTimestamp) + )} (${arroyoMessage.creationTimestamp})\n") + append("read_timestamp: ${ + SimpleDateFormat.getDateTimeInstance().format( + Date(arroyoMessage.readTimestamp) + )} (${arroyoMessage.readTimestamp})\n") + append("ml_deleted: ${messageLogger.isMessageDeleted(arroyoMessage.clientConversationId!!, arroyoMessage.clientMessageId.toLong())}, ") + append("ml_stored: ${messageLogger.getMessageObject(arroyoMessage.clientConversationId!!, arroyoMessage.clientMessageId.toLong()) != null}\n") + } + }.toString() + ) + }) { + Text("Info") + } + Button(onClick = { + val arroyoMessage = lastFocusedMessage ?: return@Button + messaging.conversationManager?.fetchMessage(arroyoMessage.clientConversationId!!, arroyoMessage.clientMessageId.toLong(), onSuccess = { message -> + val decodedAttachments = MessageDecoder.decode(message.messageContent!!) + debugAlertDialog( + context, + "Media References", + decodedAttachments.mapIndexed { index, attachment -> + StringBuilder().apply { + append("---- media $index ----\n") + append("resolveProto: ${attachment.boltKey}\n") + append("type: ${attachment.type}\n") + attachment.attachmentInfo?.apply { + encryption?.let { + append("encryption:\n - key: ${it.key}\n - iv: ${it.iv}\n") + } + resolution?.let { + append("resolution: ${it.first}x${it.second}\n") + } + duration?.let { + append("duration: $it\n") + } + } + runCatching { + attachment.boltKey?.let { + val mediaHeaders = RemoteMediaResolver.getMediaHeaders( + Base64.UrlSafe.decode(it)) + append("content-type: ${mediaHeaders["content-type"]}\n") + append("content-length: ${Formatter.formatShortFileSize(context, mediaHeaders["content-length"]?.toLongOrNull() ?: 0)}\n") + append("creation-date: ${mediaHeaders["last-modified"]}\n") + } + attachment.directUrl?.let { + append("url: $it\n") + } + } + }.toString() + }.joinToString("\n\n") + ) + }) + }) { + Text("Refs") + } + Button(onClick = { + val message = lastFocusedMessage ?: return@Button + debugAlertDialog( + context, + "Arroyo proto", + message.messageContent?.let { ProtoReader(it) }?.toString() ?: "empty" + ) + }) { + Text("Arroyo") + } + Button(onClick = { + val arroyoMessage = lastFocusedMessage ?: return@Button + messaging.conversationManager?.fetchMessage(arroyoMessage.clientConversationId!!, arroyoMessage.clientMessageId.toLong(), onSuccess = { message -> + debugAlertDialog( + context, + "Message proto", + message.messageContent?.content?.let { ProtoReader(it) }?.toString() ?: "empty" + ) + }, onError = { + this@NewChatActionMenu.context.shortToast("Failed to fetch message: $it") + }) + }) { + Text("Message") + } + } + } + } + } + + fun handle(event: AddViewEvent) { + if (event.parent is LinearLayout) return + val closeActionMenu = { event.parent.iterateParent { + it.triggerCloseTouchEvent() + false + } } + + val mediaDownloader = context.feature(MediaDownloader::class) + val messageLogger = context.feature(MessageLogger::class) + val messaging = context.feature(Messaging::class) + + val composeView = createComposeView(event.view.context) { + val primaryColor = remember { if (event.view.context.isDarkTheme()) Color.White else Color.Black } + val avenirNextMediumFont = remember { + FontFamily( + Font(context.userInterface.avenirNextFontId, FontWeight.Medium) + ) + } + + @Composable + fun ListButton( + modifier: Modifier = Modifier, + icon: ImageVector, + text: String, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .then(modifier) + .padding(top = 11.dp, bottom = 11.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + modifier = Modifier + .padding(start = 16.dp), + imageVector = icon, + tint = primaryColor, + contentDescription = text + ) + Text(text, color = primaryColor, fontFamily = avenirNextMediumFont, fontSize = 16.sp) + } + Spacer(modifier = Modifier + .height(1.dp) + .fillMaxWidth() + .border(1.dp, MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f))) + } + + Column( + modifier = Modifier.fillMaxWidth(), + ) { + if (context.config.downloader.downloadContextMenu.get()) { + ListButton(icon = Icons.Outlined.RemoveRedEye, text = context.translation["chat_action_menu.preview_button"], modifier = Modifier.clickable { + closeActionMenu() + mediaDownloader.onMessageActionMenu(true) + }) + ListButton(icon = Icons.Outlined.Download, text = context.translation["chat_action_menu.download_button"], modifier = Modifier.pointerInput(Unit) { + detectTapGestures( + onTap = { + closeActionMenu() + mediaDownloader.onMessageActionMenu(false) + }, + onLongPress = { + context.androidContext.vibrateLongPress() + mediaDownloader.onMessageActionMenu(isPreviewMode = false, forceAllowDuplicate = true) + } + ) + }) + } + + if (context.config.messaging.messageLogger.globalState == true) { + val chatEdits by rememberAsyncMutableState(defaultValue = null) { + context.feature(MessageLogger::class).getChatEdits( + messaging.openedConversationUUID.toString(), + messaging.lastFocusedMessageId + ) + } + + if (chatEdits != null && chatEdits?.isNotEmpty() == true) { + ListButton(icon = Icons.Outlined.History, text = context.translation["chat_action_menu.show_chat_edit_history"], modifier = Modifier.clickable { + closeActionMenu() + showChatEditHistory(chatEdits!!) + }) + } + + ListButton(icon = Icons.Outlined.BookmarkRemove, text = context.translation["chat_action_menu.delete_logged_message_button"], modifier = Modifier.clickable { + closeActionMenu() + context.executeAsync { + messageLogger.deleteMessage(messaging.openedConversationUUID.toString(), messaging.lastFocusedMessageId) + } + }) + } + + if (context.config.experimental.convertMessageLocally.get()) { + ListButton(icon = Icons.Outlined.Image, text = context.translation["chat_action_menu.convert_message"], modifier = Modifier.clickable { + closeActionMenu() + messaging.conversationManager?.fetchMessage( + messaging.openedConversationUUID.toString(), + messaging.lastFocusedMessageId, + onSuccess = { + context.runOnUiThread { + runCatching { + context.feature(ConvertMessageLocally::class) + .convertMessageInterface(it) + }.onFailure { + context.log.verbose("Failed to convert message: $it") + context.shortToast("Failed to edit message: $it") + } + } + }, + onError = { + context.shortToast("Failed to fetch message: $it") + } + ) + }) + } + } + }.apply { + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + event.view = ScrollView(event.view.context).apply { + addView(LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + addView(composeView) + composeView.post { + (event.parent.layoutParams as ViewGroup.MarginLayoutParams).apply { + if (height < composeView.measuredHeight) { + height += composeView.measuredHeight + } else { + setObjectField("a", null) // remove drag callback + } + } + event.parent.requestLayout() + } + addView(event.view) + }) + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaContextActionMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaContextActionMenu.kt new file mode 100644 index 0000000000..5a328f13bb --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaContextActionMenu.kt @@ -0,0 +1,200 @@ +package me.rhunk.snapenhance.core.ui.menu.impl + +import android.annotation.SuppressLint +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.content.res.use +import me.rhunk.snapenhance.common.ui.createComposeView +import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent +import me.rhunk.snapenhance.core.features.impl.OperaViewerParamsOverride +import me.rhunk.snapenhance.core.features.impl.downloader.MediaDownloader +import me.rhunk.snapenhance.core.ui.children +import me.rhunk.snapenhance.core.ui.menu.AbstractMenu +import me.rhunk.snapenhance.core.ui.triggerCloseTouchEvent +import me.rhunk.snapenhance.core.util.ktx.getIdentifier +import me.rhunk.snapenhance.core.util.ktx.vibrateLongPress +import me.rhunk.snapenhance.core.wrapper.impl.ScSize +import java.text.DateFormat +import java.util.Date + +@SuppressLint("DiscouragedApi") +class OperaContextActionMenu : AbstractMenu() { + /* + LinearLayout : + - LinearLayout: + - SnapFontTextView + - ImageView + - LinearLayout: + - SnapFontTextView + - ImageView + - LinearLayout: + - SnapFontTextView + - ImageView + */ + private fun isViewGroupButtonMenuContainer(viewGroup: ViewGroup): Boolean { + if (viewGroup !is LinearLayout) return false + val children = viewGroup.children() + return if (children.any { view: View? -> view !is LinearLayout }) + false + else children.map { view: View -> view as LinearLayout } + .any { linearLayout: LinearLayout -> + linearLayout.children().any { viewChild: View -> + viewChild.javaClass.name.endsWith("SnapFontTextView") + } + } + } + + override fun onViewAdded(event: AddViewEvent) { + val parentView = event.parent.parent as? ScrollView ?: return + val view = event.view + if (view !is LinearLayout) return + if (!isViewGroupButtonMenuContainer(view as ViewGroup)) return + + val linearLayout = LinearLayout(view.context) + linearLayout.orientation = LinearLayout.VERTICAL + linearLayout.gravity = Gravity.CENTER + linearLayout.layoutParams = + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + val translation = context.translation.getCategory("opera_context_menu") + val mediaDownloader = context.feature(MediaDownloader::class) + val paramMap = mediaDownloader.lastSeenMapParams + + if (paramMap != null && context.config.userInterface.operaMediaQuickInfo.get()) { + val playableStorySnapRecord = paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString() + val sentTimestamp = playableStorySnapRecord?.substringAfter("timestamp=") + ?.substringBefore(",")?.toLongOrNull() + ?: paramMap["MESSAGE_ID"]?.toString()?.let { messageId -> + context.database.getConversationMessageFromId( + messageId.substring(messageId.lastIndexOf(":") + 1) + .toLong() + )?.creationTimestamp + } + ?: paramMap["SNAP_TIMESTAMP"]?.toString()?.toLongOrNull() + val dateFormat = DateFormat.getDateTimeInstance() + val creationTimestamp = playableStorySnapRecord?.substringAfter("creationTimestamp=") + ?.substringBefore(",")?.toLongOrNull() + val expirationTimestamp = playableStorySnapRecord?.substringAfter("expirationTimestamp=") + ?.substringBefore(",")?.toLongOrNull() + ?: paramMap["SNAP_EXPIRATION_TIMESTAMP_MILLIS"]?.toString()?.toLongOrNull() + + val mediaSize = paramMap["snap_size"]?.let { ScSize(it) } + val durationMs = paramMap["media_duration_ms"]?.toString() + + val stringBuilder = StringBuilder().apply { + if (sentTimestamp != null) { + append(translation.format("sent_at", "date" to dateFormat.format(Date(sentTimestamp)))) + append("\n") + } + if (creationTimestamp != null) { + append(translation.format("created_at", "date" to dateFormat.format(Date(creationTimestamp)))) + append("\n") + } + if (expirationTimestamp != null) { + append(translation.format("expires_at", "date" to dateFormat.format(Date(expirationTimestamp)))) + append("\n") + } + if (mediaSize != null) { + append(translation.format("media_size", "size" to "${mediaSize.first}x${mediaSize.second}")) + append("\n") + } + if (durationMs != null) { + append(translation.format("media_duration", "duration" to durationMs)) + append("\n") + } + if (last() == '\n') deleteCharAt(length - 1) + } + + if (stringBuilder.isNotEmpty()) { + linearLayout.addView(TextView(view.context).apply { + text = stringBuilder.toString() + setPadding(40, 10, 0, 0) + }) + } + } + + if (context.config.global.videoPlaybackRateSlider.get()) { + val operaViewerParamsOverride = context.feature(OperaViewerParamsOverride::class) + + linearLayout.addView(createComposeView(view.context) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp) + ) { + var value by remember { mutableFloatStateOf(operaViewerParamsOverride.currentPlaybackRate) } + Slider( + value = value, + onValueChange = { + value = it + operaViewerParamsOverride.currentPlaybackRate = it + }, + valueRange = 0.1F..4.0F, + steps = 0, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = "x" + value.toString().take(4), + color = remember { + Color(context.userInterface.colorPrimary) + }, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + }.apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + }) + } + + if (context.config.downloader.downloadContextMenu.get()) { + linearLayout.addView(Button(view.context).apply { + text = translation["download"] + setOnClickListener { + mediaDownloader.downloadLastOperaMediaAsync(allowDuplicate = false) + parentView.triggerCloseTouchEvent() + } + setOnLongClickListener { + context.vibrateLongPress() + mediaDownloader.downloadLastOperaMediaAsync(allowDuplicate = true) + parentView.triggerCloseTouchEvent() + true + } + this@OperaContextActionMenu.context.userInterface.applyActionButtonTheme(this) + }) + } + + if (context.isDeveloper) { + linearLayout.addView(Button(view.context).apply { + text = translation["show_debug_info"] + setOnClickListener { mediaDownloader.showLastOperaDebugMediaInfo() } + this@OperaContextActionMenu.context.userInterface.applyActionButtonTheme(this) + }) + } + + (view as? ViewGroup)?.addView(linearLayout, 0) + } +} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaViewerIcons.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaViewerIcons.kt new file mode 100644 index 0000000000..360b179d37 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaViewerIcons.kt @@ -0,0 +1,172 @@ +package me.rhunk.snapenhance.core.ui.menu.impl + +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.RemoveRedEye +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material3.Icon +import androidx.compose.ui.graphics.Color +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.common.ui.createComposeView +import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent +import me.rhunk.snapenhance.core.features.impl.downloader.MediaDownloader +import me.rhunk.snapenhance.core.features.impl.messaging.AutoMarkAsRead +import me.rhunk.snapenhance.core.ui.children +import me.rhunk.snapenhance.core.ui.iterateParent +import me.rhunk.snapenhance.core.ui.menu.AbstractMenu +import me.rhunk.snapenhance.core.ui.triggerCloseTouchEvent +import me.rhunk.snapenhance.core.util.ktx.vibrateLongPress + +class OperaViewerIcons : AbstractMenu() { + private val actionMenuIconSize by lazy { context.userInterface.dpToPx(32) } + private val actionMenuIconMargin by lazy { context.userInterface.dpToPx(5) } + private val actionMenuIconMarginTop by lazy { context.userInterface.dpToPx(10) } + + override fun onViewAdded(event: AddViewEvent) { + if (event.view is FrameLayout && event.parent.javaClass.superclass?.name?.endsWith("OpenLayout") == true) { + val viewGroup = event.view as? ViewGroup ?: return + if ( + viewGroup.childCount == 0 || + viewGroup.children().any { it !is ImageView } || + event.parent.children().none { it.javaClass.name.endsWith("ScalableCircleMaskFrameLayout") } + ) return + inject(viewGroup) + } + } + + private fun inject(parent: ViewGroup) { + val mediaDownloader = context.feature(MediaDownloader::class) + + if (context.config.downloader.operaDownloadButton.get()) { + parent.addView(LinearLayout(parent.context).apply { + orientation = LinearLayout.VERTICAL + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, + FrameLayout.LayoutParams.WRAP_CONTENT + ).apply { + setMargins(0, actionMenuIconMarginTop * 2 + actionMenuIconSize, 0, 0) + marginEnd = actionMenuIconMargin + gravity = Gravity.TOP or Gravity.END + } + addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + v.visibility = View.VISIBLE + (parent.parent as? ViewGroup)?.children()?.forEach { child -> + if (child !is ViewGroup) return@forEach + child.children().forEach { + if (it::class.java.name.endsWith("PreviewToolbar")) v.visibility = View.GONE + } + } + } + + override fun onViewDetachedFromWindow(v: View) {} + }) + + addView(createComposeView(parent.context) { + Icon( + imageVector = Icons.Outlined.Download, + tint = Color.White, + contentDescription = null + ) + }.apply { + setOnClickListener { + mediaDownloader.downloadLastOperaMediaAsync(allowDuplicate = false) + } + setOnLongClickListener { + context.vibrateLongPress() + mediaDownloader.downloadLastOperaMediaAsync(allowDuplicate = true) + true + } + layoutParams = LinearLayout.LayoutParams( + actionMenuIconSize, + actionMenuIconSize + ).apply { + setMargins(0, 0, 0, actionMenuIconMargin * 2) + } + }) + }, 0) + } + + if (context.config.messaging.markSnapAsSeenButton.get()) { + fun getMessageId(): Pair? { + return mediaDownloader.lastSeenMapParams?.get("MESSAGE_ID") + ?.toString() + ?.split(":") + ?.takeIf { it.size == 3 } + ?.let { return it[0] to it[2] } + } + + parent.addView(createComposeView(parent.context) { + Icon( + imageVector = Icons.Default.RemoveRedEye, + tint = Color.White, + contentDescription = null + ) + }.apply { + setOnClickListener { + this@OperaViewerIcons.context.apply { + coroutineScope.launch { + val (conversationId, clientMessageId) = getMessageId() ?: return@launch + val result = feature(AutoMarkAsRead::class).markSnapAsSeen(conversationId, clientMessageId.toLong()) + + if (result == "DUPLICATEREQUEST" || result == null) { + if (config.messaging.skipWhenMarkingAsSeen.get()) { + withContext(Dispatchers.Main) { + parent.iterateParent { + it.triggerCloseTouchEvent() + false + } + } + } + } + + if (result == "DUPLICATEREQUEST") return@launch + if (result == null) { + inAppOverlay.showStatusToast( + Icons.Default.Info, + translation["mark_as_seen.seen_toast"], + durationMs = 800 + ) + } else { + inAppOverlay.showStatusToast( + Icons.Default.Info, + "Failed to mark as seen: $result", + ) + } + } + } + } + + addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + v.visibility = View.GONE + this@OperaViewerIcons.context.coroutineScope.launch(Dispatchers.Main) { + delay(250) + v.visibility = if (getMessageId() != null) View.VISIBLE else View.GONE + } + } + override fun onViewDetachedFromWindow(v: View) {} + }) + + layoutParams = FrameLayout.LayoutParams( + (actionMenuIconSize * 1.5).toInt(), + (actionMenuIconSize * 1.5).toInt() + ).apply { + setMargins(0, 0, 0, actionMenuIconMarginTop * 2 + this@OperaViewerIcons.context.userInterface.dpToPx(80)) + marginEnd = actionMenuIconMarginTop * 2 + marginStart = actionMenuIconMarginTop * 2 + gravity = Gravity.BOTTOM or Gravity.END + } + }) + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/SettingsMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/SettingsMenu.kt new file mode 100644 index 0000000000..e4460589fb --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/SettingsMenu.kt @@ -0,0 +1,28 @@ +package me.rhunk.snapenhance.core.ui.menu.impl + +import android.view.View +import android.widget.FrameLayout +import me.rhunk.snapenhance.common.ui.OverlayType +import me.rhunk.snapenhance.core.ui.menu.AbstractMenu +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.ktx.getId + +class SettingsMenu : AbstractMenu() { + private val hovaHeaderSearchIconId by lazy { + context.resources.getId("hova_header_search_icon") + } + + override fun init() { + context.androidContext.classLoader.loadClass("com.snap.ui.view.SnapFontTextView").hook("setText", HookStage.BEFORE) { param -> + val view = param.thisObject() + if ((view.parent as? FrameLayout)?.findViewById(hovaHeaderSearchIconId) != null) { + view.post { + view.setOnClickListener { + context.bridgeClient.openOverlay(OverlayType.SETTINGS) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/CallbackBuilder.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/CallbackBuilder.kt similarity index 63% rename from app/src/main/kotlin/me/rhunk/snapenhance/util/CallbackBuilder.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/util/CallbackBuilder.kt index 64dde416f3..ad499d5c5c 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/util/CallbackBuilder.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/CallbackBuilder.kt @@ -1,9 +1,9 @@ -package me.rhunk.snapenhance.util +package me.rhunk.snapenhance.core.util import de.robv.android.xposed.XC_MethodHook -import me.rhunk.snapenhance.hook.HookAdapter -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.Hooker +import me.rhunk.snapenhance.core.util.hook.HookAdapter +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.Hooker import java.lang.reflect.Constructor import java.lang.reflect.Field import java.lang.reflect.Modifier @@ -13,22 +13,23 @@ class CallbackBuilder( ) { internal class Override( val methodName: String, + val shouldUnhook: Boolean = true, val callback: (HookAdapter) -> Unit ) private val methodOverrides = mutableListOf() - fun override(methodName: String, callback: (HookAdapter) -> Unit = {}): CallbackBuilder { - methodOverrides.add(Override(methodName, callback)) + fun override(methodName: String, shouldUnhook: Boolean = true, callback: (HookAdapter) -> Unit = {}): CallbackBuilder { + methodOverrides.add(Override(methodName, shouldUnhook, callback)) return this } fun build(): Any { //get the first param of the first constructor to get the class of the invoker - val invokerClass: Class<*> = callbackClass.constructors[0].parameterTypes[0] - //get the invoker field based on the invoker class - val invokerField = callbackClass.fields.first { field: Field -> - field.type.isAssignableFrom(invokerClass) + val rxEmitter: Class<*> = callbackClass.constructors[0].parameterTypes[0] + //get the emitter field based on the class + val rxEmitterField = callbackClass.fields.first { field: Field -> + field.type.isAssignableFrom(rxEmitter) } //get the callback field based on the callback class val callbackInstance = createEmptyObject(callbackClass.constructors[0])!! @@ -43,12 +44,10 @@ class CallbackBuilder( //default hook that unhooks the callback and returns null val defaultHook: (HookAdapter) -> Boolean = defaultHook@{ - //checking invokerField ensure that's the callback was created by the CallbackBuilder - if (invokerField.get(it.thisObject()) != null) return@defaultHook false + //ensure that's the callback was created by the CallbackBuilder + if (rxEmitterField.get(it.thisObject()) != null) return@defaultHook false if ((it.thisObject() as Any).hashCode() != callbackInstanceHashCode) return@defaultHook false - it.setResult(null) - unhooks.forEach { unhook -> unhook.unhook() } true } @@ -59,6 +58,7 @@ class CallbackBuilder( hook = { if (defaultHook(it)) { callback(it) + if (shouldUnhook) unhooks.forEach { unhook -> unhook.unhook() } } } } @@ -73,15 +73,16 @@ class CallbackBuilder( //compute the args for the constructor with null or default primitive values val args = constructor.parameterTypes.map { type: Class<*> -> if (type.isPrimitive) { - when (type.name) { - "boolean" -> return@map false - "byte" -> return@map 0.toByte() - "char" -> return@map 0.toChar() - "short" -> return@map 0.toShort() - "int" -> return@map 0 - "long" -> return@map 0L - "float" -> return@map 0f - "double" -> return@map 0.0 + return@map when (type.name) { + "boolean" -> false + "byte" -> 0.toByte() + "char" -> 0.toChar() + "short" -> 0.toShort() + "int" -> 0 + "long" -> 0L + "float" -> 0f + "double" -> 0.0 + else -> null } } null diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/DataClassBuilder.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/DataClassBuilder.kt new file mode 100644 index 0000000000..6ac8f5c441 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/DataClassBuilder.kt @@ -0,0 +1,97 @@ +package me.rhunk.snapenhance.core.util + +import java.lang.reflect.Proxy + + +inline fun Any?.dataBuilder(dataClassBuilder: DataClassBuilder.() -> Unit): Any? { + return DataClassBuilder( + when (this) { + is Class<*> -> CallbackBuilder.createEmptyObject( + this.constructors.firstOrNull() ?: return null + ) ?: return null + else -> this + } ?: return null + ).apply(dataClassBuilder).build() +} + +fun makeFunctionProxy(function: Any, handler: (args: Array, originalCall: (Array) -> Any?) -> Any?): Any { + val type = function.javaClass.interfaces.firstOrNull() ?: function.javaClass + val method = type.methods.firstOrNull { + it.declaringClass == type + } + + return Proxy.newProxyInstance( + function::class.java.classLoader, + arrayOf(type) + ) { _, _, args -> + handler(args) { newArgs -> + method?.invoke(function, *newArgs) + } + } +} + +// Util for building/editing data classes +class DataClassBuilder( + private val instance: Any, +) { + fun set(fieldName: String, value: Any?) { + val field = instance::class.java.declaredFields.firstOrNull { it.name == fieldName } ?: return + val fieldType = field.type ?: return + field.isAccessible = true + + when { + fieldType.isEnum -> { + val enumValue = fieldType.enumConstants?.firstOrNull { it.toString() == value } ?: return + field.set(instance, enumValue) + } + fieldType.isPrimitive -> { + when (fieldType) { + Boolean::class.javaPrimitiveType -> field.setBoolean(instance, value as Boolean) + Byte::class.javaPrimitiveType -> field.setByte(instance, value as Byte) + Char::class.javaPrimitiveType -> field.setChar(instance, value as Char) + Short::class.javaPrimitiveType -> field.setShort(instance, value as Short) + Int::class.javaPrimitiveType -> field.setInt(instance, value as Int) + Long::class.javaPrimitiveType -> field.setLong(instance, value as Long) + Float::class.javaPrimitiveType -> field.setFloat(instance, value as Float) + Double::class.javaPrimitiveType -> field.setDouble(instance, value as Double) + } + } + else -> field.set(instance, value) + } + } + + fun set(vararg fields: Pair) = fields.forEach { set(it.first, it.second) } + + @Suppress("UNCHECKED_CAST") + fun get(fieldName: String): T? { + val field = instance::class.java.declaredFields.firstOrNull { it.name == fieldName } ?: return null + field.isAccessible = true + return field.get(instance) as? T + } + + fun from(fieldName: String, new: Boolean = false, callback: DataClassBuilder.() -> Unit) { + val field = instance::class.java.declaredFields.firstOrNull { it.name == fieldName } ?: return + field.isAccessible = true + + val lazyInstance by lazy { CallbackBuilder.createEmptyObject(field.type.constructors.firstOrNull() ?: return@lazy null) ?: return@lazy null } + val builderInstance = if (new) lazyInstance else { + field.get(instance).takeIf { it != null } ?: lazyInstance + } + + DataClassBuilder(builderInstance ?: return).apply(callback) + + field.set(instance, builderInstance) + } + + fun cast(type: Class, callback: T.() -> Unit) { + type.cast(instance)?.let { callback(it) } + } + + fun interceptFieldInterface(fieldName: String, callback: (args: Array, originalCall: (Array) -> Any?) -> Any?) { + val field = instance.javaClass.declaredFields.firstOrNull { it.name == fieldName } ?: return + field.isAccessible = true + set(fieldName, field.get(instance)?.let { makeFunctionProxy(it, callback) } ?: return) + } + + fun build() = instance +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/EvictingMap.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/EvictingMap.kt new file mode 100644 index 0000000000..dc7bf533b6 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/EvictingMap.kt @@ -0,0 +1,5 @@ +package me.rhunk.snapenhance.core.util + +class EvictingMap(private val maxSize: Int) : LinkedHashMap(maxSize, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry) = size > maxSize +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/LSPatchUpdater.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/LSPatchUpdater.kt new file mode 100644 index 0000000000..0252b704cb --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/LSPatchUpdater.kt @@ -0,0 +1,78 @@ +package me.rhunk.snapenhance.core.util + +import me.rhunk.snapenhance.common.Constants +import me.rhunk.snapenhance.core.ModContext +import java.io.File +import java.util.zip.ZipFile + +object LSPatchUpdater { + private const val TAG = "LSPatchUpdater" + + var HAS_LSPATCH = false + private set + + private fun getModuleUniqueHash(module: ZipFile): String { + return module.entries().asSequence() + .filter { !it.isDirectory } + .map { it.crc } + .reduce { acc, crc -> acc xor crc } + .toString(16) + } + + fun onBridgeConnected(context: ModContext) { + val obfuscatedModulePath by lazy { + (runCatching { + context::class.java.classLoader?.loadClass("org.lsposed.lspatch.share.Constants") + }.getOrNull())?.declaredFields?.firstOrNull { it.name == "MANAGER_PACKAGE_NAME" }?.also { + it.isAccessible = true + }?.get(null) as? String + } + + val embeddedModule = context.androidContext.cacheDir + .resolve("lspatch") + .resolve(Constants.SE_PACKAGE_NAME).let { moduleDir -> + if (!moduleDir.exists()) return@let null + moduleDir.listFiles()?.firstOrNull { it.extension == "apk" } + } ?: obfuscatedModulePath?.let { path -> + context.androidContext.cacheDir.resolve(path).let dir@{ moduleDir -> + if (!moduleDir.exists()) return@dir null + moduleDir.listFiles()?.firstOrNull { it.extension == "apk" } + } ?: return + } ?: return + + HAS_LSPATCH = true + context.log.verbose("Found embedded SE at ${embeddedModule.absolutePath}", TAG) + + val seAppApk = File(context.bridgeClient.getApplicationApkPath()).also { + if (!it.canRead()) { + throw IllegalStateException("Cannot read SnapEnhance apk") + } + } + + runCatching { + if (getModuleUniqueHash(ZipFile(embeddedModule)) == getModuleUniqueHash(ZipFile(seAppApk))) { + context.log.verbose("Embedded SE is up to date", TAG) + return + } + }.onFailure { + throw IllegalStateException("Failed to compare module signature", it) + } + + context.log.verbose("updating", TAG) + context.shortToast("Updating SnapEnhance. Please wait...") + // copy embedded module to cache + runCatching { + seAppApk.copyTo(embeddedModule, overwrite = true) + }.onFailure { + seAppApk.delete() + context.log.error("Failed to copy embedded module", it, TAG) + context.longToast("Failed to update SnapEnhance. Please check logcat for more details.") + context.forceCloseApp() + return + } + + context.longToast("SnapEnhance updated!") + context.log.verbose("updated", TAG) + context.softRestartApp() + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/RandomWalking.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/RandomWalking.kt new file mode 100644 index 0000000000..fa81e3965d --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/RandomWalking.kt @@ -0,0 +1,65 @@ +package me.rhunk.snapenhance.core.util + +import kotlin.math.cos +import kotlin.math.hypot +import kotlin.math.pow +import kotlin.math.sin +import kotlin.math.sqrt + +class RandomWalking( + private var walkRadius: Double?, +) { + var current_x = 0.0 + private set + var current_y = 0.0 + private set + private var last_update_time = 0L + private var pause_expire = 0L + private var target_x = 0.0 + private var target_y = 0.0 + + fun updatePosition() { + //Latitude ft / deg /Longitude ft /deg = 1.26301179736 + // 4ft/s * 1 degree/364000ft (latitude) * 1s/1000ms = .000000010989011 degrees/ms + val max_speed = 4.0 / 364000.0 / 1000.0 + val pause_chance = .0023 // .23% chance to pause every second = after 5 minutes 50% chance of pause + val pause_duration = 60000L //ms + val pause_random = 30000L //ms + + val now = System.currentTimeMillis() + + if(current_x == target_x && current_y == target_y) { + val walk_rad = if (walkRadius == null + ) 0.0 else (walkRadius!! / 364000.0) //Lat deg + + if(last_update_time == 0L){ //Start at random position + val radius1 = sqrt(Math.random()) * walk_rad + val theta1 = Math.PI * 2.0 * Math.random() + current_x = cos(theta1) * radius1 * 1.26301179736 + current_y = sin(theta1) * radius1 + } + + val radius2 = sqrt(Math.random()) * walk_rad + val theta2 = Math.PI * 2.0 * Math.random() + target_x = cos(theta2) * radius2 * 1.26301179736 + target_y = sin(theta2) * radius2 + } else if (pause_expire < now) { + val deltat = now - last_update_time + if(Math.random() > (1.0 - pause_chance).pow(deltat / 1000.0)){ + pause_expire = now + pause_duration + (pause_random * Math.random()).toLong() + } else { + val max_dist = max_speed * deltat + val dist = hypot(target_x - current_x, target_y - current_y) + + if (dist <= max_dist) { + current_x = target_x + current_y = target_y + } else { + current_x += (target_x - current_x) / dist * max_dist + current_y += (target_y - current_y) / dist * max_dist + } + } + } + last_update_time = now + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/hook/HookAdapter.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/hook/HookAdapter.kt similarity index 81% rename from app/src/main/kotlin/me/rhunk/snapenhance/hook/HookAdapter.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/util/hook/HookAdapter.kt index 2765b6ee4d..c29ec52da5 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/hook/HookAdapter.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/hook/HookAdapter.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.hook +package me.rhunk.snapenhance.core.util.hook import de.robv.android.xposed.XC_MethodHook import de.robv.android.xposed.XposedBridge @@ -13,6 +13,10 @@ class HookAdapter( return methodHookParam.thisObject as T } + fun nullableThisObject(): T? { + return methodHookParam.thisObject as T? + } + fun method(): Member { return methodHookParam.method } @@ -22,7 +26,7 @@ class HookAdapter( } fun argNullable(index: Int): T? { - return methodHookParam.args[index] as T? + return methodHookParam.args.getOrNull(index) as T? } fun setArg(index: Int, value: Any?) { @@ -30,7 +34,7 @@ class HookAdapter( methodHookParam.args[index] = value } - fun args(): Array { + fun args(): Array { return methodHookParam.args } @@ -54,7 +58,7 @@ class HookAdapter( return XposedBridge.invokeOriginalMethod(method(), thisObject(), args()) } - fun invokeOriginal(args: Array): Any? { + fun invokeOriginal(args: Array): Any? { return XposedBridge.invokeOriginalMethod(method(), thisObject(), args) } @@ -62,7 +66,7 @@ class HookAdapter( invokeOriginalSafe(args(), errorCallback) } - fun invokeOriginalSafe(args: Array, errorCallback: Consumer) { + fun invokeOriginalSafe(args: Array, errorCallback: Consumer) { runCatching { setResult(XposedBridge.invokeOriginalMethod(method(), thisObject(), args)) }.onFailure { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/hook/HookStage.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/hook/HookStage.kt similarity index 51% rename from app/src/main/kotlin/me/rhunk/snapenhance/hook/HookStage.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/util/hook/HookStage.kt index dddf134234..f991eb0196 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/hook/HookStage.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/hook/HookStage.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.hook +package me.rhunk.snapenhance.core.util.hook enum class HookStage { BEFORE, diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/hook/Hooker.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/hook/Hooker.kt new file mode 100644 index 0000000000..99d7297b79 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/hook/Hooker.kt @@ -0,0 +1,199 @@ +package me.rhunk.snapenhance.core.util.hook + +import de.robv.android.xposed.XC_MethodHook +import de.robv.android.xposed.XposedBridge +import me.rhunk.snapenhance.common.logger.AbstractLogger +import org.lsposed.hiddenapibypass.HiddenApiBypass +import java.lang.reflect.Constructor +import java.lang.reflect.Member +import java.lang.reflect.Method +import java.lang.reflect.Modifier + +object Hooker { + inline fun newMethodHook( + stage: HookStage, + crossinline consumer: (HookAdapter) -> Unit, + crossinline filter: ((HookAdapter) -> Boolean) = { true } + ): XC_MethodHook { + return if (stage == HookStage.BEFORE) object : XC_MethodHook() { + override fun beforeHookedMethod(param: MethodHookParam<*>) { + runCatching { + HookAdapter(param).takeIf(filter)?.also(consumer) + }.onFailure { + AbstractLogger.directError("Failed to execute before hook", it) + } + } + } else object : XC_MethodHook() { + override fun afterHookedMethod(param: MethodHookParam<*>) { + runCatching { + HookAdapter(param).takeIf(filter)?.also(consumer) + }.onFailure { + AbstractLogger.directError("Failed to execute after hook", it) + } + } + } + } + + inline fun hook( + clazz: Class<*>, + methodName: String, + stage: HookStage, + crossinline filter: (HookAdapter) -> Boolean, + noinline consumer: (HookAdapter) -> Unit + ): Set = XposedBridge.hookAllMethods(clazz, methodName, newMethodHook(stage, consumer, filter)) + + inline fun hook( + member: Member, + stage: HookStage, + crossinline filter: ((HookAdapter) -> Boolean), + crossinline consumer: (HookAdapter) -> Unit + ): XC_MethodHook.Unhook { + return XposedBridge.hookMethod(member, newMethodHook(stage, consumer, filter)) + } + + fun hook( + clazz: Class<*>, + methodName: String, + stage: HookStage, + consumer: (HookAdapter) -> Unit + ): Set = hook(clazz, methodName, stage, { true }, consumer) + + fun hook( + member: Member, + stage: HookStage, + consumer: (HookAdapter) -> Unit + ): XC_MethodHook.Unhook { + return hook(member, stage, { true }, consumer) + } + + fun hookConstructor( + clazz: Class<*>, + stage: HookStage, + consumer: (HookAdapter) -> Unit + ): Set = XposedBridge.hookAllConstructors(clazz, newMethodHook(stage, consumer)) + + fun hookConstructor( + clazz: Class<*>, + stage: HookStage, + filter: ((HookAdapter) -> Boolean), + consumer: (HookAdapter) -> Unit + ) { + XposedBridge.hookAllConstructors(clazz, newMethodHook(stage, consumer, filter)) + } + + inline fun hookObjectMethod( + clazz: Class<*>, + instance: Any, + methodName: String, + stage: HookStage, + crossinline hookConsumer: (HookAdapter) -> Unit + ): List<() -> Unit> { + val unhooks = mutableSetOf() + hook(clazz, methodName, stage) { param-> + if (param.nullableThisObject().let { + if (it == null) unhooks.forEach { u -> u.unhook() } + it != instance + }) return@hook + hookConsumer(param) + }.also { unhooks.addAll(it) } + return unhooks.map { + { it.unhook() } + } + } + + inline fun ephemeralHook( + clazz: Class<*>, + methodName: String, + stage: HookStage, + crossinline hookConsumer: (HookAdapter) -> Unit + ) { + val unhooks: MutableSet = HashSet() + hook(clazz, methodName, stage) { param-> + hookConsumer(param) + unhooks.forEach{ it.unhook() } + }.also { unhooks.addAll(it) } + } + + inline fun ephemeralHookObjectMethod( + clazz: Class<*>, + instance: Any, + methodName: String, + stage: HookStage, + crossinline hookConsumer: (HookAdapter) -> Unit + ): Set<() -> Unit> { + val unhooks = mutableSetOf() + hook(clazz, methodName, stage) { param-> + if (param.nullableThisObject() != instance) return@hook + unhooks.forEach { it.unhook() } + hookConsumer(param) + }.also { unhooks.addAll(it) } + return unhooks.map { + { it.unhook() } + }.toSet() + } + + inline fun ephemeralHookConstructor( + clazz: Class<*>, + stage: HookStage, + crossinline hookConsumer: (HookAdapter) -> Unit + ) { + val unhooks: MutableSet = HashSet() + hookConstructor(clazz, stage) { param-> + hookConsumer(param) + unhooks.forEach{ it.unhook() } + }.also { unhooks.addAll(it) } + } +} + +fun Class<*>.hookConstructor( + stage: HookStage, + consumer: (HookAdapter) -> Unit +) = Hooker.hookConstructor(this, stage, consumer) + +fun Class<*>.hookConstructor( + stage: HookStage, + filter: ((HookAdapter) -> Boolean), + consumer: (HookAdapter) -> Unit +) = Hooker.hookConstructor(this, stage, filter, consumer) + +fun Class<*>.hook( + methodName: String, + stage: HookStage, + consumer: (HookAdapter) -> Unit +): Set = Hooker.hook(this, methodName, stage, consumer) + +fun Class<*>.hook( + methodName: String, + stage: HookStage, + filter: (HookAdapter) -> Boolean, + consumer: (HookAdapter) -> Unit +): Set = Hooker.hook(this, methodName, stage, filter, consumer) + +fun Member.hook( + stage: HookStage, + consumer: (HookAdapter) -> Unit +): XC_MethodHook.Unhook = Hooker.hook(this, stage, consumer) + +fun Member.hook( + stage: HookStage, + filter: ((HookAdapter) -> Boolean), + consumer: (HookAdapter) -> Unit +): XC_MethodHook.Unhook = Hooker.hook(this, stage, filter, consumer) + +fun Array.hookAll(stage: HookStage, param: (HookAdapter) -> Unit) { + filter { it.declaringClass != Object::class.java && !Modifier.isAbstract(it.modifiers) }.forEach { + it.hook(stage, param) + } +} + +fun Class<*>.findRestrictedMethod( + predicate: (Method) -> Boolean +): Method? { + return declaredMethods.find(predicate) ?: HiddenApiBypass.getDeclaredMethods(this).filterIsInstance().find(predicate) +} + +fun Class<*>.findRestrictedConstructor( + predicate: (Constructor<*>) -> Boolean +): Constructor<*>? { + return declaredConstructors.find(predicate) ?: HiddenApiBypass.getDeclaredMethods(this).filterIsInstance>().find(predicate) +} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/AndroidExt.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/AndroidExt.kt new file mode 100644 index 0000000000..49635d0b81 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/AndroidExt.kt @@ -0,0 +1,65 @@ +package me.rhunk.snapenhance.core.util.ktx + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.Resources +import android.content.res.Resources.Theme +import android.content.res.TypedArray +import android.graphics.drawable.Drawable +import android.os.VibrationEffect +import android.os.Vibrator +import androidx.core.graphics.ColorUtils +import me.rhunk.snapenhance.common.Constants +import me.rhunk.snapenhance.common.logger.AbstractLogger + +val notFoundCache = mutableSetOf() + +@SuppressLint("DiscouragedApi") +fun Resources.getIdentifier(name: String, type: String): Int { + return getIdentifier(name, type, Constants.SNAPCHAT_PACKAGE_NAME).also { id -> + if (id != 0) return@also + "$type#$name".takeIf { it !in notFoundCache}?.let { + AbstractLogger.directDebug("Resource not found: $it") + notFoundCache.add(it) + } + } +} + +fun Resources.getId(name: String): Int { + return getIdentifier(name, "id") +} + +fun Resources.getLayoutId(name: String): Int { + return getIdentifier(name, "layout") +} + +fun Resources.getDimens(name: String): Int { + return getDimensionPixelSize(getIdentifier(name, "dimen").takeIf { it > 0 } ?: return 0) +} + +fun Resources.getDimensFloat(name: String): Float { + return getDimension(getIdentifier(name, "dimen").takeIf { it > 0 } ?: return 0F) +} + +fun Resources.getStyledAttributes(name: String, theme: Theme): TypedArray { + return getIdentifier(name, "attr").let { + theme.obtainStyledAttributes(intArrayOf(it)) + } +} + +fun Resources.getDrawable(name: String, theme: Theme): Drawable { + return getDrawable(getIdentifier(name, "drawable"), theme) +} + +@SuppressLint("MissingPermission") +fun Context.vibrateLongPress() { + getSystemService(Vibrator::class.java).vibrate(VibrationEffect.createOneShot(50, VibrationEffect.DEFAULT_AMPLITUDE)) +} + +fun Context.isDarkTheme(): Boolean { + return theme.obtainStyledAttributes( + intArrayOf(android.R.attr.colorPrimary) + ).getColor(0, 0).let { + ColorUtils.calculateLuminance(it) < 0.5 + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/FileHandleManagerKtx.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/FileHandleManagerKtx.kt new file mode 100644 index 0000000000..9a4ab4377a --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/FileHandleManagerKtx.kt @@ -0,0 +1,31 @@ +package me.rhunk.snapenhance.core.util.ktx + +import android.os.Build +import android.os.ParcelFileDescriptor +import me.rhunk.snapenhance.bridge.storage.FileHandleManager +import me.rhunk.snapenhance.common.bridge.FileHandleScope +import me.rhunk.snapenhance.common.util.ktx.longHashCode +import me.rhunk.snapenhance.core.ModContext +import java.io.FileOutputStream +import kotlin.math.absoluteValue + +fun FileHandleManager.getFileHandleLocalPath( + context: ModContext, + scope: FileHandleScope, + name: String, + fileUniqueIdentifier: String, +): String? { + return getFileHandle(scope.key, name)?.open(ParcelFileDescriptor.MODE_READ_ONLY)?.use { pfd -> + val cacheFile = context.androidContext.cacheDir.also { + it.mkdirs() + }.resolve((fileUniqueIdentifier + Build.FINGERPRINT).longHashCode().absoluteValue.toString(16)) + if (!cacheFile.exists() || pfd.statSize != cacheFile.length()) { + FileOutputStream(cacheFile).use { output -> + ParcelFileDescriptor.AutoCloseInputStream(pfd).use { input -> + input.copyTo(output) + } + } + } + cacheFile.absolutePath + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/XposedHelperMacros.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/XposedHelperExt.kt similarity index 56% rename from app/src/main/kotlin/me/rhunk/snapenhance/util/XposedHelperMacros.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/XposedHelperExt.kt index 9ed6d33054..45adb1b062 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/util/XposedHelperMacros.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/XposedHelperExt.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.util +package me.rhunk.snapenhance.core.util.ktx import de.robv.android.xposed.XposedHelpers @@ -6,6 +6,13 @@ fun Any.getObjectField(fieldName: String): Any? { return XposedHelpers.getObjectField(this, fieldName) } +fun Any.setEnumField(fieldName: String, value: String) { + this::class.java.getDeclaredField(fieldName) + .type.enumConstants?.firstOrNull { it.toString() == value }?.let { enum -> + setObjectField(fieldName, enum) + } +} + fun Any.setObjectField(fieldName: String, value: Any?) { XposedHelpers.setObjectField(this, fieldName, value) } @@ -13,7 +20,7 @@ fun Any.setObjectField(fieldName: String, value: Any?) { fun Any.getObjectFieldOrNull(fieldName: String): Any? { return try { getObjectField(fieldName) - } catch (e: Exception) { + } catch (t: Throwable) { null } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/media/HttpServer.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/media/HttpServer.kt new file mode 100644 index 0000000000..080bae891b --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/media/HttpServer.kt @@ -0,0 +1,217 @@ +package me.rhunk.snapenhance.core.util.media + +import kotlinx.coroutines.* +import me.rhunk.snapenhance.common.logger.AbstractLogger +import java.io.BufferedReader +import java.io.InputStream +import java.io.InputStreamReader +import java.io.PrintWriter +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketException +import java.util.Locale +import java.util.StringTokenizer +import java.util.concurrent.ConcurrentHashMap +import kotlin.coroutines.suspendCoroutine +import kotlin.random.Random + +class HttpServer( + private val timeout: Int = 10000 +) { + private fun newRandomPort() = Random.nextInt(10000, 65535) + + var port = newRandomPort() + private set + + private val coroutineScope = CoroutineScope(Dispatchers.IO) + private var timeoutJob: Job? = null + private var socketJob: Job? = null + + abstract class HttpBody { + abstract val readBytes: (byteArray: ByteArray) -> Int + open val onOpen: () -> Unit = {} + open val onClose: () -> Unit = {} + } + + abstract class HttpContent { + abstract val contentType: String + abstract val chunked: Boolean + abstract val contentLength: Long? + abstract val newBody: () -> HttpBody + } + + private val cachedData = ConcurrentHashMap() + private var serverSocket: ServerSocket? = null + + fun ensureServerStarted(): HttpServer? { + if (serverSocket != null && serverSocket?.isClosed != true) return this + + return runBlocking { + withTimeoutOrNull(5000L) { + suspendCoroutine { continuation -> + coroutineScope.launch(Dispatchers.IO) { + AbstractLogger.directDebug("Starting http server on port $port") + for (i in 0..5) { + try { + serverSocket = ServerSocket(port) + break + } catch (e: Throwable) { + AbstractLogger.directError("failed to start http server on port $port", e) + port = newRandomPort() + } + } + continuation.resumeWith(Result.success(if (serverSocket == null) null.also { + return@launch + } else this@HttpServer)) + + while (!serverSocket!!.isClosed) { + try { + val socket = serverSocket!!.accept() + timeoutJob?.cancel() + launch { + handleRequest(socket) + timeoutJob = launch { + delay(timeout.toLong()) + AbstractLogger.directDebug("http server closed due to timeout") + runCatching { + socketJob?.cancel() + socket.close() + serverSocket?.close() + }.onFailure { + AbstractLogger.directError("failed to close socket", it) + } + } + } + } catch (e: SocketException) { + AbstractLogger.directDebug("http server timed out") + break; + } catch (e: Throwable) { + AbstractLogger.directError("failed to handle request", e) + } + } + }.also { socketJob = it } + } + } + } + } + + fun close() { + runCatching { + serverSocket?.close() + } + } + + fun putDownloadableContent(inputStream: InputStream, size: Long): String { + val key = System.nanoTime().toString(16) + cachedData[key] = object : HttpContent() { + override val contentType: String = "application/octet-stream" + override val chunked: Boolean = false + override val contentLength: Long = size + override val newBody: () -> HttpBody = { + object : HttpBody() { + override val readBytes: (byteArray: ByteArray) -> Int = { byteArray -> + inputStream.read(byteArray) + } + } + } + } + return "http://127.0.0.1:$port/$key" + } + + fun putContent(httpContent: HttpContent): String { + val key = System.nanoTime().toString(16) + cachedData[key] = httpContent + return "http://127.0.0.1:$port/$key" + } + + fun removeUrl(url: String) { + val key = url.substringAfterLast('/') + cachedData.remove(key) + } + + private fun handleRequest(socket: Socket) { + val reader = BufferedReader(InputStreamReader(socket.getInputStream())) + val outputStream = socket.getOutputStream() + val writer = PrintWriter(outputStream) + val line = reader.readLine() ?: return + fun close() { + runCatching { + reader.close() + writer.close() + outputStream.close() + socket.close() + }.onFailure { + AbstractLogger.directError("failed to close socket", it) + } + } + val parse = StringTokenizer(line) + val method = parse.nextToken().uppercase(Locale.getDefault()) + var fileRequested = parse.nextToken().lowercase(Locale.getDefault()) + AbstractLogger.directDebug("[http-server:${port}] $method $fileRequested") + + if (method != "GET") { + with(writer) { + println("HTTP/1.1 501 Not Implemented") + println("Content-type: " + "application/octet-stream") + println("Content-length: " + 0) + println() + flush() + } + close() + return + } + if (fileRequested.startsWith("/")) { + fileRequested = fileRequested.substring(1) + } + val requestedData = cachedData[fileRequested] ?: writer.run { + println("HTTP/1.1 404 Not Found") + println("Content-type: " + "application/octet-stream") + println("Content-length: " + 0) + println() + flush() + close() + return + } + with(writer) { + println("HTTP/1.1 200 OK") + println("Content-type: " + "application/octet-stream") + if (requestedData.chunked) println("Transfer-encoding: chunked") + else println("Content-length: " + requestedData.contentLength) + println() + flush() + } + + val responseBody = requestedData.newBody() + responseBody.onOpen() + try { + if (requestedData.chunked) { + val buffer = ByteArray(32768) + while (true) { + val read = responseBody.readBytes(buffer) + if (read == -1) break + outputStream.write(Integer.toHexString(read).toByteArray()) + outputStream.write("\r\n".toByteArray()) + outputStream.write(buffer, 0, read) + outputStream.write("\r\n".toByteArray()) + outputStream.flush() + } + } else { + cachedData.remove(fileRequested) + val buffer = ByteArray(4096) + while (true) { + val read = responseBody.readBytes(buffer) + if (read == -1) break + outputStream.write(buffer, 0, read) + outputStream.flush() + } + } + } catch (t: Throwable) { + AbstractLogger.directDebug("failed to write to socket ${t.localizedMessage}") + } finally { + if (requestedData.chunked) runCatching { outputStream.write("0\r\n\r\n".toByteArray()) } + responseBody.onClose() + } + outputStream.flush() + close() + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/media/PreviewUtils.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/media/PreviewUtils.kt new file mode 100644 index 0000000000..8dbe46aa4e --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/media/PreviewUtils.kt @@ -0,0 +1,94 @@ +package me.rhunk.snapenhance.core.util.media + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Matrix +import android.media.MediaDataSource +import android.media.MediaMetadataRetriever +import me.rhunk.snapenhance.common.data.FileType +import java.io.File +import kotlin.math.max + +object PreviewUtils { + fun createPreview(data: ByteArray, isVideo: Boolean): Bitmap? { + if (!isVideo) { + return BitmapFactory.decodeByteArray(data, 0, data.size) + } + return MediaMetadataRetriever().apply { + setDataSource(object : MediaDataSource() { + override fun readAt( + position: Long, + buffer: ByteArray, + offset: Int, + size: Int + ): Int { + var newSize = size + val length = data.size + if (position >= length) { + return -1 + } + if (position + newSize > length) { + newSize = length - position.toInt() + } + System.arraycopy(data, position.toInt(), buffer, offset, newSize) + return newSize + } + + override fun getSize(): Long { + return data.size.toLong() + } + override fun close() {} + }) + }.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC) + } + + fun createPreviewFromFile(file: File): Bitmap? { + return if (FileType.fromFile(file).isVideo) { + MediaMetadataRetriever().apply { + setDataSource(file.absolutePath) + }.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC) + } else { + BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options()) + } + } + + fun resizeBitmap(source: Bitmap, outWidth: Int, outHeight: Int): Bitmap { + val sourceWidth = source.getWidth() + val sourceHeight = source.getHeight() + val scale = max(outWidth.toFloat() / sourceWidth, outHeight.toFloat() / sourceHeight) + + val dx = (outWidth - (scale * sourceWidth)) / 2F + val dy = (outHeight - (scale * sourceHeight)) / 2F + val dest = Bitmap.createBitmap(outWidth, outHeight, source.getConfig()) + val canvas = Canvas(dest) + canvas.drawBitmap(source, Matrix().apply { + postScale(scale, scale) + postTranslate(dx, dy) + }, null) + return dest + } + + fun mergeBitmapOverlay(originalMedia: Bitmap, overlayLayer: Bitmap): Bitmap { + val biggestBitmap = if (originalMedia.width * originalMedia.height > overlayLayer.width * overlayLayer.height) originalMedia else overlayLayer + val smallestBitmap = if (biggestBitmap == originalMedia) overlayLayer else originalMedia + + val mergedBitmap = Bitmap.createBitmap(biggestBitmap.width, biggestBitmap.height, biggestBitmap.config) + + with(Canvas(mergedBitmap)) { + val scaleMatrix = Matrix().apply { + postScale(biggestBitmap.width.toFloat() / smallestBitmap.width.toFloat(), biggestBitmap.height.toFloat() / smallestBitmap.height.toFloat()) + } + + if (biggestBitmap == originalMedia) { + drawBitmap(originalMedia, 0f, 0f, null) + drawBitmap(overlayLayer, scaleMatrix, null) + } else { + drawBitmap(originalMedia, scaleMatrix, null) + drawBitmap(overlayLayer, 0f, 0f, null) + } + } + + return mergedBitmap + } +} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/AbstractWrapper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/AbstractWrapper.kt new file mode 100644 index 0000000000..c8bbd8908e --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/AbstractWrapper.kt @@ -0,0 +1,68 @@ +package me.rhunk.snapenhance.core.wrapper + +import de.robv.android.xposed.XposedHelpers +import me.rhunk.snapenhance.core.util.CallbackBuilder +import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID +import kotlin.reflect.KProperty + +abstract class AbstractWrapper( + protected open var instance: Any? +) { + protected val uuidArrayListMapper: (Any?) -> ArrayList get() = { (it as ArrayList<*>).map { i -> SnapUUID(i) }.toCollection(ArrayList()) } + + @Suppress("UNCHECKED_CAST") + inner class EnumAccessor(private val fieldName: String, private val defaultValue: T) { + operator fun getValue(obj: Any, property: KProperty<*>): T? = getEnumValue(fieldName, defaultValue as Enum<*>) as? T + operator fun setValue(obj: Any, property: KProperty<*>, value: Any?) = setEnumValue(fieldName, value as Enum<*>) + } + + inner class FieldAccessor(private val fieldName: String, private val mapper: ((Any?) -> T?)? = null) { + @Suppress("UNCHECKED_CAST") + operator fun getValue(obj: Any, property: KProperty<*>): T? { + return runCatching { + val value = XposedHelpers.getObjectField(instance, fieldName) + mapper?.invoke(value) ?: value as? T + }.getOrNull() + } + + operator fun setValue(obj: Any, property: KProperty<*>, value: Any?) { + XposedHelpers.setObjectField(instance, fieldName, when (value) { + is AbstractWrapper -> value.instance + is ArrayList<*> -> value.map { if (it is AbstractWrapper) it.instance else it }.toMutableList() + else -> value + }) + } + } + + companion object { + fun newEmptyInstance(clazz: Class<*>): Any { + return CallbackBuilder.createEmptyObject(clazz.constructors[0]) ?: throw NullPointerException() + } + } + + fun instanceNonNull(): Any = instance ?: throw NullPointerException("Instance of ${this::class.simpleName} is null") + fun isPresent(): Boolean = instance != null + + override fun hashCode(): Int { + return instance.hashCode() + } + + override fun toString(): String { + return instance.toString() + } + + protected fun enum(fieldName: String, defaultValue: T) = EnumAccessor(fieldName, defaultValue) + protected fun field(fieldName: String, mapper: ((Any?) -> T?)? = null) = FieldAccessor(fieldName, mapper) + + fun > getEnumValue(fieldName: String, defaultValue: T?): T? { + if (defaultValue == null || instance == null) return null + val mContentType = XposedHelpers.getObjectField(instance, fieldName) as? Enum<*> ?: return null + return java.lang.Enum.valueOf(defaultValue::class.java, mContentType.name) + } + + @Suppress("UNCHECKED_CAST") + fun setEnumValue(fieldName: String, value: Enum<*>) { + val type = instance!!.javaClass.declaredFields.find { it.name == fieldName }?.type as Class> + XposedHelpers.setObjectField(instance, fieldName, java.lang.Enum.valueOf(type, value.name)) + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ConversationManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ConversationManager.kt new file mode 100644 index 0000000000..51f10ff34c --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ConversationManager.kt @@ -0,0 +1,207 @@ +package me.rhunk.snapenhance.core.wrapper.impl + +import me.rhunk.snapenhance.common.data.MessageUpdate +import me.rhunk.snapenhance.core.ModContext +import me.rhunk.snapenhance.core.util.CallbackBuilder +import me.rhunk.snapenhance.core.util.dataBuilder +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.util.ktx.setObjectField +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper +import me.rhunk.snapenhance.mapper.impl.CallbackMapper + +typealias CallbackResult = (error: String?) -> Unit + +class ConversationManager( + val context: ModContext, + obj: Any +) : AbstractWrapper(obj) { + private fun findMethodByName(name: String) = context.classCache.conversationManager.declaredMethods.find { it.name == name } ?: throw RuntimeException("Could not find method $name") + + private val updateMessageMethod by lazy { findMethodByName("updateMessage") } + private val fetchConversationWithMessagesPaginatedMethod by lazy { findMethodByName("fetchConversationWithMessagesPaginated") } + private val fetchConversationWithMessagesMethod by lazy { findMethodByName("fetchConversationWithMessages") } + private val fetchMessageByServerId by lazy { findMethodByName("fetchMessageByServerId") } + private val fetchMessagesByServerIds by lazy { findMethodByName("fetchMessagesByServerIds") } + private val displayedMessagesMethod by lazy { findMethodByName("displayedMessages") } + private val fetchMessage by lazy { findMethodByName("fetchMessage") } + private val clearConversation by lazy { findMethodByName("clearConversation") } + private val getOneOnOneConversationIds by lazy { findMethodByName("getOneOnOneConversationIds") } + private val dismissStreakRestore by lazy { findMethodByName("dismissStreakRestore") } + private val reactToMessageMethod by lazy { findMethodByName("reactToMessage") } + + + private fun getCallbackClass(name: String): Class<*> { + lateinit var result: Class<*> + context.mappings.useMapper(CallbackMapper::class) { + result = context.androidContext.classLoader.loadClass(callbacks.get()!![name]) + } + return result + } + + + fun updateMessage(conversationId: String, messageId: Long, action: MessageUpdate, onResult: CallbackResult = {}) { + updateMessageMethod.invoke( + instanceNonNull(), + SnapUUID(conversationId).instanceNonNull(), + messageId, + context.classCache.messageUpdateEnum.enumConstants!!.first { it.toString() == action.toString() }, + CallbackBuilder(getCallbackClass("Callback")) + .override("onSuccess") { onResult(null) } + .override("onError") { onResult(it.arg(0).toString()) }.build() + ) + } + + fun fetchConversationWithMessagesPaginated(conversationId: String, lastMessageId: Long, amount: Int, onSuccess: (message: List) -> Unit, onError: (error: String) -> Unit) { + val callback = CallbackBuilder(getCallbackClass("FetchConversationWithMessagesCallback")) + .override("onFetchConversationWithMessagesComplete") { param -> + onSuccess(param.arg>(1).map { Message(it) }) + } + .override("onServerRequest", shouldUnhook = false) {} + .override("onError") { + onError(it.arg(0).toString()) + }.build() + fetchConversationWithMessagesPaginatedMethod.invoke(instanceNonNull(), conversationId.toSnapUUID().instanceNonNull(), lastMessageId, amount, callback) + } + + fun fetchConversationWithMessages(conversationId: String, onSuccess: (List) -> Unit, onError: (error: String) -> Unit) { + fetchConversationWithMessagesMethod.invoke( + instanceNonNull(), + conversationId.toSnapUUID().instanceNonNull(), + CallbackBuilder(getCallbackClass("FetchConversationWithMessagesCallback")) + .override("onFetchConversationWithMessagesComplete") { param -> + onSuccess(param.arg>(1).map { Message(it) }) + } + .override("onServerRequest", shouldUnhook = false) {} + .override("onError") { + onError(it.arg(0).toString()) + }.build() + ) + } + + fun displayedMessages(conversationId: String, messageId: Long, onResult: CallbackResult = {}) { + displayedMessagesMethod.invoke( + instanceNonNull(), + conversationId.toSnapUUID().instanceNonNull(), + messageId, + CallbackBuilder(getCallbackClass("Callback")) + .override("onSuccess") { onResult(null) } + .override("onError") { onResult(it.arg(0).toString()) }.build() + ) + } + + fun fetchMessage(conversationId: String, messageId: Long, onSuccess: (Message) -> Unit, onError: (error: String) -> Unit = {}) { + fetchMessage.invoke( + instanceNonNull(), + conversationId.toSnapUUID().instanceNonNull(), + messageId, + CallbackBuilder(getCallbackClass("FetchMessageCallback")) + .override("onFetchMessageComplete") { param -> + onSuccess(Message(param.arg(0))) + } + .override("onError") { + onError(it.arg(0).toString()) + }.build() + ) + } + + fun fetchMessageByServerId(conversationId: String, serverMessageId: Long, onSuccess: (Message) -> Unit, onError: (error: String) -> Unit) { + val serverMessageIdentifier = context.classCache.serverMessageIdentifier.dataBuilder { + set("mServerConversationId", conversationId.toSnapUUID().instanceNonNull()) + set("mServerMessageId", serverMessageId) + } + + fetchMessageByServerId.invoke( + instanceNonNull(), + serverMessageIdentifier, + CallbackBuilder(getCallbackClass("FetchMessageCallback")) + .override("onFetchMessageComplete") { param -> + onSuccess(Message(param.arg(0))) + } + .override("onError") { + onError(it.arg(0).toString()) + }.build() + ) + } + + fun fetchMessagesByServerIds(conversationId: String, serverMessageIds: List, onSuccess: (List) -> Unit, onError: (error: String) -> Unit) { + fetchMessagesByServerIds.invoke( + instanceNonNull(), + serverMessageIds.map { + CallbackBuilder.createEmptyObject(context.classCache.serverMessageIdentifier.constructors.first())?.apply { + setObjectField("mServerConversationId", conversationId.toSnapUUID().instanceNonNull()) + setObjectField("mServerMessageId", it) + } + }, + CallbackBuilder(getCallbackClass("FetchMessagesByServerIdsCallback")) + .override("onSuccess") { param -> + onSuccess(param.arg>(0).mapNotNull { + Message(it?.getObjectField("mMessage") ?: return@mapNotNull null) + }) + } + .override("onError") { + onError(it.arg(0).toString()) + }.build() + ) + } + + fun clearConversation(conversationId: String, onSuccess: () -> Unit, onError: (error: String) -> Unit) { + val callback = CallbackBuilder(getCallbackClass("Callback")) + .override("onSuccess") { onSuccess() } + .override("onError") { onError(it.arg(0).toString()) }.build() + clearConversation.invoke(instanceNonNull(), conversationId.toSnapUUID().instanceNonNull(), callback) + } + + fun getOneOnOneConversationIds(userIds: List, onSuccess: (List>) -> Unit, onError: (error: String) -> Unit) { + val callback = CallbackBuilder(getCallbackClass("GetOneOnOneConversationIdsCallback")) + .override("onSuccess") { param -> + onSuccess(param.arg>(0).map { + SnapUUID(it.getObjectField("mUserId")).toString() to SnapUUID(it.getObjectField("mConversationId")).toString() + }) + } + .override("onError") { onError(it.arg(0).toString()) }.build() + getOneOnOneConversationIds.invoke(instanceNonNull(), userIds.map { it.toSnapUUID().instanceNonNull() }.toMutableList(), callback) + } + + fun editMessage(conversationId: String, messageId: Long, content: ByteArray, onSuccess: () -> Unit, onError: (error: String) -> Unit) { + val editMessageMethod = instanceNonNull()::class.java.methods.first { it.name == "editMessage" } + editMessageMethod.invoke(instanceNonNull(), editMessageMethod.parameterTypes[0].dataBuilder { + set("mConversationId", conversationId.toSnapUUID().instanceNonNull()) + set("mMessageId", messageId) + }, editMessageMethod.parameterTypes[1].dataBuilder { + set("mContent", content) + set("mMentionInfo", null) + }, CallbackBuilder(getCallbackClass("Callback")) + .override("onSuccess") { onSuccess() } + .override("onError") { onError(it.arg(0).toString()) }.build() + ) + } + + fun isEditMessageSupported() = instanceNonNull()::class.java.methods.any { it.name == "editMessage" } + + fun dismissStreakRestore(conversationId: String, onSuccess: () -> Unit, onError: (error: String) -> Unit) { + val callback = CallbackBuilder(getCallbackClass("Callback")) + .override("onSuccess") { onSuccess() } + .override("onError") { onError(it.arg(0).toString()) }.build() + dismissStreakRestore.invoke(instanceNonNull(), conversationId.toSnapUUID().instanceNonNull(), callback) + } + + fun reactToMessage(conversationId: String, messageId: Long, emoji: String? = null, intentionType: Long? = null, onSuccess: () -> Unit, onError: (error: String) -> Unit) { + reactToMessageMethod.invoke( + instanceNonNull(), + conversationId.toSnapUUID().instanceNonNull(), + messageId, + reactToMessageMethod.parameterTypes[2].dataBuilder { + set("mEmoji", emoji) + set("mIntentionType", intentionType) + }, + reactToMessageMethod.parameterTypes[3].dataBuilder { + set("mMetricsMessageMediaType", "NO_MEDIA") + set("mMetricsMessageType", "TEXT") + set("mReactionSource", "NONE") + }, + CallbackBuilder(getCallbackClass("Callback")) + .override("onSuccess") { onSuccess() } + .override("onError") { onError(it.arg(0).toString()) }.build() + ) + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/Message.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/Message.kt new file mode 100644 index 0000000000..e58404f441 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/Message.kt @@ -0,0 +1,52 @@ +package me.rhunk.snapenhance.core.wrapper.impl + +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.data.MessageState +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper +import org.mozilla.javascript.annotations.JSGetter +import org.mozilla.javascript.annotations.JSSetter + + +fun ByteArray.getMessageText(contentType: ContentType): String? { + val protoReader by lazy { ProtoReader(this) } + return when (contentType) { + ContentType.CHAT -> protoReader.getString(2, 1) ?: "Failed to parse message" + ContentType.TINY_SNAP -> protoReader.getString(19, 1, 1) + ContentType.EXTERNAL_MEDIA -> protoReader.getString(7, 11, 1) + ContentType.SNAP -> protoReader.followPath(11, 5)?.run { + val captions = mutableListOf() + + eachBuffer(1) { + followPath(4) { + val caption = getString(3, 2, 1) + if (caption != null) { + captions.add(caption) + } + } + } + + captions.takeIf { it.isNotEmpty() }?.joinToString("\n") + } + else -> null + } +} + +class Message(obj: Any?) : AbstractWrapper(obj) { + @get:JSGetter @set:JSSetter + var orderKey by field("mOrderKey") + @get:JSGetter @set:JSSetter + var senderId by field("mSenderId") { SnapUUID(it) } + @get:JSGetter @set:JSSetter + var messageContent by field("mMessageContent") { MessageContent(it) } + @get:JSGetter @set:JSSetter + var messageDescriptor by field("mDescriptor") { MessageDescriptor(it) } + @get:JSGetter @set:JSSetter + var messageMetadata by field("mMetadata") { MessageMetadata(it) } + @get:JSGetter @set:JSSetter + var messageState by enum("mState", MessageState.COMMITTED) + + fun serialize(): String?{ + return messageContent?.content?.getMessageText(messageContent?.contentType ?: return null) + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageContent.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageContent.kt new file mode 100644 index 0000000000..d9ceb52f70 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageContent.kt @@ -0,0 +1,17 @@ +package me.rhunk.snapenhance.core.wrapper.impl + +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper +import org.mozilla.javascript.annotations.JSGetter +import org.mozilla.javascript.annotations.JSSetter + +class MessageContent(obj: Any?) : AbstractWrapper(obj) { + @get:JSGetter @set:JSSetter + var content by field("mContent") + @get:JSGetter @set:JSSetter + var quotedMessage by field("mQuotedMessage") { QuotedMessage(it) } + @get:JSGetter @set:JSSetter + var contentType by enum("mContentType", ContentType.UNKNOWN) + @get:JSGetter @set:JSSetter + var localMediaReferences by field>("mLocalMediaReferences") +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageDescriptor.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageDescriptor.kt new file mode 100644 index 0000000000..d1f04341ed --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageDescriptor.kt @@ -0,0 +1,11 @@ +package me.rhunk.snapenhance.core.wrapper.impl + +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper +import org.mozilla.javascript.annotations.JSGetter +import org.mozilla.javascript.annotations.JSSetter + +class MessageDescriptor(obj: Any?) : AbstractWrapper(obj) { + @get:JSGetter @set:JSSetter + var messageId by field("mMessageId") + val conversationId by field("mConversationId") { SnapUUID(it) } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageDestinations.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageDestinations.kt new file mode 100644 index 0000000000..b6221f284e --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageDestinations.kt @@ -0,0 +1,9 @@ +package me.rhunk.snapenhance.core.wrapper.impl + +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper + +class MessageDestinations(obj: Any) : AbstractWrapper(obj){ + var conversations by field("mConversations", uuidArrayListMapper) + var stories by field>("mStories") + var mPhoneNumbers by field>("mPhoneNumbers") +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageMetadata.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageMetadata.kt new file mode 100644 index 0000000000..804f44c7ae --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageMetadata.kt @@ -0,0 +1,42 @@ +package me.rhunk.snapenhance.core.wrapper.impl + +import me.rhunk.snapenhance.common.data.PlayableSnapState +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper +import org.mozilla.javascript.annotations.JSGetter +import org.mozilla.javascript.annotations.JSSetter + +class MessageMetadata(obj: Any?) : AbstractWrapper(obj){ + @get:JSGetter @set:JSSetter + var createdAt by field("mCreatedAt") + @get:JSGetter @set:JSSetter + var readAt by field("mReadAt") + @get:JSGetter @set:JSSetter + var playableSnapState by enum("mPlayableSnapState", PlayableSnapState.PLAYABLE) + + @get:JSGetter @set:JSSetter + var savedBy by field("mSavedBy", uuidArrayListMapper) + @get:JSGetter @set:JSSetter + var openedBy by field("mOpenedBy", uuidArrayListMapper) + @get:JSGetter @set:JSSetter + var seenBy by field("mSeenBy", uuidArrayListMapper) + @get:JSGetter @set:JSSetter + var screenRecordedBy by field("mScreenRecordedBy", uuidArrayListMapper) + @get:JSGetter @set:JSSetter + var screenShottedBy by field("mScreenShottedBy", uuidArrayListMapper) + @get:JSGetter @set:JSSetter + var reactions by field("mReactions") { + (it as ArrayList<*>).map { i -> UserIdToReaction(i) }.toMutableList() + } + @get:JSGetter @set:JSSetter + var isSaveable by field("mIsSaveable") + @get:JSGetter @set:JSSetter + var isEditable by field("mIsEditable") + @get:JSGetter @set:JSSetter + var isEdited by field("mIsEdited") + @get:JSGetter @set:JSSetter + var isErasable by field("mIsErasable") + @get:JSGetter @set:JSSetter + var isFriendLinkPending by field("mIsFriendLinkPending") + @get:JSGetter @set:JSSetter + var isReactable by field("mIsReactable") +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/QuotedMessage.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/QuotedMessage.kt new file mode 100644 index 0000000000..16242e3622 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/QuotedMessage.kt @@ -0,0 +1,13 @@ +package me.rhunk.snapenhance.core.wrapper.impl + +import me.rhunk.snapenhance.common.data.QuotedMessageContentStatus +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper +import org.mozilla.javascript.annotations.JSGetter +import org.mozilla.javascript.annotations.JSSetter + +class QuotedMessage(obj: Any?) : AbstractWrapper(obj) { + @get:JSGetter @set:JSSetter + var content by field("mContent") { QuotedMessageContent(it) } + @get:JSGetter + val status by enum("mStatus", QuotedMessageContentStatus.UNKNOWN) +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/QuotedMessageContent.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/QuotedMessageContent.kt new file mode 100644 index 0000000000..b89ede1695 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/QuotedMessageContent.kt @@ -0,0 +1,10 @@ +package me.rhunk.snapenhance.core.wrapper.impl + +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper +import org.mozilla.javascript.annotations.JSGetter +import org.mozilla.javascript.annotations.JSSetter + +class QuotedMessageContent(obj: Any?) : AbstractWrapper(obj) { + @get:JSGetter @set:JSSetter + var messageId by field("mMessageId") +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/ScSize.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ScSize.kt similarity index 87% rename from app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/ScSize.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ScSize.kt index 3cba467fd8..86d45a42fe 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/ScSize.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ScSize.kt @@ -1,6 +1,6 @@ -package me.rhunk.snapenhance.data.wrapper.impl +package me.rhunk.snapenhance.core.wrapper.impl -import me.rhunk.snapenhance.data.wrapper.AbstractWrapper +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper class ScSize( obj: Any? diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/SnapUUID.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/SnapUUID.kt new file mode 100644 index 0000000000..a5f32a57cf --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/SnapUUID.kt @@ -0,0 +1,58 @@ +package me.rhunk.snapenhance.core.wrapper.impl + +import me.rhunk.snapenhance.core.SnapEnhance +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper +import java.nio.ByteBuffer +import java.util.UUID + +fun String.toSnapUUID() = SnapUUID(this) +fun ByteArray.toSnapUUID() = SnapUUID(this) + +fun UUID.toBytes(): ByteArray = + ByteBuffer.allocate(16).let { + it.putLong(this.mostSignificantBits) + it.putLong(this.leastSignificantBits) + it.array() + } + +class SnapUUID( + private val obj: Any? +) : AbstractWrapper(obj) { + private val uuidBytes by lazy { + when { + obj is String -> { + UUID.fromString(obj).toBytes() + } + obj is ByteArray -> { + assert(obj.size == 16) + obj + } + obj is UUID -> obj.toBytes() + SnapEnhance.classCache.snapUUID.isInstance(obj) -> { + obj?.getObjectField("mId") as ByteArray + } + else -> ByteArray(16) + } + } + + private val uuidString by lazy { ByteBuffer.wrap(uuidBytes).run { UUID(long, long) }.toString() } + + override var instance: Any? + set(_) {} + get() = SnapEnhance.classCache.snapUUID.getConstructor(ByteArray::class.java).newInstance(uuidBytes) + + override fun toString(): String { + return uuidString + } + + fun toBytes() = uuidBytes + + override fun equals(other: Any?): Boolean { + return other is SnapUUID && other.uuidBytes.contentEquals(this.uuidBytes) + } + + override fun hashCode(): Int { + return uuidBytes.contentHashCode() + } +} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/Snapchatter.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/Snapchatter.kt new file mode 100644 index 0000000000..38619c59cd --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/Snapchatter.kt @@ -0,0 +1,28 @@ +package me.rhunk.snapenhance.core.wrapper.impl + +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper +import org.mozilla.javascript.annotations.JSGetter +import org.mozilla.javascript.annotations.JSSetter + + +class BitmojiInfo(obj: Any?) : AbstractWrapper(obj) { + @get:JSGetter @set:JSSetter + var avatarId by field("mAvatarId") + @get:JSGetter @set:JSSetter + var backgroundId by field("mBackgroundId") + @get:JSGetter @set:JSSetter + var sceneId by field("mSceneId") + @get:JSGetter @set:JSSetter + var selfieId by field("mSelfieId") +} + +class Snapchatter(obj: Any?) : AbstractWrapper(obj) { + @get:JSGetter + val bitmojiInfo by field("mBitmojiInfo") + @get:JSGetter @set:JSSetter + var displayName by field("mDisplayName") + @get:JSGetter @set:JSSetter + var userId by field("mUserId") { SnapUUID(it) } + @get:JSGetter @set:JSSetter + var username by field("mUsername") +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/UserIdToReaction.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/UserIdToReaction.kt new file mode 100644 index 0000000000..1bf67f5118 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/UserIdToReaction.kt @@ -0,0 +1,21 @@ +package me.rhunk.snapenhance.core.wrapper.impl + +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.util.ktx.setObjectField +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper +import org.mozilla.javascript.annotations.JSGetter +import org.mozilla.javascript.annotations.JSSetter + +class UserIdToReaction(obj: Any?) : AbstractWrapper(obj) { + @get:JSGetter @set:JSSetter + var userId by field("mUserId") { SnapUUID(it) } + @get:JSGetter @set:JSSetter + var reactionId get() = (instanceNonNull().getObjectField("mReaction") + ?.getObjectField("mReactionContent") + ?.getObjectField("mIntentionType") as Long?) ?: -1 + set(value) { + instanceNonNull().getObjectField("mReaction") + ?.getObjectField("mReactionContent") + ?.setObjectField("mIntentionType", value) + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/composer/ComposerContext.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/composer/ComposerContext.kt new file mode 100644 index 0000000000..0396e8ed56 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/composer/ComposerContext.kt @@ -0,0 +1,24 @@ +package me.rhunk.snapenhance.core.wrapper.impl.composer + +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper +import java.lang.ref.WeakReference +import java.lang.reflect.Proxy + +class ComposerContext(obj: Any): AbstractWrapper(obj) { + val componentPath by field("componentPath") + val viewModel by field("innerViewModel") + val moduleName by field("moduleName") + val componentContext by field>("componentContext") + + fun enqueueNextRenderCallback(callback: () -> Unit) { + val method = instanceNonNull()::class.java.methods.firstOrNull { + it.name == "onNextLayout" + } + method?.invoke(instanceNonNull(), Proxy.newProxyInstance( + instanceNonNull()::class.java.classLoader, + arrayOf(method.parameterTypes[0]) + ) { _, _, _ -> + callback() + }) + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/composer/ComposerMarshaller.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/composer/ComposerMarshaller.kt new file mode 100644 index 0000000000..10ecdb8c8a --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/composer/ComposerMarshaller.kt @@ -0,0 +1,13 @@ +package me.rhunk.snapenhance.core.wrapper.impl.composer + +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper + +class ComposerMarshaller(obj: Any): AbstractWrapper(obj) { + private val getUntypedMethod by lazy { instanceNonNull().javaClass.methods.first { it.name == "getUntyped" } } + private val getSizeMethod by lazy { instanceNonNull().javaClass.methods.first { it.name == "getSize" } } + private val pushUntypedMethod by lazy { instanceNonNull().javaClass.methods.first { it.name == "pushUntyped" } } + + fun getUntyped(index: Int): Any? = getUntypedMethod.invoke(instanceNonNull(), index) + fun getSize() = getSizeMethod.invoke(instanceNonNull()) as Int + fun pushUntyped(value: Any?): Any? = pushUntypedMethod.invoke(instanceNonNull(), value) +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/composer/ComposerViewNode.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/composer/ComposerViewNode.kt new file mode 100644 index 0000000000..c4c2ec3d02 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/composer/ComposerViewNode.kt @@ -0,0 +1,58 @@ +package me.rhunk.snapenhance.core.wrapper.impl.composer + +import me.rhunk.snapenhance.core.SnapEnhance +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper +import java.lang.reflect.Proxy + +fun createComposerFunction(block: (args: Array<*>) -> Any?): Any { + return SnapEnhance.classCache.composerFunctionActionAdapter.constructors.first().newInstance( + Proxy.newProxyInstance( + SnapEnhance.classCache.composerAction.classLoader, + arrayOf(SnapEnhance.classCache.composerAction), + ) { _, _, args -> + block(args?.get(0) as Array<*>) + } + ) +} + +class ComposerViewNode(obj: Long) : AbstractWrapper(obj) { + companion object { + fun fromNode(composerViewNode: Any?): ComposerViewNode? { + return (composerViewNode?.javaClass?.methods?.firstOrNull { + it.name == "getNativeHandle" + }?.invoke(composerViewNode) as? Long)?.let { ComposerViewNode(it) } ?: return null + } + } + + fun getAttribute(name: String): Any? { + return SnapEnhance.classCache.nativeBridge.methods.firstOrNull { + it.name == "getValueForAttribute" + }?.invoke(null, instanceNonNull(), name) + } + + fun setAttribute(name: String, value: Any) { + SnapEnhance.classCache.nativeBridge.methods.firstOrNull { + it.name == "setValueForAttribute" + }?.invoke(null, instanceNonNull(), name, value, false) + } + + fun getChildren(): List { + return ((SnapEnhance.classCache.nativeBridge.methods.firstOrNull { + it.name == "getRetainedViewNodeChildren" + }?.invoke(null, instanceNonNull(), 1))!! as? LongArray)?.map { + ComposerViewNode(it) + } ?: emptyList() + } + + fun getClassName(): String { + return SnapEnhance.classCache.nativeBridge.methods.firstOrNull { + it.name == "getViewClassName" + }?.invoke(null, instanceNonNull()).toString() + } + + override fun toString(): String { + return SnapEnhance.classCache.nativeBridge.methods.firstOrNull { + it.name == "getViewNodeDebugDescription" + }?.invoke(null, instanceNonNull()).toString() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/EncryptionWrapper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/EncryptionWrapper.kt similarity index 82% rename from app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/EncryptionWrapper.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/EncryptionWrapper.kt index 981d2226be..f4e08da76d 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/EncryptionWrapper.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/EncryptionWrapper.kt @@ -1,6 +1,7 @@ -package me.rhunk.snapenhance.data.wrapper.impl.media +package me.rhunk.snapenhance.core.wrapper.impl.media -import me.rhunk.snapenhance.data.wrapper.AbstractWrapper +import me.rhunk.snapenhance.common.data.download.MediaEncryptionKeyPair +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper import java.io.InputStream import java.io.OutputStream import java.lang.reflect.Field @@ -9,6 +10,8 @@ import javax.crypto.CipherInputStream import javax.crypto.CipherOutputStream import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi class EncryptionWrapper(instance: Any?) : AbstractWrapper(instance) { fun decrypt(data: ByteArray?): ByteArray { @@ -71,3 +74,8 @@ class EncryptionWrapper(instance: Any?) : AbstractWrapper(instance) { searchByteArrayField(16)[instance] as ByteArray } } + + +@OptIn(ExperimentalEncodingApi::class) +fun EncryptionWrapper.toKeyPair() + = MediaEncryptionKeyPair(Base64.UrlSafe.encode(this.keySpec), Base64.UrlSafe.encode(this.ivKeyParameterSpec)) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/MediaInfo.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/MediaInfo.kt similarity index 82% rename from app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/MediaInfo.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/MediaInfo.kt index 5abe9bfea2..70ae295506 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/MediaInfo.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/MediaInfo.kt @@ -1,8 +1,8 @@ -package me.rhunk.snapenhance.data.wrapper.impl.media +package me.rhunk.snapenhance.core.wrapper.impl.media import android.os.Parcelable -import me.rhunk.snapenhance.data.wrapper.AbstractWrapper -import me.rhunk.snapenhance.util.getObjectField +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper import java.lang.reflect.Field @@ -16,7 +16,7 @@ class MediaInfo(obj: Any?) : AbstractWrapper(obj) { init { instance?.let { if (it is List<*>) { - if (it.size == 0) { + if (it.isEmpty()) { throw RuntimeException("MediaInfo is empty") } instance = it[0]!! diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/dash/LongformVideoPlaylistItem.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/dash/LongformVideoPlaylistItem.kt similarity index 74% rename from app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/dash/LongformVideoPlaylistItem.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/dash/LongformVideoPlaylistItem.kt index 8610af3938..1456c935fc 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/dash/LongformVideoPlaylistItem.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/dash/LongformVideoPlaylistItem.kt @@ -1,6 +1,6 @@ -package me.rhunk.snapenhance.data.wrapper.impl.media.dash +package me.rhunk.snapenhance.core.wrapper.impl.media.dash -import me.rhunk.snapenhance.data.wrapper.AbstractWrapper +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper class LongformVideoPlaylistItem(obj: Any?) : AbstractWrapper(obj) { private val chapterList by lazy { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/dash/SnapChapter.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/dash/SnapChapter.kt similarity index 77% rename from app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/dash/SnapChapter.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/dash/SnapChapter.kt index 54b5e23a3e..acef32389f 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/dash/SnapChapter.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/dash/SnapChapter.kt @@ -1,6 +1,6 @@ -package me.rhunk.snapenhance.data.wrapper.impl.media.dash +package me.rhunk.snapenhance.core.wrapper.impl.media.dash -import me.rhunk.snapenhance.data.wrapper.AbstractWrapper +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper class SnapChapter (obj: Any?) : AbstractWrapper(obj) { val snapId by lazy { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/dash/SnapPlaylistItem.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/dash/SnapPlaylistItem.kt similarity index 66% rename from app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/dash/SnapPlaylistItem.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/dash/SnapPlaylistItem.kt index d490818d4e..5fa736a02f 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/dash/SnapPlaylistItem.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/dash/SnapPlaylistItem.kt @@ -1,6 +1,6 @@ -package me.rhunk.snapenhance.data.wrapper.impl.media.dash +package me.rhunk.snapenhance.core.wrapper.impl.media.dash -import me.rhunk.snapenhance.data.wrapper.AbstractWrapper +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper class SnapPlaylistItem (obj: Any?) : AbstractWrapper(obj) { val snapId by lazy { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/opera/Layer.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/opera/Layer.kt new file mode 100644 index 0000000000..665852975d --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/opera/Layer.kt @@ -0,0 +1,19 @@ +package me.rhunk.snapenhance.core.wrapper.impl.media.opera + +import me.rhunk.snapenhance.common.util.ktx.findFieldsToString +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper + +class Layer(obj: Any?) : AbstractWrapper(obj) { + val paramMap: ParamMap + get() { + val layerControllerField = instanceNonNull()::class.java.findFieldsToString(instance, once = true) { _, value -> + value.contains("OperaPageModel") + }.firstOrNull() ?: throw RuntimeException("Could not find layerController field") + + val paramsMapHashMap = layerControllerField.type.findFieldsToString(layerControllerField[instance], once = true) { _, value -> + value.contains("OperaPageModel") + }.firstOrNull() ?: throw RuntimeException("Could not find paramsMap field") + + return ParamMap(paramsMapHashMap[layerControllerField[instance]]!!) + } +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/ParamMap.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/opera/ParamMap.kt similarity index 62% rename from app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/ParamMap.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/opera/ParamMap.kt index 7605e563f8..a3a75910cd 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/ParamMap.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/opera/ParamMap.kt @@ -1,18 +1,17 @@ -package me.rhunk.snapenhance.data.wrapper.impl.media.opera +package me.rhunk.snapenhance.core.wrapper.impl.media.opera -import me.rhunk.snapenhance.data.wrapper.AbstractWrapper -import me.rhunk.snapenhance.util.ReflectionHelper -import me.rhunk.snapenhance.util.getObjectField +import me.rhunk.snapenhance.common.util.ktx.findFields +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper import java.lang.reflect.Field import java.util.concurrent.ConcurrentHashMap @Suppress("UNCHECKED_CAST") class ParamMap(obj: Any?) : AbstractWrapper(obj) { - private val paramMapField: Field by lazy { - ReflectionHelper.searchFieldTypeInSuperClasses( - instanceNonNull().javaClass, - ConcurrentHashMap::class.java - )!! + val paramMapField: Field by lazy { + instanceNonNull()::class.java.findFields(once = true) { + it.type == ConcurrentHashMap::class.java || runCatching { it.get(instance) }.getOrNull() is ConcurrentHashMap<*, *> + }.firstOrNull() ?: throw RuntimeException("Could not find paramMap field") } val concurrentHashMap: ConcurrentHashMap diff --git a/gradle.properties b/gradle.properties index 022338b784..336f86be8b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,25 +1,6 @@ -# Project-wide Gradle settings. -# IDE (e.g. Android Studio) users: -# Gradle settings configured through the IDE *will override* -# any settings specified in this file. -# For more details on how to configure your build environment visit -# http://www.gradle.org/docs/current/userguide/build_environment.html -# Specifies the JVM arguments used for the daemon process. -# The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -# When configured, Gradle will run in incubating parallel mode. -# This option should only be used with decoupled projects. More details, visit -# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects -# org.gradle.parallel=true -# AndroidX package structure to make it clearer which packages are bundled with the -# Android operating system, and which are packaged with your app"s APK -# https://developer.android.com/topic/libraries/support-library/androidx-rn +org.gradle.jvmargs=-Xms12G -Xmx12G -Dfile.encoding=UTF-8 +org.gradle.parallel=true android.useAndroidX=true -# Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official -# Enables namespacing of each library's R class so that its R class includes only the -# resources declared in the library itself and none from the library's dependencies, -# thereby reducing the size of the R class for that library android.nonTransitiveRClass=true -android.defaults.buildfeatures.buildconfig=true -android.nonFinalResIds=false \ No newline at end of file +android.nonFinalResIds=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000000..c19ded50d2 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,68 @@ +[versions] +agp = "8.4.2" +colorpicker-compose = "1.0.8" +libsu = "5.2.2" +guava = "33.2.1-jre" +jsoup = "1.17.2" +kotlin = "2.0.0" +compose-compiler = "2.0.21" +kotlinx-coroutines-android = "1.8.1" +activity-ktx = "1.9.3" +androidx-documentfile = "1.1.0-alpha01" +coil-compose = "2.6.0" +navigation-compose = "2.8.5" +osmdroid-android = "6.1.20" +recyclerview = "1.3.2" +compose-bom = "2024.12.01" # make sure all ui components are fully working after updating this +#noinspection GradleDependency +bcprov-jdk18on = "1.78.1" +dexlib2 = "3.0.9" +gson = "2.11.0" +junit = "5.10.2" +material3 = "1.3.1" +okhttp = "5.0.0-alpha.14" +rhino = "1.7.15" +rhino-android = "1.6.0" +rust-android = "0.9.6" +hiddenapibypass = "4.3" +smart-exception-java = "0.2.1" + +[libraries] +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } +androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity-ktx" } +androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "androidx-documentfile" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } +androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core" } +androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } +androidx-material-ripple = { module = "androidx.compose.material:material-ripple" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" } +androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +apksig = { module = "com.android.tools.build:apksig", version.ref = "agp" } +bcprov-jdk18on = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bcprov-jdk18on" } +coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil-compose" } +coil-video = { module = "io.coil-kt:coil-video", version.ref = "coil-compose" } +colorpicker-compose = { module = "com.github.skydoves:colorpicker-compose", version.ref = "colorpicker-compose" } +libsu = { module = "com.github.topjohnwu.libsu:core", version.ref = "libsu" } +coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinx-coroutines-android" } +dexlib2 = { group = "com.android.tools.smali", name = "smali-dexlib2", version.ref = "dexlib2" } +gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } +guava = { module = "com.google.guava:guava", version.ref = "guava" } +jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } +junit = { group = "org.junit.vintage", name = "junit-vintage-engine", version.ref = "junit" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +osmdroid-android = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid-android" } +hiddenapibypass = { module = "org.lsposed.hiddenapibypass:hiddenapibypass", version.ref = "hiddenapibypass" } +recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" } +rhino = { module = "org.mozilla:rhino", version.ref = "rhino" } +rhino-android = { group = "com.faendir.rhino", name = "rhino-android", version.ref = "rhino-android" } +smart-exception-java = { module = "com.arthenica:smart-exception-java", version.ref = "smart-exception-java" } + +[plugins] +androidApplication = { id = "com.android.application", version.ref = "agp" } +androidLibrary = { id = "com.android.library", version.ref = "agp" } +kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "compose-compiler" } +rust-android = { id = "org.mozilla.rust-android-gradle.rust-android", version.ref = "rust-android" } + +[bundles] \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c023..e6441136f3 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 63c83be0a7..dab2a015b8 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Fri May 12 21:23:16 CEST 2023 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip distributionPath=wrapper/dists -zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists \ No newline at end of file diff --git a/gradlew b/gradlew index 4f906e0c81..b740cf1339 100644 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 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. @@ -17,67 +17,99 @@ # ############################################################################## -## -## Gradle start up script for UN*X -## +# +# 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 -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +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 -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# 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"' +# 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 "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +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 - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar @@ -87,9 +119,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 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" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,88 +130,120 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + 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" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +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 -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -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" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + 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 - i=`expr $i + 1` + # 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 - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# 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, 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" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# 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" "$@" diff --git a/gradlew.bat b/gradlew.bat index 107acd32c4..25da30dbde 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +25,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,13 +41,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +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 @@ -56,11 +57,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +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 @@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +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! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +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 diff --git a/manager/.gitignore b/manager/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/manager/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/manager/build.gradle.kts b/manager/build.gradle.kts new file mode 100644 index 0000000000..e0c3c9cc7f --- /dev/null +++ b/manager/build.gradle.kts @@ -0,0 +1,88 @@ +import com.android.build.gradle.internal.api.BaseVariantOutputImpl + +plugins { + alias(libs.plugins.androidApplication) + alias(libs.plugins.kotlinAndroid) + alias(libs.plugins.compose.compiler) + id("kotlin-parcelize") +} + +android { + namespace = rootProject.ext["applicationId"].toString() + ".manager" + compileSdk = 34 + + androidResources { + noCompress += ".so" + } + + buildFeatures { + compose = true + buildConfig = true + } + + defaultConfig { + buildConfigField("String", "APPLICATION_ID", "\"${rootProject.ext["applicationId"]}\"") + applicationId = rootProject.ext["applicationId"].toString() + ".manager" + versionCode = 1 + versionName = "1.0.0" + minSdk = 28 + targetSdk = 34 + multiDexEnabled = true + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles += file("proguard-rules.pro") + } + debug { + (properties["debug_flavor"] == null).also { + isDebuggable = !it + isMinifyEnabled = it + isShrinkResources = it + } + proguardFiles += file("proguard-rules.pro") + } + } + + applicationVariants.all { + outputs.map { it as BaseVariantOutputImpl }.forEach { outputVariant -> + outputVariant.outputFileName = "manager.apk" + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + kotlinOptions { + jvmTarget = "21" + } +} + +configurations { + all { + resolutionStrategy { + exclude(group = "com.google.guava", module = "listenablefuture") + } + } +} + +dependencies { + implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar")))) + implementation(libs.libsu) + implementation(libs.guava) + implementation(libs.apksig) + implementation(libs.dexlib2) + implementation(libs.gson) + implementation(libs.jsoup) + implementation(libs.okhttp) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.material3) + implementation(libs.androidx.activity.ktx) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.material.icons.core) + implementation(libs.androidx.material.ripple) + implementation(libs.androidx.material.icons.extended) +} \ No newline at end of file diff --git a/manager/libs/ManifestEditor-1.0.2.jar b/manager/libs/ManifestEditor-1.0.2.jar new file mode 100644 index 0000000000..7dc59a7b80 Binary files /dev/null and b/manager/libs/ManifestEditor-1.0.2.jar differ diff --git a/manager/libs/apkzlib.jar b/manager/libs/apkzlib.jar new file mode 100644 index 0000000000..ca2dea2e69 Binary files /dev/null and b/manager/libs/apkzlib.jar differ diff --git a/manager/proguard-rules.pro b/manager/proguard-rules.pro new file mode 100644 index 0000000000..717606da5c --- /dev/null +++ b/manager/proguard-rules.pro @@ -0,0 +1,5 @@ +-dontwarn com.google.errorprone.annotations.** +-dontwarn com.google.auto.value.** +-keep enum * { *; } +-keep class com.android.tools.smali.dexlib2.** { *; } +-keep class me.rhunk.snapenhance.manager.ui.tab.** { *; } \ No newline at end of file diff --git a/manager/src/main/AndroidManifest.xml b/manager/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..636d87d7f6 --- /dev/null +++ b/manager/src/main/AndroidManifest.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/manager/src/main/assets/lspatch/dexes/loader.dex b/manager/src/main/assets/lspatch/dexes/loader.dex new file mode 100644 index 0000000000..5e06ac4410 Binary files /dev/null and b/manager/src/main/assets/lspatch/dexes/loader.dex differ diff --git a/manager/src/main/assets/lspatch/dexes/metaloader.dex b/manager/src/main/assets/lspatch/dexes/metaloader.dex new file mode 100644 index 0000000000..57d0f47ea1 Binary files /dev/null and b/manager/src/main/assets/lspatch/dexes/metaloader.dex differ diff --git a/manager/src/main/assets/lspatch/keystore.jks b/manager/src/main/assets/lspatch/keystore.jks new file mode 100644 index 0000000000..581ea8e0e0 Binary files /dev/null and b/manager/src/main/assets/lspatch/keystore.jks differ diff --git a/manager/src/main/assets/lspatch/so/arm64-v8a/liblspatch.so b/manager/src/main/assets/lspatch/so/arm64-v8a/liblspatch.so new file mode 100644 index 0000000000..b8e6a3133e Binary files /dev/null and b/manager/src/main/assets/lspatch/so/arm64-v8a/liblspatch.so differ diff --git a/manager/src/main/assets/lspatch/so/armeabi-v7a/liblspatch.so b/manager/src/main/assets/lspatch/so/armeabi-v7a/liblspatch.so new file mode 100644 index 0000000000..19add99716 Binary files /dev/null and b/manager/src/main/assets/lspatch/so/armeabi-v7a/liblspatch.so differ diff --git a/manager/src/main/assets/lspatch/version.txt b/manager/src/main/assets/lspatch/version.txt new file mode 100644 index 0000000000..5d4294b912 --- /dev/null +++ b/manager/src/main/assets/lspatch/version.txt @@ -0,0 +1 @@ +0.5.1 \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/APKMirror.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/APKMirror.kt new file mode 100644 index 0000000000..9fd3fb25e0 --- /dev/null +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/APKMirror.kt @@ -0,0 +1,80 @@ +package me.rhunk.snapenhance.manager.data + +import android.os.Parcelable +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import okhttp3.OkHttpClient +import okhttp3.Request +import org.jsoup.Jsoup +import kotlin.math.absoluteValue + +@Parcelize +data class DownloadItem( + val title: String, + val releaseDate: String, + val downloadPage: String +): Parcelable { + @IgnoredOnParcel + val shortTitle = title.substringBefore("(").trim() + @IgnoredOnParcel + val hash = (title + releaseDate + downloadPage).hashCode().absoluteValue.toString(16) + @IgnoredOnParcel + val isBeta = title.contains("Beta", ignoreCase = true) +} + +class APKMirror { + val okhttpClient = OkHttpClient.Builder().addInterceptor { + it.proceed( + it.request().newBuilder() + .addHeader("User-Agent", System.getProperty("http.agent")!!) + .build() + ) + }.build() + + companion object { + private const val BASE_URL = "https://www.apkmirror.com" + private const val FETCH_BUILD_URL = "$BASE_URL/apk/snap-inc/snapchat/variant-%7B%22arches_slug%22%3A%5B%22arm64-v8a%22%2C%22armeabi-v7a%22%5D%2C%22dpis_slug%22%3A%5B%22nodpi%22%5D%7D/page/{page}/" + } + + fun fetchDownloadLink(downloadPageUri: String): String? { + okhttpClient.newCall( + Request.Builder() + .url("$BASE_URL$downloadPageUri") + .build() + ).execute().use { response -> + if (!response.isSuccessful) return null + val finalDownloadPageUri = Jsoup.parse(response.body.string()).getElementsByClass("downloadButton").first()?.attr("href") + + okhttpClient.newCall( + Request.Builder() + .url("$BASE_URL$finalDownloadPageUri") + .build() + ).execute().use { response2 -> + if (!response2.isSuccessful) return null + val document = Jsoup.parse(response2.body.string()) + val downloadLink = document.getElementById("download-link")?.attr("href") ?: return null + return BASE_URL + downloadLink + } + } + } + + fun fetchSnapchatVersions(page: Int = 1): List? { + val versions = mutableListOf() + okhttpClient.newCall( + Request.Builder() + .url(FETCH_BUILD_URL.replace("{page}", page.toString())) + .build() + ).execute().use { response -> + if (!response.isSuccessful) return null + val document = Jsoup.parse(response.body.string()) + document.getElementById("primary")?.getElementsByClass("appRow")?.forEach { app -> + val title = app.getElementsByTag("h5").first()?.attr("title") ?: return@forEach + val releaseDate = app.getElementsByClass("dateyear_utc").attr("data-utcdate") ?: return@forEach + val downloadPage = app.getElementsByClass("downloadLink").first()?.attr("href") ?: return@forEach + + versions.add(DownloadItem(title, releaseDate, downloadPage)) + } + } + return versions + } +} \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/SharedConfig.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/SharedConfig.kt new file mode 100644 index 0000000000..05d82240de --- /dev/null +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/SharedConfig.kt @@ -0,0 +1,30 @@ +package me.rhunk.snapenhance.manager.data + +import android.content.Context +import me.rhunk.snapenhance.manager.BuildConfig + +class SharedConfig( + context: Context +) { + private val sharedPreferences = context.getSharedPreferences("snapenhance", Context.MODE_PRIVATE) + + val apkCache by lazy { + context.cacheDir.resolve("snapchat_apk_cache").also { + if (!it.exists()) it.mkdirs() + } + } + + var snapchatPackageName get() = sharedPreferences.getString("snapchatPackageName", "com.snapchat.android")?.takeIf { it.isNotEmpty() } ?: "com.snapchat.android" + set(value) = sharedPreferences.edit().putString("snapchatPackageName", value).apply() + + var snapEnhancePackageName get() = sharedPreferences.getString("snapEnhancePackageName", BuildConfig.APPLICATION_ID)?.takeIf { it.isNotEmpty() } ?: BuildConfig.APPLICATION_ID + set(value) = sharedPreferences.edit().putString("snapEnhancePackageName", value).apply() + var enableRepackage get() = sharedPreferences.getBoolean("enableRepackage", false) + set(value) = sharedPreferences.edit().putBoolean("enableRepackage", value).apply() + + var useRootInstaller get() = sharedPreferences.getBoolean("useRootInstaller", false) + set(value) = sharedPreferences.edit().putBoolean("useRootInstaller", value).apply() + + var obfuscateLSPatch get() = sharedPreferences.getBoolean("obfuscateLSPatch", false) + set(value) = sharedPreferences.edit().putBoolean("obfuscateLSPatch", value).apply() +} \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/download/InstallStage.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/download/InstallStage.kt new file mode 100644 index 0000000000..e93d8e3b6c --- /dev/null +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/download/InstallStage.kt @@ -0,0 +1,9 @@ +package me.rhunk.snapenhance.manager.data.download + +enum class InstallStage { + DOWNLOADING, + UNINSTALLING, + INSTALLING, + DONE, + ERROR; +} \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/download/SEArtifact.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/download/SEArtifact.kt new file mode 100644 index 0000000000..9193328a87 --- /dev/null +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/download/SEArtifact.kt @@ -0,0 +1,11 @@ +package me.rhunk.snapenhance.manager.data.download + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class SEArtifact( + val fileName: String, + val size: Long, + val downloadUrl: String, +) : Parcelable \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/download/SEVersion.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/download/SEVersion.kt new file mode 100644 index 0000000000..8fe6a33809 --- /dev/null +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/download/SEVersion.kt @@ -0,0 +1,7 @@ +package me.rhunk.snapenhance.manager.data.download + +data class SEVersion( + val versionName: String, + val releaseDate: String, + val downloadAssets: Map, +) \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/LSPatch.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/LSPatch.kt new file mode 100644 index 0000000000..2ac11a9cfb --- /dev/null +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/LSPatch.kt @@ -0,0 +1,238 @@ +package me.rhunk.snapenhance.manager.patch + +import android.content.Context +import com.android.tools.build.apkzlib.zip.AlignmentRules +import com.android.tools.build.apkzlib.zip.ZFile +import com.android.tools.build.apkzlib.zip.ZFileOptions +import com.google.gson.Gson +import com.wind.meditor.core.ManifestEditor +import com.wind.meditor.property.AttributeItem +import com.wind.meditor.property.ModificationProperty +import me.rhunk.snapenhance.manager.patch.config.Constants.PROXY_APP_COMPONENT_FACTORY +import me.rhunk.snapenhance.manager.patch.config.PatchConfig +import me.rhunk.snapenhance.manager.patch.util.ApkSignatureHelper +import me.rhunk.snapenhance.manager.patch.util.ApkSignatureHelper.provideSigningExtension +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.zip.ZipFile +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.random.Random + + +//https://github.com/LSPosed/LSPatch/blob/master/patch/src/main/java/org/lsposed/patch/LSPatch.java +class LSPatch( + private val context: Context, + private val modules: Map, //packageName -> file + private val obfuscate: Boolean, + private val printLog: (Any) -> Unit +) { + + private fun patchManifest(data: ByteArray, lspatchMetadata: Pair): ByteArray { + val property = ModificationProperty() + + property.addApplicationAttribute(AttributeItem("appComponentFactory", PROXY_APP_COMPONENT_FACTORY)) + property.addMetaData(ModificationProperty.MetaData(lspatchMetadata.first, lspatchMetadata.second)) + + return ByteArrayOutputStream().apply { + ManifestEditor(ByteArrayInputStream(data), this, property).processManifest() + flush() + close() + }.toByteArray() + } + + private fun resignApk(inputApkFile: File, outputFile: File) { + printLog("Resigning ${inputApkFile.absolutePath} to ${outputFile.absolutePath}") + val dstZFile = ZFile.openReadWrite(outputFile, ZFileOptions()) + val inZFile = ZFile.openReadOnly(inputApkFile) + + inZFile.entries().forEach { entry -> + dstZFile.add(entry.centralDirectoryHeader.name, entry.open()) + } + + // sign apk + runCatching { + provideSigningExtension(context.assets.open("lspatch/keystore.jks")).register(dstZFile) + }.onFailure { + throw Exception("Failed to sign apk", it) + } + + dstZFile.realign() + dstZFile.close() + inZFile.close() + printLog("Done") + } + + private fun uniqueHash(): String { + return Random.nextBytes(Random.nextInt(5, 10)).joinToString("") { "%02x".format(it) } + } + + @Suppress("UNCHECKED_CAST") + @OptIn(ExperimentalEncodingApi::class) + private fun patchApk(inputApkFile: File, outputFile: File) { + printLog("Patching ${inputApkFile.absolutePath} to ${outputFile.absolutePath}") + + val obfuscationCacheFolder = File(context.cacheDir, "lspatch").apply { + if (exists()) deleteRecursively() + mkdirs() + } + val lspatchObfuscation = LSPatchObfuscation(obfuscationCacheFolder) { printLog(it) } + val dexObfuscationConfig = if (obfuscate) DexObfuscationConfig( + packageName = uniqueHash(), + metadataManifestField = uniqueHash(), + metaLoaderFilePath = uniqueHash(), + configFilePath = uniqueHash(), + loaderFilePath = uniqueHash(), + libNativeFilePath = mapOf( + "arm64-v8a" to uniqueHash() + ".so", + "armeabi-v7a" to uniqueHash() + ".so", + ), + originApkPath = uniqueHash(), + cachedOriginApkPath = uniqueHash(), + openAtApkPath = uniqueHash(), + assetModuleFolderPath = uniqueHash(), + ) else null + + val dstZFile = ZFile.openReadWrite(outputFile, ZFileOptions().setAlignmentRule( + AlignmentRules.compose( + AlignmentRules.constantForSuffix(".so", 4096), + AlignmentRules.constantForSuffix("assets/" + (dexObfuscationConfig?.originApkPath ?: "lspatch/origin.apk"), 4096) + ) + )) + + val patchConfig = PatchConfig( + useManager = false, + debuggable = false, + overrideVersionCode = false, + sigBypassLevel = 2, + originalSignature = ApkSignatureHelper.getApkSignInfo(inputApkFile.absolutePath), + appComponentFactory = "androidx.core.app.CoreComponentFactory" + ).let { Gson().toJson(it) } + + // sign apk + runCatching { + provideSigningExtension(context.assets.open("lspatch/keystore.jks")).register(dstZFile) + }.onFailure { + throw Exception("Failed to sign apk", it) + } + + printLog("Patching manifest") + + val sourceApkFile = dstZFile.addNestedZip({ "assets/" + (dexObfuscationConfig?.originApkPath ?: "lspatch/origin.apk") }, inputApkFile, false) + val originalManifestEntry = sourceApkFile.get("AndroidManifest.xml") ?: throw Exception("No original manifest found") + originalManifestEntry.open().use { inputStream -> + val patchedManifestData = patchManifest(inputStream.readBytes(), (dexObfuscationConfig?.metadataManifestField ?: "lspatch") to Base64.encode(patchConfig.toByteArray())) + dstZFile.add("AndroidManifest.xml", patchedManifestData.inputStream()) + } + + //add config + printLog("Adding config") + dstZFile.add("assets/" + (dexObfuscationConfig?.configFilePath ?: "lspatch/config.json"), ByteArrayInputStream(patchConfig.toByteArray())) + + // add loader dex + printLog("Adding loader dex") + context.assets.open("lspatch/dexes/loader.dex").use { inputStream -> + dstZFile.add("assets/" + (dexObfuscationConfig?.loaderFilePath ?: "lspatch/loader.dex"), dexObfuscationConfig?.let { + lspatchObfuscation.obfuscateLoader(inputStream, it).inputStream() + } ?: inputStream) + } + + //add natives + printLog("Adding natives") + context.assets.list("lspatch/so")?.forEach { native -> + dstZFile.add("assets/${dexObfuscationConfig?.libNativeFilePath?.get(native) ?: "lspatch/so/$native/liblspatch.so"}", context.assets.open("lspatch/so/$native/liblspatch.so"), false) + } + + //embed modules + printLog("Embedding modules") + modules.forEach { (packageName, module) -> + val obfuscatedPackageName = dexObfuscationConfig?.packageName ?: packageName + printLog("- $obfuscatedPackageName") + dstZFile.add("assets/${dexObfuscationConfig?.assetModuleFolderPath ?: "lspatch/modules"}/$obfuscatedPackageName.apk", module.inputStream()) + } + + // link apk entries + printLog("Linking apk entries") + + for (entry in sourceApkFile.entries()) { + val name = entry.centralDirectoryHeader.name + if (dexObfuscationConfig == null && name.startsWith("classes") && name.endsWith(".dex")) continue + if (dstZFile[name] != null) continue + if (name == "AndroidManifest.xml") continue + if (name.startsWith("META-INF") && (name.endsWith(".SF") || name.endsWith(".MF") || name.endsWith( + ".RSA" + )) + ) continue + sourceApkFile.addFileLink(name, name) + } + + printLog("Adding meta loader dex") + context.assets.open("lspatch/dexes/metaloader.dex").use { inputStream -> + dstZFile.add(dexObfuscationConfig?.let { + val dexFileIndex = sourceApkFile.entries().count { + it.centralDirectoryHeader.name.startsWith("classes") && it.centralDirectoryHeader.name.endsWith(".dex") + } + 1 + "classes${dexFileIndex}.dex" + } ?: "classes.dex", dexObfuscationConfig?.let { + lspatchObfuscation.obfuscateMetaLoader(inputStream, it).inputStream() + } ?: inputStream) + } + + printLog("Writing apk") + dstZFile.realign() + dstZFile.close() + sourceApkFile.close() + + printLog("Cleaning obfuscation cache") + obfuscationCacheFolder.deleteRecursively() + printLog("Done") + } + + fun patchSplits(inputs: List): Map { + val outputs = mutableMapOf() + inputs.forEach { input -> + val outputFile = File.createTempFile("patched", ".apk", context.externalCacheDir ?: context.cacheDir) + if (input.name.contains("split")) { + resignApk(input, outputFile) + outputs[input.name] = outputFile + return@forEach + } + patch(input, outputFile) + outputs["base.apk"] = outputFile + } + return outputs + } + + private fun patch(input: File, outputFile: File) { + //check if input apk is already patched + var isAlreadyPatched = false + var inputFile = input + + // extract origin + printLog("Extracting origin apk") + ZipFile(input).use { zipFile -> + zipFile.getEntry("assets/lspatch/origin.apk")?.apply { + inputFile = File.createTempFile("origin", ".apk") + inputFile.outputStream().use { + zipFile.getInputStream(this).copyTo(it) + } + isAlreadyPatched = true + } + } + + if (outputFile.exists()) outputFile.delete() + + printLog("Patching apk") + runCatching { + patchApk(inputFile, outputFile) + }.onFailure { + if (isAlreadyPatched) { + inputFile.delete() + } + outputFile.delete() + printLog("Failed to patch") + printLog(it) + } + } +} \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/LSPatchObfuscation.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/LSPatchObfuscation.kt new file mode 100644 index 0000000000..237f1b5ef5 --- /dev/null +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/LSPatchObfuscation.kt @@ -0,0 +1,58 @@ +package me.rhunk.snapenhance.manager.patch + +import me.rhunk.snapenhance.manager.patch.util.obfuscateDexFile +import java.io.File +import java.io.InputStream + +data class DexObfuscationConfig( + val packageName: String, + val metadataManifestField: String? = null, + val metaLoaderFilePath: String? = null, + val configFilePath: String? = null, + val loaderFilePath: String? = null, + val originApkPath: String? = null, + val cachedOriginApkPath: String? = null, + val openAtApkPath: String? = null, + val assetModuleFolderPath: String? = null, + val libNativeFilePath: Map = mapOf(), +) + +class LSPatchObfuscation( + private val cacheFolder: File, + private val printLog: (String) -> Unit = { println(it) } +) { + + fun obfuscateMetaLoader(inputStream: InputStream, config: DexObfuscationConfig): File { + return inputStream.obfuscateDexFile(cacheFolder, mapOf( + "assets/lspatch/config.json" to "assets/${config.configFilePath}", + "assets/lspatch/loader.dex" to "assets/${config.loaderFilePath}", + ) + (config.libNativeFilePath.takeIf { it.isNotEmpty() }?.let { + mapOf( + "!/assets/lspatch/so/" to "!/assets/", + "assets/lspatch/so/" to "assets/", + "/liblspatch.so" to "", + "arm64-v8a" to config.libNativeFilePath["arm64-v8a"], + "armeabi-v7a" to config.libNativeFilePath["armeabi-v7a"], + "x86" to config.libNativeFilePath["x86"], + "x86_64" to config.libNativeFilePath["x86_64"], + ) + } ?: mapOf())) + } + + fun obfuscateLoader(inputStream: InputStream, config: DexObfuscationConfig): File { + return inputStream.obfuscateDexFile(cacheFolder, mapOf( + "assets/lspatch/config.json" to config.configFilePath?.let { "assets/$it" }, + "assets/lspatch/loader.dex" to config.loaderFilePath?.let { "assets/$it" }, + "assets/lspatch/metaloader.dex" to config.metaLoaderFilePath?.let { "assets/$it" }, + "assets/lspatch/origin.apk" to config.originApkPath?.let { "assets/$it" }, + "/lspatch/origin/" to config.cachedOriginApkPath?.let { "/$it/" }, // context.getCacheDir() + ==> "/lspatch/origin/" <== + sourceFile.getEntry(ORIGINAL_APK_ASSET_PATH).getCrc() + ".apk"; + "/lspatch/" to config.cachedOriginApkPath?.let { "/$it/" }, // context.getCacheDir() + "/lspatch/" + packageName + "/" + "cache/lspatch/origin/" to config.cachedOriginApkPath?.let { "cache/$it" }, //LSPApplication => Path originPath = Paths.get(appInfo.dataDir, "cache/lspatch/origin/"); + "assets/lspatch/modules/" to config.assetModuleFolderPath?.let { "assets/$it/" }, // Constants.java => EMBEDDED_MODULES_ASSET_PATH + "lspatch/modules" to config.assetModuleFolderPath, // LocalApplicationService.java => context.getAssets().list("lspatch/modules"), + "lspatch/modules/" to config.assetModuleFolderPath?.let { "$it/" }, // LocalApplicationService.java => try (var is = context.getAssets().open("lspatch/modules/" + name)) { + "lspatch" to config.metadataManifestField, // SigBypass.java => "lspatch", + "org.lsposed.lspatch" to config.cachedOriginApkPath?.let { "$it/${config.packageName}/" }, // Constants.java => "org.lsposed.lspatch", (Used in LSPatchUpdater.kt) + )) + } +} \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/Repackager.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/Repackager.kt new file mode 100644 index 0000000000..52157d6de7 --- /dev/null +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/Repackager.kt @@ -0,0 +1,89 @@ +package me.rhunk.snapenhance.manager.patch + +import android.content.Context +import com.android.tools.build.apkzlib.zip.AlignmentRules +import com.android.tools.build.apkzlib.zip.ZFile +import com.android.tools.build.apkzlib.zip.ZFileOptions +import com.wind.meditor.core.ManifestEditor +import com.wind.meditor.property.AttributeItem +import com.wind.meditor.property.ModificationProperty +import me.rhunk.snapenhance.manager.BuildConfig +import me.rhunk.snapenhance.manager.patch.util.ApkSignatureHelper.provideSigningExtension +import me.rhunk.snapenhance.manager.patch.util.obfuscateDexFile +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File + +class Repackager( + private val context: Context, + private val cacheFolder: File, + private val packageName: String, +) { + private fun patchManifest(data: ByteArray): ByteArray { + val property = ModificationProperty() + + property.addManifestAttribute(AttributeItem("package", packageName).apply { + type = 3 + namespace = null + }) + + return ByteArrayOutputStream().apply { + ManifestEditor(ByteArrayInputStream(data), this, property).processManifest() + flush() + close() + }.toByteArray() + } + + fun patch(apkFile: File): File { + val outputFile = File(cacheFolder, "patched-${apkFile.name}") + runCatching { + patch(apkFile, outputFile) + }.onFailure { + outputFile.delete() + throw it + } + return outputFile + } + + fun patch(apkFile: File, outputFile: File) { + val dstZFile = ZFile.openReadWrite(outputFile, ZFileOptions().setAlignmentRule( + AlignmentRules.compose(AlignmentRules.constantForSuffix(".so", 4096)) + )) + provideSigningExtension(context.assets.open("lspatch/keystore.jks")).register(dstZFile) + val srcZFile = ZFile.openReadOnly(apkFile) + val dexFiles = mutableListOf() + + for (entry in srcZFile.entries()) { + val name = entry.centralDirectoryHeader.name + if (name.startsWith("AndroidManifest.xml")) { + dstZFile.add(name, ByteArrayInputStream( + patchManifest(entry.read()) + ), false) + continue + } + if (name.startsWith("classes") && name.endsWith(".dex")) { + println("obfuscating $name") + val inputStream = entry.open() ?: continue + val obfuscatedDexFile = inputStream.obfuscateDexFile(cacheFolder, { dexFile -> + dexFile.classes.firstOrNull { it.type == "Lme/rhunk/snapenhance/common/Constants;" } != null + }, mapOf( + BuildConfig.APPLICATION_ID to packageName + ))?.also { dexFiles.add(it) } + + if (obfuscatedDexFile == null) { + inputStream.close() + dstZFile.add(name, entry.open(), false) + continue + } + + dstZFile.add(name, obfuscatedDexFile.inputStream(), false) + continue + } + dstZFile.add(name, entry.open(), false) + } + dstZFile.realign() + dstZFile.close() + srcZFile.close() + dexFiles.forEach { it.delete() } + } +} \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/config/Constants.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/config/Constants.kt new file mode 100644 index 0000000000..80e30f32f0 --- /dev/null +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/config/Constants.kt @@ -0,0 +1,7 @@ +package me.rhunk.snapenhance.manager.patch.config + +//https://github.com/LSPosed/LSPatch/blob/master/share/java/src/main/java/org/lsposed/lspatch/share/Constants.java +object Constants { + const val PROXY_APP_COMPONENT_FACTORY = + "org.lsposed.lspatch.metaloader.LSPAppComponentFactoryStub" +} \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/config/PatchConfig.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/config/PatchConfig.kt new file mode 100644 index 0000000000..ea06cf83ae --- /dev/null +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/config/PatchConfig.kt @@ -0,0 +1,19 @@ +package me.rhunk.snapenhance.manager.patch.config + +data class PatchConfig( + val useManager: Boolean = false, + val debuggable: Boolean = false, + val overrideVersionCode: Boolean = false, + val sigBypassLevel: Int = 0, + val originalSignature: String? = null, + val appComponentFactory: String? = null, + val lspConfig: LSPConfig? = LSPConfig() +) { + data class LSPConfig( + var API_CODE: Int = 93, + var VERSION_CODE: Int = 360, + var VERSION_NAME: String = "0.5.1", + var CORE_VERSION_CODE: Int = 6649, + var CORE_VERSION_NAME: String = "1.8.5", + ) +} \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/util/ApkSignatureHelper.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/util/ApkSignatureHelper.kt new file mode 100644 index 0000000000..5121f30455 --- /dev/null +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/util/ApkSignatureHelper.kt @@ -0,0 +1,167 @@ +package me.rhunk.snapenhance.manager.patch.util; + +import com.android.tools.build.apkzlib.sign.SigningExtension +import com.android.tools.build.apkzlib.sign.SigningOptions +import java.io.IOException +import java.io.InputStream +import java.io.RandomAccessFile +import java.io.UnsupportedEncodingException +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.security.KeyStore +import java.security.cert.Certificate +import java.security.cert.X509Certificate +import java.util.Enumeration +import java.util.jar.JarEntry +import java.util.jar.JarFile + + +//https://github.com/LSPosed/LSPatch/blob/master/patch/src/main/java/org/lsposed/patch/util/ApkSignatureHelper.java +object ApkSignatureHelper { + private val APK_V2_MAGIC = charArrayOf('A', 'P', 'K', ' ', 'S', 'i', 'g', ' ', + 'B', 'l', 'o', 'c', 'k', ' ', '4', '2') + + fun provideSigningExtension(keyStoreInputStream: InputStream): SigningExtension { + val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) + keyStore.load(keyStoreInputStream, "123456".toCharArray()) + val key = keyStore.getEntry("key0", KeyStore.PasswordProtection("123456".toCharArray())) as KeyStore.PrivateKeyEntry + val certificates = key.certificateChain.mapNotNull { it as? X509Certificate }.toTypedArray() + + return SigningExtension( + SigningOptions.builder().apply { + setMinSdkVersion(28) + setV2SigningEnabled(true) + setCertificates(*certificates) + setKey(key.privateKey) + }.build() + ) + } + + private fun toChars(mSignature: ByteArray): CharArray { + val N = mSignature.size + val N2 = N * 2 + val text = CharArray(N2) + for (j in 0 until N) { + val v = mSignature[j] + var d = v.toInt() shr 4 and 0xf + text[j * 2] = (if (d >= 10) 'a'.code + d - 10 else '0'.code + d).toChar() + d = v.toInt() and 0xf + text[j * 2 + 1] = (if (d >= 10) 'a'.code + d - 10 else '0'.code + d).toChar() + } + return text + } + + private fun loadCertificates( + jarFile: JarFile, + je: JarEntry?, + readBuffer: ByteArray + ): Array? { + try { + val `is` = jarFile.getInputStream(je) + while (`is`.read(readBuffer, 0, readBuffer.size) != -1) { + } + `is`.close() + return je?.certificates as Array? + } catch (e: Exception) { + } + return null + } + + fun getApkSignInfo(apkFilePath: String): String? { + return try { + getApkSignV2(apkFilePath) + } catch (e: Exception) { + getApkSignV1(apkFilePath) + } + } + + fun getApkSignV1(apkFilePath: String?): String? { + val readBuffer = ByteArray(8192) + var certs: Array? = null + try { + val jarFile = JarFile(apkFilePath) + val entries: Enumeration<*> = jarFile.entries() + while (entries.hasMoreElements()) { + val je = entries.nextElement() as JarEntry + if (je.isDirectory) { + continue + } + if (je.name.startsWith("META-INF/")) { + continue + } + val localCerts = loadCertificates(jarFile, je, readBuffer) + if (certs == null) { + certs = localCerts + } else { + for (i in certs.indices) { + var found = false + for (j in localCerts!!.indices) { + if (certs[i] != null && certs[i] == localCerts[j]) { + found = true + break + } + } + if (!found || certs.size != localCerts.size) { + jarFile.close() + return null + } + } + } + } + jarFile.close() + return if (certs != null) String(toChars(certs[0]!!.encoded)) else null + } catch (ignored: Throwable) { + } + return null + } + + @Throws(IOException::class) + private fun getApkSignV2(apkFilePath: String): String { + RandomAccessFile(apkFilePath, "r").use { apk -> + val buffer = ByteBuffer.allocate(0x10) + buffer.order(ByteOrder.LITTLE_ENDIAN) + apk.seek(apk.length() - 0x6) + apk.readFully(buffer.array(), 0x0, 0x6) + val offset = buffer.getInt() + if (buffer.getShort().toInt() != 0) { + throw UnsupportedEncodingException("no zip") + } + apk.seek((offset - 0x10).toLong()) + apk.readFully(buffer.array(), 0x0, 0x10) + if (!buffer.array().contentEquals(APK_V2_MAGIC.map { it.code.toByte() }.toByteArray())) { + throw UnsupportedEncodingException("no apk v2") + } + + // Read and compare size fields + apk.seek((offset - 0x18).toLong()) + apk.readFully(buffer.array(), 0x0, 0x8) + buffer.rewind() + var size = buffer.getLong().toInt() + val block = ByteBuffer.allocate(size + 0x8) + block.order(ByteOrder.LITTLE_ENDIAN) + apk.seek((offset - block.capacity()).toLong()) + apk.readFully(block.array(), 0x0, block.capacity()) + if (size.toLong() != block.getLong()) { + throw UnsupportedEncodingException("no apk v2") + } + while (block.remaining() > 24) { + size = block.getLong().toInt() + if (block.getInt() == 0x7109871a) { + // signer-sequence length, signer length, signed data length + block.position(block.position() + 12) + size = block.getInt() // digests-sequence length + + // digests, certificates length + block.position(block.position() + size + 0x4) + size = block.getInt() // certificate length + break + } else { + block.position(block.position() + size - 0x4) + } + } + val certificate = ByteArray(size) + block[certificate] + return String(toChars(certificate)) + } + } +} \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/util/DexLibExt.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/util/DexLibExt.kt new file mode 100644 index 0000000000..d0bdf350cc --- /dev/null +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/util/DexLibExt.kt @@ -0,0 +1,63 @@ +package me.rhunk.snapenhance.manager.patch.util + +import com.android.tools.smali.dexlib2.Opcodes +import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile +import com.android.tools.smali.dexlib2.iface.DexFile +import com.android.tools.smali.dexlib2.iface.reference.StringReference +import com.android.tools.smali.dexlib2.writer.io.FileDataStore +import com.android.tools.smali.dexlib2.writer.pool.DexPool +import com.android.tools.smali.dexlib2.writer.pool.StringPool +import java.io.BufferedInputStream +import java.io.File +import java.io.InputStream + + +private fun obfuscateStrings(dexFile: DexFile, dexStrings: Map): DexPool { + val dexPool = object: DexPool(dexFile.opcodes) { + override fun getSectionProvider(): SectionProvider { + val dexPool = this + return object: DexPoolSectionProvider() { + override fun getStringSection() = object: StringPool(dexPool) { + private val cacheMap = mutableMapOf() + + override fun intern(string: CharSequence) { + dexStrings[string.toString()]?.let { + cacheMap[string.toString()] = it + println("mapping $string to $it") + super.intern(it) + return + } + super.intern(string) + } + + override fun getItemIndex(key: CharSequence): Int { + return cacheMap[key.toString()]?.let { + internedItems[it] + } ?: super.getItemIndex(key) + } + + override fun getItemIndex(key: StringReference): Int { + return cacheMap[key.toString()]?.let { + internedItems[it] + } ?: super.getItemIndex(key) + } + } + } + } + } + dexFile.classes.forEach { dexBackedClassDef -> + dexPool.internClass(dexBackedClassDef) + } + return dexPool +} + +fun InputStream.obfuscateDexFile(cacheFolder: File, dexStrings: Map) + = this.obfuscateDexFile(cacheFolder, { true }, dexStrings)!! + +fun InputStream.obfuscateDexFile(cacheFolder: File, filter: (DexFile) -> Boolean, dexStrings: Map): File? { + val dexFile = DexBackedDexFile.fromInputStream(Opcodes.forApi(29), BufferedInputStream(this)) + if (!filter(dexFile)) return null + val outputFile = File.createTempFile("dexobf", ".dex", cacheFolder) + obfuscateStrings(dexFile, dexStrings).writeTo(FileDataStore(outputFile)) + return outputFile +} diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/MainActivity.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/MainActivity.kt new file mode 100644 index 0000000000..e87e771c42 --- /dev/null +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/MainActivity.kt @@ -0,0 +1,81 @@ +package me.rhunk.snapenhance.manager.ui + +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.* +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.navigation.compose.rememberNavController +import com.topjohnwu.superuser.Shell +import me.rhunk.snapenhance.manager.BuildConfig +import me.rhunk.snapenhance.manager.data.SharedConfig +import me.rhunk.snapenhance.manager.ui.tab.Tab +import me.rhunk.snapenhance.manager.ui.tab.impl.HomeTab +import me.rhunk.snapenhance.manager.ui.tab.impl.SettingsTab +import me.rhunk.snapenhance.manager.ui.tab.impl.download.InstallPackageTab +import me.rhunk.snapenhance.manager.ui.tab.impl.download.RepackageTab + +class MainActivity : ComponentActivity() { + companion object{ + private val primaryTabs = listOf(HomeTab::class, SettingsTab::class, InstallPackageTab::class, RepackageTab::class) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + Shell.enableVerboseLogging = BuildConfig.DEBUG; + Shell.setDefaultBuilder(Shell.Builder.create() + .setFlags(Shell.FLAG_REDIRECT_STDERR) + .setTimeout(10) + ); + val tabs = primaryTabs.mapNotNull { + runCatching { it.java.constructors.first().newInstance() as Tab }.getOrNull() + }.toMutableList().apply { + forEach { it.init(this@MainActivity) } + fun addNestedTabsRecursively(tabs: List) { + tabs.forEach { tab -> + add(tab) + addNestedTabsRecursively(tab.nestedTabs) + } + } + toList().forEach { addNestedTabsRecursively(it.nestedTabs) } + } + setContent { + MaterialTheme( + colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (isSystemInDarkTheme()) dynamicDarkColorScheme(LocalContext.current) + else dynamicLightColorScheme(LocalContext.current) + } else darkColorScheme() + ) { + val navHostController = rememberNavController() + val sharedConfig = remember { SharedConfig(this) } + + + val navigation = remember { + Navigation( + navHostController = navHostController, + tabs = tabs, + defaultTab = HomeTab::class + ).also { + tabs.forEach { tab -> + tab.navigation = it + tab.sharedConfig = sharedConfig + } + } + } + + Scaffold( + bottomBar = { navigation.BottomBar() }, + topBar = { navigation.TopBar() }, + floatingActionButton = { navigation.FloatingActionButtons() }, + floatingActionButtonPosition = FabPosition.End, + ) { + navigation.NavigationHost(it) + } + } + } + } +} \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/Navigation.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/Navigation.kt new file mode 100644 index 0000000000..4f3e6c27b7 --- /dev/null +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/Navigation.kt @@ -0,0 +1,124 @@ +package me.rhunk.snapenhance.manager.ui + +import android.os.Bundle +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.sp +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.currentBackStackEntryAsState +import me.rhunk.snapenhance.manager.ui.tab.Tab +import kotlin.reflect.KClass + + +class Navigation( + val navHostController: NavHostController, + private val tabs: List, + private val defaultTab: KClass +) { + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun TopBar() { + val navBackStackEntry by navHostController.currentBackStackEntryAsState() + val currentTab = tabs.firstOrNull { it.route == navBackStackEntry?.destination?.route } + TopAppBar(title = { + Text(text = currentTab?.route ?: "") + }, navigationIcon = { + currentTab?.icon?.let { + Icon(imageVector = it, contentDescription = null) + } + }, actions = { + currentTab?.TopBar() + }) + } + + @Composable + fun FloatingActionButtons() { + val navBackStackEntry by navHostController.currentBackStackEntryAsState() + tabs.firstOrNull { it.route == navBackStackEntry?.destination?.route }?.FloatingActionButtons() + } + + fun navigateTo(tab: KClass, noHistory: Boolean = false) { + navHostController.navigate(tabs.first { it::class == tab }.route) { + if (noHistory) { + restoreState = false + launchSingleTop = true + popUpTo(navHostController.graph.findStartDestination().id) { + saveState = true + } + } + } + } + + + fun navigateTo(tab: KClass, args: Bundle, noHistory: Boolean = false) { + navigateTo(tab, noHistory) + navHostController.currentBackStackEntry?.savedStateHandle?.set("args", args) + } + + @Composable + fun NavigationHost( + innerPadding: PaddingValues + ) { + NavHost( + navHostController, + startDestination = tabs.first { it::class == defaultTab }.route, + Modifier.padding(innerPadding), + enterTransition = { fadeIn(tween(200)) }, + exitTransition = { fadeOut(tween(200)) } + ) { + tabs.forEach { tab -> + tab.build(this) + } + } + } + + + @Composable + fun BottomBar() { + NavigationBar { + val navBackStackEntry by navHostController.currentBackStackEntryAsState() + + remember { tabs.filter { it.isPrimary } }.forEach { tab -> + val tabSubRoutes = remember { tab.nestedTabs.map { it.route } } + NavigationBarItem( + selected = navBackStackEntry?.destination?.hierarchy?.any { it.route == tab.route || tabSubRoutes.contains(it.route) } == true, + alwaysShowLabel = false, + icon = { + Icon(imageVector = tab.icon!!, contentDescription = null) + }, + label = { + Text( + textAlign = TextAlign.Center, + softWrap = false, + fontSize = 12.sp, + modifier = Modifier.wrapContentWidth(unbounded = true), + text = tab.route + ) + }, + onClick = { + navHostController.navigate(tab.route) { + popUpTo(navHostController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + ) + } + } + } +} \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/components/Dialogs.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/components/Dialogs.kt new file mode 100644 index 0000000000..43bc4a1763 --- /dev/null +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/components/Dialogs.kt @@ -0,0 +1,69 @@ +package me.rhunk.snapenhance.manager.ui.components + +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.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + + +@Composable +fun ConfirmationDialog(title: String, onDismiss: () -> Unit, onConfirm: () -> Unit) { + Card { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text(title) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Button(onClick = { onDismiss() }) { + Text("Cancel") + } + Button(onClick = { onConfirm() }) { + Text("Yes") + } + } + } + } +} + + +@Composable +fun DowngradeNoticeDialog(onDismiss: () -> Unit, onSuccess: () -> Unit) { + Card { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text(text = "Downgrade Notice", fontSize = 24.sp) + Text(text = "You are about to update the app. If you're installing an older version over a newer one, make sure you have CorePatch installed. Otherwise, you will need to uninstall and install.", fontSize = 12.sp) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceAround + ) { + Button(onClick = onDismiss) { + Text(text = "Cancel") + } + Button(onClick = onSuccess) { + Text(text = "Continue") + } + } + } +} \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/Tab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/Tab.kt new file mode 100644 index 0000000000..3c7487fe3b --- /dev/null +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/Tab.kt @@ -0,0 +1,57 @@ +package me.rhunk.snapenhance.manager.ui.tab + +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import me.rhunk.snapenhance.manager.data.SharedConfig +import me.rhunk.snapenhance.manager.ui.Navigation +import kotlin.reflect.KClass + +open class Tab( + val route: String, + val isPrimary: Boolean = false, + val icon: ImageVector? = null, +) { + lateinit var activity: ComponentActivity + val nestedTabs = mutableListOf() + + fun getNestedTab(tab: KClass) = nestedTabs.firstOrNull { it::class == tab } + + fun registerNestedTab(tab: KClass) = nestedTabs.add((tab.java.constructors.first().newInstance() as Tab).also { + it.init(activity) + }) + + lateinit var navigation: Navigation + lateinit var sharedConfig: SharedConfig + + fun getArguments() = navigation.navHostController.currentBackStackEntry?.savedStateHandle?.get("args") + + open fun init(activity: ComponentActivity) { + this.activity = activity + } + + open fun build(navGraphBuilder: NavGraphBuilder) { + navGraphBuilder.composable(route) { + Content() + } + } + + @Composable + open fun TopBar() {} + + @Composable + open fun FloatingActionButtons() {} + + @Composable + open fun Content() {} + + fun toast(message: String) { + activity.runOnUiThread { + Toast.makeText(activity, message, Toast.LENGTH_SHORT).show() + } + } +} \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/HomeTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/HomeTab.kt new file mode 100644 index 0000000000..5eb07736b0 --- /dev/null +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/HomeTab.kt @@ -0,0 +1,147 @@ +package me.rhunk.snapenhance.manager.ui.tab.impl + +import android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE +import android.content.pm.PackageInfo +import androidx.activity.ComponentActivity +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.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.OpenInNew +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.manager.patch.config.Constants +import me.rhunk.snapenhance.manager.ui.tab.Tab +import me.rhunk.snapenhance.manager.ui.tab.impl.download.SEDownloadTab +import me.rhunk.snapenhance.manager.ui.tab.impl.download.SnapchatPatchTab + +class HomeTab : Tab("home", true, icon = Icons.Default.Home) { + override fun init(activity: ComponentActivity) { + super.init(activity) + registerNestedTab(SEDownloadTab::class) + registerNestedTab(SnapchatPatchTab::class) + } + + @Composable + override fun Content() { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + var snapchatAppInfo by remember { mutableStateOf(null as PackageInfo?) } + var snapEnhanceInfo by remember { mutableStateOf(null as PackageInfo?) } + + Column { + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + navigation.navigateTo(SEDownloadTab::class) + } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text(text = "SnapEnhance", fontSize = 24.sp, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold) + snapEnhanceInfo?.let { + Text(text = "${it.versionName} (${it.longVersionCode}) - ${if ((it.applicationInfo.flags and FLAG_DEBUGGABLE) != 0) "Debug" else "Release"}", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(it.packageName, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + Row( + modifier = Modifier + .weight(1f), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + snapEnhanceInfo?.let { + Text(text = "Installed", fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurface) + } ?: run { + Text(text = "Not installed", fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurface) + } + + Icon(imageVector = Icons.Default.OpenInNew, contentDescription = null, Modifier.padding(10.dp)) + } + } + } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + navigation.navigateTo(SnapchatPatchTab::class) + } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text(text = "Snapchat", fontSize = 24.sp, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold) + snapchatAppInfo?.let { + Text(text = "${it.versionName} (${it.longVersionCode})", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + Row( + modifier = Modifier + .weight(1f), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically + ) { + snapchatAppInfo?.let { appInfo -> + val isLSPatched = appInfo.applicationInfo.appComponentFactory == Constants.PROXY_APP_COMPONENT_FACTORY + if (isLSPatched) { + Icon(imageVector = Icons.Default.Check, contentDescription = null) + Text(text = "Patched", fontSize = 16.sp) + } else { + Icon(imageVector = Icons.Default.Close, contentDescription = null) + Text(text = "Not patched", fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurface) + } + } ?: run { + Text(text = "Not installed", fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurface) + } + + } + } + + } + } + + SideEffect { + coroutineScope.launch(Dispatchers.IO) { + runCatching { + snapchatAppInfo = runCatching { + context.packageManager.getPackageInfo(sharedConfig.snapchatPackageName, 0) + }.getOrNull() + snapEnhanceInfo = runCatching { + context.packageManager.getPackageInfo(sharedConfig.snapEnhancePackageName, 0) + }.getOrNull() + } + } + } + } +} \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/SettingsTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/SettingsTab.kt new file mode 100644 index 0000000000..e153edacf6 --- /dev/null +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/SettingsTab.kt @@ -0,0 +1,166 @@ +package me.rhunk.snapenhance.manager.ui.tab.impl + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import me.rhunk.snapenhance.manager.ui.tab.Tab +import kotlin.random.Random + +class SettingsTab : Tab("settings", isPrimary = true, icon = Icons.Default.Settings) { + @Composable + private fun ConfigEditRow(getValue: () -> String?, setValue: (String) -> Unit, label: String, randomValueProvider: (() -> String)? = null) { + var showDialog by remember { mutableStateOf(false) } + + if (showDialog) { + val focusRequester = remember { FocusRequester() } + + Dialog(onDismissRequest = { + showDialog = false + }) { + Card { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text(text = label) + var textFieldValue by remember { mutableStateOf((getValue() ?: "").let { + TextFieldValue(it, TextRange(it.length)) + }) } + + TextField( + value = textFieldValue, + onValueChange = { + textFieldValue = it + }, + modifier = Modifier + .focusRequester(focusRequester) + .onGloballyPositioned { + focusRequester.requestFocus() + } + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceAround + ) { + Button(onClick = { + showDialog = false + }) { + Text(text = "Cancel") + } + if (randomValueProvider != null) { + Button(onClick = { + textFieldValue = TextFieldValue(randomValueProvider(), TextRange(0)) + }) { + Text(text = "Random") + } + } + Button(onClick = { + setValue(textFieldValue.text) + showDialog = false + }) { + Text(text = "Save") + } + } + } + } + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + showDialog = true + }, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text(text = label, fontSize = 16.sp) + Text(text = getValue() ?: "(Not specified)", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Icon(imageVector = Icons.Default.Edit, contentDescription = null, modifier = Modifier.padding(16.dp)) + } + } + + + @Composable + private fun ConfigBooleanRow(getValue: () -> Boolean, setValue: (Boolean) -> Unit, label: String) { + var value by remember { mutableStateOf(getValue()) } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + value = !value + setValue(value) + }, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text(text = label, fontSize = 16.sp) + } + Checkbox(checked = value, onCheckedChange = { + value = it + setValue(it) + }, modifier = Modifier.padding(5.dp)) + } + } + + @Composable + override fun Content() { + Column { + Spacer(modifier = Modifier.height(16.dp)) + ConfigEditRow( + getValue = { sharedConfig.snapEnhancePackageName }, + setValue = { sharedConfig.snapEnhancePackageName = it }, + label = "Override SnapEnhance package name", + randomValueProvider = { + (0..Random.nextInt(7, 16)).map { ('a'..'z').random() }.joinToString("").chunked(4).joinToString(".") + } + ) + ConfigBooleanRow( + getValue = { sharedConfig.enableRepackage }, + setValue = { sharedConfig.enableRepackage = it }, + label = "Repackage SnapEnhance (experimental)" + ) + ConfigBooleanRow( + getValue = { sharedConfig.useRootInstaller }, + setValue = { sharedConfig.useRootInstaller = it }, + label = "Use root installer" + ) + ConfigBooleanRow( + getValue = { sharedConfig.obfuscateLSPatch }, + setValue = { sharedConfig.obfuscateLSPatch = it }, + label = "Obfuscate LSPatch (experimental)" + ) + } + } +} \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/InstallPackageTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/InstallPackageTab.kt new file mode 100644 index 0000000000..609e3a9837 --- /dev/null +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/InstallPackageTab.kt @@ -0,0 +1,231 @@ +package me.rhunk.snapenhance.manager.ui.tab.impl.download + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider +import androidx.core.net.toUri +import com.topjohnwu.superuser.Shell +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.manager.data.download.InstallStage +import me.rhunk.snapenhance.manager.ui.tab.Tab +import me.rhunk.snapenhance.manager.ui.tab.impl.HomeTab +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File + + +class InstallPackageTab : Tab("install_app") { + private lateinit var installPackageIntentLauncher: ActivityResultLauncher + private lateinit var uninstallPackageIntentLauncher: ActivityResultLauncher + private var uninstallPackageCallback: ((resultCode: Int) -> Unit)? = null + private var installPackageCallback: ((resultCode: Int) -> Unit)? = null + + private val hasRoot get() = sharedConfig.useRootInstaller + + override fun init(activity: ComponentActivity) { + super.init(activity) + installPackageIntentLauncher = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + installPackageCallback?.invoke(it.resultCode) + } + uninstallPackageIntentLauncher = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + uninstallPackageCallback?.invoke(it.resultCode) + } + } + + private fun downloadArtifact(url: String, progress: (Float) -> Unit): File? { + val urlScheme = Uri.parse(url).scheme + if (urlScheme != "https" && urlScheme != "http") { + val file = File(url) + val dest = File(activity.externalCacheDirs.first(), file.name).also { + it.deleteOnExit() + } + if (dest.exists()) return file + file.copyTo(dest) + return dest + } + + val endpoint = Request.Builder().url(url).build() + val response = OkHttpClient().newCall(endpoint).execute() + if (!response.isSuccessful) throw Throwable("Failed to download artifact: ${response.code}") + + return response.body.byteStream().use { input -> + val file = File.createTempFile("artifact", ".apk", activity.externalCacheDirs.first()).also { + it.deleteOnExit() + } + runCatching { + file.outputStream().use { output -> + val buffer = ByteArray(4 * 1024) + var read: Int + var totalRead = 0L + val totalSize = response.body.contentLength() + while (input.read(buffer).also { read = it } != -1) { + output.write(buffer, 0, read) + totalRead += read + progress(totalRead.toFloat() / totalSize.toFloat()) + } + } + file + }.getOrNull() + } + } + + + @Composable + @Suppress("DEPRECATION") + override fun Content() { + val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + var installStage by remember { mutableStateOf(InstallStage.DOWNLOADING) } + var downloadProgress by remember { mutableFloatStateOf(-1f) } + var downloadedFile by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + uninstallPackageCallback = null + installPackageCallback = null + } + + val downloadPath = remember { getArguments()?.getString("downloadPath") } ?: return + val appPackage = remember { getArguments()?.getString("appPackage") } ?: return + val shouldUninstall = remember { getArguments()?.getBoolean("uninstall")?.let { + if (runCatching { activity.packageManager.getPackageInfo(appPackage, 0) }.getOrNull() == null) { + false + } else it + } ?: false } + + BackHandler(installStage != InstallStage.DONE || installStage != InstallStage.ERROR) {} + Column( + modifier = Modifier.fillMaxSize().padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + if (installStage != InstallStage.DONE && installStage != InstallStage.ERROR) { + CircularProgressIndicator() + } + } + + when (installStage) { + InstallStage.DOWNLOADING -> { + Text(text = "Downloading ...") + LinearProgressIndicator(progress = downloadProgress, Modifier.fillMaxWidth().height(4.dp), strokeCap = StrokeCap.Round) + } + InstallStage.UNINSTALLING -> { + Text(text = "Uninstalling app $appPackage...") + } + InstallStage.INSTALLING -> { + Text(text = "Installing ...") + } + InstallStage.DONE -> { + LaunchedEffect(Unit) { + navigation.navigateTo(HomeTab::class, noHistory = true) + Toast.makeText(context, "Successfully installed $appPackage!", Toast.LENGTH_SHORT).show() + } + } + InstallStage.ERROR -> Text(text = "Failed to install $appPackage. Check logcat for more details.") + } + } + + fun uninstallPackageRoot(): Boolean { + val result = Shell.su("pm uninstall $appPackage").exec() + if (result.isSuccess) { + return true + } + toast("Root uninstall failed: ${result.out}") + return false + } + + fun installPackageRoot(): Boolean { + val result = Shell.su( + "cp \"${downloadedFile!!.absolutePath}\" /data/local/tmp/", + "pm install -r \"/data/local/tmp/${downloadedFile!!.name}\"", + "rm /data/local/tmp/${downloadedFile!!.name}" + ).exec() + if (result.isSuccess) { + installStage = InstallStage.DONE + return true + } + toast("Root install failed: ${result.out}") + return false + } + + fun installPackage() { + installStage = InstallStage.INSTALLING + if (hasRoot && installPackageRoot()) { + downloadedFile?.delete() + return + } + installPackageCallback = resultCallbacks@{ code -> + installStage = if (code != Activity.RESULT_OK) { + InstallStage.ERROR + } else { + InstallStage.DONE + } + downloadedFile?.delete() + } + + installPackageIntentLauncher.launch(Intent(Intent.ACTION_INSTALL_PACKAGE).apply { + data = FileProvider.getUriForFile(context, "me.rhunk.snapenhance.manager.provider", downloadedFile!!) + setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + putExtra(Intent.EXTRA_RETURN_RESULT, true) + }) + } + + + LaunchedEffect(Unit) { + coroutineScope.launch(Dispatchers.IO) { + runCatching { + downloadedFile = downloadArtifact(downloadPath) { downloadProgress = it } ?: run { + installStage = InstallStage.ERROR + return@launch + } + if (shouldUninstall) { + installStage = InstallStage.UNINSTALLING + if (hasRoot && uninstallPackageRoot()) { + installPackage() + return@launch + } + val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE).apply { + data = "package:$appPackage".toUri() + putExtra(Intent.EXTRA_RETURN_RESULT, true) + } + uninstallPackageCallback = resultCallback@{ resultCode -> + if (resultCode != Activity.RESULT_OK) { + installStage = InstallStage.ERROR + downloadedFile?.delete() + return@resultCallback + } + installPackage() + } + uninstallPackageIntentLauncher.launch(intent) + } else { + installPackage() + } + }.onFailure { + it.printStackTrace() + installStage = InstallStage.ERROR + downloadedFile?.delete() + } + } + } + } +} diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/LSPatchTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/LSPatchTab.kt new file mode 100644 index 0000000000..75522e16c1 --- /dev/null +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/LSPatchTab.kt @@ -0,0 +1,230 @@ +package me.rhunk.snapenhance.manager.ui.tab.impl.download + +import android.os.Bundle +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.manager.data.APKMirror +import me.rhunk.snapenhance.manager.data.DownloadItem +import me.rhunk.snapenhance.manager.patch.LSPatch +import me.rhunk.snapenhance.manager.ui.components.DowngradeNoticeDialog +import me.rhunk.snapenhance.manager.ui.tab.Tab +import okio.use +import java.io.File +import kotlin.properties.Delegates + +class LSPatchTab : Tab("lspatch") { + private val apkMirror = APKMirror() + + private fun patch( + log: (Any?) -> Unit, + onProgress: (Float) -> Unit, + downloadItem: DownloadItem? = null, + snapEnhanceModule: File? = null, + localItemFile: File? = null, + patchedApk: MutableState, + ) { + var apkFile: File? = localItemFile + + downloadItem?.let { + log("Fetching download link for ${it.title}...") + val downloadLink = apkMirror.fetchDownloadLink(it.downloadPage) ?: run { + log("== Failed to fetch download link ==") + return + } + log("Downloading apk...") + + val downloadResponse = apkMirror.okhttpClient.newCall( + okhttp3.Request.Builder() + .url(downloadLink) + .build() + ).execute() + + if (!downloadResponse.isSuccessful) { + log("== Failed to download apk ==") + log("Response code: ${downloadResponse.code}") + return + } + + apkFile = sharedConfig.apkCache.resolve("${it.hash}.apk") + apkFile!!.outputStream().use { outputStream -> + runCatching { + downloadResponse.body.byteStream().use { inputStream -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var read: Int + var totalRead = 0L + val totalSize = downloadResponse.body.contentLength() + while (inputStream.read(buffer).also { read = it } != -1) { + outputStream.write(buffer, 0, read) + totalRead += read + onProgress(totalRead.toFloat() / totalSize.toFloat()) + } + } + }.onFailure { throwable -> + log("== Failed to download apk ==") + log(throwable) + return + } + } + + apkFile!!.renameTo(File(activity.externalCacheDir!!, "base.apk")) + } + + log("== Downloaded apk ==") + snapEnhanceModule?.let { module -> + val lsPatch = LSPatch(activity, mapOf( + sharedConfig.snapEnhancePackageName to module, + ), printLog = { + log("[LSPatch] $it") + }, obfuscate = sharedConfig.obfuscateLSPatch) + + log("== Patching apk ==") + val outputFiles = lsPatch.patchSplits(listOf(apkFile!!)) + + patchedApk.value = outputFiles["base.apk"] ?: run { + log("== Failed to patch apk ==") + return + } + return + } + patchedApk.value = apkFile + } + + @Suppress("DEPRECATION") + override fun build(navGraphBuilder: NavGraphBuilder) { + var currentJob: Job? = null + val coroutineScope = CoroutineScope(Dispatchers.IO) + val patchedApk = mutableStateOf(null) + val status = mutableStateOf("") + var progress by mutableFloatStateOf(-1f) + var isRunning by Delegates.observable(false) { _, _, newValue -> + if (!newValue) { + currentJob?.cancel() + currentJob = null + progress = -1f + } + } + + navGraphBuilder.composable(route) { + var showDowngradeNoticeDialog by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + if (isRunning) return@LaunchedEffect + status.value = "" + coroutineScope.launch(Dispatchers.IO) { + isRunning = true + runCatching { + patch( + localItemFile = getArguments()?.getString("localItemFile")?.let { File(it) } , + log = { + coroutineScope.launch { + status.value += when (it) { + is Throwable -> it.message + "\n" + it.stackTraceToString() + else -> it.toString() + } + "\n" + } + }, + downloadItem = getArguments()?.getParcelable("downloadItem"), + snapEnhanceModule = getArguments()?.getString("modulePath")?.let { + File(it) + }, + patchedApk = patchedApk, + onProgress = { progress = it } + ) + }.onFailure { + coroutineScope.launch { + status.value += it.message + "\n" + it.stackTraceToString() + } + } + isRunning = false + }.also { currentJob = it } + } + + DisposableEffect(Unit) { + onDispose { + if (isRunning) return@onDispose + patchedApk.value = null + } + } + + val scrollState = rememberScrollState() + + fun triggerInstallation(shouldUninstall: Boolean) { + navigation.navigateTo(InstallPackageTab::class, args = Bundle().apply { + putString("downloadPath", patchedApk.value?.absolutePath) + putString("appPackage", sharedConfig.snapchatPackageName) + putBoolean("uninstall", shouldUninstall) + }) + } + BackHandler(isRunning) {} + Column( + modifier = Modifier + .fillMaxSize() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Card( + modifier = Modifier + .weight(1f) + .padding(10.dp), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + ) { + Text(text = status.value, overflow = TextOverflow.Visible, modifier = Modifier.padding(10.dp)) + } + } + if (progress != -1f) { + LinearProgressIndicator(progress = progress, modifier = Modifier.height(10.dp), strokeCap = StrokeCap.Round) + } + + if (patchedApk.value != null) { + Button(modifier = Modifier.fillMaxWidth(), onClick = { + triggerInstallation(true) + }) { + Text(text = "Uninstall & Install") + } + + Button(modifier = Modifier.fillMaxWidth(), onClick = { + showDowngradeNoticeDialog = true + }) { + Text(text = "Update") + } + } + + LaunchedEffect(status) { + scrollState.scrollTo(scrollState.maxValue) + } + } + + if (showDowngradeNoticeDialog) { + Dialog(onDismissRequest = { showDowngradeNoticeDialog = false }) { + DowngradeNoticeDialog(onDismiss = { showDowngradeNoticeDialog = false }, onSuccess = { + triggerInstallation(false) + }) + } + } + } + } +} \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/RepackageTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/RepackageTab.kt new file mode 100644 index 0000000000..c34c5f17e9 --- /dev/null +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/RepackageTab.kt @@ -0,0 +1,86 @@ +package me.rhunk.snapenhance.manager.ui.tab.impl.download + +import android.os.Bundle +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.manager.patch.Repackager +import me.rhunk.snapenhance.manager.ui.tab.Tab +import java.io.File + +enum class RepackageState { + IDLE, + WORKING, + SUCCESS, + FAILED +} + +class RepackageTab : Tab("repackage") { + private var throwable: Throwable? = null + + private suspend fun repackage(apk: File, oldPackage: String, state: MutableState) { + state.value = RepackageState.WORKING + val repackager = Repackager(activity, activity.externalCacheDirs.first(), sharedConfig.snapEnhancePackageName) + + runCatching { + repackager.patch(apk) + }.onFailure { + throwable = it + state.value = RepackageState.FAILED + return + }.onSuccess { originApk -> + state.value = RepackageState.SUCCESS + + withContext(Dispatchers.Main) { + navigation.navigateTo(InstallPackageTab::class, Bundle().apply { + putString("downloadPath", originApk.absolutePath) + putString("appPackage", oldPackage) + putBoolean("uninstall", true) + }, noHistory = true) + } + + return + } + } + + @Composable + override fun Content() { + val apkPath = remember { getArguments()?.getString("apkPath") } ?: return + val oldPackage = remember { getArguments()?.getString("oldPackage") } ?: return + val state = remember { mutableStateOf(RepackageState.IDLE) } + + LaunchedEffect(apkPath) { + launch(Dispatchers.IO) { + repackage(File(apkPath), oldPackage, state) + } + } + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator() + when (state.value) { + RepackageState.WORKING -> Text(text = "Repackaging ...") + RepackageState.FAILED -> { + Text(text = "Failed") + Text(text = (throwable?.localizedMessage + throwable?.stackTraceToString())) + } + else -> {} + } + } + } +} \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/SEDownloadTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/SEDownloadTab.kt new file mode 100644 index 0000000000..4d047d2052 --- /dev/null +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/SEDownloadTab.kt @@ -0,0 +1,262 @@ +package me.rhunk.snapenhance.manager.ui.tab.impl.download + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Android +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.google.gson.JsonParser +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.manager.BuildConfig +import me.rhunk.snapenhance.manager.data.download.SEArtifact +import me.rhunk.snapenhance.manager.data.download.SEVersion +import me.rhunk.snapenhance.manager.ui.components.DowngradeNoticeDialog +import me.rhunk.snapenhance.manager.ui.tab.Tab +import okhttp3.OkHttpClient +import okhttp3.Request +import java.text.SimpleDateFormat +import java.util.Locale + + +class SEDownloadTab : Tab("se_download") { + private fun fetchSEReleases(): List? { + return runCatching { + val endpoint = Request.Builder().url("https://api.github.com/repos/rhunk/SnapEnhance/releases").build() + val response = OkHttpClient().newCall(endpoint).execute() + if (!response.isSuccessful) return null + + val releases = JsonParser.parseString(response.body.string()).asJsonArray.also { + if (it.size() == 0) return null + } + val isoDateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.getDefault()) + + releases.map { releaseObject -> + val release = releaseObject.asJsonObject + val versionName = release.getAsJsonPrimitive("tag_name").asString + val releaseDate = release.getAsJsonPrimitive("published_at").asString.let { time -> + isoDateFormat.parse(time)?.let { date -> + SimpleDateFormat("dd MMM yyyy", Locale.getDefault()).format(date) + } ?: time + } + val downloadAssets = release.getAsJsonArray("assets").associate { asset -> + val assetObject = asset.asJsonObject + SEArtifact( + fileName = assetObject.getAsJsonPrimitive("name").asString, + size = assetObject.getAsJsonPrimitive("size").asLong, + downloadUrl = assetObject.getAsJsonPrimitive("browser_download_url").asString + ).let { it.fileName to it } + } + SEVersion(versionName, releaseDate, downloadAssets) + } + }.onFailure { + it.printStackTrace() + }.getOrNull() + } + + override fun init(activity: ComponentActivity) { + super.init(activity) + } + + @Composable + override fun Content() { + val coroutineScope = rememberCoroutineScope() + val snapEnhanceReleases = remember { + mutableStateOf(null as List?) + } + + var selectedVersion by remember { mutableStateOf(null as SEVersion?) } + var selectedArtifact by remember { mutableStateOf(null as SEArtifact?) } + val snapEnhanceApp = remember { + runCatching { activity.packageManager.getPackageInfo(BuildConfig.APPLICATION_ID, 0) }.getOrNull() + } + + var showDowngradeNotice by remember { mutableStateOf(false) } + + fun triggerPackageInstallation(shouldUninstall: Boolean) { + navigation.navigateTo(InstallPackageTab::class, Bundle().apply { + putString("downloadPath", selectedArtifact?.downloadUrl) + putString("appPackage", sharedConfig.snapEnhancePackageName) + putBoolean("uninstall", shouldUninstall) + }, noHistory = true) + } + + if (showDowngradeNotice) { + Dialog(onDismissRequest = { showDowngradeNotice = false }) { + DowngradeNoticeDialog(onDismiss = { showDowngradeNotice = false }, onSuccess = { + triggerPackageInstallation(false) + }) + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = "Choose SnapEnhance version") + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + item { + if (snapEnhanceReleases.value == null) { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + CircularProgressIndicator() + } + } + } + items(snapEnhanceReleases.value ?: listOf()) { version -> + OutlinedCard( + shape = MaterialTheme.shapes.small, + modifier = Modifier + .clickable { + selectedArtifact = + if (selectedVersion != version) null else selectedArtifact + selectedVersion = if (selectedVersion == version) null else version + }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text(text = version.versionName, fontSize = 24.sp) + Text(text = "Release ${version.releaseDate}", fontSize = 12.sp) + } + Row( + modifier = Modifier + .weight(1f), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = "${version.downloadAssets.size} assets", fontSize = 12.sp) + } + } + } + + selectedVersion?.takeIf { it == version }?.let { selVersion -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + selVersion.downloadAssets.values.forEach { artifact -> + Row( + modifier = Modifier + .fillMaxWidth() + .border( + shape = MaterialTheme.shapes.medium, + width = 1.dp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + .clickable { + selectedArtifact = + if (selectedArtifact == artifact) null else artifact + } + .background( + if (selectedArtifact == artifact) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.surface, + shape = MaterialTheme.shapes.medium + ) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(imageVector = Icons.Default.Android, contentDescription = null, modifier = Modifier.padding(start = 2.dp, end = 2.dp)) + Column( + modifier = Modifier + .padding(start = 13.dp) + ) { + Text(text = artifact.fileName, fontSize = 15.sp) + Text( + text = "${artifact.size / 1024 / 1024} MB", + fontSize = 10.sp + ) + } + } + } + } + } + } + } + Column( + modifier = Modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (snapEnhanceApp != null) { + if (sharedConfig.enableRepackage && sharedConfig.snapEnhancePackageName != snapEnhanceApp.packageName) { + Button( + onClick = { + navigation.navigateTo(RepackageTab::class, Bundle().apply { + putString("apkPath", snapEnhanceApp.applicationInfo.sourceDir) + putString("oldPackage", snapEnhanceApp.packageName) + }, noHistory = true) + }, + enabled = true, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = "Repackage installed version (>=2.0.0)") + } + } + Button( + onClick = { + triggerPackageInstallation(true) + }, + enabled = selectedVersion != null && selectedArtifact != null, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = "Uninstall & Install") + } + } + Button( + onClick = { + if (snapEnhanceApp != null) { + showDowngradeNotice = true + } else { + triggerPackageInstallation(false) + } + }, + enabled = selectedVersion != null && selectedArtifact != null, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = if (snapEnhanceApp != null) "Update" else "Install") + } + } + } + + LaunchedEffect(Unit) { + coroutineScope.launch(Dispatchers.IO) { + snapEnhanceReleases.value = fetchSEReleases() + } + } + } +} \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/SnapchatPatchTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/SnapchatPatchTab.kt new file mode 100644 index 0000000000..fc15e69fad --- /dev/null +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/SnapchatPatchTab.kt @@ -0,0 +1,326 @@ +package me.rhunk.snapenhance.manager.ui.tab.impl.download + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.manager.R +import me.rhunk.snapenhance.manager.data.APKMirror +import me.rhunk.snapenhance.manager.data.DownloadItem +import me.rhunk.snapenhance.manager.patch.config.Constants +import me.rhunk.snapenhance.manager.ui.components.ConfirmationDialog +import me.rhunk.snapenhance.manager.ui.tab.Tab +import java.io.File +import java.util.zip.ZipFile + +@OptIn(ExperimentalMaterial3Api::class) +class SnapchatPatchTab : Tab("snapchat_download") { + private val apkMirror = APKMirror() + private val cachedDownloadItems = mutableListOf() + private var currentPage by mutableIntStateOf(1) + + override fun init(activity: ComponentActivity) { + super.init(activity) + registerNestedTab(LSPatchTab::class) + } + + @Composable + override fun TopBar() { + var deleteAllDialog by remember { mutableStateOf(false) } + IconButton(onClick = { deleteAllDialog = true }) { + Icon(imageVector = Icons.Default.DeleteForever, contentDescription = null) + } + + if (deleteAllDialog) { + AlertDialog(onDismissRequest = { deleteAllDialog = false }) { + ConfirmationDialog(title = "Are you sure you want to delete all downloads?", onDismiss = { deleteAllDialog = false }) { + deleteAllDialog = false + runCatching { + sharedConfig.apkCache.listFiles()?.forEach { it.deleteRecursively() } + }.onFailure { + toast("Failed to delete downloads") + it.printStackTrace() + } + toast("Done!") + } + } + } + } + + @Composable + private fun DownloadItemRow(item: DownloadItem, onSelected: () -> Unit = {}) { + ElevatedCard( + modifier = Modifier.padding(10.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(5.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.padding(10.dp), + ) { + Icon(painter = painterResource(R.drawable.sclogo), contentDescription = null, tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(40.dp)) + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(5.dp) + ) { + Text(item.shortTitle) + if (!item.isBeta) { + Text("Recommended", color = MaterialTheme.colorScheme.tertiary) + } + } + Row( + modifier = Modifier.padding(5.dp), + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + FilledIconButton(onClick = { onSelected() }) { + Icon(imageVector = Icons.Default.Check, contentDescription = null) + } + } + } + } + } + + + @Composable + private fun SelectSnapchatVersionDialog(onSelected: (DownloadItem) -> Unit = {}) { + val coroutineScope = rememberCoroutineScope() + var isFetching by remember { mutableStateOf(false) } + val downloadItems = remember { cachedDownloadItems.toMutableStateList() } + + LazyColumn { + items(downloadItems, key = { it.hash }) { item -> + DownloadItemRow(item) { + onSelected(item) + } + } + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(modifier = Modifier.alpha(if (isFetching) 1f else 0f)) + } + SideEffect { + if (isFetching) return@SideEffect + isFetching = true + coroutineScope.launch { + runCatching { + withContext(Dispatchers.IO) { + apkMirror.fetchSnapchatVersions(currentPage)?.let { + withContext(Dispatchers.Main) { + cachedDownloadItems.addAll(it) + downloadItems.addAll(it) + } + } + } + }.onFailure { + it.printStackTrace() + } + ++currentPage + isFetching = false + } + } + } + } + } + + @Composable + override fun Content() { + var showSelectSnapchatVersionDialog by remember { mutableStateOf(false) } + var showRestoreMenuDialog by remember { mutableStateOf(false) } + + var selectedSnapchatVersion by remember { mutableStateOf(null as DownloadItem?) } + val installedSnapEnhanceVersion = remember { runCatching { activity.packageManager.getPackageInfo( + sharedConfig.snapEnhancePackageName, 0) }.getOrNull() } + + val installedSnapchatPackage = remember { runCatching { activity.packageManager.getPackageInfo( + sharedConfig.snapchatPackageName, 0) }.getOrNull() } + val isInstalledSnapchatPatched = remember { installedSnapchatPackage?.applicationInfo?.appComponentFactory == Constants.PROXY_APP_COMPONENT_FACTORY } + val isSnapchatNotSplitConfig = remember { + installedSnapchatPackage?.applicationInfo?.let { it.splitSourceDirs == null || it.splitSourceDirs?.isEmpty() == true } ?: false + } + + if (showRestoreMenuDialog) { + fun triggerSnapchatInstallation(shouldUninstall: Boolean) { + val apkFile = File(installedSnapchatPackage?.applicationInfo?.sourceDir ?: return).also { + if (!it.exists()) return + } + toast("Extracting origin apk") + val originApk = File.createTempFile("origin", ".apk", activity.externalCacheDirs.first()).also { + it.deleteOnExit() + } + ZipFile(apkFile).let { zipFile -> + zipFile.getEntry("assets/lspatch/origin.apk")?.apply { + originApk.outputStream().use { output -> + zipFile.getInputStream(this).copyTo(output) + } + } ?: run { + toast("Failed to extract origin apk") + return + } + } + + showRestoreMenuDialog = false + + navigation.navigateTo(InstallPackageTab::class, args = Bundle().apply { + putString("downloadPath", originApk.absolutePath) + putString("appPackage", sharedConfig.snapchatPackageName) + putBoolean("uninstall", shouldUninstall) + }, noHistory = true) + } + + AlertDialog(onDismissRequest = { showRestoreMenuDialog = false }) { + Card { + Column( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Button(onClick = { + triggerSnapchatInstallation(true) + }) { + Text("Uninstall & Install") + } + Button(onClick = { + triggerSnapchatInstallation(false) + }) { + Text("Update") + } + } + } + } + } + + + if (showSelectSnapchatVersionDialog) { + AlertDialog(onDismissRequest = { showSelectSnapchatVersionDialog = false }) { + SelectSnapchatVersionDialog { + selectedSnapchatVersion = it + showSelectSnapchatVersionDialog = false + } + } + } + + Column( + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(5.dp) + ) { + Text("Patch Snapchat") + + ElevatedCard( + modifier = Modifier.padding(10.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Icon(painter = painterResource(R.drawable.sclogo), contentDescription = null, tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(40.dp)) + if (selectedSnapchatVersion == null) { + Text(text = "Snapchat") + } + Text(text = selectedSnapchatVersion?.shortTitle ?: "Not Selected") + Button(onClick = { showSelectSnapchatVersionDialog = true }) { + Text("Choose") + } + } + } + + ElevatedCard( + modifier = Modifier.padding(10.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = "SnapEnhance") + Text(text = installedSnapEnhanceVersion?.versionName ?: "Not installed") + } + } + + Column( + modifier = Modifier.padding(top = 10.dp, bottom = 10.dp, start = 20.dp, end = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(5.dp) + ) { + Button( + modifier = Modifier.fillMaxWidth(), + enabled = selectedSnapchatVersion != null && installedSnapEnhanceVersion != null, + onClick = { + navigation.navigateTo(LSPatchTab::class, args = Bundle().apply { + putParcelable("downloadItem", selectedSnapchatVersion) + putString("modulePath", installedSnapEnhanceVersion?.applicationInfo?.sourceDir) + }) + } + ) { + Text("Download & Patch") + } + + if (isSnapchatNotSplitConfig) { + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + navigation.navigateTo(LSPatchTab::class, args = Bundle().apply { + putString("localItemFile", installedSnapchatPackage?.applicationInfo?.sourceDir ?: return@apply) + putString("modulePath", installedSnapEnhanceVersion?.applicationInfo?.sourceDir ?: return@apply) + }) + } + ) { + Text("Patch from existing installation") + } + } + + Text("Restore Snapchat", modifier = Modifier.padding(20.dp)) + Button( + modifier = Modifier.fillMaxWidth(), + enabled = selectedSnapchatVersion != null, + onClick = { + navigation.navigateTo(LSPatchTab::class, args = Bundle().apply { + putParcelable("downloadItem", selectedSnapchatVersion) + }) + } + ) { + Text("Install/Restore Original Snapchat") + } + + if (isInstalledSnapchatPatched && isSnapchatNotSplitConfig) { + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { showRestoreMenuDialog = true } + ) { + Text("Restore Snapchat from existing installation") + } + } + } + } + } +} \ No newline at end of file diff --git a/manager/src/main/res/drawable/sclogo.xml b/manager/src/main/res/drawable/sclogo.xml new file mode 100644 index 0000000000..651227e8a3 --- /dev/null +++ b/manager/src/main/res/drawable/sclogo.xml @@ -0,0 +1,15 @@ + + + + diff --git a/manager/src/main/res/values/themes.xml b/manager/src/main/res/values/themes.xml new file mode 100644 index 0000000000..fedf7d1c3b --- /dev/null +++ b/manager/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/manager/src/main/res/xml/provider_paths.xml b/manager/src/main/res/xml/provider_paths.xml new file mode 100644 index 0000000000..8d13fa1778 --- /dev/null +++ b/manager/src/main/res/xml/provider_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/mapper/.gitignore b/mapper/.gitignore new file mode 100644 index 0000000000..d16386367f --- /dev/null +++ b/mapper/.gitignore @@ -0,0 +1 @@ +build/ \ No newline at end of file diff --git a/mapper/build.gradle.kts b/mapper/build.gradle.kts new file mode 100644 index 0000000000..3cced89282 --- /dev/null +++ b/mapper/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + alias(libs.plugins.androidLibrary) + alias(libs.plugins.kotlinAndroid) +} + +android { + namespace = rootProject.ext["applicationId"].toString() + ".mapper" + compileSdk = 34 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + kotlinOptions { + jvmTarget = "21" + } + + defaultConfig { + minSdk = 28 + } +} + +dependencies { + implementation(libs.gson) + implementation(libs.coroutines) + implementation(libs.dexlib2) + testImplementation(libs.junit) +} \ No newline at end of file diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/AbstractClassMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/AbstractClassMapper.kt new file mode 100644 index 0000000000..2387cabbee --- /dev/null +++ b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/AbstractClassMapper.kt @@ -0,0 +1,113 @@ +package me.rhunk.snapenhance.mapper + +import android.util.Log +import com.google.gson.Gson +import com.google.gson.JsonObject + +abstract class AbstractClassMapper( + val mapperName: String +) { + lateinit var classLoader: ClassLoader + + private val gson = Gson() + private val values = mutableMapOf() + private val mappers = mutableListOf Unit>() + + private fun findClassSafe(className: String?) = runCatching { + classLoader.loadClass(className) + }.onFailure { + Log.e("Mapper", it.stackTraceToString()) + }.getOrNull() + + @Suppress("UNCHECKED_CAST") + inner class PropertyDelegate( + private val key: String, + defaultValue: Any? = null, + private val setter: (Any?) -> Unit = { values[key] = it }, + private val getter: (Any?) -> T? = { it as? T } + ) { + init { + values[key] = defaultValue + } + + operator fun getValue(thisRef: Any?, property: Any?): T? { + return getter(values[key]) + } + + operator fun setValue(thisRef: Any?, property: Any?, value: Any?) { + setter(value) + } + + fun set(value: String?) { + values[key] = value + } + + fun get(): T? { + return getter(values[key]) + } + + fun getAsClass(): Class<*>? { + return getter(values[key]) as? Class<*> + } + + fun getAsString(): String? { + return getter(values[key])?.toString() + } + + fun getClass(key: String): Class<*>? { + return (get() as? Map)?.let { + findClassSafe(it[key].toString()) + } + } + + override fun toString() = getter(values[key]).toString() + } + + fun string(key: String): PropertyDelegate = PropertyDelegate(key, null) + + fun classReference(key: String): PropertyDelegate> = PropertyDelegate(key, getter = { findClassSafe(it as? String) }) + + fun map(key: String, value: MutableMap = mutableMapOf()): PropertyDelegate> = PropertyDelegate(key, value) + + fun readFromJson(json: JsonObject) { + values.forEach { (key, _) -> + runCatching { + val jsonElement = json.get(key) ?: return@forEach + when (jsonElement) { + is JsonObject -> values[key] = gson.fromJson(jsonElement, HashMap::class.java) + else -> values[key] = jsonElement.asString + } + }.onFailure { + Log.e("Mapper","Failed to deserialize property $key") + } + } + } + + fun writeFromJson(json: JsonObject): List { + val warns = mutableListOf() + values.forEach { (key, value) -> + runCatching { + when (value) { + is String -> json.addProperty(key, value) + is Class<*> -> json.addProperty(key, value.name) + is Map<*, *> -> json.add(key, gson.toJsonTree(value)) + else -> json.addProperty(key, value.toString()) + } + }.onFailure { + Log.e("Mapper","Failed to serialize property $key") + } + if (json.get(key).let { it.isJsonNull || (it.isJsonPrimitive && it.asString == "null") }) { + warns.add("Failed to serialize property $key in $mapperName") + } + } + return warns + } + + fun mapper(task: MapperContext.() -> Unit) { + mappers.add(task) + } + + fun run(context: MapperContext) { + mappers.forEach { it(context) } + } +} \ No newline at end of file diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/ClassMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/ClassMapper.kt new file mode 100644 index 0000000000..013cef0119 --- /dev/null +++ b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/ClassMapper.kt @@ -0,0 +1,100 @@ +package me.rhunk.snapenhance.mapper + +import com.android.tools.smali.dexlib2.Opcodes +import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile +import com.android.tools.smali.dexlib2.iface.ClassDef +import com.google.gson.JsonObject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.mapper.impl.* +import java.io.BufferedInputStream +import java.io.InputStream +import java.util.zip.ZipFile +import java.util.zip.ZipInputStream + +class ClassMapper( + private vararg val mappers: AbstractClassMapper = DEFAULT_MAPPERS, +) { + private val classes = mutableListOf() + private val warnings = mutableListOf() + + companion object { + val DEFAULT_MAPPERS get() = arrayOf( + BCryptClassMapper(), + CallbackMapper(), + DefaultMediaItemMapper(), + MediaQualityLevelProviderMapper(), + OperaPageViewControllerMapper(), + PlusSubscriptionMapper(), + StoryBoostStateMapper(), + FriendsFeedEventDispatcherMapper(), + ChatEventDispatcherMapper(), + CompositeConfigurationProviderMapper(), + ScoreUpdateMapper(), + FriendRelationshipChangerMapper(), + ViewBinderMapper(), + OperaViewerParamsMapper(), + MemoriesPresenterMapper(), + StreaksExpirationMapper(), + COFObservableMapper(), + FoldingLayoutMapper(), + PlatformClientAttestationMapper(), + ) + } + + + fun loadApk(path: String) { + val apkFile = ZipFile(path) + val apkEntries = apkFile.entries().toList() + + fun readClass(stream: InputStream) = runCatching { + classes.addAll( + DexBackedDexFile.fromInputStream(Opcodes.getDefault(), BufferedInputStream(stream)).classes + ) + }.onFailure { + throw Throwable("Failed to load dex file", it) + } + + fun filterDexClasses(name: String) = name.startsWith("classes") && name.endsWith(".dex") + + apkEntries.firstOrNull { it.name.endsWith("lspatch/origin.apk") }?.let { origin -> + val originApk = ZipInputStream(apkFile.getInputStream(origin)) + var nextEntry = originApk.nextEntry + while (nextEntry != null) { + if (filterDexClasses(nextEntry.name)) { + readClass(originApk) + } + originApk.closeEntry() + nextEntry = originApk.nextEntry + } + return + } + + apkEntries.toList().filter { filterDexClasses(it.name) }.forEach { + readClass(apkFile.getInputStream(it)) + } + } + + fun getWarns() = warnings + + suspend fun run(): JsonObject { + val context = MapperContext(classes.associateBy { it.type }) + + withContext(Dispatchers.IO) { + mappers.forEach { mapper -> + launch { + mapper.run(context) + } + } + } + + val outputJson = JsonObject() + mappers.forEach { mapper -> + outputJson.add(mapper.mapperName, JsonObject().apply { + warnings.addAll(mapper.writeFromJson(this)) + }) + } + return outputJson + } +} diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/MapperContext.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/MapperContext.kt new file mode 100644 index 0000000000..7d74a95887 --- /dev/null +++ b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/MapperContext.kt @@ -0,0 +1,20 @@ +package me.rhunk.snapenhance.mapper + +import com.android.tools.smali.dexlib2.iface.ClassDef + +class MapperContext( + private val classMap: Map +) { + val classes: Collection + get() = classMap.values + + fun getClass(name: String?): ClassDef? { + if (name == null) return null + return classMap[name] + } + + fun getClass(name: CharSequence?): ClassDef? { + if (name == null) return null + return classMap[name.toString()] + } +} diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/ext/DexClassDef.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/ext/DexClassDef.kt new file mode 100644 index 0000000000..aea13738d9 --- /dev/null +++ b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/ext/DexClassDef.kt @@ -0,0 +1,24 @@ +package me.rhunk.snapenhance.mapper.ext + +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.iface.ClassDef + +fun ClassDef.isEnum(): Boolean = accessFlags and AccessFlags.ENUM.value != 0 +fun ClassDef.isAbstract(): Boolean = accessFlags and AccessFlags.ABSTRACT.value != 0 +fun ClassDef.isInterface(): Boolean = accessFlags and AccessFlags.INTERFACE.value != 0 +fun ClassDef.isFinal(): Boolean = accessFlags and AccessFlags.FINAL.value != 0 + +fun ClassDef.hasStaticConstructorString(string: String): Boolean = methods.any { + it.name == "" && it.implementation?.findConstString(string) == true +} + +fun ClassDef.hasConstructorString(string: String): Boolean = methods.any { + it.name == "" && it.implementation?.findConstString(string) == true +} + +fun ClassDef.getStaticConstructor() = methods.firstOrNull { + it.name == "" +} + +fun ClassDef.getClassName() = type.replaceFirst("L", "").replaceFirst(";", "") +fun ClassDef.getSuperClassName() = superclass?.replaceFirst("L", "")?.replaceFirst(";", "") diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/ext/DexMethod.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/ext/DexMethod.kt new file mode 100644 index 0000000000..cd9e668e2c --- /dev/null +++ b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/ext/DexMethod.kt @@ -0,0 +1,47 @@ +package me.rhunk.snapenhance.mapper.ext + +import com.android.tools.smali.dexlib2.iface.MethodImplementation +import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction21c +import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction22c +import com.android.tools.smali.dexlib2.iface.reference.FieldReference +import com.android.tools.smali.dexlib2.iface.reference.StringReference + +fun MethodImplementation.findConstString( + string: String, + contains: Boolean = false, + startsWith: Boolean = false, + ignoreCase: Boolean = false +): Boolean = instructions.filterIsInstance().any { + (it.reference as? StringReference)?.string?.let { str -> + if (contains && str.contains(string, ignoreCase = ignoreCase)) return@any true + if (startsWith && str.startsWith(string, ignoreCase = ignoreCase)) return@any true + str == string + } == true +} + +fun MethodImplementation.getAllConstStrings(): List = instructions.filterIsInstance().mapNotNull { + it.reference as? StringReference +}.map { + it.string +} + +fun MethodImplementation.searchNextFieldReference(constString: String, contains: Boolean = false): FieldReference? = this.instructions.let { + var found = false + for (instruction in it) { + if (instruction is Instruction21c && instruction.reference is StringReference) { + val str = (instruction.reference as StringReference).string + if (if (contains) str.contains(constString) else str == constString) { + found = true + } + } + + if (!found) continue + + if (instruction is Instruction22c && + instruction.reference is FieldReference + ) { + return@let (instruction.reference as FieldReference) + } + } + null +} diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/BCryptClassMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/BCryptClassMapper.kt new file mode 100644 index 0000000000..511c6d5b02 --- /dev/null +++ b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/BCryptClassMapper.kt @@ -0,0 +1,37 @@ +package me.rhunk.snapenhance.mapper.impl + +import com.android.tools.smali.dexlib2.iface.instruction.formats.ArrayPayload +import me.rhunk.snapenhance.mapper.AbstractClassMapper +import me.rhunk.snapenhance.mapper.ext.getClassName +import me.rhunk.snapenhance.mapper.ext.getStaticConstructor +import me.rhunk.snapenhance.mapper.ext.isFinal + +class BCryptClassMapper : AbstractClassMapper("BCryptClass") { + val classReference = classReference("class") + val hashMethod = string("hashMethod") + + init { + mapper { + for (clazz in classes) { + if (!clazz.isFinal()) continue + + val isBcryptClass = clazz.getStaticConstructor()?.let { constructor -> + constructor.implementation?.instructions?.filterIsInstance()?.any { it.arrayElements.size == 18 && it.arrayElements[0] == 608135816 } + } + + if (isBcryptClass == true) { + val hashDexMethod = clazz.methods.first { + it.parameterTypes.size == 2 && + it.parameterTypes[0] == "Ljava/lang/String;" && + it.parameterTypes[1] == "Ljava/lang/String;" && + it.returnType == "Ljava/lang/String;" + } + + hashMethod.set(hashDexMethod.name) + classReference.set(clazz.getClassName()) + return@mapper + } + } + } + } +} diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/COFObservableMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/COFObservableMapper.kt new file mode 100644 index 0000000000..3ebaec1cd7 --- /dev/null +++ b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/COFObservableMapper.kt @@ -0,0 +1,34 @@ +package me.rhunk.snapenhance.mapper.impl + +import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction35c +import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import me.rhunk.snapenhance.mapper.AbstractClassMapper +import me.rhunk.snapenhance.mapper.ext.getClassName + +class COFObservableMapper: AbstractClassMapper("COFObservable") { + val classReference = classReference("class") + val getBooleanObservable = string("getBooleanObservable") + + init { + mapper { + for (classDef in classes) { + if (classDef.interfaces.isEmpty()) continue + if (classDef.methods.none { it.name == "dispose" }) continue + + val getBooleanObservableDexMethod = classDef.methods.firstOrNull { method -> + method.parameterTypes.size == 2 && + method.parameterTypes[0] == "Ljava/lang/String;" && + getClass(method.returnType)?.methods?.any { it.name == "mergeFrom" } == true + } ?: continue + + if (getBooleanObservableDexMethod.implementation?.instructions?.any { instruction -> + instruction is Instruction35c && (instruction.reference as? MethodReference)?.name == "elapsedRealtime" + } == true) { + getBooleanObservable.set(getBooleanObservableDexMethod.name) + classReference.set(classDef.getClassName()) + return@mapper + } + } + } + } +} \ No newline at end of file diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/CallbackMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/CallbackMapper.kt new file mode 100644 index 0000000000..47ce73dee5 --- /dev/null +++ b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/CallbackMapper.kt @@ -0,0 +1,41 @@ +package me.rhunk.snapenhance.mapper.impl + +import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction35c +import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import me.rhunk.snapenhance.mapper.AbstractClassMapper +import me.rhunk.snapenhance.mapper.ext.getClassName +import me.rhunk.snapenhance.mapper.ext.getSuperClassName +import me.rhunk.snapenhance.mapper.ext.isFinal + +class CallbackMapper : AbstractClassMapper("Callbacks") { + val callbacks = map("callbacks") + + init { + mapper { + val callbackClasses = classes.filter { clazz -> + if (clazz.superclass == null) return@filter false + + val superclassName = clazz.getSuperClassName()!! + if ((!superclassName.endsWith("Callback") && !superclassName.endsWith("Delegate") && !superclassName.endsWith("EventHandler")) + || superclassName.endsWith("\$Callback")) return@filter false + + if (clazz.getClassName().endsWith("\$CppProxy")) return@filter false + + // ignore dummy ContentCallback classes + if (superclassName.endsWith("ContentCallback") && clazz.methods.none { method -> + method.name == "handleContentResult" && + method.implementation?.instructions?.firstOrNull { instruction -> + instruction is Instruction35c && (instruction.reference as? MethodReference)?.name == "getBoltContentId" + } != null + }) return@filter false + + val superClass = getClass(clazz.superclass) ?: return@filter false + !superClass.isFinal() + }.map { + it.getSuperClassName()!!.substringAfterLast("/") to it.getClassName() + } + + callbacks.get()?.putAll(callbackClasses) + } + } +} diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/ChatEventDispatcherMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/ChatEventDispatcherMapper.kt new file mode 100644 index 0000000000..a485944711 --- /dev/null +++ b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/ChatEventDispatcherMapper.kt @@ -0,0 +1,18 @@ +package me.rhunk.snapenhance.mapper.impl + +import me.rhunk.snapenhance.mapper.AbstractClassMapper +import me.rhunk.snapenhance.mapper.ext.getClassName + +class ChatEventDispatcherMapper : AbstractClassMapper("ChatEventDispatcher") { + val classReference = classReference("class") + + init { + mapper { + for (clazz in classes) { + if (clazz.methods.firstOrNull { it.name == "onChatItemDoubleClickEvent" } == null) continue + classReference.set(clazz.getClassName()) + return@mapper + } + } + } +} \ No newline at end of file diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/CompositeConfigurationProviderMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/CompositeConfigurationProviderMapper.kt new file mode 100644 index 0000000000..3803536de9 --- /dev/null +++ b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/CompositeConfigurationProviderMapper.kt @@ -0,0 +1,108 @@ +package me.rhunk.snapenhance.mapper.impl + +import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction21c +import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction35c +import com.android.tools.smali.dexlib2.iface.reference.FieldReference +import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import me.rhunk.snapenhance.mapper.AbstractClassMapper +import me.rhunk.snapenhance.mapper.ext.findConstString +import me.rhunk.snapenhance.mapper.ext.getClassName +import me.rhunk.snapenhance.mapper.ext.hasStaticConstructorString +import me.rhunk.snapenhance.mapper.ext.isEnum +import java.lang.reflect.Modifier + +class CompositeConfigurationProviderMapper : AbstractClassMapper("CompositeConfigurationProvider") { + val classReference = classReference("class") + val observeProperty = string("observeProperty") + val getProperty = string("getProperty") + val configEnumMapping = mapOf( + "class" to classReference("enumClass"), + "getValue" to string("enumGetValue"), + "getCategory" to string("enumGetCategory"), + "defaultValueField" to string("enumDefaultValueField") + ) + val appExperimentProvider = mapOf( + "class" to classReference("appExperimentProviderClass"), + "getBooleanAppExperimentClass" to classReference("getBooleanAppExperimentClass"), + "hasExperimentMethod" to string("hasExperimentMethod") + ) + + init { + mapper { + for (classDef in classes) { + val constructor = classDef.methods.firstOrNull { it.name == "" } ?: continue + if (constructor.parameterTypes.size == 0 || constructor.parameterTypes[0] != "Ljava/util/List;") continue + if (constructor.implementation?.findConstString("CompositeConfigurationProvider") != true) continue + + val getPropertyMethod = classDef.methods.first { method -> + method.parameterTypes.size > 1 && + method.returnType == "Ljava/lang/Object;" && + getClass(method.parameterTypes[0])?.interfaces?.contains("Ljava/io/Serializable;") == true && + getClass(method.parameterTypes[1])?.let { it.isEnum() && it.hasStaticConstructorString("BOOLEAN") } == true + } + + val configEnumInterface = getClass(getPropertyMethod.parameterTypes[0])!! + val enumType = getClass(getPropertyMethod.parameterTypes[1])!! + + val observePropertyMethod = classDef.methods.first { + it.parameterTypes.size > 2 && + it.parameterTypes[0] == configEnumInterface.type && + it.parameterTypes[1] == "Ljava/lang/String;" && + it.parameterTypes[2] == enumType.type + } + + val hasExperimentMethodReference = observePropertyMethod.implementation?.instructions?.firstOrNull { instruction -> + if (instruction !is Instruction35c) return@firstOrNull false + (instruction.reference as? MethodReference)?.let { methodRef -> + methodRef.returnType == "Z" && methodRef.parameterTypes.size == 1 && methodRef.parameterTypes[0] == configEnumInterface.type + } == true + }?.let { (it as Instruction35c).reference as MethodReference } + + val getBooleanAppExperimentClass = classDef.methods.first { + // search for observeBoolean method + it.parameterTypes.size == 1 && + it.parameterTypes[0] == configEnumInterface.type && + it.implementation?.findConstString("observeBoolean") == true + }.let { method -> + // search for static field invocation of GetBooleanAppExperiment class + val getBooleanAppExperimentClassFieldInstruction = method.implementation?.instructions?.firstOrNull { instruction -> + if (instruction !is Instruction21c) return@firstOrNull false + val fieldReference = instruction.reference as? FieldReference ?: return@firstOrNull false + getClass(fieldReference.definingClass)?.methods?.any { + it.returnType == "Ljava/lang/Object;" && + it.parameterTypes.size == 2 && + (0..1).all { i -> it.parameterTypes[i] == "Ljava/lang/Object;" } + } == true + }?.let { (it as Instruction21c).reference as FieldReference } + + getClass(getBooleanAppExperimentClassFieldInstruction?.definingClass)?.getClassName() + } + + val enumGetDefaultValueMethod = configEnumInterface.methods.first { getClass(it.returnType)?.interfaces?.contains("Ljava/io/Serializable;") == true } + val enumGetCategoryMethod = configEnumInterface.methods.first { it.parameterTypes.size == 0 && getClass(it.returnType)?.isEnum() == true } + val defaultValueField = getClass(enumGetDefaultValueMethod.returnType)!!.fields.first { + Modifier.isFinal(it.accessFlags) && + Modifier.isPublic(it.accessFlags) && + it.type == "Ljava/lang/Object;" + } + + classReference.set(classDef.getClassName()) + observeProperty.set(observePropertyMethod.name) + getProperty.set(getPropertyMethod.name) + + configEnumMapping["class"]?.set(configEnumInterface.getClassName()) + configEnumMapping["getValue"]?.set(enumGetDefaultValueMethod.name) + configEnumMapping["getCategory"]?.set(enumGetCategoryMethod.name) + configEnumMapping["defaultValueField"]?.set(defaultValueField.name) + + hasExperimentMethodReference?.let { + appExperimentProvider["class"]?.set(getClass(it.definingClass)?.getClassName()) + appExperimentProvider["getBooleanAppExperimentClass"]?.set(getBooleanAppExperimentClass) + appExperimentProvider["hasExperimentMethod"]?.set(hasExperimentMethodReference.name) + } + + return@mapper + } + } + } +} diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/DefaultMediaItemMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/DefaultMediaItemMapper.kt new file mode 100644 index 0000000000..3786930198 --- /dev/null +++ b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/DefaultMediaItemMapper.kt @@ -0,0 +1,45 @@ +package me.rhunk.snapenhance.mapper.impl + +import me.rhunk.snapenhance.mapper.AbstractClassMapper +import me.rhunk.snapenhance.mapper.ext.findConstString +import me.rhunk.snapenhance.mapper.ext.getClassName +import me.rhunk.snapenhance.mapper.ext.isAbstract +import me.rhunk.snapenhance.mapper.ext.searchNextFieldReference + +class DefaultMediaItemMapper : AbstractClassMapper("DefaultMediaItem") { + val cameraRollMediaId = classReference("cameraRollMediaIdClass") + val durationMsField = string("durationMsField") + val defaultMediaItemClass = classReference("defaultMediaItemClass") + val defaultMediaItemDurationMsField = string("defaultMediaItemDurationMsField") + + init { + mapper { + for (clazz in classes) { + if (clazz.methods.find { it.name == "toString" }?.implementation?.findConstString("CameraRollMediaId", contains = true) != true) { + continue + } + val durationMsDexField = clazz.fields.firstOrNull { it.type == "J" } ?: continue + + cameraRollMediaId.set(clazz.getClassName()) + durationMsField.set(durationMsDexField.name) + return@mapper + } + } + + mapper { + for (clazz in classes) { + val superClass = getClass(clazz.superclass) ?: continue + + if (!superClass.isAbstract() || superClass.interfaces.isEmpty() || superClass.interfaces[0] != "Ljava/lang/Comparable;") continue + if (clazz.methods.none { it.returnType == "Landroid/net/Uri;" }) continue + + val durationInMillisDexField = clazz.methods.firstOrNull { it.name == "toString" }?.implementation?.takeIf { + it.findConstString("metadata", contains = true) + }?.searchNextFieldReference("durationInMillis", contains = true) ?: continue + defaultMediaItemClass.set(clazz.getClassName()) + defaultMediaItemDurationMsField.set(durationInMillisDexField.name) + return@mapper + } + } + } +} \ No newline at end of file diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/FoldingLayoutMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/FoldingLayoutMapper.kt new file mode 100644 index 0000000000..472496c584 --- /dev/null +++ b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/FoldingLayoutMapper.kt @@ -0,0 +1,35 @@ +package me.rhunk.snapenhance.mapper.impl + +import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction22c +import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction35c +import com.android.tools.smali.dexlib2.iface.reference.FieldReference +import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import me.rhunk.snapenhance.mapper.AbstractClassMapper + +class FoldingLayoutMapper: AbstractClassMapper("FoldingLayoutMapper") { + val onLayoutCompletedMethod = string("onLayoutCompletedMethod") + val shouldScrollToBottomField = string("shouldScrollToBottomField") + val sizeSparseArrayField = string("sizeSparseArrayField") + val recyclerViewField = string("recyclerViewField") + + init { + mapper { + val foldingLayoutManagerClass = getClass("Lcom/snap/messaging/chat/features/messagelist/FoldingLayoutManager;") ?: return@mapper + + sizeSparseArrayField.set(foldingLayoutManagerClass.fields.firstOrNull { it.type == "Landroid/util/SparseIntArray;" }?.name ?: return@mapper) + recyclerViewField.set(foldingLayoutManagerClass.fields.firstOrNull { it.type == "Landroidx/recyclerview/widget/RecyclerView;" }?.name ?: return@mapper) + + foldingLayoutManagerClass.methods.firstOrNull { + it.parameterTypes.size == 1 && it.returnType == "V" && it.implementation?.instructions?.any { + ((it as? Instruction35c)?.reference as? MethodReference)?.name == "invoke" + } == true + }?.let { method -> + onLayoutCompletedMethod.set(method.name) + for (instruction in method.implementation?.instructions ?: return@mapper) { + shouldScrollToBottomField.set(((instruction as? Instruction22c)?.reference as? FieldReference)?.takeIf { it.type == "Z" }?.name ?: continue) + break + } + } + } + } +} \ No newline at end of file diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/FriendRelationshipChangerMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/FriendRelationshipChangerMapper.kt new file mode 100644 index 0000000000..21d57e8a9d --- /dev/null +++ b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/FriendRelationshipChangerMapper.kt @@ -0,0 +1,52 @@ +package me.rhunk.snapenhance.mapper.impl + +import me.rhunk.snapenhance.mapper.AbstractClassMapper +import me.rhunk.snapenhance.mapper.ext.findConstString +import me.rhunk.snapenhance.mapper.ext.getClassName +import me.rhunk.snapenhance.mapper.ext.isAbstract +import me.rhunk.snapenhance.mapper.ext.isEnum +import java.lang.reflect.Modifier + +class FriendRelationshipChangerMapper : AbstractClassMapper("FriendRelationshipChanger") { + val classReference = classReference("class") + + val friendshipRelationshipChangerKtx = classReference("removeFriendClass") + val addFriendMethod = string("addFriendMethod") + val runFriendDurableJob = string("runFriendDurableJob") + + init { + mapper { + for (classDef in classes) { + classDef.methods.firstOrNull { it.name == "" }?.implementation?.findConstString("FriendRelationshipChangerImpl")?.takeIf { it } ?: continue + classReference.set(classDef.getClassName()) + + runFriendDurableJob.set(classDef.methods.firstOrNull { + Modifier.isStatic(it.accessFlags) && + it.returnType.contains("CompletableAndThenCompletable") && + it.parameterTypes.size == 5 && + it.parameterTypes[0] == classDef.type && + it.parameterTypes[1] == "Ljava/lang/String;" && + it.parameterTypes[3] == "I" && + it.parameterTypes[4] == "Ljava/lang/String;" + }?.name ?: continue) + } + } + mapper { + for (classDef in classes) { + if (!classDef.isAbstract()) continue + val addFriendDexMethod = classDef.methods.firstOrNull { + Modifier.isStatic(it.accessFlags) && + it.parameterTypes.size == 6 && + it.parameterTypes[1] == "Ljava/lang/String;" && + getClass(it.parameterTypes[2])?.isEnum() == true && + getClass(it.parameterTypes[4])?.isEnum() == true && + it.parameterTypes[5] == "I" + } ?: continue + + friendshipRelationshipChangerKtx.set(classDef.getClassName()) + addFriendMethod.set(addFriendDexMethod.name) + return@mapper + } + } + } +} \ No newline at end of file diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/FriendsFeedEventDispatcherMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/FriendsFeedEventDispatcherMapper.kt new file mode 100644 index 0000000000..3bd45001ac --- /dev/null +++ b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/FriendsFeedEventDispatcherMapper.kt @@ -0,0 +1,30 @@ +package me.rhunk.snapenhance.mapper.impl + +import me.rhunk.snapenhance.mapper.AbstractClassMapper +import me.rhunk.snapenhance.mapper.ext.findConstString +import me.rhunk.snapenhance.mapper.ext.getClassName + + +class FriendsFeedEventDispatcherMapper : AbstractClassMapper("FriendsFeedEventDispatcher") { + val classReference = classReference("class") + val viewModelField = string("viewModelField") + + init { + mapper { + for (clazz in classes) { + if (clazz.methods.count { it.name == "onClickFeed" || it.name == "onItemLongPress" } != 2) continue + val onItemLongPress = clazz.methods.first { it.name == "onItemLongPress" } + val viewHolderContainerClass = getClass(onItemLongPress.parameterTypes[0]) ?: continue + + val viewModelDexField = viewHolderContainerClass.fields.firstOrNull { field -> + val typeClass = getClass(field.type) ?: return@firstOrNull false + typeClass.methods.firstOrNull {it.name == "toString"}?.implementation?.findConstString("FriendFeedItemViewModel", contains = true) == true + }?.name ?: continue + + classReference.set(clazz.getClassName()) + viewModelField.set(viewModelDexField) + return@mapper + } + } + } +} \ No newline at end of file diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/MediaQualityLevelProviderMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/MediaQualityLevelProviderMapper.kt new file mode 100644 index 0000000000..ed7285fd5b --- /dev/null +++ b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/MediaQualityLevelProviderMapper.kt @@ -0,0 +1,43 @@ +package me.rhunk.snapenhance.mapper.impl + +import com.android.tools.smali.dexlib2.AccessFlags +import me.rhunk.snapenhance.mapper.AbstractClassMapper +import me.rhunk.snapenhance.mapper.ext.getClassName +import me.rhunk.snapenhance.mapper.ext.hasStaticConstructorString +import me.rhunk.snapenhance.mapper.ext.isAbstract +import me.rhunk.snapenhance.mapper.ext.isEnum + +class MediaQualityLevelProviderMapper : AbstractClassMapper("MediaQualityLevelProvider") { + val mediaQualityLevelProvider = classReference("mediaQualityLevelProvider") + val mediaQualityLevelProviderMethod = string("mediaQualityLevelProviderMethod") + + init { + var enumQualityLevel : String? = null + + mapper { + for (enumClass in classes) { + if (!enumClass.isEnum()) continue + + if (enumClass.hasStaticConstructorString("LEVEL_MAX")) { + enumQualityLevel = enumClass.getClassName() + break; + } + } + } + + mapper { + if (enumQualityLevel == null) return@mapper + + for (clazz in classes) { + if (!clazz.isAbstract()) continue + if (clazz.fields.none { it.accessFlags and AccessFlags.TRANSIENT.value != 0 }) continue + + clazz.methods.firstOrNull { it.returnType == "L$enumQualityLevel;" }?.let { + mediaQualityLevelProvider.set(clazz.getClassName()) + mediaQualityLevelProviderMethod.set(it.name) + return@mapper + } + } + } + } +} diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/MemoriesPresenterMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/MemoriesPresenterMapper.kt new file mode 100644 index 0000000000..64fd70474b --- /dev/null +++ b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/MemoriesPresenterMapper.kt @@ -0,0 +1,25 @@ +package me.rhunk.snapenhance.mapper.impl + +import me.rhunk.snapenhance.mapper.AbstractClassMapper +import me.rhunk.snapenhance.mapper.ext.findConstString +import me.rhunk.snapenhance.mapper.ext.getClassName + +class MemoriesPresenterMapper : AbstractClassMapper("MemoriesPresenter") { + val classReference = classReference("class") + val onNavigationEventMethod = string("onNavigationEventMethod") + + init { + mapper { + for (clazz in classes) { + if (clazz.interfaces.size != 1) continue + val getNameMethod = clazz.methods.firstOrNull { it.name == "getName" } ?: continue + if (getNameMethod.implementation?.findConstString("MemoriesAsyncPresenterFragmentSubscriber") != true) continue + + val onNavigationEvent = clazz.methods.firstOrNull { it.implementation?.findConstString("Memories") == true } ?: continue + + classReference.set(clazz.getClassName()) + onNavigationEventMethod.set(onNavigationEvent.name) + } + } + } +} \ No newline at end of file diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/OperaPageViewControllerMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/OperaPageViewControllerMapper.kt new file mode 100644 index 0000000000..492c2f9294 --- /dev/null +++ b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/OperaPageViewControllerMapper.kt @@ -0,0 +1,58 @@ +package me.rhunk.snapenhance.mapper.impl + +import me.rhunk.snapenhance.mapper.AbstractClassMapper +import me.rhunk.snapenhance.mapper.ext.getClassName +import me.rhunk.snapenhance.mapper.ext.hasConstructorString +import me.rhunk.snapenhance.mapper.ext.hasStaticConstructorString +import me.rhunk.snapenhance.mapper.ext.isAbstract +import me.rhunk.snapenhance.mapper.ext.isEnum + +class OperaPageViewControllerMapper : AbstractClassMapper("OperaPageViewController") { + val classReference = classReference("class") + val viewStateField = string("viewStateField") + val layerListField = string("layerListField") + val onDisplayStateChange = string("onDisplayStateChange") + val onDisplayStateChangeGesture = string("onDisplayStateChangeGesture") + + init { + mapper { + for (clazz in classes) { + if (!clazz.hasConstructorString("OperaPageViewController") || !clazz.hasStaticConstructorString("ad_product_type")) { + continue + } + + val viewStateDexField = clazz.fields.first { field -> + val fieldClass = getClass(field.type) ?: return@first false + fieldClass.isEnum() && fieldClass.hasStaticConstructorString("FULLY_DISPLAYED") + } + + val layerListDexField = clazz.fields.first { it.type == "Ljava/util/ArrayList;" } + + val onDisplayStateChangeDexMethod = clazz.methods.firstOrNull { + if (it.returnType != "V" || it.parameterTypes.size != 1) return@firstOrNull false + val firstParameterType = getClass(it.parameterTypes[0]) ?: return@firstOrNull false + if (firstParameterType.type == clazz.type || !firstParameterType.isAbstract()) return@firstOrNull false + //check if the class contains a field with the enumViewStateClass type + firstParameterType.fields.any { field -> + field.type == viewStateDexField.type + } + } + + val onDisplayStateChangeGestureDexMethod = clazz.methods.first { + if (it.returnType != "V" || it.parameterTypes.size != 2) return@first false + val firstParameterType = getClass(it.parameterTypes[0]) ?: return@first false + val secondParameterType = getClass(it.parameterTypes[1]) ?: return@first false + firstParameterType.isEnum() && secondParameterType.isEnum() + } + + classReference.set(clazz.getClassName()) + viewStateField.set(viewStateDexField.name) + layerListField.set(layerListDexField.name) + onDisplayStateChange.set(onDisplayStateChangeDexMethod?.name) + onDisplayStateChangeGesture.set(onDisplayStateChangeGestureDexMethod.name) + + return@mapper + } + } + } +} \ No newline at end of file diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/OperaViewerParamsMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/OperaViewerParamsMapper.kt new file mode 100644 index 0000000000..dd95e875ea --- /dev/null +++ b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/OperaViewerParamsMapper.kt @@ -0,0 +1,38 @@ +package me.rhunk.snapenhance.mapper.impl + +import com.android.tools.smali.dexlib2.iface.Method +import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction35c +import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import me.rhunk.snapenhance.mapper.AbstractClassMapper +import me.rhunk.snapenhance.mapper.ext.findConstString +import me.rhunk.snapenhance.mapper.ext.getClassName + +class OperaViewerParamsMapper : AbstractClassMapper("OperaViewerParams") { + val classReference = classReference("class") + + private fun Method.hasHashMapReference(methodName: String) = implementation?.instructions?.any { + val instruction = it as? Instruction35c ?: return@any false + val reference = instruction.reference as? MethodReference ?: return@any false + reference.name == methodName && reference.definingClass == "Ljava/util/concurrent/ConcurrentHashMap;" + } == true + + init { + mapper { + for (classDef in classes) { + if (classDef.methods.firstOrNull { it.name == "toString" }?.implementation?.findConstString("Params") != true) continue + + classDef.methods.firstOrNull { method -> + method.returnType == "Ljava/lang/Object;" && + method.hasHashMapReference("get") + } ?: continue + classDef.methods.firstOrNull { method -> + method.returnType == "V" && + method.hasHashMapReference("remove") + } ?: continue + + classReference.set(classDef.getClassName()) + return@mapper + } + } + } +} diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/PlatformClientAttestationMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/PlatformClientAttestationMapper.kt new file mode 100644 index 0000000000..a6a6b596dd --- /dev/null +++ b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/PlatformClientAttestationMapper.kt @@ -0,0 +1,41 @@ +package me.rhunk.snapenhance.mapper.impl + +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction35c +import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import me.rhunk.snapenhance.mapper.AbstractClassMapper +import me.rhunk.snapenhance.mapper.ext.getClassName +import me.rhunk.snapenhance.mapper.ext.getSuperClassName + +class PlatformClientAttestationMapper: AbstractClassMapper("PlatformClientAttestationMapper") { + val pluginNativeClass = classReference("pluginNativeClass") + val apiInvocationHandler = classReference("apiInvocationHandler") + + init { + mapper { + for (clazz in classes) { + if (clazz.interfaces.firstOrNull()?.endsWith("InvocationHandler;") != true) continue + val invokeMethod = clazz.methods.firstOrNull { it.name == "invoke" } ?: continue + invokeMethod.implementation?.instructions?.firstOrNull { it is Instruction35c && (it.reference as? MethodReference)?.name == "getDeclaringClass" } ?: continue + + apiInvocationHandler.set(clazz.getClassName()) + return@mapper + } + } + + mapper { + for (clazz in classes) { + if (clazz.getSuperClassName()?.endsWith("PlatformClientAttestation") != true) continue + val getSignatureMethod = clazz.methods.firstOrNull { it.name == "getSignature" } ?: continue + + getSignatureMethod.implementation?.instructions?.firstOrNull { instruction -> + instruction.opcode == Opcode.INVOKE_STATIC && instruction is Instruction35c && (instruction.reference as? MethodReference)?.takeIf { it.definingClass.count { it == '/' } == 1 }?.returnType == "[B" + }?.let { instruction -> + val method = (instruction as Instruction35c).reference as MethodReference + pluginNativeClass.set(method.definingClass.replaceFirst("L", "").replaceFirst(";", "").replace("/", ".")) + } + return@mapper + } + } + } +} \ No newline at end of file diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/PlusSubscriptionMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/PlusSubscriptionMapper.kt new file mode 100644 index 0000000000..520ab8f5c9 --- /dev/null +++ b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/PlusSubscriptionMapper.kt @@ -0,0 +1,40 @@ +package me.rhunk.snapenhance.mapper.impl + +import me.rhunk.snapenhance.mapper.AbstractClassMapper +import me.rhunk.snapenhance.mapper.ext.findConstString +import me.rhunk.snapenhance.mapper.ext.getClassName +import me.rhunk.snapenhance.mapper.ext.searchNextFieldReference + +class PlusSubscriptionMapper : AbstractClassMapper("PlusSubscription"){ + val classReference = classReference("class") + val tierField = string("tierField") + val statusField = string("statusField") + val originalSubscriptionTimeMillisField = string("originalSubscriptionTimeMillisField") + val expirationTimeMillisField = string("expirationTimeMillisField") + + init { + mapper { + for (clazz in classes) { + if (clazz.directMethods.filter { it.name == "" }.none { + it.parameterTypes.size > 3 + }) continue + + val toStringMethod = clazz.virtualMethods.firstOrNull { it.name == "toString" }?.implementation ?: continue + if (!toStringMethod.let { + it.findConstString("SubscriptionInfo", contains = true) && it.findConstString("expirationTimeMillis", contains = true) + }) continue + + classReference.set(clazz.getClassName()) + + toStringMethod.apply { + searchNextFieldReference("tier", contains = true)?.let { tierField.set(it.name) } + searchNextFieldReference("status", contains = true)?.let { statusField.set(it.name) } + searchNextFieldReference("original", contains = true)?.let { originalSubscriptionTimeMillisField.set(it.name) } + searchNextFieldReference("expirationTimeMillis", contains = true)?.let { expirationTimeMillisField.set(it.name) } + } + + return@mapper + } + } + } +} \ No newline at end of file diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/ScoreUpdateMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/ScoreUpdateMapper.kt new file mode 100644 index 0000000000..e6ae6d52c5 --- /dev/null +++ b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/ScoreUpdateMapper.kt @@ -0,0 +1,28 @@ +package me.rhunk.snapenhance.mapper.impl + +import me.rhunk.snapenhance.mapper.AbstractClassMapper +import me.rhunk.snapenhance.mapper.ext.findConstString +import me.rhunk.snapenhance.mapper.ext.getClassName + +class ScoreUpdateMapper : AbstractClassMapper("ScoreUpdate") { + val classReference = classReference("class") + + init { + mapper { + for (classDef in classes) { + val toStringMethod = classDef.methods.firstOrNull { + it.name == "toString" + } ?: continue + if (classDef.methods.none { + it.name == "" && + it.parameterTypes.size > 4 + }) continue + + if (toStringMethod.implementation?.findConstString("selectFriendUserScoresNeedToUpdate", contains = true) != true) continue + + classReference.set(classDef.getClassName()) + return@mapper + } + } + } +} \ No newline at end of file diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/StoryBoostStateMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/StoryBoostStateMapper.kt new file mode 100644 index 0000000000..bab2a82fe7 --- /dev/null +++ b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/StoryBoostStateMapper.kt @@ -0,0 +1,24 @@ +package me.rhunk.snapenhance.mapper.impl + +import me.rhunk.snapenhance.mapper.AbstractClassMapper +import me.rhunk.snapenhance.mapper.ext.findConstString +import me.rhunk.snapenhance.mapper.ext.getClassName + +class StoryBoostStateMapper : AbstractClassMapper("StoryBoostState") { + val classReference = classReference("class") + + init { + mapper { + for (clazz in classes) { + val firstConstructor = clazz.directMethods.firstOrNull { it.name == "" } ?: continue + if (firstConstructor.parameters.size != 3) continue + if (firstConstructor.parameterTypes[1] != "J" || firstConstructor.parameterTypes[2] != "J") continue + + if (clazz.methods.firstOrNull { it.name == "toString" }?.implementation?.findConstString("StoryBoostState", contains = true) != true) continue + + classReference.set(clazz.getClassName()) + return@mapper + } + } + } +} \ No newline at end of file diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/StreaksExpirationMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/StreaksExpirationMapper.kt new file mode 100644 index 0000000000..0a63b38312 --- /dev/null +++ b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/StreaksExpirationMapper.kt @@ -0,0 +1,38 @@ +package me.rhunk.snapenhance.mapper.impl + +import me.rhunk.snapenhance.mapper.AbstractClassMapper +import me.rhunk.snapenhance.mapper.ext.findConstString +import me.rhunk.snapenhance.mapper.ext.getClassName +import java.lang.reflect.Modifier + +class StreaksExpirationMapper: AbstractClassMapper("StreaksExpirationMapper") { + val simpleStreaksFormatterClass = classReference("simpleStreaksFormatterClass") + val formatSimpleStreaksTextMethod = string("formatSimpleStreaksTextMethod") + + init { + mapper { + var streaksResultClass: String? = null + for (clazz in classes) { + val toStringMethod = clazz.methods.firstOrNull { it.name == "toString" } ?: continue + if (toStringMethod.implementation?.findConstString("StreaksResult(", startsWith = true) != true) continue + streaksResultClass = clazz.type + break + } + + if (streaksResultClass == null) return@mapper + + for (clazz in classes) { + val formatStreaksTextDexMethod = clazz.methods.firstOrNull { method -> + Modifier.isStatic(method.accessFlags) && + method.returnType == "Ljava/lang/String;" && + method.parameterTypes.let { + it.size >= 3 && it.first() == streaksResultClass && it[1] == "Ljava/lang/String;" && it[2] == "J" + } + } ?: continue + simpleStreaksFormatterClass.set(clazz.getClassName()) + formatSimpleStreaksTextMethod.set(formatStreaksTextDexMethod.name) + return@mapper + } + } + } +} \ No newline at end of file diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/ViewBinderMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/ViewBinderMapper.kt new file mode 100644 index 0000000000..95904bbd3a --- /dev/null +++ b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/ViewBinderMapper.kt @@ -0,0 +1,41 @@ +package me.rhunk.snapenhance.mapper.impl + +import me.rhunk.snapenhance.mapper.AbstractClassMapper +import me.rhunk.snapenhance.mapper.ext.getClassName +import me.rhunk.snapenhance.mapper.ext.isAbstract +import me.rhunk.snapenhance.mapper.ext.isInterface +import java.lang.reflect.Modifier + +class ViewBinderMapper : AbstractClassMapper("ViewBinder") { + val classReference = classReference("class") + val bindMethod = string("bindMethod") + val getViewMethod = string("getViewMethod") + + init { + mapper { + for (clazz in classes) { + if (!clazz.isAbstract() || clazz.isInterface()) continue + + val getViewDexMethod = clazz.methods.firstOrNull { it.returnType == "Landroid/view/View;" && it.parameterTypes.size == 0 } ?: continue + + // update view + clazz.methods.filter { + Modifier.isAbstract(it.accessFlags) && it.parameterTypes.size == 1 && it.parameterTypes[0] == "Landroid/view/View;" && it.returnType == "V" + }.also { + if (it.size != 1) return@also + }.firstOrNull() ?: continue + + val bindDexMethod = clazz.methods.filter { + Modifier.isAbstract(it.accessFlags) && it.parameterTypes.size == 2 && it.parameterTypes[0] == it.parameterTypes[1] && it.returnType == "V" + }.also { + if (it.size != 1) return@also + }.firstOrNull() ?: continue + + classReference.set(clazz.getClassName()) + bindMethod.set(bindDexMethod.name) + getViewMethod.set(getViewDexMethod.name) + return@mapper + } + } + } +} \ No newline at end of file diff --git a/mapper/src/test/kotlin/android/util/Log.kt b/mapper/src/test/kotlin/android/util/Log.kt new file mode 100644 index 0000000000..cae8817537 --- /dev/null +++ b/mapper/src/test/kotlin/android/util/Log.kt @@ -0,0 +1,27 @@ +package android.util + +object Log { + @JvmStatic + fun d(tag: String, msg: String): Int { + println("[$tag] $msg") + return 0 + } + + @JvmStatic + fun e(tag: String, msg: String): Int { + println("[$tag] $msg") + return 0 + } + + @JvmStatic + fun i(tag: String, msg: String): Int { + println("[$tag] $msg") + return 0 + } + + @JvmStatic + fun v(tag: String, msg: String): Int { + println("[$tag] $msg") + return 0 + } +} \ No newline at end of file diff --git a/mapper/src/test/kotlin/me/rhunk/snapenhance/mapper/tests/TestMappings.kt b/mapper/src/test/kotlin/me/rhunk/snapenhance/mapper/tests/TestMappings.kt new file mode 100644 index 0000000000..7f2e77052d --- /dev/null +++ b/mapper/src/test/kotlin/me/rhunk/snapenhance/mapper/tests/TestMappings.kt @@ -0,0 +1,23 @@ +package me.rhunk.snapenhance.mapper.tests + +import com.google.gson.GsonBuilder +import kotlinx.coroutines.runBlocking +import me.rhunk.snapenhance.mapper.ClassMapper +import org.junit.Test +import java.io.File + + +class TestMappings { + @Test + fun testMappings() { + val classMapper = ClassMapper() + + val gson = GsonBuilder().setPrettyPrinting().create() + val apkFile = File(System.getenv("SNAPCHAT_APK")!!) + classMapper.loadApk(apkFile.absolutePath) + runBlocking { + val result = classMapper.run() + println("Mappings: ${gson.toJson(result)}") + } + } +} diff --git a/native/.gitignore b/native/.gitignore new file mode 100644 index 0000000000..72ff93553d --- /dev/null +++ b/native/.gitignore @@ -0,0 +1,3 @@ +/build +/.cxx +/rust/target \ No newline at end of file diff --git a/native/build.gradle.kts b/native/build.gradle.kts new file mode 100644 index 0000000000..4579e0fa89 --- /dev/null +++ b/native/build.gradle.kts @@ -0,0 +1,72 @@ +plugins { + alias(libs.plugins.rust.android) + alias(libs.plugins.androidLibrary) + alias(libs.plugins.kotlinAndroid) +} + +val nativeName = rootProject.ext.get("buildHash") + +android { + namespace = rootProject.ext["applicationId"].toString() + ".nativelib" + compileSdk = 34 + + buildToolsVersion = "34.0.0" + ndkVersion = System.getenv("ANDROID_NDK_HOME")?.trimEnd('/')?.substringAfterLast("/") ?: "27.1.12297006" + + buildFeatures { + buildConfig = true + } + + defaultConfig { + buildConfigField("String", "NATIVE_NAME", "\"$nativeName\".toString()") + minSdk = 28 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + kotlinOptions { + jvmTarget = "21" + } +} + +cargo { + module = "rust" + libname = nativeName.toString() + targetIncludes = arrayOf("libsnapenhance.so") + profile = "release" + targets = listOf("arm64", "arm") +} + +fun getNativeFiles() = File(projectDir, "build/rustJniLibs/android").listFiles()?.flatMap { abiFolder -> + abiFolder.takeIf { it.isDirectory }?.listFiles()?.toList() ?: emptyList() +} + + +val buildAndRename by tasks.registering { + dependsOn("cargoBuild") + doLast { + getNativeFiles()?.forEach { file -> + if (file.name.endsWith(".so")) { + println("Renaming ${file.absolutePath}") + file.renameTo(File(file.parent, "lib$nativeName.so")) + } + } + } +} + +val cleanNatives by tasks.registering { + finalizedBy(buildAndRename) + doFirst { + println("Cleaning native files") + getNativeFiles()?.forEach { file -> + file.deleteRecursively() + } + } +} + +tasks.named("preBuild").configure { + dependsOn(cleanNatives) +} diff --git a/native/rust/.cargo/config.toml b/native/rust/.cargo/config.toml new file mode 100644 index 0000000000..351acab466 --- /dev/null +++ b/native/rust/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +target = "aarch64-linux-android" \ No newline at end of file diff --git a/native/rust/Cargo.lock b/native/rust/Cargo.lock new file mode 100644 index 0000000000..136e8766e6 --- /dev/null +++ b/native/rust/Cargo.lock @@ -0,0 +1,868 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_log-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ecc8056bf6ab9892dcd53216c83d1597487d7dacac16c8df6b877d127df9937" + +[[package]] +name = "android_logger" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b07e8e73d720a1f2e4b6014766e6039fd2e96a4fa44e2a78d0e1fa2ff49826" +dependencies = [ + "android_log-sys", + "env_filter", + "log", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "bitflags" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1be3f42a67d6d345ecd59f675f3f012d6974981560836e938c22b424b85ce1be" + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" + +[[package]] +name = "cc" +version = "1.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8293772165d9345bdaaa39b45b2109591e63fe5e6fbc23c6ff930a048aa310b" +dependencies = [ + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "num-traits", + "windows-targets 0.52.6", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "dobby-rs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b8fbf3688f3584f3f87ec7024032d81da6e8b868a1d23d8a6c0a399e7c9935" +dependencies = [ + "dobby-sys", + "thiserror", +] + +[[package]] +name = "dobby-sys" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cdffdeaa52be950db80677f22f549050675f226b77e97fdbe10bb2dd846ac7b" + +[[package]] +name = "env_filter" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "errno" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "flate2" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c936bfdafb507ebbf50b8074c54fa31c5be9a1e7e5f467dd659697041407d07c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "itoa" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +dependencies = [ + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.169" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "log" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "miniz_oxide" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8402cab7aefae129c6977bb0ff1b8fd9a04eb5b51efc50a70bea51cda0c7924" +dependencies = [ + "adler2", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pkg-config" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" + +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "procfs" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc5b72d8145275d844d4b5f6d4e1eef00c8cd889edb6035c21675d1bb1f45c9f" +dependencies = [ + "bitflags", + "chrono", + "flate2", + "hex", + "procfs-core", + "rustix", +] + +[[package]] +name = "procfs-core" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "239df02d8349b06fc07398a3a1697b06418223b1c7725085e801e7c0fc6a12ec" +dependencies = [ + "bitflags", + "chrono", + "hex", +] + +[[package]] +name = "quote" +version = "1.0.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "rustix" +version = "0.38.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a78891ee6bf2340288408954ac787aa063d8e8817e9f53abb37c695c6d834ef6" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustversion" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.217" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.217" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.135" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b0d7ba2887406110130a978386c4e1befb98c674b4fba677954e4db976630d9" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "snapenhance" +version = "0.1.0" +dependencies = [ + "android_logger", + "dobby-rs", + "jni", + "log", + "nix", + "once_cell", + "paste", + "procfs", + "rand", + "serde_json", + "zstd", +] + +[[package]] +name = "syn" +version = "2.0.96" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d0adab1ae378d7f53bdebc67a39f1f151407ef230f0ce2883572f5d8985c80" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zstd" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcf2b778a664581e31e389454a7072dab1647606d44f7feea22cd5abb9c9f3f9" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54a3ab4db68cea366acc5c897c7b4d4d1b8994a9cd6e6f841f8964566a419059" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.13+zstd.1.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38ff0f21cfee8f97d94cef41359e0c89aa6113028ab0291aa8ca0038995a95aa" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/native/rust/Cargo.toml b/native/rust/Cargo.toml new file mode 100644 index 0000000000..61e0fdb110 --- /dev/null +++ b/native/rust/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "snapenhance" +version = "0.1.0" +authors = ["rhunk"] +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +android_logger = "0.14.1" +dobby-rs = "0.1.0" +jni = "0.21.1" +log = "0.4.25" +nix = { version = "0.29.0", features = ["fs"] } +once_cell = "1.20.2" +paste = "1.0.15" +procfs = "0.17.0" +rand = "0.8.5" +serde_json = "1.0.135" +zstd = "0.13.2" diff --git a/native/rust/build.rs b/native/rust/build.rs new file mode 100644 index 0000000000..06df7d1aa2 --- /dev/null +++ b/native/rust/build.rs @@ -0,0 +1,3 @@ +fn main() { + println!("cargo:rustc-link-lib=static=c++"); +} \ No newline at end of file diff --git a/native/rust/src/common.rs b/native/rust/src/common.rs new file mode 100644 index 0000000000..6e4033c814 --- /dev/null +++ b/native/rust/src/common.rs @@ -0,0 +1,49 @@ +use jni::{objects::GlobalRef, JavaVM}; +use once_cell::sync::{Lazy, OnceCell}; + +use crate::mapped_lib::MappedLib; + +static NATIVE_LIB_INSTANCE: OnceCell = OnceCell::new(); +static JAVA_VM: OnceCell = OnceCell::new(); + +pub static CLIENT_MODULE: Lazy = Lazy::new(|| { + let mut client_module = MappedLib::new("libclient.so".into()); + + if let Err(error) = client_module.search() { + warn!("Unable to find libclient.so: {}", error); + + client_module = MappedLib::new("split_config.arm".into()); + + if let Err(error) = client_module.search() { + panic!("Unable to find split_config.arm: {}", error); + } + } + + client_module +}); + + +pub fn set_native_lib_instance(instance: GlobalRef) { + NATIVE_LIB_INSTANCE.set(instance).expect("NativeLib instance already set"); +} + +pub fn native_lib_instance() -> GlobalRef { + NATIVE_LIB_INSTANCE.get().expect("NativeLib instance not set").clone() +} + +pub fn set_java_vm(vm: *mut jni::sys::JavaVM) { + JAVA_VM.set(vm as usize).expect("JavaVM already set"); +} + +pub fn java_vm() -> JavaVM { + unsafe { + JavaVM::from_raw(*JAVA_VM.get().expect("JavaVM not set") as *mut jni::sys::JavaVM).expect("Failed to get JavaVM") + } +} + +pub fn attach_jni_env(block: impl FnOnce(&mut jni::JNIEnv)) { + let jvm = java_vm(); + let mut env: jni::AttachGuard = jvm.attach_current_thread().expect("Failed to attach to current thread"); + + block(&mut env); +} \ No newline at end of file diff --git a/native/rust/src/config.rs b/native/rust/src/config.rs new file mode 100644 index 0000000000..895b682e12 --- /dev/null +++ b/native/rust/src/config.rs @@ -0,0 +1,54 @@ +use std::{error::Error, sync::Mutex}; +use jni::{objects::JObject, JNIEnv}; +use crate::util::get_jni_string; + +static NATIVE_CONFIG: Mutex> = Mutex::new(None); + +pub fn native_config() -> NativeConfig { + NATIVE_CONFIG.lock().unwrap().as_ref().expect("NativeConfig not loaded").clone() +} + +#[derive(Debug, Clone)] +pub(crate) struct NativeConfig { + pub disable_bitmoji: bool, + pub disable_metrics: bool, + pub composer_hooks: bool, + pub custom_emoji_font_path: Option, +} + +impl NativeConfig { + fn new(env: &mut JNIEnv, obj: JObject) -> Result> { + macro_rules! get_boolean { + ($field:expr) => { + env.get_field(&obj, $field, "Z")?.z()? + }; + } + + macro_rules! get_string { + ($field:expr) => { + match env.get_field(&obj, $field, "Ljava/lang/String;")?.l()? { + jstring => if !jstring.is_null() { + Some(get_jni_string(env, jstring.into())?) + } else { + None + }, + } + }; + } + + Ok(Self { + disable_bitmoji: get_boolean!("disableBitmoji"), + disable_metrics: get_boolean!("disableMetrics"), + composer_hooks: get_boolean!("composerHooks"), + custom_emoji_font_path: get_string!("customEmojiFontPath"), + }) + } +} + +pub fn load_config(mut env: JNIEnv, _class: JObject, obj: JObject) { + NATIVE_CONFIG.lock().unwrap().replace( + NativeConfig::new(&mut env, obj).expect("Failed to load NativeConfig") + ); + + info!("Config loaded {:?}", native_config()); +} \ No newline at end of file diff --git a/native/rust/src/hook.rs b/native/rust/src/hook.rs new file mode 100644 index 0000000000..7527e4bdd8 --- /dev/null +++ b/native/rust/src/hook.rs @@ -0,0 +1,50 @@ +use std::sync::Mutex; + +pub static MUTEX: Mutex<()> = Mutex::new(()); + +#[macro_export] +macro_rules! def_hook { + ($func:ident, $ret:ty, | $($arg:ident : $arg_type:ty),* | $body:block) => { + paste::item! { + #[allow(non_upper_case_globals)] + static mut [<$func _original>]: std::option::Option $ret> = None; + + fn $func($($arg: $arg_type),*) -> $ret { + { + #[allow(unused_unsafe)] + unsafe { + $body + } + } + } + } + }; +} + +#[macro_export] +macro_rules! dobby_hook { + ($sym:expr, $hook:expr) => { + paste::item! { + unsafe { + if let Ok(_) = crate::hook::MUTEX.lock() { + if let Some(ptr) = dobby_rs::hook($sym, $hook as *mut std::ffi::c_void).ok().map(|x| x as *mut std::ffi::c_void) { + [<$hook _original>] = std::mem::transmute(ptr); + } + } + } + } + }; +} + +#[macro_export] +macro_rules! dobby_hook_sym { + ($lib:expr, $sym:expr, $hook:expr) => { + if let Some(hook_symbol) = dobby_rs::resolve_symbol($lib, $sym) { + crate::dobby_hook!(hook_symbol, $hook); + debug!("hooked symbol: {}", $sym); + } else { + panic!("Failed to resolve symbol: {}", $sym); + } + }; +} + diff --git a/native/rust/src/lib.rs b/native/rust/src/lib.rs new file mode 100644 index 0000000000..3503e4b77a --- /dev/null +++ b/native/rust/src/lib.rs @@ -0,0 +1,151 @@ +#[macro_use] +extern crate log; + +mod common; + +mod hook; +mod util; +mod mapped_lib; +mod config; +mod sig; + +mod modules; + +use android_logger::Config; +use log::LevelFilter; +use modules::{composer_hook, custom_font_hook, duplex_hook, fstat_hook, linker_hook, sqlite_hook, unary_call_hook}; + +use jni::objects::{JObject, JString}; +use jni::sys::{jint, jstring, JNI_VERSION_1_6}; +use jni::{JNIEnv, JavaVM, NativeMethod}; +use util::get_jni_string; + +use std::ffi::c_void; +use std::thread::JoinHandle; + +fn pre_init() { + debug!("Pre init"); + linker_hook::init(); + custom_font_hook::init(); + fstat_hook::init(); +} + +fn init(mut env: JNIEnv, _class: JObject, signature_cache: JString) -> jstring { + debug!("Initializing native lib"); + + let start_time = std::time::Instant::now(); + + // load signature cache + + if !signature_cache.is_null() { + let sig_cache_str = get_jni_string(&mut env, signature_cache).expect("Failed to convert mappings to string"); + + if let Ok(signature_cache) = serde_json::from_str(sig_cache_str.as_str()) { + sig::add_signatures(signature_cache); + } else { + error!("Failed to load signature cache"); + } + } + + common::set_native_lib_instance(env.new_global_ref(_class).ok().expect("Failed to create global ref")); + + let _ = common::CLIENT_MODULE; + + // initialize modules asynchronously + + let mut threads: Vec> = Vec::new(); + + macro_rules! async_init { + ($($f:expr),*) => { + $( + threads.push(std::thread::spawn(move || { + $f; + })); + )* + }; + } + + async_init!( + duplex_hook::init(), + unary_call_hook::init(), + composer_hook::init(), + sqlite_hook::init() + ); + + threads.into_iter().for_each(|t| t.join().unwrap()); + + info!("native init took {:?}", start_time.elapsed()); + + // send back the signature cache + if let Ok(signature_cache) = serde_json::to_string(&sig::get_signatures()) { + env.new_string(signature_cache).ok().expect("Failed to create new string").into_raw() + } else { + std::ptr::null_mut() + } +} + + +#[allow(non_snake_case)] +#[no_mangle] +pub extern "system" fn JNI_OnLoad(_vm: JavaVM, _: *mut c_void) -> jint { + android_logger::init_once( + Config::default() + .with_max_level(LevelFilter::Debug) + .with_tag("SnapEnhanceNative") + ); + + info!("JNI_OnLoad called"); + + std::panic::set_hook(Box::new(|panic_info| { + error!("{:?}", panic_info); + })); + + common::set_java_vm(_vm.get_java_vm_pointer()); + + let mut env = _vm.get_env().expect("Failed to get JNIEnv"); + + let native_lib_class = env.find_class("me/rhunk/snapenhance/nativelib/NativeLib").expect("NativeLib class not found"); + + env.register_native_methods( + native_lib_class, + &[ + NativeMethod { + name: "preInit".into(), + sig: "()V".into(), + fn_ptr: pre_init as *mut c_void, + }, + NativeMethod { + name: "init".into(), + sig: "(Ljava/lang/String;)Ljava/lang/String;".into(), + fn_ptr: init as *mut c_void, + }, + NativeMethod { + name: "loadConfig".into(), + sig: "(Lme/rhunk/snapenhance/nativelib/NativeConfig;)V".into(), + fn_ptr: config::load_config as *mut c_void, + }, + NativeMethod { + name: "addLinkerSharedLibrary".into(), + sig: "(Ljava/lang/String;[B)V".into(), + fn_ptr: linker_hook::add_linker_shared_library as *mut c_void, + }, + NativeMethod { + name: "lockDatabase".into(), + sig: "(Ljava/lang/String;Ljava/lang/Runnable;)V".into(), + fn_ptr: sqlite_hook::lock_database as *mut c_void, + }, + NativeMethod { + name: "setComposerLoader".into(), + sig: "(Ljava/lang/String;)V".into(), + fn_ptr: composer_hook::set_composer_loader as *mut c_void, + }, + NativeMethod { + name: "composerEval".into(), + sig: "(Ljava/lang/String;)Ljava/lang/String;".into(), + fn_ptr: composer_hook::composer_eval as *mut c_void, + } + ] + ).expect("Failed to register native methods"); + + JNI_VERSION_1_6 +} diff --git a/native/rust/src/mapped_lib.rs b/native/rust/src/mapped_lib.rs new file mode 100644 index 0000000000..eadbd7408d --- /dev/null +++ b/native/rust/src/mapped_lib.rs @@ -0,0 +1,49 @@ +use std::error::Error; + +use procfs::process::{MMPermissions, MMapPath}; + +#[derive(Debug)] +pub(crate) struct MappedRegion { + pub start: u64, + pub end: u64, + pub perms: MMPermissions, +} + +#[derive(Debug)] +pub(crate) struct MappedLib { + name: String, + pub regions: Vec, +} + +impl MappedLib { + pub fn new(name: String) -> Self { + Self { + name, + regions: Vec::new(), + } + } + + pub fn search(&mut self) -> Result<&Self, Box> { + procfs::process::Process::myself()?.maps()?.iter().for_each(|map| { + let pathname = &map.pathname; + + if let MMapPath::Path(path_buffer) = pathname { + let path = path_buffer.to_string_lossy(); + + if path.contains(&self.name) { + self.regions.push(MappedRegion { + start: map.address.0, + end: map.address.1, + perms: map.perms, + }); + } + } + }); + + if self.regions.is_empty() { + return Err(format!("No regions found for {}", self.name).into()); + } + + Ok(self) + } +} diff --git a/native/rust/src/modules/composer_hook.rs b/native/rust/src/modules/composer_hook.rs new file mode 100644 index 0000000000..8aeaf2d893 --- /dev/null +++ b/native/rust/src/modules/composer_hook.rs @@ -0,0 +1,257 @@ +#![allow(dead_code, unused_imports)] + +use super::util::composer_utils::{ComposerModule, ModuleTag}; +use std::{collections::HashMap, ffi::{c_void, CStr}, sync::Mutex}; +use jni::{objects::JString, sys::jobject, JNIEnv}; +use once_cell::sync::Lazy; +use crate::{common, config, def_hook, dobby_hook, dobby_hook_sym, sig, util::get_jni_string}; + +const JS_TAG_BIG_DECIMAL: i64 = -11; +const JS_TAG_BIG_INT: i64 = -10; +const JS_TAG_BIG_FLOAT: i64 = -9; +const JS_TAG_SYMBOL: i64 = -8; +const JS_TAG_STRING: i64 = -7; +const JS_TAG_MODULE: i64 = -3; +const JS_TAG_FUNCTION_BYTECODE: i64 = -2; +const JS_TAG_OBJECT: i64 = -1; +const JS_TAG_INT: i64 = 0; +const JS_TAG_BOOL: i64 = 1; +const JS_TAG_NULL: i64 = 2; +const JS_TAG_UNDEFINED: i64 = 3; +const JS_TAG_UNINITIALIZED: i64 = 4; +const JS_TAG_CATCH_OFFSET: i64 = 5; +const JS_TAG_EXCEPTION: i64 = 6; +const JS_TAG_FLOAT64: i64 = 7; + +#[repr(C)] +struct JsString { + /* + original structure : + struct JSString { + struct JSRefCountHeader { + int ref_count; + }; + uint32_t len : 31; + uint8_t is_wide_char : 1; + uint32_t hash : 30; + uint8_t atom_type : 2; + uint32_t hash_next; + + union { + uint8_t str8[0]; + uint16_t str16[0]; + } u; + }; + */ + pad: [u32; 4], + str8: [u8; 0], + str16: [u16; 0], +} + +#[repr(C)] +#[derive(Copy, Clone)] +union JsValueUnion { + int32: i32, + float64: f64, + ptr: *mut c_void, +} + +#[repr(C)] +#[derive(Copy, Clone)] +struct JsValue { + u: JsValueUnion, + tag: i64, +} + +static AASSET_MAP: Lazy>>> = Lazy::new(|| Mutex::new(HashMap::new())); +static COMPOSER_LOADER_DATA: Mutex> = Mutex::new(None); + +def_hook!( + aasset_get_length, + i32, + |arg0: *mut c_void| { + if let Some(buffer) = AASSET_MAP.lock().unwrap().get(&(arg0 as usize)) { + return buffer.len() as i32; + } + aasset_get_length_original.unwrap()(arg0) + } +); + +def_hook!( + aasset_get_buffer, + *const c_void, + |arg0: *mut c_void| { + if let Some(buffer) = AASSET_MAP.lock().unwrap().get(&(arg0 as usize)) { + return buffer.as_ptr() as *const c_void; + } + aasset_get_buffer_original.unwrap()(arg0) + } +); + +def_hook!( + aasset_manager_open, + *mut c_void, + |arg0: *mut c_void, arg1: *const u8, arg2: i32| { + let handle = aasset_manager_open_original.unwrap()(arg0, arg1, arg2); + + let path = Lazy::new(|| CStr::from_ptr(arg1).to_str().unwrap()); + if !handle.is_null() && path.starts_with("bridge_observables") { + let asset_buffer = aasset_get_buffer_original.unwrap()(handle); + let asset_length = aasset_get_length_original.unwrap()(handle); + debug!("asset buffer: {:p}, length: {}", asset_buffer, asset_length); + + let composer_loader = COMPOSER_LOADER_DATA.lock().unwrap().clone().expect("No composer loader data"); + + let archive_buffer: Vec = std::slice::from_raw_parts(asset_buffer as *const u8, asset_length as usize).to_vec(); + let decompressed = zstd::stream::decode_all(&archive_buffer[..]).expect("Failed to decompress composer archive"); + let mut composer_module = ComposerModule::parse(decompressed).expect("Failed to parse composer module"); + + let mut tags = composer_module.get_tags(); + let mut new_tags = Vec::new(); + + for (tag1, _) in tags.iter_mut() { + let name = tag1.to_string().unwrap(); + if !name.ends_with("src/utils/converter.js") { + continue; + } + + let old_file_name = name.split_once(".").unwrap().0.to_owned() + rand::random::().to_string().as_str(); + tag1.set_buffer((old_file_name.to_owned() + ".js").as_bytes().to_vec()); + let original_module_path = path.split_once(".").unwrap().0.to_owned() + "/" + &old_file_name; + + let hooked_module = format!("{};module.exports = require(\"{}\");", composer_loader, original_module_path); + + new_tags.push( + ( + ModuleTag::new(128, name.as_bytes().to_vec()), + ModuleTag::new(128, hooked_module.as_bytes().to_vec()) + ) + ); + + debug!("composer loader injected in {}", name); + break; + } + + tags.extend(new_tags); + composer_module.set_tags(tags); + + let compressed = composer_module.to_bytes(); + let compressed = zstd::stream::encode_all(&compressed[..], 3).expect("Failed to compress"); + + AASSET_MAP.lock().unwrap().insert(handle as usize, compressed); + } + handle + } +); + +def_hook!( + aasset_close, + c_void, + |handle: *mut c_void| { + AASSET_MAP.lock().unwrap().remove(&(handle as usize)); + aasset_close_original.unwrap()(handle) + } +); + +#[cfg(target_arch = "aarch64")] +static mut GLOBAL_INSTANCE: Option<*mut c_void> = None; +#[cfg(target_arch = "aarch64")] +static mut GLOBAL_CTX: Option<*mut c_void> = None; + +#[cfg(target_arch = "aarch64")] +static mut JS_EVAL_ORIGINAL2: Option JsValue> = None; + +def_hook!( + js_eval, + *mut c_void, + |arg0: *mut c_void, arg1: *mut c_void, arg2: *mut c_void, arg3: *const u8, arg4: *const u8, arg5: *const u8, arg6: *mut c_void, arg7: u32| { + #[cfg(target_arch = "aarch64")] + { + GLOBAL_INSTANCE = Some(arg0); + GLOBAL_CTX = Some(arg1); + } + js_eval_original.unwrap()(arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7) + } +); + +pub fn set_composer_loader(mut env: JNIEnv, _: *mut c_void, code: JString) { + let new_code = get_jni_string(&mut env, code).expect("Failed to get composer loader code"); + COMPOSER_LOADER_DATA.lock().unwrap().replace(new_code); +} + +#[allow(unreachable_code, unused_variables)] +pub unsafe fn composer_eval(env: JNIEnv, _: *mut c_void, script: JString) -> jobject { + #[cfg(target_arch = "aarch64")] + { + let mut env = env; + + let script_str = get_jni_string(&mut env, script).expect("Failed to get script"); + let script_length = script_str.len(); + + let js_value = JS_EVAL_ORIGINAL2.expect("No js eval found")( + GLOBAL_INSTANCE.expect("No global instance found"), + GLOBAL_CTX.expect("No global context found"), + std::ptr::null_mut(), + (script_str + "\0").as_ptr() as *mut u8, + script_length, + "\0".as_ptr(), + 0 + ); + + let result: String = if js_value.tag == JS_TAG_STRING { + let string = js_value.u.ptr as *mut JsString; + CStr::from_ptr((*string).str8.as_ptr() as *const u8).to_str().unwrap().into() + } else if js_value.tag == JS_TAG_INT { + js_value.u.int32.to_string() + } else if js_value.tag == JS_TAG_BOOL { + if js_value.u.int32 == 1 { "true" } else { "false" }.into() + } else if js_value.tag == JS_TAG_NULL { + "null".into() + } else if js_value.tag == JS_TAG_UNDEFINED { + "undefined".into() + } else if js_value.tag == JS_TAG_OBJECT { + "[object]".into() + } else if js_value.tag == JS_TAG_FLOAT64 { + js_value.u.float64.to_string() + } else if js_value.tag == JS_TAG_EXCEPTION { + "Failed to evaluate script".into() + } else { + "[unknown tag ".to_owned() + &js_value.tag.to_string() + "]".into() + }; + + return env.new_string(result).unwrap().into_raw() + } + + return env.new_string("Architecture not supported").unwrap().into_raw(); +} + +pub fn init() { + if !config::native_config().composer_hooks { + return + } + + dobby_hook_sym!("libandroid.so", "AAsset_getBuffer", aasset_get_buffer); + dobby_hook_sym!("libandroid.so", "AAsset_getLength", aasset_get_length); + dobby_hook_sym!("libandroid.so", "AAsset_close", aasset_close); + dobby_hook_sym!("libandroid.so", "AAssetManager_open", aasset_manager_open); + + #[cfg(target_arch = "aarch64")] + { + if let Some(signature) = sig::find_signature( + &common::CLIENT_MODULE, + "00 E4 00 6F 29 00 80 52 76 00 04 8B", -0x28, + "A1 B0 07 92 81 46", -0x7 + ) { + dobby_hook!(signature as *mut c_void, js_eval); + + unsafe { + JS_EVAL_ORIGINAL2 = Some(std::mem::transmute(js_eval_original.unwrap())); + } + + debug!("js_eval {:#x}", signature); + } else { + warn!("Unable to find js_eval signature"); + } + } +} + diff --git a/native/rust/src/modules/custom_font_hook.rs b/native/rust/src/modules/custom_font_hook.rs new file mode 100644 index 0000000000..d26cfb2d7d --- /dev/null +++ b/native/rust/src/modules/custom_font_hook.rs @@ -0,0 +1,34 @@ +use std::{ffi::CStr, fs}; + +use nix::libc::{self, c_uint}; + +use crate::{config, def_hook, dobby_hook_sym}; + +def_hook!( + open_hook, + i32, + |path: *const u8, flags: i32, mode: c_uint| { + if let Ok(pathname) = CStr::from_ptr(path).to_str() { + if pathname == "/system/fonts/NotoColorEmoji.ttf" { + if let Some(font_path) = config::native_config().custom_emoji_font_path { + if fs::metadata(&font_path).is_ok() { + return libc::openat(libc::AT_FDCWD, font_path.as_ptr() as *const u8, flags, mode); + } else { + warn!("custom emoji font path does not exist: {}", font_path); + } + } + } + } + + open_hook_original.unwrap()(path, flags, mode) + } +); + + +pub fn init() { + if config::native_config().custom_emoji_font_path.is_none() { + return; + } + + dobby_hook_sym!("libc.so", "open", open_hook); +} \ No newline at end of file diff --git a/native/rust/src/modules/duplex_hook.rs b/native/rust/src/modules/duplex_hook.rs new file mode 100644 index 0000000000..b73dac3b31 --- /dev/null +++ b/native/rust/src/modules/duplex_hook.rs @@ -0,0 +1,41 @@ +use std::ffi::c_void; + +use jni::{objects::JObject, sys::jboolean, JNIEnv}; + +use crate::{common, def_hook, dobby_hook, util::get_jni_string}; + + +def_hook!( + is_same_object, + jboolean, + |env: JNIEnv, obj1: JObject, obj2: JObject| { + let mut env = env; + + if obj1.is_null() || obj2.is_null() { + return is_same_object_original.unwrap()(env, obj1, obj2); + } + + let class = env.find_class("java/lang/Class").unwrap(); + + if !env.is_instance_of(&obj1, class).unwrap() { + return is_same_object_original.unwrap()(env, obj1, obj2); + } + + let obj1_class_name = env.call_method(&obj1, "getName", "()Ljava/lang/String;", &[]).unwrap().l().unwrap().into(); + let class_name = get_jni_string(&mut env, obj1_class_name).expect("Failed to get class name"); + + if class_name.contains("com.snapchat.client.duplex.MessageHandler") { + debug!("is_same_object hook: MessageHandler"); + return 0; + } + + is_same_object_original.unwrap()(env, obj1, obj2) + } +); + + +pub fn init() { + common::attach_jni_env(|env| { + dobby_hook!((**env.get_native_interface()).IsSameObject.unwrap() as *mut c_void, is_same_object); + }); +} \ No newline at end of file diff --git a/native/rust/src/modules/fstat_hook.rs b/native/rust/src/modules/fstat_hook.rs new file mode 100644 index 0000000000..5956b1015f --- /dev/null +++ b/native/rust/src/modules/fstat_hook.rs @@ -0,0 +1,37 @@ + +use std::fs; + +use nix::libc; + +use crate::{config::{self, native_config}, def_hook, dobby_hook_sym}; + +def_hook!( + fstat_hook, + i32, + |fd: i32, statbuf: *mut libc::stat| { + if let Ok(link) = fs::read_link("/proc/self/fd/".to_owned() + &fd.to_string()) { + if let Some(filename) = link.file_name().map(|t| t.to_string_lossy()) { + let config = native_config(); + if config.disable_metrics && filename.contains("files/blizzardv2/queues") { + if libc::unlink((filename.to_owned() + "\0").as_ptr()) == -1 { + warn!("Failed to unlink {}", filename); + } + return -1; + } + + if config.disable_bitmoji && filename.contains("com.snap.file_manager_4_SCContent") { + return -1; + } + } + } + + fstat_hook_original.unwrap()(fd, statbuf) + } +); + +pub fn init() { + let config = config::native_config(); + if config.disable_metrics || config.disable_bitmoji { + dobby_hook_sym!("libc.so", "fstat", fstat_hook); + } +} \ No newline at end of file diff --git a/native/rust/src/modules/linker_hook.rs b/native/rust/src/modules/linker_hook.rs new file mode 100644 index 0000000000..b73ede33d1 --- /dev/null +++ b/native/rust/src/modules/linker_hook.rs @@ -0,0 +1,60 @@ +use std::{collections::HashMap, ffi::{c_void, CStr}, sync::Mutex}; + +use jni::{objects::{JByteArray, JString}, JNIEnv}; +use nix::libc; +use once_cell::sync::Lazy; + +use crate::{def_hook, dobby_hook_sym}; + +static SHARED_LIBRARIES: Lazy>>>> = Lazy::new(|| Mutex::new(HashMap::new())); + +def_hook!( + linker_openat, + i32, + |dir_fd: i32, pathname: *mut u8, flags: i32, mode: i32| { + let pathname_str = CStr::from_ptr(pathname).to_str().unwrap().to_string(); + + if let Some(content) = SHARED_LIBRARIES.lock().unwrap().remove(&pathname_str) { + let memfd = libc::syscall(libc::SYS_memfd_create, "jit-cache\0".as_ptr(), 0) as i32; + let content = content.into_boxed_slice(); + + if libc::write(memfd, content.as_ptr() as *const c_void, content.len() as libc::size_t) == -1 { + panic!("failed to write to memfd"); + } + + if libc::lseek(memfd, 0, libc::SEEK_SET) == -1 { + panic!("failed to seek memfd"); + } + + std::mem::forget(content); + + info!("opened shared library: {}", pathname_str); + return memfd; + } + + linker_openat_original.unwrap()(dir_fd, pathname, flags, mode) + } +); + +pub fn add_linker_shared_library(mut env: JNIEnv, _: *mut c_void, path: JString, content: JByteArray) { + let path = env.get_string(&path).unwrap().to_str().unwrap().to_string(); + let content_length = env.get_array_length(&content).expect("Failed to get array length"); + let mut content_buffer = Box::new(vec![0i8; content_length as usize]); + + env.get_byte_array_region(content, 0, content_buffer.as_mut_slice()).expect("Failed to get byte array region"); + + debug!("added shared library: {}", path); + + SHARED_LIBRARIES.lock().unwrap().insert(path, content_buffer); +} + +pub fn init() { + #[cfg(target_arch = "aarch64")] + { + dobby_hook_sym!("linker64", "__dl___openat", linker_openat); + } + #[cfg(target_arch = "arm")] + { + dobby_hook_sym!("linker", "__dl___openat", linker_openat); + } +} diff --git a/native/rust/src/modules/mod.rs b/native/rust/src/modules/mod.rs new file mode 100644 index 0000000000..54f7259e99 --- /dev/null +++ b/native/rust/src/modules/mod.rs @@ -0,0 +1,8 @@ +pub mod util; +pub mod linker_hook; +pub mod duplex_hook; +pub mod sqlite_hook; +pub mod fstat_hook; +pub mod unary_call_hook; +pub mod composer_hook; +pub mod custom_font_hook; \ No newline at end of file diff --git a/native/rust/src/modules/sqlite_hook.rs b/native/rust/src/modules/sqlite_hook.rs new file mode 100644 index 0000000000..c837ebf047 --- /dev/null +++ b/native/rust/src/modules/sqlite_hook.rs @@ -0,0 +1,86 @@ +use std::{collections::HashMap, ffi::{c_void, CStr}, mem::size_of, ptr::addr_of_mut, sync::Mutex}; + +use jni::{objects::{JObject, JString}, JNIEnv}; +use nix::libc::{self, pthread_mutex_t}; +use once_cell::sync::Lazy; + +use crate::{common, def_hook, dobby_hook, sig, util::get_jni_string}; + + +#[repr(C)] +#[derive(Clone, Copy, Debug)] +struct Sqlite3Mutex { + mutex: pthread_mutex_t +} + +#[repr(C)] +struct Sqlite3 { + pad: [u8; 3 * size_of::()], + mutex: *mut Sqlite3Mutex +} + +static SQLITE3_MUTEX_MAP: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); + +def_hook!( + sqlite3_open, + i32, + |filename: *const u8, pp_db: *mut *mut Sqlite3, flags: u32, z_vfs: *const i8| { + let result = sqlite3_open_original.unwrap()(filename, pp_db, flags, z_vfs); + + if result == 0 { + let sqlite3_mutex = (**pp_db).mutex; + + if sqlite3_mutex != std::ptr::null_mut() { + let filename = CStr::from_ptr(filename).to_string_lossy().to_string().split("/").last().expect("Failed to get filename").to_string(); + debug!("sqlite3_open hook {:?}", filename); + + SQLITE3_MUTEX_MAP.lock().unwrap().insert( + filename, + (*sqlite3_mutex).mutex + ); + } + } + + result + } +); + + +pub fn lock_database(mut env: JNIEnv, _: *mut c_void, filename: JString, runnable: JObject) { + let database_filename = get_jni_string(&mut env, filename).expect("Failed to get database filename"); + let mutex = SQLITE3_MUTEX_MAP.lock().unwrap().get(&database_filename).map(|mutex| *mutex); + + let call_runnable = || { + env.call_method(runnable, "run", "()V", &[]).expect("Failed to call run method"); + }; + + if let Some(mut mutex) = mutex { + if unsafe { libc::pthread_mutex_lock(addr_of_mut!(mutex)) } != 0 { + error!("pthread_mutex_lock failed"); + return; + } + + call_runnable(); + + if unsafe { libc::pthread_mutex_unlock(addr_of_mut!(mutex)) } != 0 { + error!("pthread_mutex_unlock failed"); + } + } else { + warn!("No mutex found for database: {}", database_filename); + call_runnable(); + } +} + + +pub fn init() { + if let Some(signature) = sig::find_signature( + &common::CLIENT_MODULE, + "FF FF 00 A9 3F 00 00 F9", -0x3C, + "9A 46 90 46 78 44 89 46 05 68",-0xd + ) { + debug!("Found sqlite3_open signature: {:#x}", signature); + dobby_hook!(signature as *mut c_void, sqlite3_open); + } else { + warn!("Failed to find sqlite3_open signature"); + } +} \ No newline at end of file diff --git a/native/rust/src/modules/unary_call_hook.rs b/native/rust/src/modules/unary_call_hook.rs new file mode 100644 index 0000000000..08f8cf80d5 --- /dev/null +++ b/native/rust/src/modules/unary_call_hook.rs @@ -0,0 +1,123 @@ +use std::ffi::{c_void, CStr}; + +use jni::{objects::{JByteArray, JMethodID, JValue}, signature::ReturnType}; +use nix::libc; +use once_cell::sync::OnceCell; + +use crate::{common::{self}, def_hook, dobby_hook, sig}; + +#[repr(C)] +#[derive(Copy, Clone)] +struct RefCountedSliceByteBuffer { + ref_counter: *mut c_void, + length: usize, + data: *mut u8 +} + +#[repr(C)] +struct GrpcByteBuffer { + reserved: *mut c_void, + type_: *mut c_void, + compression: *mut c_void, + slice_buffer: *mut RefCountedSliceByteBuffer +} + +static NATIVE_LIB_ON_UNARY_CALL_METHOD: OnceCell = OnceCell::new(); + +def_hook!( + unary_call, + *mut c_void, + |unk1: *mut c_void, uri: *const u8, grpc_byte_buffer: *mut *mut GrpcByteBuffer, unk4: *mut c_void, unk5: *mut c_void, unk6: *mut c_void| { + macro_rules! call_original { + () => { + unary_call_original.unwrap()(unk1, uri, grpc_byte_buffer, unk4, unk5, unk6) + }; + } + + // make a local copy of the slice buffer + let mut slice_buffer = *(**grpc_byte_buffer).slice_buffer; + + if slice_buffer.ref_counter.is_null() { + return call_original!(); + } + + let java_vm = common::java_vm(); + let mut env = java_vm.get_env().expect("Failed to get JNIEnv"); + + let slice_buffer_length = slice_buffer.length as usize; + let jni_buffer = env.new_byte_array(slice_buffer_length as i32).expect("Failed to create new byte array"); + env.set_byte_array_region(&jni_buffer, 0, std::slice::from_raw_parts(slice_buffer.data as *const i8, slice_buffer_length)).expect("Failed to set byte array region"); + + let uri_str = CStr::from_ptr(uri).to_str().unwrap(); + + let native_request_data_object = env.call_method_unchecked( + common::native_lib_instance(), + NATIVE_LIB_ON_UNARY_CALL_METHOD.get().unwrap(), + ReturnType::Object, + &[ + JValue::from(&env.new_string(uri_str).unwrap()).as_jni(), + JValue::from(&jni_buffer).as_jni() + ] + ).expect("Failed to call onNativeUnaryCall method").l().unwrap(); + + if native_request_data_object.is_null() { + return call_original!(); + } + + let is_canceled = env.get_field(&native_request_data_object, "canceled", "Z").expect("Failed to get canceled field").z().unwrap(); + + if is_canceled { + info!("canceled request for {}", uri_str); + return std::ptr::null_mut(); + } + + let new_buffer: JByteArray = env.get_field(&native_request_data_object, "buffer", "[B").expect("Failed to get buffer field").l().unwrap().into(); + let new_buffer_length = env.get_array_length(&new_buffer).expect("Failed to get array length") as usize; + + let mut new_buffer_data = Box::new(vec![0i8; new_buffer_length]); + env.get_byte_array_region(&new_buffer, 0, new_buffer_data.as_mut_slice()).expect("Failed to get byte array region"); + + let ref_counter_struct_size = (slice_buffer.data as usize) - (slice_buffer.ref_counter as usize); + + //we need to allocate a new ref_counter struct and copy the old ref_counter and the new_buffer to it + let new_ref = { + let new_ref = libc::malloc(ref_counter_struct_size + new_buffer_length) as *mut c_void; + libc::memcpy(new_ref, slice_buffer.ref_counter, ref_counter_struct_size); + libc::memcpy(new_ref.offset(ref_counter_struct_size as isize), new_buffer_data.as_ptr() as *const c_void, new_buffer_length); + libc::free(slice_buffer.ref_counter); + new_ref + }; + + slice_buffer.ref_counter = new_ref; + slice_buffer.length = new_buffer_length; + slice_buffer.data = new_ref.offset(ref_counter_struct_size as isize) as *mut u8; + + // update the grpc byte buffer + *(**grpc_byte_buffer).slice_buffer = slice_buffer; + + debug!("unary_call {}", uri_str); + + call_original!() + } +); + +pub fn init() { + if let Some(signature) = sig::find_signature( + &common::CLIENT_MODULE, + "A8 03 1F F8 ?? 00 00 94 ?? ?? ?? 91", -0x48, + "0A 90 00 F0 3F F9", -0x37 + ) { + dobby_hook!(signature as *mut c_void, unary_call); + common::attach_jni_env(|env| { + NATIVE_LIB_ON_UNARY_CALL_METHOD.set( + env.get_method_id( + env.get_object_class(common::native_lib_instance()).unwrap(), + "onNativeUnaryCall", + "(Ljava/lang/String;[B)Lme/rhunk/snapenhance/nativelib/NativeRequestData;" + ).expect("Failed to get onNativeUnaryCall method id") + ).expect("unary call method already set"); + }); + } else { + error!("Can't find unaryCall signature"); + } +} \ No newline at end of file diff --git a/native/rust/src/modules/util/composer_utils.rs b/native/rust/src/modules/util/composer_utils.rs new file mode 100644 index 0000000000..c1573449ac --- /dev/null +++ b/native/rust/src/modules/util/composer_utils.rs @@ -0,0 +1,141 @@ +use std::{io::Error, string::FromUtf8Error}; + +#[derive(Debug, Clone)] +pub struct ModuleTag { + tag_type: u8, + buffer: Vec, +} + +impl ModuleTag { + pub fn new(module_type: u8, buffer: Vec) -> ModuleTag { + ModuleTag { + tag_type: module_type, + buffer, + } + } + + pub fn to_string(&self) -> Result { + Ok(String::from_utf8(self.buffer.clone())?) + } + + pub fn get_tag_type(&self) -> u8 { + self.tag_type + } + + pub fn get_size(&self) -> usize { + self.buffer.len() + } + + pub fn get_buffer(&self) -> &Vec { + &self.buffer + } + + pub fn set_buffer(&mut self, buffer: Vec) { + self.buffer = buffer; + } +} + +#[derive(Debug, Clone)] +pub struct ComposerModule { + tags: Vec<(ModuleTag, ModuleTag)>, // file name => file content +} + +impl ComposerModule { + pub fn parse(buffer: Vec) -> Result { + let mut offset = 0; + let magic = u32::from_be_bytes([buffer[offset], buffer[offset + 1], buffer[offset + 2], buffer[offset + 3]]); + + offset += 4; + + if magic != 0x33c60001 { + return Err(Error::new(std::io::ErrorKind::InvalidData, "Invalid magic")); + } + + // skip content length + offset += 4; + + let mut tags = Vec::new(); + + loop { + if offset >= buffer.len() { + break; + } + + fn read_u24(buffer: &Vec, offset: &mut usize) -> Result { + let b1 = buffer[*offset] as u32; + let b2 = buffer[*offset + 1] as u32; + let b3 = buffer[*offset + 2] as u32; + *offset += 3; + Ok(b1 | (b2 << 8) | (b3 << 16)) + } + + let tag_size = read_u24(&buffer, &mut offset)?; + let tag_type = buffer[offset]; + offset += 1; + let tag_buffer = buffer[offset..offset + tag_size as usize].to_vec(); + offset += tag_size as usize; + + let padding = 4 - (tag_size % 4); + + if padding != 4 { + offset += padding as usize; + } + + tags.push(ModuleTag::new(tag_type, tag_buffer)); + } + + let tags = tags.chunks(2).map(|chunk| { + (chunk[0].clone(), chunk[1].clone()) + }).collect(); + + Ok(ComposerModule { + tags, + }) + } + + pub fn to_bytes(&self) -> Vec { + let mut tag_buffer = Vec::new(); + + fn write_u24(buffer: &mut Vec, value: u32) { + buffer.push((value & 0xff) as u8); + buffer.push(((value >> 8) & 0xff) as u8); + buffer.push(((value >> 16) & 0xff) as u8); + } + + fn write_tag(buffer: &mut Vec, tag: ModuleTag) { + write_u24(buffer, tag.get_size() as u32); + buffer.push(tag.get_tag_type()); + buffer.extend(tag.get_buffer()); + + let padding = 4 - (tag.get_size() % 4); + + if padding != 4 { + for _ in 0..padding { + buffer.push(0); + } + } + } + + for (tag1, tag2) in &self.tags { + write_tag(&mut tag_buffer, tag1.clone()); + write_tag(&mut tag_buffer, tag2.clone()); + } + + let mut buffer = Vec::new(); + + buffer.extend_from_slice(&[0x33, 0xc6, 0, 1]); + buffer.extend_from_slice(&(tag_buffer.len() as u32).to_le_bytes()); + buffer.extend(tag_buffer); + + buffer + } + + pub fn get_tags(&self) -> Vec<(ModuleTag, ModuleTag)> { + self.tags.clone() + } + + pub fn set_tags(&mut self, tags: Vec<(ModuleTag, ModuleTag)>) { + self.tags = tags; + } +} + diff --git a/native/rust/src/modules/util/mod.rs b/native/rust/src/modules/util/mod.rs new file mode 100644 index 0000000000..193363b3e4 --- /dev/null +++ b/native/rust/src/modules/util/mod.rs @@ -0,0 +1 @@ +pub mod composer_utils; \ No newline at end of file diff --git a/native/rust/src/sig.rs b/native/rust/src/sig.rs new file mode 100644 index 0000000000..c3b62db614 --- /dev/null +++ b/native/rust/src/sig.rs @@ -0,0 +1,100 @@ +use std::sync::Mutex; + +use procfs::process::MMPermissions; + +use crate::mapped_lib::MappedLib; + + +static SIGNATURE_CACHE: Mutex)>> = Mutex::new(Vec::new()); + +pub fn add_signatures(signatures: Vec<(String, Vec)>) { + SIGNATURE_CACHE.lock().unwrap().extend(signatures); +} + +pub fn get_signatures() -> Vec<(String, Vec)> { + SIGNATURE_CACHE.lock().unwrap().clone() +} + +pub fn find_signatures(module_base: usize, size: usize, pattern: &str, once: bool) -> Vec { + let mut results = Vec::new(); + let mut bytes = Vec::new(); + let mut mask = Vec::new(); + let mut i = 0; + + if let Some(cache) = SIGNATURE_CACHE.lock().unwrap().iter().find(|(sig, _)| sig == pattern) { + return cache.1.clone().into_iter().map(|offset| module_base + offset).collect(); + } + + while i < pattern.len() { + if pattern.chars().nth(i).unwrap() == '?' { + bytes.push(0); + mask.push('?'); + } else { + bytes.push(u8::from_str_radix(&pattern[i..i+2], 16).unwrap()); + mask.push('x'); + } + i += 3; + } + + let mut i = 0; + let size = size - bytes.len(); + while i < size { + let mut found = true; + let mut j = 0; + + while j < bytes.len() { + if mask[j] == '?' || bytes[j] == unsafe { *(module_base as *const u8).offset(i as isize + j as isize) } { + j += 1; + continue; + } + found = false; + break; + } + if found { + if once { + SIGNATURE_CACHE.lock().unwrap().push((pattern.to_string(), vec![i])); + return vec![module_base + i]; + } + results.push(module_base + i); + } + i += 1; + } + + SIGNATURE_CACHE.lock().unwrap().push((pattern.to_string(), results.clone())); + results +} + +pub fn find_signature_executable(mapped_lib: &MappedLib, pattern: &str) -> Option { + let executable_regions = mapped_lib.regions.iter().filter(|region| { + region.perms.contains(MMPermissions::EXECUTE) && region.perms.contains(MMPermissions::READ) + }).collect::>(); + + for region in executable_regions { + let size = (region.end - region.start) as usize; + let module_base = region.start as usize; + + if size > 0 { + let results = find_signatures(module_base, size, pattern, true); + + if results.is_empty() { + warn!("Signature not found in region: {:#x} - {:#x}", region.start, region.end); + } else { + debug!("Found {} results in region: {:#x} - {:#x}", results.len(), region.start, region.end); + return Some(results[0]); + } + } + } + + None +} + +pub fn find_signature(mapped_lib: &MappedLib, _arm64_pattern: &str, _arm64_offset: i64, _arm32_pattern: &str, _arm32_offset: i64) -> Option { + #[cfg(target_arch = "aarch64")] + { + return find_signature_executable(mapped_lib, _arm64_pattern).map(|address| (address as i64 + _arm64_offset) as usize); + } + #[cfg(target_arch = "arm")] + { + return find_signature_executable(mapped_lib, _arm32_pattern).map(|address| (address as i64 + _arm32_offset) as usize); + } +} \ No newline at end of file diff --git a/native/rust/src/util.rs b/native/rust/src/util.rs new file mode 100644 index 0000000000..c598523ac0 --- /dev/null +++ b/native/rust/src/util.rs @@ -0,0 +1,8 @@ +use std::error::Error; + +use jni::{objects::JString, JNIEnv}; + +pub fn get_jni_string(env: &mut JNIEnv, obj: JString) -> Result> { + let string = env.get_string(&obj)?; + Ok(string.to_str()?.to_string()) +} diff --git a/native/src/main/kotlin/me/rhunk/snapenhance/nativelib/NativeConfig.kt b/native/src/main/kotlin/me/rhunk/snapenhance/nativelib/NativeConfig.kt new file mode 100644 index 0000000000..a41e64c17f --- /dev/null +++ b/native/src/main/kotlin/me/rhunk/snapenhance/nativelib/NativeConfig.kt @@ -0,0 +1,12 @@ +package me.rhunk.snapenhance.nativelib + +data class NativeConfig( + @JvmField + val disableBitmoji: Boolean = false, + @JvmField + val disableMetrics: Boolean = false, + @JvmField + val composerHooks: Boolean = false, + @JvmField + val customEmojiFontPath: String? = null, +) \ No newline at end of file diff --git a/native/src/main/kotlin/me/rhunk/snapenhance/nativelib/NativeLib.kt b/native/src/main/kotlin/me/rhunk/snapenhance/nativelib/NativeLib.kt new file mode 100644 index 0000000000..ea59dd56be --- /dev/null +++ b/native/src/main/kotlin/me/rhunk/snapenhance/nativelib/NativeLib.kt @@ -0,0 +1,76 @@ +package me.rhunk.snapenhance.nativelib + +import android.annotation.SuppressLint +import android.util.Log +import kotlin.math.absoluteValue +import kotlin.random.Random + +class NativeLib { + var nativeUnaryCallCallback: (NativeRequestData) -> Unit = {} + var signatureCache: String? = null + + companion object { + var initialized = false + private set + } + + fun initOnce(callback: NativeLib.() -> Unit): () -> Unit { + if (initialized) throw IllegalStateException("NativeLib already initialized") + return runCatching { + System.loadLibrary(BuildConfig.NATIVE_NAME) + initialized = true + callback(this) + preInit() + return@runCatching { + signatureCache = init(signatureCache) ?: throw IllegalStateException("NativeLib init failed. Check logcat for more info") + } + }.onFailure { + initialized = false + Log.e("SnapEnhance", "NativeLib init failed", it) + }.getOrThrow() + } + + @Suppress("unused") + private fun onNativeUnaryCall(uri: String, buffer: ByteArray): NativeRequestData? { + val nativeRequestData = NativeRequestData(uri, buffer) + runCatching { + nativeUnaryCallCallback(nativeRequestData) + }.onFailure { + Log.e("SnapEnhance", "nativeUnaryCallCallback failed", it) + } + if (nativeRequestData.canceled || !nativeRequestData.buffer.contentEquals(buffer)) return nativeRequestData + return null + } + + fun loadNativeConfig(config: NativeConfig) { + if (!initialized) return + loadConfig(config) + } + + fun lockNativeDatabase(name: String, callback: () -> Unit) { + if (!initialized) return + lockDatabase(name) { + runCatching { + callback() + }.onFailure { + Log.e("SnapEnhance", "lockNativeDatabase callback failed", it) + } + } + } + + @SuppressLint("UnsafeDynamicallyLoadedCode") + fun loadSharedLibrary(content: ByteArray) { + if (!initialized) throw IllegalStateException("NativeLib not initialized") + val generatedPath = "/data/app/${Random.nextLong().absoluteValue.toString(16)}.so" + addLinkerSharedLibrary(generatedPath, content) + System.load(generatedPath) + } + + private external fun preInit() + private external fun init(signatureCache: String?): String? + private external fun loadConfig(config: NativeConfig) + private external fun lockDatabase(name: String, callback: Runnable) + external fun setComposerLoader(code: String) + external fun composerEval(code: String): String? + private external fun addLinkerSharedLibrary(path: String, content: ByteArray) +} \ No newline at end of file diff --git a/native/src/main/kotlin/me/rhunk/snapenhance/nativelib/NativeRequestData.kt b/native/src/main/kotlin/me/rhunk/snapenhance/nativelib/NativeRequestData.kt new file mode 100644 index 0000000000..34927c08cd --- /dev/null +++ b/native/src/main/kotlin/me/rhunk/snapenhance/nativelib/NativeRequestData.kt @@ -0,0 +1,7 @@ +package me.rhunk.snapenhance.nativelib + +class NativeRequestData( + val uri: String, + var buffer: ByteArray, + var canceled: Boolean = false, +) \ No newline at end of file diff --git a/settings.gradle b/settings.gradle.kts similarity index 59% rename from settings.gradle rename to settings.gradle.kts index e06b897180..7bd6d60e4e 100644 --- a/settings.gradle +++ b/settings.gradle.kts @@ -1,16 +1,27 @@ pluginManagement { repositories { - gradlePluginPortal() google() mavenCentral() + gradlePluginPortal() } } + +@Suppress("UnstableApiUsage") dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() + maven { url = uri("https://jitpack.io") } } } + + rootProject.name = "SnapEnhance" -include ':app' +include(":common") +include(":core") +include(":composer") +include(":app") +include(":mapper") +include(":native") +include(":manager") \ No newline at end of file