diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..75daf9c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,7 @@ +root = true + +[*.{kt,kts}] +ktlint_standard_property-naming = disabled +ktlint_standard_value-argument-comment = disabled +ktlint_standard_kdoc-wrapping = disabled +ktlint_standard_no-consecutive-comments = disabled diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..65ceffd --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,9 @@ +# Run this command to always ignore specific commits in `git blame` +# git config blame.ignoreRevsFile .git-blame-ignore-revs + +# format all java files +94dae8259f075e4099cd92c258386dc39bfc30c1 + +# format all kotlin files +45178b3d348cd1fe768bcfc086b04b15ea5d69ea + diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..412eeda --- /dev/null +++ b/.gitattributes @@ -0,0 +1,22 @@ +# Auto detect text files and perform LF normalization +* text=auto + +# Custom for Visual Studio +*.cs diff=csharp +*.sln merge=union +*.csproj merge=union +*.vbproj merge=union +*.fsproj merge=union +*.dbproj merge=union + +# Standard to msysgit +*.doc diff=astextplain +*.DOC diff=astextplain +*.docx diff=astextplain +*.DOCX diff=astextplain +*.dot diff=astextplain +*.DOT diff=astextplain +*.pdf diff=astextplain +*.PDF diff=astextplain +*.rtf diff=astextplain +*.RTF diff=astextplain diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..d88493d --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: TeamAmaze +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: Team-Amaze +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +custom: ['https://www.paypal.me/vishalnehra', 'https://github.com/TeamAmaze/AmazeFileManager#support'] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..0302d59 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,33 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Smartphone (please complete the following information):** + - Device: [e.g. Google Pixel 2] + - OS: [e.g. Android 9] + - Rooted: [e.g. No] + - Version: [e.g. 3.3.4] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..11fc491 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/no-response.yml b/.github/no-response.yml new file mode 100644 index 0000000..6a0285c --- /dev/null +++ b/.github/no-response.yml @@ -0,0 +1,13 @@ +# Configuration for probot-no-response - https://github.com/probot/no-response + +# Number of days of inactivity before an Issue is closed for lack of response +daysUntilClose: 14 +# Label requiring a response +responseRequiredLabel: Needs-OPResponse +# Comment to post when closing an Issue for lack of response. Set to `false` to disable +closeComment: > + This issue has been automatically closed because there has been no response + to our request for more information from the original author. With only the + information that is currently in the issue, we don't have enough information + to take action. Please reach out if you have or find the answers we need so + that we can investigate further. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..5e294e6 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,39 @@ + + +## Description + +#### Issue tracker + + + + + +#### Automatic tests + +- [ ] Added test cases + +#### Manual tests +- [ ] Done + + + + +#### Build tasks success + +Successfully running following tasks on local: +- [ ] `./gradlew assembledebug` +- [ ] `./gradlew spotlessCheck` + + + \ No newline at end of file diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml new file mode 100644 index 0000000..c304c37 --- /dev/null +++ b/.github/workflows/android-build.yml @@ -0,0 +1,42 @@ +name: Android Build CI + +on: + push: + branches: + - '*' + - '!master' + - '!release/*' +concurrency: + group: build-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + check_spotless: + name: Check spotless + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 11 + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: 11 + - name: Check formatting using spotless + uses: gradle/actions/setup-gradle@v3 + with: + arguments: spotlessCheck --stacktrace + + build: + name: Build debug + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 11 + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: 11 + - name: Build with Gradle + uses: gradle/actions/setup-gradle@v3 + with: + arguments: assembledebug --stacktrace \ No newline at end of file diff --git a/.github/workflows/android-debug-artifact-ondemand.yml b/.github/workflows/android-debug-artifact-ondemand.yml new file mode 100644 index 0000000..d3f4360 --- /dev/null +++ b/.github/workflows/android-debug-artifact-ondemand.yml @@ -0,0 +1,62 @@ +name: Android Debug artifact ondemand + +on: + pull_request: + types: [opened] + issue_comment: + types: [created] + +jobs: + apk: + runs-on: ubuntu-latest + if: github.event.comment.body == 'Build test apk' && (github.actor == 'VishalNehra' || github.actor == 'TranceLove' || github.actor == 'EmmanuelMess' || github.actor == 'VishnuSanal') + steps: + - name: Acknowledge the request with thumbs up reaction + uses: peter-evans/create-or-update-comment@v2 + with: + comment-id: ${{ github.event.comment.id }} + reactions: '+1' + - name: Github API Request + id: request + uses: octokit/request-action@v2.0.2 + with: + route: ${{ github.event.issue.pull_request.url }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Get PR informations + id: pr_data + run: | + echo "::set-output name=repo_name::${{ fromJson(steps.request.outputs.data).head.repo.full_name }}" + echo "::set-output name=repo_clone_url::${{ fromJson(steps.request.outputs.data).head.repo.clone_url }}" + echo "::set-output name=repo_ssh_url::${{ fromJson(steps.request.outputs.data).head.repo.ssh_url }}" + - name: Checkout PR Branch + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + repository: ${{fromJson(steps.request.outputs.data).head.repo.full_name}} + ref: ${{fromJson(steps.request.outputs.data).head.ref}} + - name: Set up JDK 11 + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: 11 + - name: Build with Gradle + uses: gradle/actions/setup-gradle@v3 + with: + arguments: assembleDebug --stacktrace + - name: Upload fdroid artifact + uses: actions/upload-artifact@v4 + with: + name: Amaze-Fdroid-debug + path: app/build/outputs/apk/fdroid/debug/app-fdroid-debug.apk + - name: Upload play artifact + uses: actions/upload-artifact@v4 + with: + name: Amaze-Play-debug + path: app/build/outputs/apk/play/debug/app-play-debug.apk + - name: Notify the user with a comment once the APK is uploaded # TODO: update this with the link to the artifacts + uses: peter-evans/create-or-update-comment@v2 + with: + issue-number: ${{ github.event.issue.number }} + body: | + The requested APKs has been built. Please find them from the artifacts section of this PR. diff --git a/.github/workflows/android-debug-artifact-release.yml b/.github/workflows/android-debug-artifact-release.yml new file mode 100644 index 0000000..56cfdbf --- /dev/null +++ b/.github/workflows/android-debug-artifact-release.yml @@ -0,0 +1,30 @@ +name: Android Release CI + +on: + release: + types: [published, created] + +jobs: + apk: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 11 + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: 11 + - name: Build with Gradle + uses: gradle/actions/setup-gradle@v3 + with: + arguments: assembleDebug + - name: Upload fdroid artifact + uses: actions/upload-artifact@v4 + with: + name: Amaze-Fdroid-debug + path: app/build/outputs/apk/fdroid/debug/app-fdroid-debug.apk + - name: Upload play artifact + uses: actions/upload-artifact@v4 + with: + name: Amaze-Play-debug + path: app/build/outputs/apk/play/debug/app-play-debug.apk diff --git a/.github/workflows/android-feature.yml b/.github/workflows/android-feature.yml new file mode 100644 index 0000000..a551a2e --- /dev/null +++ b/.github/workflows/android-feature.yml @@ -0,0 +1,47 @@ +name: Android Feature CI + +on: + pull_request: + branches: + - 'master' + - 'release/*' + - 'hotfix/*' + +concurrency: + group: build-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + check_spotless: + name: Check spotless + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 11 + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: 11 + - name: Check formatting using spotless + uses: gradle/actions/setup-gradle@v3 + with: + arguments: spotlessCheck --stacktrace + + build: + name: Build debug and run Jacoco tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 11 + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: 11 + - name: Build with Gradle + uses: gradle/actions/setup-gradle@v3 + with: + arguments: assembledebug --stacktrace + - name: Run test cases + uses: gradle/actions/setup-gradle@v3 + with: + arguments: jacocoTestPlayDebugUnitTestReport --stacktrace --info \ No newline at end of file diff --git a/.github/workflows/android-main.yml b/.github/workflows/android-main.yml new file mode 100644 index 0000000..331d758 --- /dev/null +++ b/.github/workflows/android-main.yml @@ -0,0 +1,117 @@ +name: Android Main CI + +on: + push: + branches: + - 'master' + - 'release/*' + - 'hotfix/*' + +concurrency: + group: build-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + check_spotless: + name: Check spotless + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 11 + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: 11 + - name: Check formatting using spotless + uses: gradle/actions/setup-gradle@v3 + with: + arguments: spotlessCheck + + build: + name: Build debug, Jacoco test and publish to codacy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up JDK 11 + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: 11 + - name: Build with Gradle + uses: gradle/actions/setup-gradle@v3 + with: + arguments: assembledebug + - name: Run test cases + uses: gradle/actions/setup-gradle@v3 + with: + arguments: jacocoTestPlayDebugUnitTestReport + - name: Publish test cases + run: | + export CODACY_PROJECT_TOKEN=${{ secrets.CODACY_TOKEN }} + bash <(curl -Ls https://coverage.codacy.com/get.sh) + - name: Publish on Telegram + run: | + COMMIT_COUNT=$(git rev-list --count ${GITHUB_REF}) + VERSION_NAME=$(echo ${GITHUB_REF##*/} | sed 's/release\///' | sed 's/hotfix\///') + cp app/build/outputs/apk/fdroid/debug/app-fdroid-debug.apk app/build/outputs/apk/fdroid/debug/amaze-fdroid-$(echo $VERSION_NAME)-$(echo $COMMIT_COUNT).apk + cp app/build/outputs/apk/play/debug/app-play-debug.apk app/build/outputs/apk/play/debug/amaze-play-$(echo $VERSION_NAME)-$(echo $COMMIT_COUNT).apk + echo $(curl -v -F "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" -F document=@app/build/outputs/apk/fdroid/debug/amaze-fdroid-$(echo $VERSION_NAME)-$(echo $COMMIT_COUNT).apk https://api.telegram.org/${{ secrets.AMAZE_BOT_ID }}:${{ secrets.TELEGRAM_INTEGRATION_KEY }}/sendDocument) + echo $(curl -v -F "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" -F document=@app/build/outputs/apk/play/debug/amaze-play-$(echo $VERSION_NAME)-$(echo $COMMIT_COUNT).apk https://api.telegram.org/${{ secrets.AMAZE_BOT_ID }}:${{ secrets.TELEGRAM_INTEGRATION_KEY }}/sendDocument) + + + test_emulator: + timeout-minutes: 30 # Emulator can get stuck + runs-on: macos-latest + strategy: + fail-fast: true + matrix: + api-level: [ 16, 19, 28 ] + steps: + - name: checkout + uses: actions/checkout@v4 + - name: Java 15 + uses: actions/setup-java@v4 + with: + java-version: 15 + - name: Gradle cache + uses: gradle/actions/setup-gradle@v3 + - name: AVD cache + uses: actions/cache@v4 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + ~/.android/debug.keystore + key: avd-${{ matrix.api-level }} + - name: create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: false + sdcard-path-or-size: 100M + arch: x86 + ram-size: 2048M + channel: canary + script: echo "Generated AVD snapshot for caching." + + - name: run tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + sdcard-path-or-size: 100M + arch: x86 + ram-size: 2048M + channel: canary + script: | + adb logcat -c + adb logcat *:E & + ./gradlew :app:connectedCheck diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..95a913e --- /dev/null +++ b/.gitignore @@ -0,0 +1,333 @@ + +# Created by https://www.toptal.com/developers/gitignore/api/android,androidstudio,c +# Edit at https://www.toptal.com/developers/gitignore?templates=android,androidstudio,c + +### Android ### +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +.idea/jarRepositories.xml +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Android Profiling +*.hprof + +### Android Patch ### +gen-external-apklibs +output.json + +# Replacement of .externalNativeBuild directories introduced +# with Android Studio 3.5. + +### C ### +# Prerequisites +*.d + +# Object files +*.o +*.ko +*.obj +*.elf + +# Linker output +*.ilk +*.map +*.exp + +# Precompiled Headers +*.gch +*.pch + +# Libraries +*.lib +*.a +*.la +*.lo + +# Shared objects (inc. Windows DLLs) +*.dll +*.so +*.so.* +*.dylib + +# Executables +*.exe +*.out +*.app +*.i*86 +*.x86_64 +*.hex + +# Debug files +*.dSYM/ +*.su +*.idb +*.pdb + +# Kernel Module Compile Results +*.mod* +*.cmd +.tmp_versions/ +modules.order +Module.symvers +Mkfile.old +dkms.conf + +### AndroidStudio ### +# Covers files to be ignored for android development using Android Studio. + +# Built application files + +# Files for the ART/Dalvik VM + +# Java class files + +# Generated files + +# Gradle files +.gradle + +# Signing files +.signing/ + +# Local configuration file (sdk path, etc) + +# Proguard folder generated by Eclipse + +# Log Files + +# Android Studio +/*/build/ +/*/local.properties +/*/out +/*/*/build +/*/*/production +*.ipr +*~ +*.swp + +# Keystore files +*.jks +*.keystore + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Android Patch + +# External native build folder generated in Android Studio 2.2 and later + +# NDK +obj/ + +# IntelliJ IDEA +*.iws +/out/ + +# User-specific configurations +.idea/caches/ +.idea/libraries/ +.idea/shelf/ +.idea/.name +.idea/compiler.xml +.idea/copyright/profiles_settings.xml +.idea/encodings.xml +.idea/misc.xml +.idea/scopes/scope_settings.xml +.idea/vcs.xml +.idea/jsLibraryMappings.xml +.idea/datasources.xml +.idea/dataSources.ids +.idea/sqlDataSources.xml +.idea/dynamic.xml +.idea/uiDesigner.xml + +# OS-specific files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Legacy Eclipse project files +.classpath +.project +.cproject +.settings/ + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.war +*.ear + +# virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml) +hs_err_pid* + +## Plugin-specific files: + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Mongo Explorer plugin +.idea/mongoSettings.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +### AndroidStudio Patch ### + +!/gradle/wrapper/gradle-wrapper.jar + +# End of https://www.toptal.com/developers/gitignore/api/android,androidstudio,c + + +# Windows image file caches +Thumbs.db +ehthumbs.db + +# Folder config file +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msm +*.msp + +# ========================= +# Operating System Files +# ========================= + +# OSX +# ========================= + +.DS_Store +.AppleDouble +.LSOverride + +# Icon must ends with two \r. +Icon + + +# Thumbnails +._* + +# Files that might appear on external disk +.Spotlight-V100 +.Trashes + +# Android Studio +.idea/* +#.idea/workspace.xml - remove # and delete .idea if it better suit your needs. +.gradle/ +build/ +.settings +bin/ +gen/ +.classpath +local.properties +.project +.idea/ +projectFilesBackup +captures +*.iml + +/app/src/main/res/values-in/strings.xml +/app/src/main/res/values-iw/strings.xml + +# sign config +signing.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..d843f34 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..8b2acdd --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,134 @@ + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +[support@teamamaze.xyz]. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ce87f06 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,55 @@ +# Contributing to Amaze + +Happy to see you in here! Amaze was created with a vision to provide all the basic and advanced features a file manager in Android is supposed to have, with a friendly material design. As with any other open source project, contributions are the key to achieve this goal of ours. :) + +Contributions are always welcome! + +> ### Imposter's syndrome disclaimer: We want your help. No, really! Any contribution counts, no matter how small it be! + +# How to contribute? + +- **Translation**: We use [Transifex](https://www.transifex.com/amaze/amaze-file-manager/) for our translations, feel free to contribute translations there. +- **Monetary Contributions**: You can do monetary contributions via [OpenCollective](https://opencollective.com/TeamAmaze), [LiberaPay](https://liberapay.com/Team-Amaze/donate) or PayPal](https://www.paypal.me/vishalnehra). +- **Bug Reports**: Reporting bugs clearly & concisely helps us improve Amaze. It would be great for us to pinpoint the cause of a bug if there are logs attached to the bug report. Or clearcut steps to reproduce the issue you are facing. Yes, good bug reports are considered as contributions too! +- **Code Contributions**: This file discusses about code contributions. + +To start contributing, we assume you know how to use git and write and debug Android apps. + +## How to get started? + + - You can use GitHub web interface to fork `TeamAmaze/AmazeFileManager` to `/AmazeFileManager`. + - The next step is to import it into Android Studio by `New Project -> Get from Version Control`. + - There you can paste the link to your fork. + - Let Android Studio import the project & download all the necessary dependencies + - Now you can build & run the project on an emulator/real device. + +## What to do next? + + - Go to [issues]() section & have a look at [good-first-issues](https://github.com/TeamAmaze/AmazeFileManager/issues?q=is%3Aopen+is%3Aissue+label%3A%22Issue-Easy+%28good+first+issue%29%22). These are low hanging fruits ready to be picked up! + - Have a look at [NPE Crashes](https://github.com/TeamAmaze/AmazeFileManager/issues?q=is%3Aopen+is%3Aissue+label%3ACrash-NullPointerException) too. These must be literally one line fixes to bugs. + - Or if you wanna work on a feature, please make sure no one's working on it by commenting on the thread (we'll assign it to you then). + - Once you have made all the necessary changes, and everything works as expected, please run `./gradlew spotlessCheck` on your local and handle any resulting formatting issues. Most of them can be fixed by running `./gradlew spotlessApply` (others would need lil manual changes) + - If everything looks good, push it to your fork & make a PR (please make sure to fill the PR template!) + - We'll look into your PR soon, give feedback, and upon the code working as expected (i.e, fixes the bug/implements feature), the code get merged to the next release branch! Yay! + +If we feel your PR is a significant help to us, we'll award you a bounty with any of your preferred mode of payment. + +## Points to note + + - Please follow [Android/JAVA code style](https://source.android.com/docs/setup/contribute/code-style) for writing any code. Please see [this](https://github.com/TeamAmaze/AmazeFileManager/issues/986) issue too. + - Also, follow [Android Material Design guidelines](https://m3.material.io/get-started) in case you make changes to any UI element. + +## PS: please make sure to + +- Follow best practices & to write _clean code_. +- Before opening a PR, run `./gradlew spotlessCheck` on your local and handle any resulting formatting issues. Most of them can be fixed by running `./gradlew spotlessApply` (others would need lil manual changes) +- Fill in the pull request template clearly (for eg: `fixes #XXXX` for bugfixes) +- Once you've opened PR, look out for CI builds (`Checks` section on the top of your PR). If it's all green, you're good to go! Else, please fix the issues specified in the logs (you can get logs by clicking on the failed workflow & then the failed action). +- Include tests (either Unit tests or automated tests like [Robolectric](http://robolectric.org/)/[Espresso](https://developer.android.com/training/testing/espresso/)) if possible to your PR + +### Finally! + +- We have our day time work, so except security vulnerabilities, your submission may be left cold for a lil while before being picked up by us :) +- Please be patient with us while we review our code. Try to avoid favoritism, hate speech & adhere to our [code of conduct](https://github.com/TeamAmaze/AmazeFileManager/blob/release/4.0/CODE_OF_CONDUCT.md). + +## Ready to roll? Start [forking](https://github.com/TeamAmaze/AmazeFileManager/fork)! ;) diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..20d40b6 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..58d6ccc --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +![CI](https://github.com/TeamAmaze/AmazeFileManager/workflows/Android%20Main%20CI/badge.svg?branch=master) +[![Codacy Badge](https://api.codacy.com/project/badge/Grade/9ea2667dabaa4e8c98dbf0876ebacd3e)](https://app.codacy.com/gh/TeamAmaze/AmazeFileManager?utm_source=github.com&utm_medium=referral&utm_content=TeamAmaze/AmazeFileManager&utm_campaign=Badge_Grade_Settings) +[![Codacy Badge](https://app.codacy.com/project/badge/Coverage/50d8e153feba47b9a8ff82ff57274c56)](https://www.codacy.com/gh/TeamAmaze/AmazeFileManager?utm_source=github.com&utm_medium=referral&utm_content=TeamAmaze/AmazeFileManager&utm_campaign=Badge_Coverage) +[![Amaze File Manager Downloads](https://www.appbrain.com/shield/com.amaze.filemanager.svg)](https://www.appbrain.com/app/amaze-file-manager/com.amaze.filemanager) +[![GitHub release](https://img.shields.io/github/release/TeamAmaze/AmazeFileManager.svg)](https://github.com/TeamAmaze/AmazeFileManager/releases) +[![IzzyOnDroid](https://img.shields.io/endpoint?url=https://apt.izzysoft.de/fdroid/api/v1/shield/com.amaze.filemanager)](https://apt.izzysoft.de/fdroid/index/apk/com.amaze.filemanager) +[![Chat on Telegram](https://img.shields.io/badge/Telegram-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/AmazeFileManager) +[![XDA Developers](https://img.shields.io/badge/XDA-Developers%20-%23AC6E2F.svg?&style=for-the-badge&logo=XDA-Developers&logoColor=white)](http://forum.xda-developers.com/android/apps-games/app-amaze-file-managermaterial-theme-t2937314) +[![Liberapay](https://img.shields.io/liberapay/receives/Team-Amaze.svg?logo=liberapay)](https://liberapay.com/Team-Amaze/donate) + +# Amaze File Manager + +Simple and attractive Material Design file manager for Android + +Overview +--- + + + +
+ +- Open Source, light and smooth +- Based on Material Design guidelines +- Basic features like cut, copy, delete, compress, extract etc. easily accessible +- Work on multiple tabs at same time +- Multiple themes with cool icons +- Navigation drawer for quick navigation +- App Manager to open, backup, or directly uninstall any app +- Quickly access history, access bookmarks or search for any file +- Root explorer for advanced users +- AES Encryption and Decryption of files for security (Jellybean v4.3+) +- Cloud services support (Jellybean v4.3+ / requires additional plug-in) +- Inbuilt Database Reader, Zip/Rar Reader, Apk Reader, Text Reader +- No ads or In-app purchases +- lots more... + +
+ +Downloads +--- + +[Get it on Google Play](https://play.google.com/store/apps/details?id=com.amaze.filemanager) +[Get it on F-Droid](https://f-droid.org/packages/com.amaze.filemanager/) +[Get it on IzzyOnDroid](https://apt.izzysoft.de/fdroid/index/apk/com.amaze.filemanager) +[Get it on GitHub](https://github.com/TeamAmaze/AmazeFileManager/releases/latest) +[Get it on AFH](https://www.androidfilehost.com/?w=files&flid=73967) + +Contribute +--- +You can contribute via one of the following ways: +- Help us with the translations of either [Amaze File Manager](https://www.transifex.com/amaze/amaze-file-manager/) or [Amaze Utilities](https://crowdin.com/project/amaze-file-utilities) +- [Contribute](https://github.com/TeamAmaze/AmazeFileManager/blob/release/4.0/CONTRIBUTING.md) directly to the code, help us in fixing the bugs / implement new features. + +_If we feel your contribution is a significant help to us, we'll award you a bounty with any of your preferred mode of payment._ + +Support +--- +OpenCollective +PayPal +Liberapay +Or buy the [Cloud Plugin](https://play.google.com/store/apps/details?id=com.filemanager.amazecloud) supports Google Drive™, Dropbox, OneDrive and Box accounts. +Try our app - [Amaze File Utilities](https://play.google.com/store/apps/details?id=com.amaze.fileutilities) ([Fdroid](https://f-droid.org/en/packages/com.amaze.fileutilities/)) +1. List videos / images / music documents in your device in a interactive UI where you're able to group / sort and quickly jump to any headers. +2. Open videos / images / music / documents (pdf / docx / epub) with inbuilt player. +3. Share / delete / cast on your tv +4. Analyse internal storage for junk files, duplicate files, large videos / old downloads / screenshots or recordings. +5. Analyse and group images between memes, low light / blurry / selfies / group pics. +7. Transfer files directly between two mobile devices on same wifi network using high speed peer to peer network +8. Gesture support in image / video player, play in background, picture in picture mode, download subtitles within the player. + +Warning +--- + +Basic r/w operations might not work on external memory on Kitkat devices. Don't use cut/paste from or to external SD Card. You might lose your files. + +***Under the license we are not responsible for damages.*** + +See our [Privacy Policy](https://github.com/TeamAmaze/AmazeFileManager/wiki/Privacy-Policy) + +Vendors/Developers +---- +The device vendors/ROM developers are free to include Amaze apk pre-installed in system. There is no fee required; *but you must comply with the license* (for more information read the [GNU GPL v3](https://www.gnu.org/licenses/gpl-3.0.en.html) or newer). + +We strongly recommend using apk signed by us (either Play Store version or from AFH link above) so that users would be able to update directly from Play Store after distribution. Furthermore, a change in digital signature will break plug-ins. + +### License: + + Copyright (C) 2014-2018 Arpit Khurana + Copyright (C) 2014-2024 Vishal Nehra + Copyright (C) 2017-2024 Emmanuel Messulam + Copyright (C) 2018-2024 Raymond Lai + Copyright (C) 2019-2024 Vishnu Sanal T + This file is part of Amaze File Manager. + Amaze File Manager is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with this program. If not, see . diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..c73ba27 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,20 @@ +# Security Policy + +## Supported Versions + +`v3.8.5` supports Android 4.0 and above. +`v4.x.x` would only support Android 4.4 and above. + +Android devices that runs Android versions less that Android 4.4 +(Android KitKat) would not receive any more updates (the latest +supported version would be `v3.8.5`). + +## Reporting a Vulnerability + +Feel free to contact us via `support@teamamaze.xyz`. + +Please CC the maintainers too: +- `vishalmeham2@gmail.com` +- `airwave209gt@gmail.com` +- `emmanuelbendavid@gmail.com` +- `t.v.s10123@gmail.com` \ No newline at end of file diff --git a/amaze.license.spotless.txt b/amaze.license.spotless.txt new file mode 100644 index 0000000..7ded9c0 --- /dev/null +++ b/amaze.license.spotless.txt @@ -0,0 +1,20 @@ +/* + * Copyright (C) 2014-$YEAR Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..14ef672 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,2 @@ +/build +.attach_pid* \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..5685923 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,346 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-kapt' +apply plugin: 'kotlin-parcelize' +apply plugin: 'com.hiya.jacoco-android' +apply plugin: "com.starter.easylauncher" +apply plugin: 'com.google.devtools.ksp' + +android { + namespace "com.amaze.filemanager" + compileSdk libs.versions.compileSdk.get().toInteger() + packagingOptions { + resources { + excludes += ['proguard-project.txt', 'project.properties', 'META-INF/LICENSE.txt', 'META-INF/LICENSE', 'META-INF/NOTICE.txt', 'META-INF/NOTICE', 'META-INF/DEPENDENCIES.txt', 'META-INF/DEPENDENCIES'] + } + } + + + defaultConfig { + applicationId "com.amaze.filemanager" + minSdkVersion libs.versions.minSdk.get().toInteger() + targetSdkVersion libs.versions.targetSdk.get().toInteger() + versionCode 121 + versionName "3.10" + multiDexEnabled true + + vectorDrawables.useSupportLibrary = true + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + + javaCompileOptions { + annotationProcessorOptions { + arguments += ["room.schemaLocation" : "$projectDir/src/test/resources/schemas".toString(), + "room.incremental" : "true", + "room.expandProjection": "true"] + } + } + } + + signingConfigs { + release + config + } + + buildTypes { + debug { + applicationIdSuffix ".debug" + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard.cfg' + testProguardFile 'tests-proguard.cfg' + buildConfigField "String", "CRYPTO_IV", "\"LxbHiJhhUXcj\"" + buildConfigField "String", "FTP_SERVER_KEYSTORE_PASSWORD", "\"vishal007\"" + debuggable true //For "debug" banner on icon + enableUnitTestCoverage true + } + + release { + signingConfig signingConfigs.release + + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard.cfg' + buildConfigField "String", "CRYPTO_IV", "\"LxbHiJhhUXcj\"" + buildConfigField "String", "FTP_SERVER_KEYSTORE_PASSWORD", "\"vishal007\"" + } + } + + buildFeatures { + viewBinding true + buildConfig true + } + + flavorDimensions = ['static'] + + productFlavors { + fdroid { + dimension 'static' + buildConfigField "boolean", "IS_VERSION_FDROID", "true" + } + + play { + dimension 'static' + buildConfigField "boolean", "IS_VERSION_FDROID", "false" + signingConfig signingConfigs.config + } + } + + dependenciesInfo { + // Disables dependency metadata when building APKs. + includeInApk = false + // Disables dependency metadata when building Android App Bundles. + includeInBundle = false + } + + sourceSets { + test.java.srcDirs += '../testShared/src/test/java' + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = '11' + } + + testOptions { + unitTests { + includeAndroidResources = true + returnDefaultValues = true + } + } + lint { + abortOnError false + } +} + +dependencies { + + modules { + module("org.bouncycastle:bcprov-jdk15on") { + replacedBy("org.bouncycastle:bcprov-jdk18on") + } + module("org.bouncycastle:bcpkix-jdk15on") { + replacedBy("org.bouncycastle:bcpkix-jdk18on") + } + module("org.bouncycastle:bcprov-jdk15to18") { + replacedBy("org.bouncycastle:bcprov-jdk18on") + } + module("org.bouncycastle:bcpkix-jdk15to18") { + replacedBy("org.bouncycastle:bcpkix-jdk18on") + } + } + + implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation libs.systembarTint + + implementation libs.androidX.vectordrawable.animated + implementation libs.androidX.legacySupportV13 + implementation libs.androidX.material + implementation libs.androidX.fragment + implementation libs.androidX.appcompat + implementation libs.androidX.preference + implementation libs.androidX.core + implementation libs.androidX.palette + implementation libs.androidX.cardview + implementation libs.androidX.constraintLayout + implementation libs.androidX.multidex //Multiple dex files + implementation libs.androidX.biometric + implementation libs.room.runtime + implementation libs.room.rxjava2 + + ksp libs.room.compiler + ksp libs.androidX.annotation + + //For tests + testImplementation libs.junit//tests the app logic + testImplementation libs.robolectric//tests android interaction + testImplementation libs.robolectric.shadows.httpclient//tests android interaction + testImplementation libs.androidX.test.core + testImplementation libs.androidX.test.runner + testImplementation libs.androidX.test.rules + testImplementation libs.androidX.test.ext.junit + testImplementation libs.androidX.fragment.testing + testImplementation libs.mockito.core + testImplementation libs.mockito.inline + testImplementation libs.mockito.kotlin + testImplementation libs.apache.sshd + testImplementation libs.awaitility + testImplementation libs.jsoup + testImplementation libs.room.migration + testImplementation libs.mockk + testImplementation libs.kotlin.coroutine.test + testImplementation libs.androidX.core.testing + kspTest libs.auto.service + testImplementation 'ch.qos.logback:logback-classic:1.4.14' + + androidTestImplementation libs.junit //tests the app logic + androidTestImplementation libs.androidX.test.expresso + androidTestImplementation libs.androidX.test.core + androidTestImplementation libs.androidX.test.runner + androidTestImplementation libs.androidX.test.rules + androidTestImplementation libs.androidX.test.ext.junit + androidTestImplementation libs.androidX.test.uiautomator + androidTestImplementation libs.androidX.fragment.testing + androidTestImplementation libs.commons.net + androidTestImplementation libs.awaitility + + //Detect memory leaks + debugImplementation libs.leakcanary.android + + implementation libs.commons.compress + + implementation libs.materialdialogs.core + implementation libs.materialdialogs.commons + + implementation libs.apache.mina.core + implementation libs.apache.ftpserver.ftplet.api + implementation libs.apache.ftpserver.core + + implementation libs.eventbus + + implementation libs.libsu.core + implementation libs.libsu.io + + playImplementation libs.cloudrail.si.android + playImplementation libs.junrar + playImplementation libs.google.play.billing + + implementation libs.mpAndroidChart//Nice charts and graphs + + implementation libs.concurrent.trees//Concurrent tries + + //SFTP + implementation libs.sshj + //smb + implementation libs.jcifs.ng + //FTP + implementation libs.commons.net + //OkHttp + implementation libs.okhttp + + implementation libs.bcpkix.jdk18on + implementation libs.bcprov.jdk18on + + //Glide: loads icons seemlessly + implementation libs.glide + implementation (libs.glide.recyclerView) { + // Excludes the support library because it's already included by Glide. + transitive = false + } + ksp libs.glide.ksp + + implementation libs.speedDial + + //Simple library show + implementation(libs.aboutLibraries) { + transitive = true + } + + //zip4j: support password-protected zips + implementation libs.zip4j + + implementation libs.xz + + implementation libs.rxandroid + // Because RxAndroid releases are few and far between, it is recommended you also + // explicitly depend on RxJava's latest version for bug fixes and new features. + // (see https://github.com/ReactiveX/RxJava/releases for latest 3.x.x version) + implementation libs.rxjava + + implementation project(':commons_compress_7z') + implementation project(':file_operations') + implementation project(':portscanner') + + implementation libs.kotlin.stdlib.jdk8 + implementation libs.acra.core + implementation libs.slf4j.api + implementation libs.logback.android + testImplementation libs.logback.classic + + implementation libs.gson + implementation libs.amaze.trashBin +} + +kotlin { + jvmToolchain(11) +} + +configurations.configureEach { + resolutionStrategy { + dependencySubstitution { + substitute module("commons-logging:commons-logging-api:1.1") using module("commons-logging:commons-logging:1.1.1") + substitute module("com.android.support:support-annotations:27.1.1") using module("com.android.support:support-annotations:27.0.2") + // These two lines are added to prevent possible class clashes between awaitility (which uses hamcrest 2.1) and junit (which uses hamcrest 1.3). + substitute module('org.hamcrest:hamcrest-core:1.3') using module("org.hamcrest:hamcrest:2.1") + substitute module('org.hamcrest:hamcrest-library:1.3') using module("org.hamcrest:hamcrest:2.1") + } + } +} + +configurations.testImplementation { + exclude module: 'logback-android' +} + +tasks.register('supportOldLangCodes') + +[['id', 'in'], ['yi', 'ji'], ['he', 'iw']].forEach { sourceCode, destinationCode -> + def copyTask = tasks.create('copyStrings' + sourceCode + 'Into' + destinationCode, Copy) { + description('copying values-' + sourceCode + ' strings into values-' + destinationCode) + from('src/main/res/values-' + sourceCode) + into('src/main/res/values-' + destinationCode) + include('strings.xml') + } + supportOldLangCodes.dependsOn copyTask +} + +project.afterEvaluate { + preBuild.dependsOn supportOldLangCodes +} + +clean.dependsOn supportOldLangCodes +clean.mustRunAfter supportOldLangCodes + +jacoco { + toolVersion = libs.versions.jacoco.get() +} + +tasks.withType(JacocoReport).configureEach { + reports { + csv.required.set(false) + html.required.set(true) + xml.required.set(true) + } +} + +tasks.withType(Test).configureEach { + jacoco.includeNoLocationClasses = true + jacoco.excludes = ['jdk.internal.*'] +} + +Properties props = new Properties() +def propFile = new File('signing.properties') + +if (propFile.canRead()) { + props.load(new FileInputStream(propFile)) + + if (props != null && + props.containsKey('STORE_FILE') && + props.containsKey('STORE_PASSWORD') && + props.containsKey('KEY_ALIAS') && + props.containsKey('KEY_PASSWORD')) { + android.signingConfigs.release.storeFile = file(props['STORE_FILE']) + android.signingConfigs.release.storePassword = props['STORE_PASSWORD'] + android.signingConfigs.release.keyAlias = props['KEY_ALIAS'] + android.signingConfigs.release.keyPassword = props['KEY_PASSWORD'] + } else { + println 'signing.properties found but some entries are missing' + android.buildTypes.release.signingConfig = null + } +} else { + println 'signing.properties not found' + android.buildTypes.release.signingConfig = null +} +repositories { + mavenCentral() +} diff --git a/app/proguard.cfg b/app/proguard.cfg new file mode 100644 index 0000000..19bb9c2 --- /dev/null +++ b/app/proguard.cfg @@ -0,0 +1,123 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /Users/panavtec/Documents/android-sdk-macosx/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the ProGuard +# include property in project.properties. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +-dontobfuscate +-optimizationpasses 5 + +#From here Apache Commons +-keep class org.apache.http.** +-keep interface org.apache.http.** + +-dontwarn org.apache.commons.** + +#From here Apache mira +-dontwarn javax.security.sasl.* +-dontwarn org.ietf.jgss.* +-dontwarn org.apache.mina.core.session.DefaultIoSessionDataStructureFactory$DefaultIoSessionAttributeMap #Java 8 not implememnted +-dontwarn org.apache.mina.util.ExpiringMap #Java 8 not implememnted +-keepclassmembers class * implements org.apache.mina.core.service.IoProcessor { + public (java.util.concurrent.ExecutorService); + public (java.util.concurrent.Executor); + public (); +} + +#From here jcifs +-dontwarn javax.servlet.** +-dontwarn jcifs.http.NetworkExplorer + +-keep,allowoptimization,allowobfuscation class eu.masconsult.android_ntlm.* {*;} + +#From here org.codehaus +-keep class org.codehaus.jackson.** { *; } + +-dontwarn org.codehaus.** + +-keep class org.w3c.dom.bootstrap.** { *; } +-keep class org.joda.time.** { *; } + +#From here Apache regexp +-dontwarn org.apache.regexp.REDemo +-dontwarn org.apache.regexp.REDemo$1 + +#From here Apache ftpsever +-dontwarn org.apache.ftpserver.** + +#From here MPAndroidChart +-keep class com.github.mikephil.charting.** { *; } + +-dontwarn io.realm.** +#From here retrolambda +-dontwarn java.lang.invoke.* +-dontwarn **$$Lambda$* + +#From here Glide +-keep public class * implements com.bumptech.glide.module.GlideModule +-keep public class * extends com.bumptech.glide.AppGlideModule +-keep public enum com.bumptech.glide.load.resource.bitmap.ImageHeaderParser$** { + **[] $VALUES; + public *; +} + +#From here AboutLibraries +-keepclasseswithmembers class **.R$* { + public static final int define_*; +} + +#From here CloudRail +-keep class com.cloudrail.** { *; } + +#From here BouncyCastle +-keep class org.bouncycastle.crypto.* {*;} +-keep class org.bouncycastle.crypto.agreement.** {*;} +-keep class org.bouncycastle.crypto.digests.* {*;} +-keep class org.bouncycastle.crypto.ec.* {*;} +-keep class org.bouncycastle.crypto.encodings.* {*;} +-keep class org.bouncycastle.crypto.engines.* {*;} +-keep class org.bouncycastle.crypto.macs.* {*;} +-keep class org.bouncycastle.crypto.modes.* {*;} +-keep class org.bouncycastle.crypto.paddings.* {*;} +-keep class org.bouncycastle.crypto.params.* {*;} +-keep class org.bouncycastle.crypto.prng.* {*;} +-keep class org.bouncycastle.crypto.signers.* {*;} + +-keep class org.bouncycastle.jcajce.provider.asymmetric.* {*;} +-keep class org.bouncycastle.jcajce.provider.asymmetric.util.* {*;} +-keep class org.bouncycastle.jcajce.provider.asymmetric.dh.* {*;} +-keep class org.bouncycastle.jcajce.provider.asymmetric.ec.* {*;} +-keep class org.bouncycastle.jcajce.provider.asymmetric.rsa.* {*;} + +-keep class org.bouncycastle.jcajce.provider.digest.** {*;} +-keep class org.bouncycastle.jcajce.provider.keystore.** {*;} +-keep class org.bouncycastle.jcajce.provider.symmetric.** {*;} +-keep class org.bouncycastle.jcajce.spec.* {*;} +-keep class org.bouncycastle.jce.** {*;} + +-dontwarn javax.naming.** + +#From here sshj. We are not using GSSAPI to connect to SSH +-dontwarn net.schmizz.sshj.userauth.method.AuthGssApiWithMic +#Warning was at SSHClient.authGssApiWithMic, referencing javax.security.auth.login.LoginContext. +#But we are not using it too +-dontwarn net.schmizz.sshj.SSHClient + + + +#From here tests classes +#Ignore test classes see tests-proguard.cfg +-dontwarn android.test.** +-dontwarn org.junit.** + +#Commons-compress. See #2647. +#Keep constructors that involves an InputStream +-keepclassmembers class * extends org.apache.commons.compress.compressors.CompressorInputStream { + (java.io.InputStream); +} + +-keep class com.amaze.trashbin.** { *; } +-dontwarn ch.qos.logback.core.net.* \ No newline at end of file diff --git a/app/src/androidTest/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServiceEspressoTest.kt b/app/src/androidTest/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServiceEspressoTest.kt new file mode 100644 index 0000000..5083db9 --- /dev/null +++ b/app/src/androidTest/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServiceEspressoTest.kt @@ -0,0 +1,319 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.services.ftp + +import android.content.Intent +import android.os.Environment +import android.util.Base64 +import androidx.preference.PreferenceManager +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.rule.ServiceTestRule +import com.amaze.filemanager.utils.ObtainableServiceBinder +import com.amaze.filemanager.utils.PasswordUtil +import org.apache.commons.net.PrintCommandListener +import org.apache.commons.net.ftp.FTP +import org.apache.commons.net.ftp.FTPClient +import org.apache.commons.net.ftp.FTPSClient +import org.awaitility.Awaitility.await +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.FileWriter +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Socket +import java.net.SocketException +import java.security.SecureRandom +import java.util.concurrent.TimeUnit + +// Require UIAutomator if need to run test on Android 11 +// in order to obtain MANAGE_EXTERNAL_STORAGE permission +@RunWith(AndroidJUnit4::class) +@Suppress("StringLiteralDuplication") +@androidx.test.filters.Suppress +class FtpServiceEspressoTest { + @get:Rule + var serviceTestRule = ServiceTestRule() + + private var service: FtpService? = null + + /** + * Kill running FtpService if there is one. + */ + @After + fun shutDown() { + service?.onDestroy() + } + + /** + * Test FTP service + */ + @Test + fun testFtpService() { + PreferenceManager.getDefaultSharedPreferences(ApplicationProvider.getApplicationContext()) + .edit() + .putBoolean(FtpService.KEY_PREFERENCE_SECURE, false) + .putBoolean(FtpService.KEY_PREFERENCE_SAF_FILESYSTEM, false) + .remove(FtpService.KEY_PREFERENCE_USERNAME) + .remove(FtpService.KEY_PREFERENCE_PASSWORD) + .commit() + service = + create( + Intent(FtpService.ACTION_START_FTPSERVER) + .putExtra(FtpService.TAG_STARTED_BY_TILE, false), + ) + + await().atMost(10, TimeUnit.SECONDS).until { + FtpService.isRunning() && isServerReady() + } + FTPClient().run { + addProtocolCommandListener(PrintCommandListener(System.err)) + loginAndVerifyWith(this) + testUploadWith(this) + testDownloadWith(this) + } + } + + /** + * Test FTP service over SSL + */ + @Test + fun testSecureFtpService() { + PreferenceManager.getDefaultSharedPreferences(ApplicationProvider.getApplicationContext()) + .edit() + .putBoolean(FtpService.KEY_PREFERENCE_SECURE, true) + .putBoolean(FtpService.KEY_PREFERENCE_SAF_FILESYSTEM, false) + .remove(FtpService.KEY_PREFERENCE_USERNAME) + .remove(FtpService.KEY_PREFERENCE_PASSWORD) + .commit() + service = + create( + Intent(FtpService.ACTION_START_FTPSERVER) + .putExtra(FtpService.TAG_STARTED_BY_TILE, false), + ) + + await().atMost(10, TimeUnit.SECONDS).until { + FtpService.isRunning() && isServerReady() + } + + FTPSClient(true).run { + addProtocolCommandListener(PrintCommandListener(System.err)) + loginAndVerifyWith(this) + testUploadWith(this) + testDownloadWith(this) + } + } + + /** + * Test to ensure FTP service cannot login anonymously after username/password is set + */ + @Test + fun testUsernameEnabledAnonymousCannotLogin() { + PreferenceManager.getDefaultSharedPreferences(ApplicationProvider.getApplicationContext()) + .edit() + .putBoolean(FtpService.KEY_PREFERENCE_SECURE, false) + .putString(FtpService.KEY_PREFERENCE_USERNAME, "amazeftp") + .putString( + FtpService.KEY_PREFERENCE_PASSWORD, + PasswordUtil.encryptPassword( + ApplicationProvider.getApplicationContext(), + "passw0rD", + ), + ) + .commit() + service = + create( + Intent(FtpService.ACTION_START_FTPSERVER) + .putExtra(FtpService.TAG_STARTED_BY_TILE, false), + ) + + await().atMost(10, TimeUnit.SECONDS).until { + FtpService.isRunning() && isServerReady() + } + + FTPClient().run { + connect("localhost", FtpService.DEFAULT_PORT) + assertFalse(login("anonymous", "test@example.com")) + assertTrue(login("amazeftp", "passw0rD")) + logout() + } + } + + private fun loginAndVerifyWith(ftpClient: FTPClient) { + ftpClient.connect("localhost", FtpService.DEFAULT_PORT) + ftpClient.login("anonymous", "test@example.com") + ftpClient.changeWorkingDirectory("/") + val files = ftpClient.listFiles() + assertNotNull(files) + assertTrue( + "No files found on device? It is also possible that app doesn't have " + + "permission to access storage, which may occur on broken Android emulators", + files.isNotEmpty(), + ) + var downloadFolderExists = false + for (f in files) { + if (f.name.equals("download", ignoreCase = true)) downloadFolderExists = true + } + ftpClient.logout() + ftpClient.disconnect() + assertTrue( + "Download folder not found on device. Either storage is not available, " + + "or something is really wrong with FtpService. Check logcat.", + downloadFolderExists, + ) + } + + private fun testUploadWith(ftpClient: FTPClient) { + val bytes1 = ByteArray(32) + val bytes2 = ByteArray(32) + SecureRandom().run { + setSeed(System.currentTimeMillis()) + nextBytes(bytes1) + nextBytes(bytes2) + } + + val randomString = Base64.encodeToString(bytes1, Base64.DEFAULT) + ftpClient.run { + connect("localhost", FtpService.DEFAULT_PORT) + login("anonymous", "test@example.com") + changeWorkingDirectory("/") + enterLocalPassiveMode() + setFileType(FTP.ASCII_FILE_TYPE) + ByteArrayInputStream(randomString.toByteArray(charset("utf-8"))).run { + this.copyTo(storeFileStream("test.txt")) + close() + } + ByteArrayInputStream(bytes2).run { + assertTrue(setFileType(FTP.BINARY_FILE_TYPE)) + this.copyTo(storeFileStream("test.bin")) + close() + } + logout() + disconnect() + + File(Environment.getExternalStorageDirectory(), "test.txt").run { + assertTrue(exists()) + val verifyContent = ByteArrayOutputStream() + FileInputStream(this).copyTo(verifyContent) + assertEquals(randomString, verifyContent.toString("utf-8")) + delete() + } + + File(Environment.getExternalStorageDirectory(), "test.bin").run { + assertTrue(exists()) + val verifyContent = ByteArrayOutputStream() + FileInputStream(this).copyTo(verifyContent) + assertArrayEquals(bytes2, verifyContent.toByteArray()) + delete() + } + } + } + + private fun testDownloadWith(ftpClient: FTPClient) { + val testFile1 = File(Environment.getExternalStorageDirectory(), "test.txt") + val testFile2 = File(Environment.getExternalStorageDirectory(), "test.bin") + val bytes1 = ByteArray(32) + val bytes2 = ByteArray(32) + SecureRandom().run { + setSeed(System.currentTimeMillis()) + nextBytes(bytes1) + nextBytes(bytes2) + } + + val randomString = Base64.encodeToString(bytes1, Base64.DEFAULT) + FileWriter(testFile1).run { + write(randomString) + close() + } + + FileOutputStream(testFile2).run { + write(bytes2, 0, bytes2.size) + close() + } + + ftpClient.run { + connect("localhost", FtpService.DEFAULT_PORT) + login("anonymous", "test@example.com") + changeWorkingDirectory("/") + enterLocalPassiveMode() + setFileType(FTP.ASCII_FILE_TYPE) + + ByteArrayOutputStream().run { + retrieveFile("test.txt", this) + close() + assertEquals(randomString, toString("utf-8")) + } + + setFileType(FTP.BINARY_FILE_TYPE) + + ByteArrayOutputStream().run { + retrieveFile("test.bin", this) + close() + assertArrayEquals(bytes2, toByteArray()) + } + + logout() + disconnect() + } + + testFile1.delete() + testFile2.delete() + } + + private fun create(intent: Intent): FtpService { + val binder = + serviceTestRule + .bindService( + intent.setClass( + ApplicationProvider.getApplicationContext(), + FtpService::class.java, + ), + ) + return ((binder as ObtainableServiceBinder).service as FtpService).also { + it.onStartCommand(intent, 0, 0) + } + } + + private fun isServerReady(): Boolean { + return Socket().let { + try { + it.connect(InetSocketAddress(InetAddress.getLocalHost(), FtpService.DEFAULT_PORT)) + true + } catch (e: SocketException) { + false + } finally { + it.close() + } + } + } +} diff --git a/app/src/androidTest/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServiceStaticMethodsTest.kt b/app/src/androidTest/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServiceStaticMethodsTest.kt new file mode 100644 index 0000000..85fc913 --- /dev/null +++ b/app/src/androidTest/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServiceStaticMethodsTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.services.ftp + +import android.content.Context +import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES.N_MR1 +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.amaze.filemanager.utils.NetworkUtil +import org.junit.Assert.assertNotNull +import org.junit.Assert.fail +import org.junit.Test +import org.junit.runner.RunWith + +/** + * This test is separated from FtpServiceEspressoTest since it does not actually requires the FTP + * service itself. + * + * + * It is expected that you are not running all the cases in one go. **You have been warned**. + */ +@SmallTest +@RunWith(AndroidJUnit4::class) +class FtpServiceStaticMethodsTest { + /** To test [FtpService.getLocalInetAddress] must not return an empty string. */ + @Test + fun testGetLocalInetAddressMustNotBeEmpty() { + /* Android emulator's "wifi connectivity" only exists from API 25. + * On the other hand, we don't do wifi AP in code, either it's not possible + * for lower APIs, nor we currently have no plans on doing this - see #515, #2720 - + * therefore we only run this test from API 25 or above. + * - TranceLove + */ + if (SDK_INT >= N_MR1) { + ApplicationProvider.getApplicationContext().run { + if (!NetworkUtil.isConnectedToLocalNetwork(this)) { + fail("Please connect your device to network to run this test!") + } + + NetworkUtil.getLocalInetAddress(this).also { + assertNotNull(it) + assertNotNull(it?.hostAddress) + } + } + } + } +} diff --git a/app/src/androidTest/java/com/amaze/filemanager/database/UtilsHandlerTest.java b/app/src/androidTest/java/com/amaze/filemanager/database/UtilsHandlerTest.java new file mode 100644 index 0000000..b089ec9 --- /dev/null +++ b/app/src/androidTest/java/com/amaze/filemanager/database/UtilsHandlerTest.java @@ -0,0 +1,142 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database; + +import static org.awaitility.Awaitility.await; +import static org.junit.Assert.assertEquals; + +import java.io.File; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import com.amaze.filemanager.database.models.OperationData; +import com.amaze.filemanager.filesystem.ftp.NetCopyClientUtils; + +import android.content.Context; +import android.os.Environment; + +import androidx.annotation.NonNull; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.Suppress; +import androidx.test.platform.app.InstrumentationRegistry; + +@RunWith(AndroidJUnit4.class) +public class UtilsHandlerTest { + + private UtilitiesDatabase utilitiesDatabase; + + private UtilsHandler utilsHandler; + + @Before + public void setUp() { + Context ctx = InstrumentationRegistry.getInstrumentation().getTargetContext(); + utilitiesDatabase = UtilitiesDatabase.initialize(ctx); + utilsHandler = new UtilsHandler(ctx, utilitiesDatabase); + utilitiesDatabase.getOpenHelper().getWritableDatabase().execSQL("DELETE FROM sftp;"); + utilitiesDatabase.getOpenHelper().getWritableDatabase().execSQL("DELETE FROM bookmarks;"); + } + + @After + public void tearDown() { + if (utilitiesDatabase.isOpen()) { + utilitiesDatabase.clearAllTables(); + utilitiesDatabase.close(); + } + } + + @Test + public void testEncodeEncryptUri1() { + performEncryptUriTest("ssh://test:testP@ssw0rd@127.0.0.1:5460"); + } + + @Test + public void testEncodeEncryptUri2() { + performEncryptUriTest("ssh://test:testP@##word@127.0.0.1:22"); + } + + @Test + public void testEncodeEncryptUri3() { + performEncryptUriTest("ssh://test@example.com:testP@ssw0rd@127.0.0.1:22"); + } + + @Test + public void testEncodeEncryptUri4() { + performEncryptUriTest("ssh://test@example.com:testP@ssw0##$@127.0.0.1:22"); + } + + @Test + @Suppress + public void testRepeatedSaveBookmarkShouldNeverThrowException() { + OperationData operationData = + new OperationData( + UtilsHandler.Operation.BOOKMARKS, + "My Documents", + new File(Environment.getExternalStorageDirectory(), "My Documents").getAbsolutePath()); + utilsHandler.addCommonBookmarks(); + await() + .atMost(10, TimeUnit.SECONDS) + .until( + () -> { + List verify = utilsHandler.getBookmarksList(); + assertEquals(5, verify.size()); + return true; + }); + for (int i = 0; i < 5; i++) { + utilsHandler.saveToDatabase(operationData); + await() + .atMost(10, TimeUnit.SECONDS) + .until( + () -> { + List verify = utilsHandler.getBookmarksList(); + assertEquals(6, verify.size()); + return true; + }); + } + } + + private void performEncryptUriTest(@NonNull final String origPath) { + String encryptedPath = NetCopyClientUtils.INSTANCE.encryptFtpPathAsNecessary(origPath); + + utilsHandler.saveToDatabase( + new OperationData( + UtilsHandler.Operation.SFTP, + encryptedPath, + "Test", + "00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff", + null, + null)); + + await().atMost(10, TimeUnit.SECONDS).until(() -> utilsHandler.getSftpList().size() > 0); + + List result = utilsHandler.getSftpList(); + assertEquals(1, result.size()); + assertEquals("Test", result.get(0)[0]); + assertEquals(encryptedPath, result.get(0)[1]); + assertEquals( + "00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff", + utilsHandler.getRemoteHostKey(encryptedPath)); + } +} diff --git a/app/src/androidTest/java/com/amaze/filemanager/filesystem/HybridFileParcelableTest.java b/app/src/androidTest/java/com/amaze/filemanager/filesystem/HybridFileParcelableTest.java new file mode 100644 index 0000000..be1d8ae --- /dev/null +++ b/app/src/androidTest/java/com/amaze/filemanager/filesystem/HybridFileParcelableTest.java @@ -0,0 +1,221 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem; + +import static org.junit.Assert.assertEquals; + +import org.junit.Before; +import org.junit.Test; + +import com.amaze.filemanager.fileoperations.filesystem.OpenMode; + +import android.os.Parcel; + +/** Created by Rustam Khadipash on 29/3/2018. */ +public class HybridFileParcelableTest { + private HybridFileParcelable filePath; + private HybridFileParcelable directory; + private HybridFileParcelable file; + + @Before + public void setUp() { + filePath = new HybridFileParcelable("/storage/sdcard0/Test1/Test1.txt"); + + directory = new HybridFileParcelable("/storage/sdcard0/Test2", "rw", 123456, 654321, true); + + file = + new HybridFileParcelable("/storage/sdcard0/Test3/Test3.txt", "rw", 123456, 654321, false); + } + + /** Purpose: Get name of a file / directory Input: no Expected: return file / directory's name */ + @Test + public void getName() { + assertEquals("Test1.txt", filePath.getName()); + assertEquals("Test2", directory.getName()); + assertEquals("Test3.txt", file.getName()); + } + + /** + * Purpose: Change name of a file / directory Input: setName newName Expected: File / directory's + * name is changed + */ + @Test + public void setName() { + filePath.setName("Tset1.txt"); + assertEquals("Tset1.txt", filePath.getName()); + directory.setName("Tset2"); + assertEquals("Tset2", directory.getName()); + file.setName("Tset3.txt"); + assertEquals("Tset3.txt", file.getName()); + } + + /** + * Purpose: Get open mode of a file / directory Input: no Expected: return OpenMode.FILE + * + *

All files and directories in this class have open mode set as OpenMode.FILE + */ + @Test + public void getMode() { + assertEquals(OpenMode.FILE, filePath.getMode()); + assertEquals(OpenMode.FILE, directory.getMode()); + assertEquals(OpenMode.FILE, file.getMode()); + } + + /** Purpose: Get link to a file / directory Input: no Expected: return empty string */ + @Test + public void getLink() { + assertEquals("", filePath.getLink()); + assertEquals("", directory.getLink()); + assertEquals("", file.getLink()); + } + + /** + * Purpose: Set link to a file / directory Input: setLink newLink Expected: File / directory's + * link is changed + */ + @Test + public void setLink() { + filePath.setLink("abc"); + assertEquals("abc", filePath.getLink()); + directory.setLink("def"); + assertEquals("def", directory.getLink()); + file.setLink("ghi"); + assertEquals("ghi", file.getLink()); + } + + /** Purpose: Get creation date of a file / directory Input: no Expected: return date */ + @Test + public void getDate() { + assertEquals(0, filePath.getDate()); + assertEquals(123456, directory.getDate()); + assertEquals(123456, file.getDate()); + } + + /** + * Purpose: Change creation date of a file / directory Input: setDate newDate Expected: File / + * directory's creation date is changed + */ + @Test + public void setDate() { + filePath.setDate(746352); + assertEquals(746352, filePath.getDate()); + directory.setDate(474587); + assertEquals(474587, directory.getDate()); + file.setDate(3573335); + assertEquals(3573335, file.getDate()); + } + + /** Purpose: Get size of a file / directory Input: no Expected: return size */ + @Test + public void getSize() { + assertEquals(0, filePath.getSize()); + assertEquals(654321, directory.getSize()); + assertEquals(654321, file.getSize()); + } + + /** + * Purpose: Change size of a file / directory Input: setSize newSize Expected: File / directory's + * size is changed + */ + @Test + public void setSize() { + filePath.setSize(3534546); + assertEquals(3534546, filePath.getSize()); + directory.setSize(1745534); + assertEquals(1745534, directory.getSize()); + file.setSize(7546543); + assertEquals(7546543, file.getSize()); + } + + /** Purpose: Check whether it is a file or directory Input: no Expected: return directory */ + @Test + public void isDirectory() { + assertEquals(false, filePath.isDirectory()); + assertEquals(true, directory.isDirectory()); + assertEquals(false, file.isDirectory()); + } + + /** + * Purpose: Change type to file / directory Input: setDirectory isDirectory Expected: File / + * directory's type is changed + */ + @Test + public void setDirectory() { + filePath.setDirectory(true); + assertEquals(true, filePath.isDirectory()); + directory.setDirectory(false); + assertEquals(false, directory.isDirectory()); + file.setDirectory(true); + assertEquals(true, file.isDirectory()); + } + + /** Purpose: Get path to a file / directory Input: no Expected: return path */ + @Test + public void getPath() { + assertEquals("/storage/sdcard0/Test1/Test1.txt", filePath.getPath()); + assertEquals("/storage/sdcard0/Test2", directory.getPath()); + assertEquals("/storage/sdcard0/Test3/Test3.txt", file.getPath()); + } + + /** Purpose: Get file / directory's permissions Input: no Expected: return permissions */ + @Test + public void getPermission() { + assertEquals(null, filePath.getPermission()); + assertEquals("rw", directory.getPermission()); + assertEquals("rw", file.getPermission()); + } + + /** + * Purpose: Change permissions of a file / directory Input: setPermission newPermission Expected: + * File / directory's permissions are changed + */ + @Test + public void setPermission() { + filePath.setPermission("rwx"); + assertEquals("rwx", filePath.getPermission()); + directory.setPermission("rwx"); + assertEquals("rwx", directory.getPermission()); + file.setPermission("rwx"); + assertEquals("rwx", file.getPermission()); + } + + /** + * Purpose: Write an object into a parcel, send it through an intent and read the object from the + * parcel Input: writeToParcel parcel object Expected: The parcel can be sent and the object can + * be extracted from the parcel + */ + @Test + public void writeToParcel() { + Parcel parcel = Parcel.obtain(); + file.writeToParcel(parcel, file.describeContents()); + parcel.setDataPosition(0); + + HybridFileParcelable createdFromParcel = HybridFileParcelable.CREATOR.createFromParcel(parcel); + assertEquals(file.getDate(), createdFromParcel.getDate()); + assertEquals(file.getLink(), createdFromParcel.getLink()); + assertEquals(file.getMode(), createdFromParcel.getMode()); + assertEquals(file.getName(), createdFromParcel.getName()); + assertEquals(file.getPath(), createdFromParcel.getPath()); + assertEquals(file.getPermission(), createdFromParcel.getPermission()); + assertEquals(file.getSize(), createdFromParcel.getSize()); + assertEquals(file.isDirectory(), createdFromParcel.isDirectory()); + } +} diff --git a/app/src/androidTest/java/com/amaze/filemanager/filesystem/files/CryptUtilTest.java b/app/src/androidTest/java/com/amaze/filemanager/filesystem/files/CryptUtilTest.java new file mode 100644 index 0000000..7b7728b --- /dev/null +++ b/app/src/androidTest/java/com/amaze/filemanager/filesystem/files/CryptUtilTest.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.files; + +import static org.junit.Assert.assertEquals; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import com.amaze.filemanager.BuildConfig; +import com.amaze.filemanager.utils.PasswordUtil; + +import android.content.Context; +import android.util.Base64; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +@RunWith(AndroidJUnit4.class) +public class CryptUtilTest { + + private Context context; + + @Before + public void setUp() { + context = InstrumentationRegistry.getInstrumentation().getTargetContext(); + } + + @Test + public void testIvValueIsCorrect() { + assertEquals("LxbHiJhhUXcj", BuildConfig.CRYPTO_IV); + } + + @Test + public void testEncryptDecrypt() throws Exception { + String password = "hackme"; + String encrypted = PasswordUtil.INSTANCE.encryptPassword(context, password, Base64.URL_SAFE); + assertEquals( + password, PasswordUtil.INSTANCE.decryptPassword(context, encrypted, Base64.URL_SAFE)); + } +} diff --git a/app/src/androidTest/java/com/amaze/filemanager/filesystem/files/GenericCopyUtilEspressoTest.java b/app/src/androidTest/java/com/amaze/filemanager/filesystem/files/GenericCopyUtilEspressoTest.java new file mode 100644 index 0000000..5ff4562 --- /dev/null +++ b/app/src/androidTest/java/com/amaze/filemanager/filesystem/files/GenericCopyUtilEspressoTest.java @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.files; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.channels.Channels; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil; +import com.amaze.filemanager.test.DummyFileGenerator; +import com.amaze.filemanager.utils.ProgressHandler; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +@RunWith(AndroidJUnit4.class) +public class GenericCopyUtilEspressoTest { + + private GenericCopyUtil copyUtil; + + private File file1, file2; + + @Before + public void setUp() throws IOException { + copyUtil = + new GenericCopyUtil( + InstrumentationRegistry.getInstrumentation().getTargetContext(), new ProgressHandler()); + file1 = File.createTempFile("test", "bin"); + file2 = File.createTempFile("test", "bin"); + file1.deleteOnExit(); + file2.deleteOnExit(); + } + + @Test + public void testCopyFile1() throws IOException, NoSuchAlgorithmException { + doTestCopyFile1(512); + doTestCopyFile1(10 * 1024 * 1024); + } + + @Test + public void testCopyFile2() throws IOException, NoSuchAlgorithmException { + doTestCopyFile2(512); + doTestCopyFile2(10 * 1024 * 1024); + } + + @Test + public void testCopyFile3() throws IOException, NoSuchAlgorithmException { + doTestCopyFile3(512); + doTestCopyFile3(10 * 1024 * 1024); + } + + // doCopy(ReadableByteChannel in, WritableByteChannel out) + private void doTestCopyFile1(int size) throws IOException, NoSuchAlgorithmException { + byte[] checksum = DummyFileGenerator.createFile(file1, size); + copyUtil.doCopy( + new FileInputStream(file1).getChannel(), + Channels.newChannel(new FileOutputStream(file2)), + ServiceWatcherUtil.UPDATE_POSITION); + assertEquals(file1.length(), file2.length()); + assertSha1Equals(checksum, file2); + } + + // copy(FileChannel in, FileChannel out) + private void doTestCopyFile2(int size) throws IOException, NoSuchAlgorithmException { + byte[] checksum = DummyFileGenerator.createFile(file1, size); + copyUtil.copyFile( + new FileInputStream(file1).getChannel(), + new FileOutputStream(file2).getChannel(), + ServiceWatcherUtil.UPDATE_POSITION); + assertEquals(file1.length(), file2.length()); + assertSha1Equals(checksum, file2); + } + + // copy(BufferedInputStream in, BufferedOutputStream out) + private void doTestCopyFile3(int size) throws IOException, NoSuchAlgorithmException { + byte[] checksum = DummyFileGenerator.createFile(file1, size); + copyUtil.copyFile( + new BufferedInputStream(new FileInputStream(file1)), + new BufferedOutputStream(new FileOutputStream(file2)), + ServiceWatcherUtil.UPDATE_POSITION); + assertEquals(file1.length(), file2.length()); + assertSha1Equals(checksum, file2); + } + + private void assertSha1Equals(byte[] expected, File file) + throws NoSuchAlgorithmException, IOException { + MessageDigest md = MessageDigest.getInstance("SHA-1"); + DigestInputStream in = new DigestInputStream(new FileInputStream(file), md); + byte[] buffer = new byte[GenericCopyUtil.DEFAULT_BUFFER_SIZE]; + while (in.read(buffer) > -1) {} + in.close(); + assertArrayEquals(expected, md.digest()); + } +} diff --git a/app/src/androidTest/java/com/amaze/filemanager/ssh/SshClientUtilTest.java b/app/src/androidTest/java/com/amaze/filemanager/ssh/SshClientUtilTest.java new file mode 100644 index 0000000..42774f9 --- /dev/null +++ b/app/src/androidTest/java/com/amaze/filemanager/ssh/SshClientUtilTest.java @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ssh; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import com.amaze.filemanager.filesystem.ftp.NetCopyClientUtils; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +@RunWith(AndroidJUnit4.class) +public class SshClientUtilTest { + + @Test + public void testEncryptDecryptUriWithNoPassword() { + String uri = "ssh://testuser@127.0.0.1:22"; + assertEquals(uri, NetCopyClientUtils.INSTANCE.encryptFtpPathAsNecessary(uri)); + assertEquals(uri, NetCopyClientUtils.INSTANCE.encryptFtpPathAsNecessary(uri)); + } + + @Test + public void testEncryptDecryptPasswordWithMinusSign1() { + String uri = "ssh://testuser:abcd-efgh@127.0.0.1:22"; + String result = NetCopyClientUtils.INSTANCE.encryptFtpPathAsNecessary(uri); + assertTrue(result.contains("ssh://testuser:")); + assertTrue(result.contains("@127.0.0.1:22")); + String verify = NetCopyClientUtils.INSTANCE.decryptFtpPathAsNecessary(result); + assertEquals(uri, verify); + } + + @Test + public void testEncryptDecryptPasswordWithMinusSign2() { + String uri = "ssh://testuser:---------------@127.0.0.1:22"; + String result = NetCopyClientUtils.INSTANCE.encryptFtpPathAsNecessary(uri); + assertTrue(result.contains("ssh://testuser:")); + assertTrue(result.contains("@127.0.0.1:22")); + String verify = NetCopyClientUtils.INSTANCE.decryptFtpPathAsNecessary(result); + assertEquals(uri, verify); + } + + @Test + public void testEncryptDecryptPasswordWithMinusSign3() { + String uri = "ssh://testuser:--agdiuhdpost15@127.0.0.1:22"; + String result = NetCopyClientUtils.INSTANCE.encryptFtpPathAsNecessary(uri); + assertTrue(result.contains("ssh://testuser:")); + assertTrue(result.contains("@127.0.0.1:22")); + String verify = NetCopyClientUtils.INSTANCE.decryptFtpPathAsNecessary(result); + assertEquals(uri, verify); + } + + @Test + public void testEncryptDecryptPasswordWithMinusSign4() { + String uri = "ssh://testuser:t-h-i-s-i-s-p-a-s-s-w-o-r-d-@127.0.0.1:22"; + String result = NetCopyClientUtils.INSTANCE.encryptFtpPathAsNecessary(uri); + assertTrue(result.contains("ssh://testuser:")); + assertTrue(result.contains("@127.0.0.1:22")); + String verify = NetCopyClientUtils.INSTANCE.decryptFtpPathAsNecessary(result); + assertEquals(uri, verify); + } +} diff --git a/app/src/androidTest/java/com/amaze/filemanager/test/DummyFileGenerator.java b/app/src/androidTest/java/com/amaze/filemanager/test/DummyFileGenerator.java new file mode 100644 index 0000000..43f6591 --- /dev/null +++ b/app/src/androidTest/java/com/amaze/filemanager/test/DummyFileGenerator.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.test; + +import static com.amaze.filemanager.filesystem.files.GenericCopyUtil.DEFAULT_BUFFER_SIZE; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.security.DigestOutputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Random; + +import androidx.annotation.NonNull; +import androidx.annotation.RestrictTo; +import androidx.annotation.VisibleForTesting; + +/** + * Generate file of specified size using randomly generated bytes. + * + *

Because of the need that both Espresso and Robolectric tests depends on it, it needs to be + * placed at the main source tree, and hide it using {@link VisibleForTesting}. + */ +@VisibleForTesting(otherwise = VisibleForTesting.NONE) +@RestrictTo(RestrictTo.Scope.TESTS) +public abstract class DummyFileGenerator { + + /** + * @param destFile File to be generated with random bytes + * @param size file size + * @return SHA1 checksum of the generated file, as byte array + * @throws IOException in case any I/O error occurred + */ + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + @RestrictTo(RestrictTo.Scope.TESTS) + public static byte[] createFile(@NonNull File destFile, int size) throws IOException { + Random rand = new SecureRandom(); + MessageDigest md = null; + try { + md = MessageDigest.getInstance("SHA-1"); + } catch (NoSuchAlgorithmException shouldNeverHappen) { + throw new IOException("SHA-1 implementation not found"); + } + + FileOutputStream out = new FileOutputStream(destFile); + DigestOutputStream dout = new DigestOutputStream(out, md); + int count = 0; + for (int i = size; i >= 0; i -= DEFAULT_BUFFER_SIZE, count += DEFAULT_BUFFER_SIZE) { + byte[] bytes = new byte[i > DEFAULT_BUFFER_SIZE ? DEFAULT_BUFFER_SIZE : i]; + rand.nextBytes(bytes); + dout.write(bytes); + } + dout.flush(); + dout.close(); + + return md.digest(); + } +} diff --git a/app/src/androidTest/java/com/amaze/filemanager/test/StoragePermissionHelper.kt b/app/src/androidTest/java/com/amaze/filemanager/test/StoragePermissionHelper.kt new file mode 100644 index 0000000..c71d892 --- /dev/null +++ b/app/src/androidTest/java/com/amaze/filemanager/test/StoragePermissionHelper.kt @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.test + +// import android.content.Intent +// import android.content.Intent.FLAG_ACTIVITY_NEW_TASK +// import android.net.Uri +// import android.os.Build +// import android.os.Build.VERSION.SDK_INT +// import android.os.Environment +// import android.provider.Settings +// import android.widget.Switch +// import androidx.test.platform.app.InstrumentationRegistry +// import androidx.test.uiautomator.UiDevice +// import androidx.test.uiautomator.UiSelector +// import org.junit.Assert.assertTrue + +object StoragePermissionHelper { + /** + * This method is intended for Android R or above devices to obtain MANAGE_EXTERNAL_STORAGE + * permission via UI Automator framework when running relevant Espresso tests. + * + * This method is flat commented out because UI Automator requires Android SDK 18, while + * currently we still want to support SDK 14. + */ + @JvmStatic + fun obtainManageAppAllFileAccessPermissionAutomatically() { +// if (!Environment.isExternalStorageManager() && SDK_INT > Build.VERSION_CODES.R) { +// InstrumentationRegistry.getInstrumentation().run { +// val device = androidx.test.uiautomator.UiDevice.getInstance(this) +// val context = this.targetContext +// device.pressHome() +// val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) +// .setData(Uri.parse("package:${context.packageName}")) +// .addFlags(FLAG_ACTIVITY_NEW_TASK) +// context.startActivity(intent) +// val switch = device.findObject( +// androidx.test.uiautomator.UiSelector() +// .packageName("com.android.settings") +// .className(Switch::class.java.name) +// .resourceId("android:id/switch_widget") +// ) +// switch.click() +// assertTrue(switch.isChecked) +// device.pressHome() +// } +// } +// assertTrue(Environment.isExternalStorageManager()) + return // Try to get codacy happy if they ever check me... pretend I am doing something + } +} diff --git a/app/src/androidTest/java/com/amaze/filemanager/ui/activities/TextEditorActivityEspressoTest.java b/app/src/androidTest/java/com/amaze/filemanager/ui/activities/TextEditorActivityEspressoTest.java new file mode 100644 index 0000000..e88eab8 --- /dev/null +++ b/app/src/androidTest/java/com/amaze/filemanager/ui/activities/TextEditorActivityEspressoTest.java @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.activities; + +import static org.junit.Assert.assertNotEquals; + +import java.io.File; +import java.util.concurrent.CountDownLatch; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import com.amaze.filemanager.ui.activities.texteditor.TextEditorActivity; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.SmallTest; +import androidx.test.filters.Suppress; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.rule.ActivityTestRule; + +@SmallTest +@RunWith(AndroidJUnit4.class) +@Suppress +// Have to rewrite to cope with Android 11 storage access model +public class TextEditorActivityEspressoTest { + + @Rule + public ActivityTestRule activityRule = + new ActivityTestRule<>(TextEditorActivity.class, true, false); + + private Context context; + + private Uri uri; + + @Before + public void setUp() { + context = InstrumentationRegistry.getInstrumentation().getTargetContext(); + + File file = new File("/default.prop"); + uri = Uri.fromFile(file); + } + + @Test + public void testOpenFile() throws Exception { + Intent intent = + new Intent(context, TextEditorActivity.class) + .setAction(Intent.ACTION_VIEW) + .addCategory(Intent.CATEGORY_DEFAULT) + .setType("text/plain") + .setData(uri); + activityRule.launchActivity(intent); + CountDownLatch waiter = new CountDownLatch(1); + while ("".equals(activityRule.getActivity().mainTextView.getText().toString())) { + waiter.await(); + } + waiter.countDown(); + assertNotEquals("", activityRule.getActivity().mainTextView.getText()); + assertNotEquals("foobar", activityRule.getActivity().mainTextView.getText()); + // Add extra time for you to see the Activity did load, and text is actually there + // Thread.sleep(1000); + } +} diff --git a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt new file mode 100644 index 0000000..027f5c5 --- /dev/null +++ b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt @@ -0,0 +1,244 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.fragments + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.net.Uri +import androidx.lifecycle.Lifecycle +import androidx.preference.PreferenceManager +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.amaze.filemanager.R +import com.amaze.filemanager.ui.activities.PreferencesActivity +import com.amaze.filemanager.ui.fragments.preferencefragments.BackupPrefsFragment +import com.google.gson.GsonBuilder +import com.google.gson.reflect.TypeToken +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +@RunWith(AndroidJUnit4::class) +class BackupPrefsFragmentTest { + var storagePath = "/storage/emulated/0" + var fileName = "amaze_backup.json" + + /** Test exporting */ + @Test + fun testExport() { + val backupPrefsFragment = BackupPrefsFragment() + + val activityScenario = ActivityScenario.launch(PreferencesActivity::class.java) + + activityScenario.moveToState(Lifecycle.State.STARTED) + + val exportFile = + File( + storagePath + + File.separator + + fileName, + ) + + exportFile.delete() // delete if already exists + + activityScenario.onActivity { + it.supportFragmentManager.beginTransaction() + .add(backupPrefsFragment, null) + .commitNow() + + backupPrefsFragment.exportPrefs() + } + + val tempFile = + File( + ApplicationProvider.getApplicationContext().cacheDir.absolutePath + + File.separator + + fileName, + ) + + Assert.assertTrue(tempFile.exists()) + + // terrible hack :cringe: + onView(withId(R.id.home)).perform(ViewActions.click()) + Thread.sleep(500) + + onView(withText(R.string.save)).perform(ViewActions.click()) + Thread.sleep(500) + + Assert.assertTrue(exportFile.exists()) + } + + /** Test whether the exported file contains the expected preference values */ + @Test + fun verifyExportFile() { + val backupPrefsFragment = BackupPrefsFragment() + + val activityScenario = ActivityScenario.launch(PreferencesActivity::class.java) + + activityScenario.moveToState(Lifecycle.State.STARTED) + + val file = + File( + storagePath + + File.separator + + fileName, + ) + + activityScenario.onActivity { preferencesActivity -> + preferencesActivity.supportFragmentManager.beginTransaction() + .add(backupPrefsFragment, null) + .commitNow() + + val preferences = + PreferenceManager + .getDefaultSharedPreferences(preferencesActivity) + + val preferenceMap: Map = preferences.all + + val inputString = + file + .inputStream() + .bufferedReader() + .use { + it.readText() + } + + val type = object : TypeToken>() {}.type + + val importMap: Map = + GsonBuilder() + .create() + .fromJson( + inputString, + type, + ) + + for ((key, value) in preferenceMap) { + var mapValue = importMap[key] + + if (mapValue!!::class.simpleName.equals("Double")) { + mapValue = (mapValue as Double).toInt() // since Gson parses Integer as Double + } + + Assert.assertEquals(value, mapValue) + } + } + } + + /** Test import */ + @Test + fun testImport() { + val backupPrefsFragment = BackupPrefsFragment() + + val activityScenario = ActivityScenario.launch(PreferencesActivity::class.java) + + activityScenario.moveToState(Lifecycle.State.STARTED) + + val exportFile = + File( + storagePath + + File.separator + + fileName, + ) + + exportFile.delete() // delete if already exists + + activityScenario.onActivity { preferencesActivity -> + preferencesActivity.supportFragmentManager.beginTransaction() + .add(backupPrefsFragment, null) + .commitNow() + + javaClass.getResourceAsStream("/$fileName")?.copyTo(exportFile.outputStream()) + + backupPrefsFragment.onActivityResult( + BackupPrefsFragment.IMPORT_BACKUP_FILE, + Activity.RESULT_OK, + Intent().setData( + Uri.fromFile(exportFile), + ), + ) + + val inputString = + exportFile + .inputStream() + .bufferedReader() + .use { + it.readText() + } + + val type = object : TypeToken>() {}.type + + val importMap: Map = + GsonBuilder() + .create() + .fromJson( + inputString, + type, + ) + + val preferences = + PreferenceManager + .getDefaultSharedPreferences(preferencesActivity) + + val preferenceMap: Map = preferences.all + + for ((key, value) in preferenceMap) { + Assert.assertTrue(checkPrefEqual(preferences, importMap, key, value)) + } + } + } + + private fun checkPrefEqual( + preferences: SharedPreferences, + importMap: Map, + key: String?, + value: Any?, + ): Boolean { + when (value!!::class.simpleName) { + "Boolean" -> return importMap[key] as Boolean == + preferences.getBoolean(key, false) + "Float" -> + importMap[key] as Float == + preferences.getFloat(key, 0f) + "Int" -> { + // since Gson parses Integer as Double + val toInt = (importMap[key] as Double).toInt() + + return toInt == preferences.getInt(key, 0) + } + "Long" -> return importMap[key] as Long == + preferences.getLong(key, 0L) + "String" -> return importMap[key] as String == + preferences.getString(key, null) + "Set<*>" -> return importMap[key] as Set<*> == + preferences.getStringSet(key, null) + } + return false + } +} diff --git a/app/src/androidTest/java/com/amaze/filemanager/utils/CryptUtilEspressoTest.kt b/app/src/androidTest/java/com/amaze/filemanager/utils/CryptUtilEspressoTest.kt new file mode 100644 index 0000000..d6bba34 --- /dev/null +++ b/app/src/androidTest/java/com/amaze/filemanager/utils/CryptUtilEspressoTest.kt @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils + +import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES.JELLY_BEAN_MR2 +import android.os.Environment +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import androidx.test.rule.GrantPermissionRule +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.filesystem.HybridFileParcelable +import com.amaze.filemanager.filesystem.files.CryptUtil +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.io.ByteArrayInputStream +import java.io.File +import java.io.FileOutputStream +import kotlin.random.Random + +/** + * Test for [CryptUtil] against real devices. + * + */ +@SmallTest +@RunWith(AndroidJUnit4::class) +@Suppress("StringLiteralDuplication") +class CryptUtilEspressoTest { + @Rule @JvmField + val storagePermissionRule: GrantPermissionRule = + GrantPermissionRule + .grant(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) + + /** + * Sanity test of CryptUtil legacy method, to ensure refactoring won't break + * on physical devices + */ + @Test + fun testEncryptDecryptLegacyMethod() { + val source = Random(System.currentTimeMillis()).nextBytes(117) + val sourceFile = File(Environment.getExternalStorageDirectory(), "test.bin") + ByteArrayInputStream(source).copyTo(FileOutputStream(sourceFile)) + CryptUtil( + AppConfig.getInstance(), + HybridFileParcelable(sourceFile.absolutePath), + ProgressHandler(), + ArrayList(), + "test.bin${CryptUtil.CRYPT_EXTENSION}", + false, + null, + ) + val targetFile = + File( + Environment.getExternalStorageDirectory(), + "test.bin${CryptUtil.CRYPT_EXTENSION}", + ) + assertTrue(targetFile.exists()) + if (SDK_INT < JELLY_BEAN_MR2) { + // Quirks for SDK < 18. File is not encrypted at all. + assertTrue( + "Source and target file size should be the same = ${source.size}", + source.size.toLong() == targetFile.length(), + ) + } else { + assertTrue( + "Source size = ${source.size} target file size = ${targetFile.length()}", + targetFile.length() > source.size, + ) + } + sourceFile.delete() + CryptUtil( + AppConfig.getInstance(), + HybridFileParcelable(targetFile.absolutePath).also { + it.setSize(targetFile.length()) + }, + Environment.getExternalStorageDirectory().absolutePath, + ProgressHandler(), + ArrayList(), + null, + ) + File(Environment.getExternalStorageDirectory(), "test.bin").run { + assertTrue(this.exists()) + assertArrayEquals(source, this.readBytes()) + } + } + + /** + * Test encrypt and decrypt routine with AESCrypt format. + */ + @Test + fun testEncryptDecryptAescrypt() { + val source = Random(System.currentTimeMillis()).nextBytes(117) + val sourceFile = File(Environment.getExternalStorageDirectory(), "test.bin") + ByteArrayInputStream(source).copyTo(FileOutputStream(sourceFile)) + CryptUtil( + AppConfig.getInstance(), + HybridFileParcelable(sourceFile.absolutePath), + ProgressHandler(), + ArrayList(), + "test.bin${CryptUtil.AESCRYPT_EXTENSION}", + true, + "12345678", + ) + val targetFile = + File( + Environment.getExternalStorageDirectory(), + "test.bin${CryptUtil.AESCRYPT_EXTENSION}", + ) + assertTrue(targetFile.exists()) + assertTrue( + "Source size = ${source.size} target file size = ${targetFile.length()}", + targetFile.length() > source.size, + ) + sourceFile.delete() + CryptUtil( + AppConfig.getInstance(), + HybridFileParcelable(targetFile.absolutePath).also { + it.setSize(targetFile.length()) + }, + Environment.getExternalStorageDirectory().absolutePath, + ProgressHandler(), + ArrayList(), + "12345678", + ) + File(Environment.getExternalStorageDirectory(), "test.bin").run { + assertTrue(this.exists()) + assertArrayEquals(source, this.readBytes()) + } + } +} diff --git a/app/src/androidTest/java/com/amaze/filemanager/utils/security/SecretKeygenEspressoTest.kt b/app/src/androidTest/java/com/amaze/filemanager/utils/security/SecretKeygenEspressoTest.kt new file mode 100644 index 0000000..507bd8f --- /dev/null +++ b/app/src/androidTest/java/com/amaze/filemanager/utils/security/SecretKeygenEspressoTest.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils.security + +import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES.ICE_CREAM_SANDWICH +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.fail +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Test [SecretKeygen] runs on real device. Necessary since Robolectric doesn't have shadows for + * AndroidKeyStore. + */ +@SmallTest +@RunWith(AndroidJUnit4::class) +class SecretKeygenEspressoTest { + /** + * Test [SecretKeygen.getSecretKey]. + * + * Officially our lowest supported SDK is 14, hence we will throw exception + * if the device is so. + */ + @Test + fun testGetSecretKey() { + SecretKeygen.getSecretKey()?.run { + assertNotNull(this) + assertEquals("aes", this.algorithm.lowercase()) + } ?: if (SDK_INT < ICE_CREAM_SANDWICH) { + fail("Android version not supported") + } else { + // Do nothing but let it pass + } + } +} diff --git a/app/src/androidTest/resources/amaze_backup.json b/app/src/androidTest/resources/amaze_backup.json new file mode 100644 index 0000000..b3b3ddc --- /dev/null +++ b/app/src/androidTest/resources/amaze_backup.json @@ -0,0 +1,33 @@ +{ + "": 1, + "showThumbs": true, + "showHidden": true, + "documents": true, + "image/jpeg_LAST": "com.oppo.gallery3d.app.ViewGallery com.coloros.gallery3d", + "video": true, + "typeablepaths": false, + "showPermissions": false, + "texteditor_newstack": false, + "legacyListing": false, + "sidebar_quickaccess_enable": false, + "fastaccess": true, + "books_added": true, + "application/pdf_LAST": "com.google.android.apps.viewer.PdfViewerActivity com.google.android.apps.docs", + "showLastModified": true, + "acra.legacyAlreadyConvertedTo4.8.0": true, + "audio/mpeg_LAST": "phone.vishnu.dialogmusicplayer.MainActivity phone.vishnu.dialogmusicplayer", + "acra.legacyAlreadyConvertedToJson": true, + "showFileSize": true, + "sidebar_bookmarks_enable": false, + "audio": true, + "needtosethome": false, + "rootmode": false, + "recent": true, + "image": true, + "savepaths": false, + "delete_confirmation": true, + "apks": true, + "audio/mpeg_DEFAULT": "phone.vishnu.dialogmusicplayer.MainActivity phone.vishnu.dialogmusicplayer", + "acra.lastVersionNr": 117, + "goBack_checkbox": false +} \ No newline at end of file diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml new file mode 100644 index 0000000..c8aa7c6 --- /dev/null +++ b/app/src/debug/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Amaze Debug + diff --git a/app/src/debug/res/xml-v25/shortcuts.xml b/app/src/debug/res/xml-v25/shortcuts.xml new file mode 100644 index 0000000..4e990dd --- /dev/null +++ b/app/src/debug/res/xml-v25/shortcuts.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/fdroid/java/com/amaze/filemanager/asynchronous/asynctasks/CloudLoaderAsyncTask.java b/app/src/fdroid/java/com/amaze/filemanager/asynchronous/asynctasks/CloudLoaderAsyncTask.java new file mode 100644 index 0000000..e176ef4 --- /dev/null +++ b/app/src/fdroid/java/com/amaze/filemanager/asynchronous/asynctasks/CloudLoaderAsyncTask.java @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks; + +import java.lang.ref.WeakReference; + +import com.amaze.filemanager.database.CloudHandler; +import com.amaze.filemanager.ui.activities.MainActivity; + +import android.database.Cursor; +import android.os.AsyncTask; + +import androidx.annotation.NonNull; + +public class CloudLoaderAsyncTask extends AsyncTask { + + private final WeakReference mainActivity; + + public CloudLoaderAsyncTask(MainActivity mainActivity, CloudHandler unused1, Cursor unused2) { + this.mainActivity = new WeakReference<>(mainActivity); + } + + @Override + @NonNull + public Boolean doInBackground(Void... voids) { + return false; + } + + @Override + protected void onCancelled() { + super.onCancelled(); + final MainActivity mainActivity = this.mainActivity.get(); + if (mainActivity != null) { + mainActivity + .getSupportLoaderManager() + .destroyLoader(MainActivity.REQUEST_CODE_CLOUD_LIST_KEY); + mainActivity + .getSupportLoaderManager() + .destroyLoader(MainActivity.REQUEST_CODE_CLOUD_LIST_KEYS); + } + } + + @Override + public void onPostExecute(@NonNull Boolean result) { + if (result) { + final MainActivity mainActivity = this.mainActivity.get(); + if (mainActivity != null) { + mainActivity.getDrawer().refreshDrawer(); + mainActivity.invalidateFragmentAndBundle(null, true); + } + } + } +} diff --git a/app/src/fdroid/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/RarExtractor.kt b/app/src/fdroid/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/RarExtractor.kt new file mode 100644 index 0000000..c01fa18 --- /dev/null +++ b/app/src/fdroid/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/RarExtractor.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.compressed.extractcontents.helpers + +import android.content.Context +import com.amaze.filemanager.fileoperations.utils.UpdatePosition +import com.amaze.filemanager.filesystem.compressed.extractcontents.Extractor + +class RarExtractor( + context: Context, + filePath: String, + outputPath: String, + listener: OnUpdate, + updatePosition: UpdatePosition, +) : Extractor(context, filePath, outputPath, listener, updatePosition) { + override fun extractWithFilter(filter: Filter) { + // no-op + } +} diff --git a/app/src/fdroid/java/com/amaze/filemanager/filesystem/compressed/showcontents/helpers/RarDecompressor.kt b/app/src/fdroid/java/com/amaze/filemanager/filesystem/compressed/showcontents/helpers/RarDecompressor.kt new file mode 100644 index 0000000..e28f965 --- /dev/null +++ b/app/src/fdroid/java/com/amaze/filemanager/filesystem/compressed/showcontents/helpers/RarDecompressor.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.compressed.showcontents.helpers + +import android.content.Context +import com.amaze.filemanager.asynchronous.asynctasks.compress.UnknownCompressedFileHelperCallable +import com.amaze.filemanager.filesystem.compressed.showcontents.Decompressor + +class RarDecompressor(context: Context) : Decompressor(context) { + override fun changePath( + path: String, + addGoBackItem: Boolean, + ) = UnknownCompressedFileHelperCallable(filePath, addGoBackItem) + + override fun realRelativeDirectory(dir: String): String { + return "" + } +} diff --git a/app/src/fdroid/java/com/amaze/filemanager/utils/Billing.java b/app/src/fdroid/java/com/amaze/filemanager/utils/Billing.java new file mode 100644 index 0000000..0093eef --- /dev/null +++ b/app/src/fdroid/java/com/amaze/filemanager/utils/Billing.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils; + +import com.amaze.filemanager.ui.activities.superclasses.BasicActivity; + +public class Billing { + + private static final String URL_AUTHOR_2_PAYPAL = "https://www.paypal.me/vishalnehra"; + + public Billing(BasicActivity activity) { + Utils.openURL(URL_AUTHOR_2_PAYPAL, activity); + } + + public void destroyBillingInstance() { + // do nothing + } +} diff --git a/app/src/fdroid/java/com/amaze/filemanager/utils/PackageInstallValidation.kt b/app/src/fdroid/java/com/amaze/filemanager/utils/PackageInstallValidation.kt new file mode 100644 index 0000000..a45e25f --- /dev/null +++ b/app/src/fdroid/java/com/amaze/filemanager/utils/PackageInstallValidation.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils + +import java.io.File + +/** + * For non Google Play variant, this class does nothing. Just a stub. + */ +object PackageInstallValidation { + /** + * Do nothing. + */ + @JvmStatic + @Throws(PackageCannotBeInstalledException::class, IllegalStateException::class) + fun validatePackageInstallability(f: File) = Unit + + /** + * Exception indicating specified package cannot be installed + */ + class PackageCannotBeInstalledException : Exception { + constructor(reason: Throwable) : super(reason) + constructor(reason: String) : super(reason) + } +} diff --git a/app/src/fdroid/java/com/cloudrail/si/CloudRail.java b/app/src/fdroid/java/com/cloudrail/si/CloudRail.java new file mode 100644 index 0000000..4deb918 --- /dev/null +++ b/app/src/fdroid/java/com/cloudrail/si/CloudRail.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.cloudrail.si; + +import android.content.Intent; + +public class CloudRail { + public static void setAuthenticationResponse(Intent i) {} + + public static void setAppKey(String id) {} +} diff --git a/app/src/fdroid/java/com/cloudrail/si/interfaces/CloudStorage.java b/app/src/fdroid/java/com/cloudrail/si/interfaces/CloudStorage.java new file mode 100644 index 0000000..853aacb --- /dev/null +++ b/app/src/fdroid/java/com/cloudrail/si/interfaces/CloudStorage.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.cloudrail.si.interfaces; + +import java.io.InputStream; +import java.util.Collections; +import java.util.List; + +import com.cloudrail.si.types.CloudMetaData; +import com.cloudrail.si.types.SpaceAllocation; + +public class CloudStorage { + + public List getChildren(String str) { + return Collections.emptyList(); + } + + public void logout() {} + + public void delete(String path) {} + + public void move(String path1, String path2) {} + + public void copy(String path1, String path2) {} + + public boolean exists(String path) { + return false; + } + + public void loadAsString(String str) {} + + public SpaceAllocation getAllocation() { + return new SpaceAllocation(); + } + + public void login() {} + + public String saveAsString() { + return ""; + } + + public String getUserLogin() { + return ""; + } + + public void useAdvancedAuthentication() {} + + public void createFolder(String extSyncFolder) {} + + public InputStream download(String path) { + return null; + } + + public InputStream getThumbnail(String path) { + return null; + } + + public void upload(String extSyncFile, InputStream outStream, long length, boolean b) {} + + public CloudMetaData getMetadata(String str) { + return null; + } + + public String createShareLink(String str) { + return ""; + } +} diff --git a/app/src/fdroid/java/com/cloudrail/si/services/Box.java b/app/src/fdroid/java/com/cloudrail/si/services/Box.java new file mode 100644 index 0000000..e103144 --- /dev/null +++ b/app/src/fdroid/java/com/cloudrail/si/services/Box.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.cloudrail.si.services; + +import com.cloudrail.si.interfaces.CloudStorage; + +import android.content.Context; + +public class Box extends CloudStorage { + public Box(Context unused1, String unused2, String unused3) { + super(); + } +} diff --git a/app/src/fdroid/java/com/cloudrail/si/services/Dropbox.java b/app/src/fdroid/java/com/cloudrail/si/services/Dropbox.java new file mode 100644 index 0000000..76d55c7 --- /dev/null +++ b/app/src/fdroid/java/com/cloudrail/si/services/Dropbox.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.cloudrail.si.services; + +import com.cloudrail.si.interfaces.CloudStorage; + +import android.content.Context; + +public class Dropbox extends CloudStorage { + + public Dropbox(Context unused1, String unused2, String unused3, String unused4, String unused5) { + super(); + } +} diff --git a/app/src/fdroid/java/com/cloudrail/si/services/GoogleDrive.java b/app/src/fdroid/java/com/cloudrail/si/services/GoogleDrive.java new file mode 100644 index 0000000..2af7867 --- /dev/null +++ b/app/src/fdroid/java/com/cloudrail/si/services/GoogleDrive.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.cloudrail.si.services; + +import com.cloudrail.si.interfaces.CloudStorage; + +import android.content.Context; + +public class GoogleDrive extends CloudStorage { + public GoogleDrive( + Context unused1, String unused2, String unused3, String unused4, String unused5) { + super(); + } +} diff --git a/app/src/fdroid/java/com/cloudrail/si/services/OneDrive.java b/app/src/fdroid/java/com/cloudrail/si/services/OneDrive.java new file mode 100644 index 0000000..b94d8e8 --- /dev/null +++ b/app/src/fdroid/java/com/cloudrail/si/services/OneDrive.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.cloudrail.si.services; + +import com.cloudrail.si.interfaces.CloudStorage; + +import android.content.Context; + +public class OneDrive extends CloudStorage { + public OneDrive(Context unused1, String unused2, String unused3, String unused4, String unused5) { + super(); + } +} diff --git a/app/src/fdroid/java/com/cloudrail/si/types/CloudMetaData.java b/app/src/fdroid/java/com/cloudrail/si/types/CloudMetaData.java new file mode 100644 index 0000000..4fa5b4e --- /dev/null +++ b/app/src/fdroid/java/com/cloudrail/si/types/CloudMetaData.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.cloudrail.si.types; + +public class CloudMetaData { + public String getPath() { + return ""; + } + + public String getName() { + return ""; + } + + public Long getModifiedAt() { + return 0L; + } + + public long getSize() { + return 0; + } + + public boolean getFolder() { + return false; + } + + public String getImageMetaData() { + return ""; + } +} diff --git a/app/src/fdroid/java/com/cloudrail/si/types/SpaceAllocation.java b/app/src/fdroid/java/com/cloudrail/si/types/SpaceAllocation.java new file mode 100644 index 0000000..54e3b78 --- /dev/null +++ b/app/src/fdroid/java/com/cloudrail/si/types/SpaceAllocation.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.cloudrail.si.types; + +public class SpaceAllocation { + public long getUsed() { + return 0; + } + + public long getTotal() { + return 0; + } +} diff --git a/app/src/fdroid/java/com/github/junrar/exception/UnsupportedRarV5Exception.java b/app/src/fdroid/java/com/github/junrar/exception/UnsupportedRarV5Exception.java new file mode 100644 index 0000000..4ed70a9 --- /dev/null +++ b/app/src/fdroid/java/com/github/junrar/exception/UnsupportedRarV5Exception.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.github.junrar.exception; + +public class UnsupportedRarV5Exception extends Exception { + public UnsupportedRarV5Exception(Throwable cause) { + super(cause); + } + + public UnsupportedRarV5Exception() {} +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4735783 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,329 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/logback.xml b/app/src/main/assets/logback.xml new file mode 100644 index 0000000..7f3cd78 --- /dev/null +++ b/app/src/main/assets/logback.xml @@ -0,0 +1,32 @@ + + + + + + /data/data/${PACKAGE_NAME}/cache/logs.txt + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{35} - %msg%n + + + + + + + + %logger{12} + + + [%-20thread] %msg + + + + + + + + + diff --git a/app/src/main/java/com/amaze/filemanager/AmazeFileManagerModule.java b/app/src/main/java/com/amaze/filemanager/AmazeFileManagerModule.java new file mode 100644 index 0000000..1250db1 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/AmazeFileManagerModule.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager; + +import com.amaze.filemanager.adapters.glide.apkimage.ApkImageModelLoaderFactory; +import com.amaze.filemanager.adapters.glide.cloudicon.CloudIconModelFactory; +import com.bumptech.glide.Glide; +import com.bumptech.glide.Registry; +import com.bumptech.glide.annotation.GlideModule; +import com.bumptech.glide.module.AppGlideModule; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; + +/** Ensures that Glide's generated API is created for the Gallery sample. */ +@GlideModule +public class AmazeFileManagerModule extends AppGlideModule { + @Override + public void registerComponents(Context context, Glide glide, Registry registry) { + registry.prepend(String.class, Drawable.class, new ApkImageModelLoaderFactory(context)); + registry.prepend(String.class, Bitmap.class, new CloudIconModelFactory(context)); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/LogHelper.java b/app/src/main/java/com/amaze/filemanager/LogHelper.java new file mode 100644 index 0000000..38df523 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/LogHelper.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class LogHelper { + private static final Logger LOG = LoggerFactory.getLogger(LogHelper.class); + + private LogHelper() {} + + public static final void logOnProductionOrCrash(String message) { + if (BuildConfig.DEBUG) { + throw new IllegalStateException(message); + } else { + LOG.error(message); + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/TagsHelper.java b/app/src/main/java/com/amaze/filemanager/TagsHelper.java new file mode 100644 index 0000000..30d9b9e --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/TagsHelper.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager; + +public final class TagsHelper { + private TagsHelper() {} + + public static String getTag(Class clazz) { + String className = clazz.getSimpleName(); + return className.substring(0, Math.min(23, className.length())); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/adapters/AppsRecyclerAdapter.kt b/app/src/main/java/com/amaze/filemanager/adapters/AppsRecyclerAdapter.kt new file mode 100644 index 0000000..d3bf5ae --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/adapters/AppsRecyclerAdapter.kt @@ -0,0 +1,561 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.graphics.Color +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.Settings +import android.text.TextUtils +import android.util.SparseBooleanArray +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.PopupMenu +import android.widget.Toast +import androidx.annotation.IntDef +import androidx.appcompat.view.ContextThemeWrapper +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.RecyclerView +import com.afollestad.materialdialogs.DialogAction +import com.afollestad.materialdialogs.MaterialDialog +import com.amaze.filemanager.R +import com.amaze.filemanager.adapters.data.AppDataParcelable +import com.amaze.filemanager.adapters.glide.AppsAdapterPreloadModel +import com.amaze.filemanager.adapters.holders.AppHolder +import com.amaze.filemanager.adapters.holders.EmptyViewHolder +import com.amaze.filemanager.adapters.holders.SpecialViewHolder +import com.amaze.filemanager.asynchronous.asynctasks.DeleteTask +import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil +import com.amaze.filemanager.asynchronous.services.CopyService +import com.amaze.filemanager.fileoperations.filesystem.OpenMode +import com.amaze.filemanager.filesystem.HybridFileParcelable +import com.amaze.filemanager.filesystem.RootHelper +import com.amaze.filemanager.filesystem.files.FileUtils +import com.amaze.filemanager.ui.activities.MainActivity +import com.amaze.filemanager.ui.activities.superclasses.PreferenceActivity +import com.amaze.filemanager.ui.activities.superclasses.ThemedActivity +import com.amaze.filemanager.ui.dialogs.OpenFileDialogFragment.Companion.buildIntent +import com.amaze.filemanager.ui.dialogs.OpenFileDialogFragment.Companion.setLastOpenedApp +import com.amaze.filemanager.ui.fragments.AdjustListViewForTv +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants +import com.amaze.filemanager.ui.startActivityCatchingSecurityException +import com.amaze.filemanager.ui.theme.AppTheme +import com.amaze.filemanager.utils.AnimUtils.marqueeAfterDelay +import com.amaze.filemanager.utils.Utils +import com.amaze.filemanager.utils.safeLet +import java.io.File +import kotlin.math.roundToInt + +class AppsRecyclerAdapter( + private val fragment: Fragment, + private val modelProvider: AppsAdapterPreloadModel, + private val isBottomSheet: Boolean, + private val adjustListViewCallback: AdjustListViewForTv, + private val appDataParcelableList: MutableList, +) : RecyclerView.Adapter() { + private val myChecked = SparseBooleanArray() + private var appDataListItem: MutableList = mutableListOf() + set(value) { + value.clear() + val headerFlags = BooleanArray(2) + appDataParcelableList.forEach { + if (!isBottomSheet && it.isSystemApp && !headerFlags[0]) { + value.add(ListItem(TYPE_HEADER_SYSTEM)) + modelProvider.addItem("") + headerFlags[0] = true + } else if (!isBottomSheet && !it.isSystemApp && !headerFlags[1]) { + value.add(ListItem(TYPE_HEADER_THIRD_PARTY)) + modelProvider.addItem("") + headerFlags[1] = true + } + modelProvider.addItem(it.path) + value.add(ListItem(it)) + } + if (!isBottomSheet) { + modelProvider.addItem("") + value.add(ListItem(EMPTY_LAST_ITEM)) + } + field = value + } + + init { + appDataListItem = mutableListOf() + } + + private val mInflater: LayoutInflater get() = + fragment.requireActivity() + .getSystemService(Activity.LAYOUT_INFLATER_SERVICE) as LayoutInflater + + companion object { + const val TYPE_ITEM = 0 + const val TYPE_HEADER_SYSTEM = 1 + const val TYPE_HEADER_THIRD_PARTY = 2 + const val EMPTY_LAST_ITEM = 3 + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): RecyclerView.ViewHolder { + var view = View(fragment.requireContext()) + when (viewType) { + TYPE_ITEM -> { + view = mInflater.inflate(R.layout.rowlayout, parent, false) + return AppHolder(view) + } + TYPE_HEADER_SYSTEM, TYPE_HEADER_THIRD_PARTY -> { + view = mInflater.inflate(R.layout.list_header, parent, false) + return SpecialViewHolder( + fragment.requireContext(), + view, + (fragment.requireActivity() as MainActivity).utilsProvider, + if (viewType == TYPE_HEADER_SYSTEM) { + SpecialViewHolder.HEADER_SYSTEM_APP + } else { + SpecialViewHolder.HEADER_USER_APP + }, + ) + } + EMPTY_LAST_ITEM -> { + view.minimumHeight = + ( + fragment.requireActivity().resources.getDimension(R.dimen.fab_height) + + fragment.requireContext().resources + .getDimension(R.dimen.fab_margin) + ).roundToInt() + return EmptyViewHolder(view) + } + else -> { + throw IllegalStateException("Illegal $viewType in apps adapter") + } + } + } + + override fun getItemViewType(position: Int): Int { + return appDataListItem[position].listItemType + } + + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + ) { + if (holder is AppHolder) { + appDataListItem[position].appDataParcelable?.let { rowItem -> + if (isBottomSheet) { + holder.about.visibility = View.GONE + holder.txtDesc.text = rowItem.openFileParcelable?.className + holder.txtDesc.isSingleLine = true + holder.txtDesc.ellipsize = TextUtils.TruncateAt.MIDDLE + modelProvider.loadApkImage(rowItem.packageName, holder.apkIcon) + } else { + modelProvider.loadApkImage(rowItem.path, holder.apkIcon) + } + if (holder.about != null && !isBottomSheet) { + if ((fragment.requireActivity() as MainActivity).appTheme == AppTheme.LIGHT) { + holder.about.setColorFilter( + Color.parseColor("#ff666666"), + ) + } + showPopup(holder.about, rowItem) + } + holder.rl.setOnFocusChangeListener { _, _ -> + adjustListViewCallback.adjustListViewForTv( + holder, + fragment.requireActivity() as MainActivity, + ) + } + holder.txtTitle.text = rowItem.label + holder.packageName.text = rowItem.packageName + holder.packageName.isSelected = true // for marquee + val enableMarqueeFilename = + (fragment.requireActivity() as MainActivity) + .getBoolean(PreferencesConstants.PREFERENCE_ENABLE_MARQUEE_FILENAME) + if (enableMarqueeFilename) { + holder.txtTitle.ellipsize = + if (enableMarqueeFilename) { + TextUtils.TruncateAt.MARQUEE + } else { + TextUtils.TruncateAt.MIDDLE + } + marqueeAfterDelay(2000, holder.txtTitle) + } + if (!isBottomSheet) { + holder.txtDesc.text = rowItem.fileSize + " |" + } + holder.rl.isClickable = true + holder.rl.nextFocusRightId = holder.about.id + holder.rl.setOnClickListener { + startActivityForRowItem(rowItem) + } + } + if (myChecked[position]) { + holder.rl.setBackgroundColor( + Utils.getColor(fragment.context, R.color.appsadapter_background), + ) + } else { + if ((fragment.requireActivity() as MainActivity).appTheme == AppTheme.LIGHT) { + holder.rl.setBackgroundResource(R.drawable.safr_ripple_white) + } else { + holder.rl.setBackgroundResource(R.drawable.safr_ripple_black) + } + } + } + } + + override fun getItemCount(): Int { + return appDataListItem.size + } + + /** + * Set list elements + * @param showSystemApps whether to filter system apps or not + */ + fun setData( + data: List, + showSystemApps: Boolean, + ) { + appDataParcelableList.run { + clear() + val list = if (!showSystemApps) data.filter { !it.isSystemApp } else data + addAll(list) + appDataListItem = mutableListOf() + notifyDataSetChanged() + } + } + + private fun startActivityForRowItem(rowItem: AppDataParcelable?) { + rowItem?.run { + if (isBottomSheet) { + val openFileParcelable = rowItem.openFileParcelable + openFileParcelable?.let { + safeLet( + openFileParcelable.uri, + openFileParcelable.mimeType, + openFileParcelable.useNewStack, + ) { + uri, mimeType, useNewStack -> + val intent = + buildIntent( + fragment.requireContext(), + uri, + mimeType, + useNewStack, + openFileParcelable.className, + openFileParcelable.packageName, + ) + setLastOpenedApp( + rowItem, + fragment.requireActivity() as PreferenceActivity, + ) + fragment.requireContext().startActivityCatchingSecurityException(intent) + } + } + } else { + val i1 = + fragment.requireContext().packageManager.getLaunchIntentForPackage( + rowItem.packageName, + ) + if (i1 != null) { + fragment.startActivity(i1) + } else { + Toast.makeText( + fragment.context, + fragment.getString(R.string.not_allowed), + Toast.LENGTH_LONG, + ) + .show() + // TODO: Implement this method + } + } + } + } + + private fun showPopup( + v: View, + rowItem: AppDataParcelable?, + ) { + v.setOnClickListener { view: View? -> + var context = fragment.context + if (( + fragment.requireActivity() + as MainActivity + ).appTheme == AppTheme.BLACK + ) { + context = ContextThemeWrapper(context, R.style.overflow_black) + } + val popupMenu = PopupMenu(context, view) + popupMenu.setOnMenuItemClickListener { item: MenuItem -> + val themedActivity: MainActivity = fragment.requireActivity() as MainActivity + val colorAccent = themedActivity.accent + when (item.itemId) { + R.id.open -> { + rowItem?.let { + popupOpen(it) + } + return@setOnMenuItemClickListener true + } + R.id.share -> { + rowItem?.let { + popupShare(it, themedActivity, colorAccent) + } + return@setOnMenuItemClickListener true + } + R.id.unins -> { + rowItem?.let { + popupUninstall(it, themedActivity, colorAccent) + } + return@setOnMenuItemClickListener true + } + R.id.play -> { + rowItem?.let { + popupPlay(it) + } + return@setOnMenuItemClickListener true + } + R.id.properties -> { + fragment.startActivity( + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse( + String.format("package:%s", rowItem!!.packageName), + ), + ), + ) + return@setOnMenuItemClickListener true + } + R.id.backup -> { + rowItem?.let { + popupBackup(it) + } + return@setOnMenuItemClickListener true + } + } + false + } + popupMenu.inflate(R.menu.app_options) + popupMenu.show() + } + } + + private fun popupOpen(appDataParcelable: AppDataParcelable) { + val i1 = + fragment + .context + ?.packageManager + ?.getLaunchIntentForPackage(appDataParcelable.packageName) + if (i1 != null) { + fragment.startActivity(i1) + } else { + Toast.makeText( + fragment.context, + fragment.getString(R.string.not_allowed), + Toast.LENGTH_LONG, + ).show() + } + } + + private fun popupShare( + appDataParcelable: AppDataParcelable, + themedActivity: ThemedActivity, + colorAccent: Int, + ) { + val arrayList2 = + ArrayList() + arrayList2.add(File(appDataParcelable.path)) + themedActivity.colorPreference + FileUtils.shareFiles( + arrayList2, + fragment.activity, + themedActivity.utilsProvider.appTheme, + colorAccent, + ) + } + + private fun popupUninstall( + appDataParcelable: AppDataParcelable, + themedActivity: ThemedActivity, + colorAccent: Int, + ) { + val f1 = HybridFileParcelable(appDataParcelable.path) + f1.mode = OpenMode.ROOT + if (appDataParcelable.isSystemApp) { + // system package + if ((fragment.requireActivity() as MainActivity).getBoolean( + PreferencesConstants.PREFERENCE_ROOTMODE, + ) + ) { + showDeleteSystemAppDialog(themedActivity, colorAccent, f1) + } else { + Toast.makeText( + fragment.context, + fragment.getString(R.string.enablerootmde), + Toast.LENGTH_SHORT, + ) + .show() + } + } else { + FileUtils.uninstallPackage( + appDataParcelable.packageName, + fragment.context, + ) + } + } + + private fun popupPlay(appDataParcelable: AppDataParcelable) { + val intent1 = + Intent(Intent.ACTION_VIEW) + try { + intent1.data = + Uri.parse( + String.format( + "market://details?id=%s", + appDataParcelable.packageName, + ), + ) + fragment.startActivity(intent1) + } catch (ifPlayStoreNotInstalled: ActivityNotFoundException) { + intent1.data = + Uri.parse( + String.format( + "https://play.google.com/store/apps/details?id=%s", + appDataParcelable.packageName, + ), + ) + fragment.startActivity(intent1) + } + } + + private fun popupBackup(appDataParcelable: AppDataParcelable) { + val baseApkFile = File(appDataParcelable.path) + val filesToCopyList = ArrayList() + val dst = + File( + Environment.getExternalStorageDirectory() + .path + "/app_backup", + ) + if (!dst.exists() || !dst.isDirectory) dst.mkdirs() + val intent = + Intent( + fragment.context, + CopyService::class.java, + ) + val mainApkFile = RootHelper.generateBaseFile(baseApkFile, true) + val startIndex = appDataParcelable.packageName.indexOf("_") + val subString = appDataParcelable.packageName.substring(startIndex + 1) + val fileBaseName = appDataParcelable.label + "_$subString" + mainApkFile.name = "$fileBaseName.apk" + filesToCopyList.add(mainApkFile) + val splitPathList = appDataParcelable.splitPathList + if (splitPathList != null) { + for (splitApkPath: String in splitPathList) { + val splitApkFile = File(splitApkPath) + val splitParcelableFile = RootHelper.generateBaseFile(splitApkFile, true) + var name = splitApkFile.name.lowercase() + if (name.endsWith(".apk")) { + name = name.substring(0, name.length - 4) + } + val dotIdx = name.lastIndexOf('.') + name = name.substring(dotIdx + 1) + name = "${fileBaseName}_$name.apk" + splitParcelableFile.name = name + filesToCopyList.add(splitParcelableFile) + } + } + intent.putParcelableArrayListExtra(CopyService.TAG_COPY_SOURCES, filesToCopyList) + intent.putExtra(CopyService.TAG_COPY_TARGET, dst.path) + intent.putExtra(CopyService.TAG_COPY_OPEN_MODE, 0) + + Toast.makeText( + fragment.context, + fragment.getString(R.string.copyingapks, filesToCopyList.size, dst.path), + Toast.LENGTH_LONG, + ) + .show() + + ServiceWatcherUtil.runService(fragment.context, intent) + } + + private fun showDeleteSystemAppDialog( + themedActivity: ThemedActivity, + colorAccent: Int, + f1: HybridFileParcelable, + ) { + val builder1 = + MaterialDialog.Builder(fragment.requireContext()) + builder1 + .theme( + themedActivity.appTheme.getMaterialDialogTheme(), + ) + .content(fragment.getString(R.string.unin_system_apk)) + .title(fragment.getString(R.string.warning)) + .negativeColor(colorAccent) + .positiveColor(colorAccent) + .negativeText(fragment.getString(R.string.no)) + .positiveText(fragment.getString(R.string.yes)) + .onNegative { dialog: MaterialDialog, _: DialogAction? -> dialog.cancel() } + .onPositive { _: MaterialDialog?, _: DialogAction? -> + val files = + ArrayList() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + val parent = + f1.getParent(fragment.context) + if (parent != "app" && parent != "priv-app") { + val baseFile = + HybridFileParcelable( + f1.getParent(fragment.context), + ) + baseFile.mode = + OpenMode.ROOT + files.add(baseFile) + } else { + files.add(f1) + } + } else { + files.add(f1) + } + DeleteTask(fragment.requireContext(), false).execute(files) + } + .build() + .show() + } + + @Target(AnnotationTarget.TYPE) + @IntDef( + TYPE_ITEM, + TYPE_HEADER_SYSTEM, + TYPE_HEADER_THIRD_PARTY, + EMPTY_LAST_ITEM, + ) + annotation class ListItemType + + data class ListItem( + var appDataParcelable: AppDataParcelable?, + var listItemType: @ListItemType Int = TYPE_ITEM, + ) { + constructor(listItemType: @ListItemType Int) : this(null, listItemType) + } +} diff --git a/app/src/main/java/com/amaze/filemanager/adapters/ColorAdapter.java b/app/src/main/java/com/amaze/filemanager/adapters/ColorAdapter.java new file mode 100644 index 0000000..0c8c67f --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/adapters/ColorAdapter.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.ui.views.CheckableCircleView; +import com.amaze.filemanager.utils.Utils; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; + +import androidx.annotation.ColorInt; +import androidx.annotation.ColorRes; +import androidx.annotation.NonNull; + +public class ColorAdapter extends ArrayAdapter implements AdapterView.OnItemClickListener { + + private LayoutInflater inflater; + private @ColorInt int selectedColor; + private OnColorSelected onColorSelected; + + /** + * Constructor for adapter that handles the view creation of color chooser dialog in preferences + * + * @param context the context + * @param colors array list of color hex values in form of string; for the views + * @param selectedColor currently selected color + * @param l OnColorSelected listener for when a color is selected + */ + public ColorAdapter( + Context context, Integer[] colors, @ColorInt int selectedColor, OnColorSelected l) { + super(context, R.layout.rowlayout, colors); + this.selectedColor = selectedColor; + this.onColorSelected = l; + + inflater = (LayoutInflater) getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE); + } + + @ColorRes + private int getColorResAt(int position) { + return getItem(position); + } + + @NonNull + @Override + public View getView(final int position, View convertView, @NonNull ViewGroup parent) { + CheckableCircleView colorView; + if (convertView != null && convertView instanceof CheckableCircleView) { + colorView = (CheckableCircleView) convertView; + } else { + colorView = (CheckableCircleView) inflater.inflate(R.layout.dialog_grid_item, parent, false); + } + + @ColorInt int color = Utils.getColor(getContext(), getColorResAt(position)); + + colorView.setChecked(color == selectedColor); + colorView.setColor(color); + + return colorView; + } + + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + this.selectedColor = Utils.getColor(getContext(), getColorResAt(position)); + ((CheckableCircleView) view).setChecked(true); + onColorSelected.onColorSelected(this.selectedColor); + } + + public interface OnColorSelected { + void onColorSelected(@ColorInt int color); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/adapters/CompressedExplorerAdapter.java b/app/src/main/java/com/amaze/filemanager/adapters/CompressedExplorerAdapter.java new file mode 100644 index 0000000..eab01ab --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/adapters/CompressedExplorerAdapter.java @@ -0,0 +1,379 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters; + +import java.util.ArrayList; +import java.util.List; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.adapters.data.CompressedObjectParcelable; +import com.amaze.filemanager.adapters.holders.CompressedItemViewHolder; +import com.amaze.filemanager.fileoperations.filesystem.OpenMode; +import com.amaze.filemanager.filesystem.HybridFileParcelable; +import com.amaze.filemanager.filesystem.compressed.CompressedHelper; +import com.amaze.filemanager.filesystem.compressed.showcontents.Decompressor; +import com.amaze.filemanager.ui.colors.ColorUtils; +import com.amaze.filemanager.ui.fragments.CompressedExplorerFragment; +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants; +import com.amaze.filemanager.ui.provider.UtilitiesProvider; +import com.amaze.filemanager.ui.theme.AppTheme; +import com.amaze.filemanager.ui.views.CircleGradientDrawable; +import com.amaze.filemanager.utils.AnimUtils; +import com.amaze.filemanager.utils.Utils; +import com.bumptech.glide.Glide; + +import android.app.Activity; +import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.os.Build; +import android.text.TextUtils; +import android.text.format.Formatter; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; +import android.widget.Toast; + +import androidx.appcompat.widget.AppCompatImageButton; +import androidx.appcompat.widget.AppCompatImageView; +import androidx.recyclerview.widget.RecyclerView; + +/** Created by Arpit on 25-01-2015 edited by Emmanuel Messulam */ +public class CompressedExplorerAdapter extends RecyclerView.Adapter { + + private static final int TYPE_HEADER = 0, TYPE_ITEM = 1; + + public boolean stoppedAnimation = false; + + private Context context; + private UtilitiesProvider utilsProvider; + private Drawable folder; + private List items; + private CompressedExplorerFragment compressedExplorerFragment; + private Decompressor decompressor; + private LayoutInflater mInflater; + private boolean[] itemsChecked; + private int offset = 0; + private SharedPreferences sharedPrefs; + + public CompressedExplorerAdapter( + Context c, + UtilitiesProvider utilsProvider, + List items, + CompressedExplorerFragment compressedExplorerFragment, + Decompressor decompressor, + SharedPreferences sharedPrefs) { + setHasStableIds(true); + + this.utilsProvider = utilsProvider; + this.items = items; + this.decompressor = decompressor; + + itemsChecked = new boolean[items.size()]; + + context = c; + if (c == null) return; + folder = c.getResources().getDrawable(R.drawable.ic_grid_folder_new); + this.compressedExplorerFragment = compressedExplorerFragment; + mInflater = (LayoutInflater) c.getSystemService(Activity.LAYOUT_INFLATER_SERVICE); + this.sharedPrefs = sharedPrefs; + } + + public void toggleChecked(boolean check) { + int k = 0; + + for (int i = k; i < items.size(); i++) { + itemsChecked[i] = check; + notifyItemChanged(i); + } + } + + public ArrayList getCheckedItemPositions() { + ArrayList checkedItemPositions = new ArrayList<>(); + + for (int i = 0; i < itemsChecked.length; i++) { + if (itemsChecked[i]) { + (checkedItemPositions).add(i); + } + } + + return checkedItemPositions; + } + + /** + * called as to toggle selection of any item in adapter + * + * @param position the position of the item + * @param imageView the circular {@link CircleGradientDrawable} that is to be animated + */ + private void toggleChecked(int position, AppCompatImageView imageView) { + compressedExplorerFragment.stopAnim(); + stoppedAnimation = true; + + Animation animation; + if (itemsChecked[position]) { + animation = AnimationUtils.loadAnimation(context, R.anim.check_out); + } else { + animation = AnimationUtils.loadAnimation(context, R.anim.check_in); + } + + if (imageView != null) { + imageView.setAnimation(animation); + } else { + // TODO: we don't have the check icon object probably because of config change + } + + itemsChecked[position] = !itemsChecked[position]; + + notifyDataSetChanged(); + if (!compressedExplorerFragment.selection || compressedExplorerFragment.mActionMode == null) { + compressedExplorerFragment.selection = true; + /*compressedExplorerFragment.mActionMode = compressedExplorerFragment.getActivity().startActionMode( + compressedExplorerFragment.mActionModeCallback);*/ + compressedExplorerFragment.mActionMode = + compressedExplorerFragment + .requireMainActivity() + .getAppbar() + .getToolbar() + .startActionMode(compressedExplorerFragment.mActionModeCallback); + } + compressedExplorerFragment.mActionMode.invalidate(); + if (getCheckedItemPositions().size() == 0) { + compressedExplorerFragment.selection = false; + compressedExplorerFragment.mActionMode.finish(); + compressedExplorerFragment.mActionMode = null; + } + } + + private void animate(CompressedItemViewHolder holder) { + holder.rl.clearAnimation(); + Animation localAnimation = + AnimationUtils.loadAnimation(compressedExplorerFragment.getActivity(), R.anim.fade_in_top); + localAnimation.setStartOffset(this.offset); + holder.rl.startAnimation(localAnimation); + this.offset = (30 + this.offset); + } + + public void generateZip(List arrayList) { + offset = 0; + stoppedAnimation = false; + items = arrayList; + notifyDataSetChanged(); + itemsChecked = new boolean[items.size()]; + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public int getItemViewType(int position) { + if (isPositionHeader(position)) return TYPE_HEADER; + + return TYPE_ITEM; + } + + @Override + public CompressedItemViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + if (viewType == TYPE_HEADER) { + View v = mInflater.inflate(R.layout.rowlayout, parent, false); + v.findViewById(R.id.picture_icon).setVisibility(View.INVISIBLE); + return new CompressedItemViewHolder(v); + } else if (viewType == TYPE_ITEM) { + View v = mInflater.inflate(R.layout.rowlayout, parent, false); + CompressedItemViewHolder vh = new CompressedItemViewHolder(v); + AppCompatImageButton about = v.findViewById(R.id.properties); + about.setVisibility(View.INVISIBLE); + return vh; + } else { + throw new IllegalStateException(); + } + } + + @Override + public void onBindViewHolder(final CompressedItemViewHolder holder, int position) { + if (!stoppedAnimation) { + animate(holder); + } + + boolean enableMarquee = + sharedPrefs.getBoolean(PreferencesConstants.PREFERENCE_ENABLE_MARQUEE_FILENAME, true); + holder.txtTitle.setEllipsize( + enableMarquee ? TextUtils.TruncateAt.MARQUEE : TextUtils.TruncateAt.MIDDLE); + + final CompressedObjectParcelable rowItem = items.get(position); + GradientDrawable gradientDrawable = (GradientDrawable) holder.genericIcon.getBackground(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + holder.checkImageView.setBackground( + new CircleGradientDrawable( + compressedExplorerFragment.accentColor, + utilsProvider.getAppTheme(), + compressedExplorerFragment.getResources().getDisplayMetrics())); + } else + holder.checkImageView.setBackgroundDrawable( + new CircleGradientDrawable( + compressedExplorerFragment.accentColor, + utilsProvider.getAppTheme(), + compressedExplorerFragment.getResources().getDisplayMetrics())); + + if (rowItem.type == CompressedObjectParcelable.TYPE_GOBACK) { + Glide.with(compressedExplorerFragment) + .load(R.drawable.ic_arrow_left_white_24dp) + .into(holder.genericIcon); + gradientDrawable.setColor(Utils.getColor(context, R.color.goback_item)); + holder.txtTitle.setText(".."); + holder.txtDesc.setText(""); + holder.date.setText(R.string.goback); + } else { + Glide.with(compressedExplorerFragment).load(rowItem.iconData.image).into(holder.genericIcon); + + if (compressedExplorerFragment.showLastModified) + holder.date.setText(Utils.getDate(context, rowItem.date)); + if (rowItem.directory) { + holder.genericIcon.setImageDrawable(folder); + gradientDrawable.setColor(compressedExplorerFragment.iconskin); + holder.txtTitle.setText(rowItem.name); + } else { + if (compressedExplorerFragment.showSize) + holder.txtDesc.setText(Formatter.formatFileSize(context, rowItem.size)); + holder.txtTitle.setText(rowItem.path.substring(rowItem.path.lastIndexOf("/") + 1)); + if (compressedExplorerFragment.coloriseIcons) { + ColorUtils.colorizeIcons( + context, rowItem.filetype, gradientDrawable, compressedExplorerFragment.iconskin); + } else gradientDrawable.setColor(compressedExplorerFragment.iconskin); + } + } + + holder.rl.setOnLongClickListener( + view -> { + if (rowItem.type != CompressedObjectParcelable.TYPE_GOBACK) { + toggleChecked(position, holder.checkImageView); + } + return true; + }); + holder.genericIcon.setOnClickListener( + view -> { + if (rowItem.type != CompressedObjectParcelable.TYPE_GOBACK) { + toggleChecked(position, holder.checkImageView); + } else { + compressedExplorerFragment.goBack(); + } + }); + if (utilsProvider.getAppTheme().equals(AppTheme.LIGHT)) { + holder.rl.setBackgroundResource(R.drawable.safr_ripple_white); + } else { + holder.rl.setBackgroundResource(R.drawable.safr_ripple_black); + } + holder.rl.setSelected(false); + if (itemsChecked[position]) { + // holder.genericIcon.setImageDrawable(compressedExplorerFragment.getResources().getDrawable(R.drawable.abc_ic_cab_done_holo_dark)); + holder.checkImageView.setVisibility(View.VISIBLE); + gradientDrawable.setColor(Utils.getColor(context, R.color.goback_item)); + holder.rl.setSelected(true); + } else holder.checkImageView.setVisibility(View.INVISIBLE); + + holder.rl.setOnClickListener( + p1 -> { + if (rowItem.type == CompressedObjectParcelable.TYPE_GOBACK) + compressedExplorerFragment.goBack(); + else { + if (compressedExplorerFragment.selection) { + toggleChecked(position, holder.checkImageView); + } else { + if (rowItem.directory) { + String newPath = + (rowItem.path.endsWith("/")) + ? rowItem.path.substring(0, rowItem.path.length() - 1) + : rowItem.path; + compressedExplorerFragment.changePath(newPath); + } else { + + String fileName = + CompressedHelper.getFileName( + compressedExplorerFragment.compressedFile.getName()); + String archiveCacheDirPath = + compressedExplorerFragment.getActivity().getExternalCacheDir().getPath() + + CompressedHelper.SEPARATOR + + fileName; + + HybridFileParcelable file = + new HybridFileParcelable( + archiveCacheDirPath + + CompressedHelper.SEPARATOR + + rowItem.path.replaceAll("\\\\", CompressedHelper.SEPARATOR)); + file.setMode(OpenMode.FILE); + // this file will be opened once service finishes up it's extraction + compressedExplorerFragment.files.add(file); + // setting flag for binder to know + compressedExplorerFragment.isOpen = true; + + Toast.makeText( + compressedExplorerFragment.getContext(), + compressedExplorerFragment.getContext().getString(R.string.please_wait), + Toast.LENGTH_SHORT) + .show(); + decompressor.decompress( + compressedExplorerFragment.getActivity().getExternalCacheDir().getPath(), + new String[] {rowItem.path}); + } + } + } + }); + } + + @Override + public int getItemCount() { + return items.size(); + } + + @Override + public void onViewDetachedFromWindow(CompressedItemViewHolder holder) { + super.onViewAttachedToWindow(holder); + holder.rl.clearAnimation(); + holder.txtTitle.setSelected(false); + } + + @Override + public void onViewAttachedToWindow(CompressedItemViewHolder holder) { + super.onViewAttachedToWindow(holder); + boolean enableMarqueeFilename = + sharedPrefs.getBoolean(PreferencesConstants.PREFERENCE_ENABLE_MARQUEE_FILENAME, true); + if (enableMarqueeFilename) { + AnimUtils.marqueeAfterDelay(2000, holder.txtTitle); + } + } + + @Override + public boolean onFailedToRecycleView(CompressedItemViewHolder holder) { + holder.rl.clearAnimation(); + holder.txtTitle.setSelected(false); + return super.onFailedToRecycleView(holder); + } + + private boolean isPositionHeader(int position) { + return false; // TODO: + } +} diff --git a/app/src/main/java/com/amaze/filemanager/adapters/HiddenAdapter.kt b/app/src/main/java/com/amaze/filemanager/adapters/HiddenAdapter.kt new file mode 100644 index 0000000..e0dd9db --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/adapters/HiddenAdapter.kt @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters + +import android.app.Activity +import android.content.Context +import android.content.SharedPreferences +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.afollestad.materialdialogs.MaterialDialog +import com.amaze.filemanager.R +import com.amaze.filemanager.adapters.holders.HiddenViewHolder +import com.amaze.filemanager.asynchronous.asynctasks.DeleteTask +import com.amaze.filemanager.fileoperations.filesystem.OpenMode +import com.amaze.filemanager.filesystem.HybridFile +import com.amaze.filemanager.filesystem.HybridFileParcelable +import com.amaze.filemanager.filesystem.files.FileUtils +import com.amaze.filemanager.ui.activities.MainActivity +import com.amaze.filemanager.ui.fragments.MainFragment +import com.amaze.filemanager.utils.DataUtils +import java.io.File +import kotlin.concurrent.thread + +/** + * This Adapter contains all logic related to showing the list of hidden files. + * + * @see com.amaze.filemanager.adapters.holders.HiddenViewHolder + */ +class HiddenAdapter( + private val context: Context, + private val mainFragment: MainFragment, + private val sharedPrefs: SharedPreferences, + hiddenFiles: List, + var materialDialog: MaterialDialog?, + private val hide: Boolean, +) : RecyclerView.Adapter() { + companion object { + private const val TAG = "HiddenAdapter" + } + + private val hiddenFiles = hiddenFiles.toMutableList() + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): HiddenViewHolder { + val mInflater = context.getSystemService(Activity.LAYOUT_INFLATER_SERVICE) as LayoutInflater + val view = mInflater.inflate(R.layout.bookmarkrow, parent, false) + return HiddenViewHolder(view) + } + + override fun onBindViewHolder( + holder: HiddenViewHolder, + position: Int, + ) { + val file = hiddenFiles[position] + holder.textTitle.text = file.getName(context) + holder.textDescription.text = file.getReadablePath(file.path) + if (hide) { + holder.deleteButton.visibility = View.GONE + } + holder.deleteButton.setOnClickListener { + // if the user taps on the delete button, un-hide the file. + // TODO: the "hide files" feature just hide files from view in Amaze and not create + // .nomedia + if (!file.isSmb && file.isDirectory(context)) { + val nomediaFile = + HybridFileParcelable( + hiddenFiles[position].path + "/" + FileUtils.NOMEDIA_FILE, + ) + nomediaFile.mode = OpenMode.FILE + val filesToDelete = ArrayList() + filesToDelete.add(nomediaFile) + val task = DeleteTask(context, false) + task.execute(filesToDelete) + } + DataUtils.getInstance().removeHiddenFile(hiddenFiles[position].path) + hiddenFiles.remove(hiddenFiles[position]) + notifyItemRemoved(position) + } + holder.row.setOnClickListener { + // if the user taps on the hidden file, take the user there. + materialDialog?.dismiss() + + thread { + val fragmentActivity = mainFragment.requireActivity() + if (file.isDirectory(context)) { + fragmentActivity.runOnUiThread { + mainFragment.hideFab = false + mainFragment.requireMainActivity().showFab() + mainFragment.loadlist( + file.path, + false, + OpenMode.UNKNOWN, + false, + ) + } + } else if (!file.isSmb) { + fragmentActivity.runOnUiThread { + FileUtils.openFile( + File(file.path), + (fragmentActivity as MainActivity), + sharedPrefs, + ) + } + } + } + } + } + + override fun getItemId(position: Int): Long { + return position.toLong() + } + + override fun getItemCount(): Int = hiddenFiles.size +} diff --git a/app/src/main/java/com/amaze/filemanager/adapters/RecyclerAdapter.java b/app/src/main/java/com/amaze/filemanager/adapters/RecyclerAdapter.java new file mode 100644 index 0000000..ace6284 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/adapters/RecyclerAdapter.java @@ -0,0 +1,1609 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters; + +import static com.amaze.filemanager.filesystem.compressed.CompressedHelper.fileExtension7zip; +import static com.amaze.filemanager.filesystem.compressed.CompressedHelper.fileExtensionApk; +import static com.amaze.filemanager.filesystem.compressed.CompressedHelper.fileExtensionApks; +import static com.amaze.filemanager.filesystem.compressed.CompressedHelper.fileExtensionBzip2; +import static com.amaze.filemanager.filesystem.compressed.CompressedHelper.fileExtensionBzip2TarLong; +import static com.amaze.filemanager.filesystem.compressed.CompressedHelper.fileExtensionBzip2TarShort; +import static com.amaze.filemanager.filesystem.compressed.CompressedHelper.fileExtensionGz; +import static com.amaze.filemanager.filesystem.compressed.CompressedHelper.fileExtensionGzipTarLong; +import static com.amaze.filemanager.filesystem.compressed.CompressedHelper.fileExtensionGzipTarShort; +import static com.amaze.filemanager.filesystem.compressed.CompressedHelper.fileExtensionJar; +import static com.amaze.filemanager.filesystem.compressed.CompressedHelper.fileExtensionLzma; +import static com.amaze.filemanager.filesystem.compressed.CompressedHelper.fileExtensionRar; +import static com.amaze.filemanager.filesystem.compressed.CompressedHelper.fileExtensionTar; +import static com.amaze.filemanager.filesystem.compressed.CompressedHelper.fileExtensionTarLzma; +import static com.amaze.filemanager.filesystem.compressed.CompressedHelper.fileExtensionTarXz; +import static com.amaze.filemanager.filesystem.compressed.CompressedHelper.fileExtensionXz; +import static com.amaze.filemanager.filesystem.compressed.CompressedHelper.fileExtensionZip; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_COLORIZE_ICONS; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_FILE_SIZE; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_GOBACK_BUTTON; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_HEADERS; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_LAST_MODIFIED; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_PERMISSIONS; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_THUMB; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_USE_CIRCULAR_IMAGES; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.adapters.data.IconDataParcelable; +import com.amaze.filemanager.adapters.data.LayoutElementParcelable; +import com.amaze.filemanager.adapters.glide.RecyclerPreloadModelProvider; +import com.amaze.filemanager.adapters.glide.RecyclerPreloadSizeProvider; +import com.amaze.filemanager.adapters.holders.EmptyViewHolder; +import com.amaze.filemanager.adapters.holders.ItemViewHolder; +import com.amaze.filemanager.adapters.holders.SpecialViewHolder; +import com.amaze.filemanager.application.AppConfig; +import com.amaze.filemanager.fileoperations.filesystem.OpenMode; +import com.amaze.filemanager.filesystem.PasteHelper; +import com.amaze.filemanager.filesystem.files.CryptUtil; +import com.amaze.filemanager.filesystem.files.sort.DirSortBy; +import com.amaze.filemanager.ui.ItemPopupMenu; +import com.amaze.filemanager.ui.activities.MainActivity; +import com.amaze.filemanager.ui.activities.superclasses.PreferenceActivity; +import com.amaze.filemanager.ui.colors.ColorUtils; +import com.amaze.filemanager.ui.drag.RecyclerAdapterDragListener; +import com.amaze.filemanager.ui.fragments.MainFragment; +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants; +import com.amaze.filemanager.ui.icons.Icons; +import com.amaze.filemanager.ui.icons.MimeTypes; +import com.amaze.filemanager.ui.provider.UtilitiesProvider; +import com.amaze.filemanager.ui.selection.SelectionPopupMenu; +import com.amaze.filemanager.ui.theme.AppTheme; +import com.amaze.filemanager.ui.views.CircleGradientDrawable; +import com.amaze.filemanager.utils.AnimUtils; +import com.amaze.filemanager.utils.GlideConstants; +import com.amaze.filemanager.utils.MainActivityActionMode; +import com.amaze.filemanager.utils.Utils; +import com.bumptech.glide.Glide; +import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader; +import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.engine.GlideException; +import com.bumptech.glide.request.RequestListener; +import com.bumptech.glide.request.target.Target; + +import android.app.Activity; +import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.os.Build; +import android.os.Handler; +import android.text.TextUtils; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; +import android.widget.PopupMenu; +import android.widget.Toast; + +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.view.ActionMode; +import androidx.appcompat.view.ContextThemeWrapper; +import androidx.appcompat.widget.AppCompatImageView; +import androidx.appcompat.widget.AppCompatTextView; +import androidx.recyclerview.widget.RecyclerView; + +/** + * This class is the information that serves to load the files into a "list" (a RecyclerView). There + * are 3 types of item TYPE_ITEM, TYPE_HEADER_FOLDERS and TYPE_HEADER_FILES, EMPTY_LAST_ITEM and + * TYPE_BACK represeted by ItemViewHolder, SpecialViewHolder and EmptyViewHolder respectively. The + * showPopup shows the file's popup menu. The 'go to parent' aka '..' button (go to settings to + * activate it) is just a folder. + * + *

Created by Arpit on 11-04-2015 edited by Emmanuel Messulam edited + * by Jens Klingenberg + */ +public class RecyclerAdapter extends RecyclerView.Adapter + implements RecyclerPreloadSizeProvider.RecyclerPreloadSizeProviderCallback { + + public static final int TYPE_ITEM = 0, + TYPE_HEADER_FOLDERS = 1, + TYPE_HEADER_FILES = 2, + EMPTY_LAST_ITEM = 3, + TYPE_BACK = 4; + private final Logger LOG = LoggerFactory.getLogger(RecyclerAdapter.class); + + private static final int VIEW_GENERIC = 0, VIEW_PICTURE = 1, VIEW_APK = 2, VIEW_THUMB = 3; + + public boolean stoppedAnimation = false; + + @NonNull private final PreferenceActivity preferenceActivity; + @NonNull private final UtilitiesProvider utilsProvider; + @NonNull private final MainFragment mainFragment; + @NonNull private final SharedPreferences sharedPrefs; + private RecyclerViewPreloader preloader; + private RecyclerPreloadSizeProvider sizeProvider; + private RecyclerPreloadModelProvider modelProvider; + @NonNull private final Context context; + private final LayoutInflater mInflater; + private final float minRowHeight; + private final int grey_color; + private final int accentColor; + private final int iconSkinColor; + private final int goBackColor; + private final int videoColor; + private final int audioColor; + private final int pdfColor; + private final int codeColor; + private final int textColor; + private final int archiveColor; + private final int genericColor; + private final int apkColor; + private int offset = 0; + private final boolean enableMarquee; + private final int dragAndDropPreference; + private final boolean isGrid; + + @IntDef({VIEW_GENERIC, VIEW_PICTURE, VIEW_APK, VIEW_THUMB}) + public @interface ViewType {} + + @IntDef({TYPE_ITEM, TYPE_HEADER_FOLDERS, TYPE_HEADER_FILES, EMPTY_LAST_ITEM, TYPE_BACK}) + public @interface ListElemType {} + + public RecyclerAdapter( + @NonNull PreferenceActivity preferenceActivity, + @NonNull MainFragment mainFragment, + @NonNull UtilitiesProvider utilsProvider, + @NonNull SharedPreferences sharedPrefs, + @NonNull RecyclerView recyclerView, + @NonNull List itemsRaw, + @NonNull Context context, + boolean isGrid) { + setHasStableIds(true); + + this.preferenceActivity = preferenceActivity; + this.mainFragment = mainFragment; + this.utilsProvider = utilsProvider; + this.context = context; + this.sharedPrefs = sharedPrefs; + this.enableMarquee = + sharedPrefs.getBoolean(PreferencesConstants.PREFERENCE_ENABLE_MARQUEE_FILENAME, true); + this.dragAndDropPreference = + sharedPrefs.getInt( + PreferencesConstants.PREFERENCE_DRAG_AND_DROP_PREFERENCE, + PreferencesConstants.PREFERENCE_DRAG_DEFAULT); + this.isGrid = isGrid; + + mInflater = (LayoutInflater) context.getSystemService(Activity.LAYOUT_INFLATER_SERVICE); + accentColor = mainFragment.getMainActivity().getAccent(); + iconSkinColor = mainFragment.getMainActivity().getCurrentColorPreference().getIconSkin(); + goBackColor = Utils.getColor(context, R.color.goback_item); + videoColor = Utils.getColor(context, R.color.video_item); + audioColor = Utils.getColor(context, R.color.audio_item); + pdfColor = Utils.getColor(context, R.color.pdf_item); + codeColor = Utils.getColor(context, R.color.code_item); + textColor = Utils.getColor(context, R.color.text_item); + archiveColor = Utils.getColor(context, R.color.archive_item); + genericColor = Utils.getColor(context, R.color.generic_item); + minRowHeight = context.getResources().getDimension(R.dimen.minimal_row_height); + grey_color = Utils.getColor(context, R.color.grey); + apkColor = Utils.getColor(context, R.color.apk_item); + + setItems(recyclerView, itemsRaw, false); + } + + /** + * called as to toggle selection of any item in adapter + * + * @param position the position of the item + * @param imageView the check {@link CircleGradientDrawable} that is to be animated + */ + public void toggleChecked(int position, AppCompatImageView imageView) { + if (getItemsDigested().size() <= position || position < 0) { + AppConfig.toast(context, R.string.operation_not_supported); + return; + } + + if (getItemsDigested().get(position).getChecked() == ListItem.UNCHECKABLE) { + throw new IllegalArgumentException("You have checked a header"); + } + + if (!stoppedAnimation) { + mainFragment.stopAnimation(); + } + + if (getItemsDigested().get(position).getChecked() == ListItem.CHECKED) { + // if the view at position is checked, un-check it + LOG.debug("the view at position {} is checked, un-check it", position); + getItemsDigested().get(position).setChecked(false); + + Animation iconAnimation = AnimationUtils.loadAnimation(context, R.anim.check_out); + if (imageView != null) { + imageView.clearAnimation(); + imageView.startAnimation(iconAnimation); + } else { + // TODO: we don't have the check icon object probably because of config change + } + } else { + // if view is un-checked, check it + LOG.debug("the view at position {} is unchecked, check it", position); + getItemsDigested().get(position).setChecked(true); + + Animation iconAnimation = AnimationUtils.loadAnimation(context, R.anim.check_in); + if (imageView != null) { + imageView.clearAnimation(); + imageView.startAnimation(iconAnimation); + } else { + // TODO: we don't have the check icon object probably because of config change + } + } + + invalidateSelection(); + notifyItemChanged(position); + } + + private void invalidateSelection() { + if (mainFragment.getMainFragmentViewModel() != null) { + mainFragment + .getMainActivity() + .setListItemSelected( + mainFragment.getMainFragmentViewModel().getCheckedItems().size() != 0); + } + } + + public void invalidateActionMode() { + if (mainFragment.getMainFragmentViewModel() != null) { + // we have the actionmode visible, invalidate it's views + if (mainFragment.getMainActivity().getListItemSelected()) { + if (mainFragment.getMainActivity().getActionModeHelper().getActionMode() == null) { + ActionMode.Callback mActionModeCallback = + mainFragment.getMainActivity().getActionModeHelper(); + mainFragment + .getMainActivity() + .getActionModeHelper() + .setActionMode( + mainFragment.getMainActivity().startSupportActionMode(mActionModeCallback)); + } else { + mainFragment.getMainActivity().getActionModeHelper().getActionMode().invalidate(); + } + } else { + if (mainFragment.getMainActivity().getActionModeHelper().getActionMode() != null) { + mainFragment.getMainActivity().getActionModeHelper().getActionMode().finish(); + mainFragment.getMainActivity().getActionModeHelper().setActionMode(null); + } + } + } + } + + public void toggleChecked(boolean selectAll, String path) { + int i = path.equals("/") || !getBoolean(PREFERENCE_SHOW_GOBACK_BUTTON) ? 0 : 1; + for (; i < getItemsDigested().size(); i++) { + ListItem item = getItemsDigested().get(i); + if (selectAll && item.getChecked() != ListItem.CHECKED) { + item.setChecked(true); + notifyItemChanged(i); + } else if (!selectAll && item.getChecked() == ListItem.CHECKED) { + item.setChecked(false); + notifyItemChanged(i); + } + } + invalidateSelection(); + invalidateActionMode(); + } + + public void toggleInverse(String path) { + int i = path.equals("/") || !getBoolean(PREFERENCE_SHOW_GOBACK_BUTTON) ? 0 : 1; + + for (; i < getItemsDigested().size(); i++) { + ListItem item = getItemsDigested().get(i); + if (item.getChecked() != ListItem.CHECKED) { + item.setChecked(true); + notifyItemChanged(i); + } else if (item.getChecked() == ListItem.CHECKED) { + item.setChecked(false); + notifyItemChanged(i); + } + } + } + + public void toggleSameTypes() { + ArrayList checkedItemsIndexes = getCheckedItemsIndex(); + for (int i = 0; i < checkedItemsIndexes.size(); i++) { + ListItem selectedItem = getItemsDigested().get(checkedItemsIndexes.get(i)); + + if (!selectedItem.specialTypeHasFile()) { + continue; + } + + LayoutElementParcelable selectedElement = selectedItem.requireLayoutElementParcelable(); + for (int z = 0; z < getItemsDigested().size(); z++) { + ListItem currentItem = getItemsDigested().get(z); + if (!currentItem.specialTypeHasFile()) { + // header type list item ('Files' / 'Folders') + continue; + } + + LayoutElementParcelable currentElement = currentItem.requireLayoutElementParcelable(); + if (selectedElement.isDirectory || currentElement.isDirectory) { + if (selectedElement.isDirectory && currentElement.isDirectory) { + if (currentItem.getChecked() != ListItem.CHECKED) { + currentItem.setChecked(true); + notifyItemChanged(z); + } + } + } else { + String mimeTypeCurrentItem = MimeTypes.getExtension(currentElement.desc); + String mimeTypeSelectedElement = MimeTypes.getExtension(selectedElement.desc); + if (mimeTypeCurrentItem.equalsIgnoreCase(mimeTypeSelectedElement) + && currentItem.getChecked() != ListItem.CHECKED) { + currentItem.setChecked(true); + notifyItemChanged(z); + } + } + } + } + } + + public void toggleSameDates() { + ArrayList checkedItemsIndexes = getCheckedItemsIndex(); + for (int i = 0; i < checkedItemsIndexes.size(); i++) { + ListItem selectedItem = getItemsDigested().get(checkedItemsIndexes.get(i)); + + if (!selectedItem.specialTypeHasFile()) { + continue; + } + + LayoutElementParcelable selectedElement = selectedItem.requireLayoutElementParcelable(); + for (int y = 0; y < getItemsDigested().size(); y++) { + ListItem currentItem = getItemsDigested().get(y); + if (!currentItem.specialTypeHasFile()) { + // header type list item ('Files' / 'Folders') + continue; + } + String dateModifiedCurrentItem = + currentItem.requireLayoutElementParcelable().dateModification.split("\\|")[0]; + String dateModifiedSelectedElement = selectedElement.dateModification.split("\\|")[0]; + if (dateModifiedCurrentItem.trim().equalsIgnoreCase(dateModifiedSelectedElement.trim()) + && currentItem.getChecked() != ListItem.CHECKED) { + currentItem.setChecked(true); + notifyItemChanged(y); + } + } + } + } + + public void toggleFill() { + ArrayList checkedItemsIndexes = getCheckedItemsIndex(); + Collections.sort(checkedItemsIndexes); + if (checkedItemsIndexes.size() >= 2) { + for (int i = checkedItemsIndexes.get(0); + i < checkedItemsIndexes.get(checkedItemsIndexes.size() - 1); + i++) { + Objects.requireNonNull(getItemsDigested()).get(i).setChecked(true); + notifyItemChanged(i); + } + } + } + + public void toggleSimilarNames() { + ArrayList checkedItemsIndexes = getCheckedItemsIndex(); + for (int i = 0; i < checkedItemsIndexes.size(); i++) { + ListItem selectedItem = getItemsDigested().get(checkedItemsIndexes.get(i)); + + if (!selectedItem.specialTypeHasFile()) { + continue; + } + + LayoutElementParcelable selectedElement = selectedItem.requireLayoutElementParcelable(); + int fuzzinessFactor = selectedElement.title.length() / SelectionPopupMenu.FUZZYNESS_FACTOR; + if (fuzzinessFactor >= 1) { + for (int z = 0; z < getItemsDigested().size(); z++) { + ListItem currentItem = getItemsDigested().get(z); + if (!currentItem.specialTypeHasFile()) { + // header type list item ('Files' / 'Folders') + continue; + } + int remainingFuzzyness = fuzzinessFactor; + + char[] currentItemName = currentItem.requireLayoutElementParcelable().title.toCharArray(); + char[] selectedElementName = selectedElement.title.toCharArray(); + boolean isSimilar = true; + for (int j = 0; j < Math.min(currentItemName.length, selectedElementName.length); j++) { + if (currentItemName[j] != selectedElementName[j] && remainingFuzzyness-- < 0) { + isSimilar = false; + break; + } + } + if (isSimilar + && Math.abs(currentItemName.length - selectedElementName.length) + <= remainingFuzzyness) { + if (currentItem.getChecked() != ListItem.CHECKED) { + currentItem.setChecked(true); + notifyItemChanged(z); + } + } + } + } + } + } + + /** + * called when we would want to toggle check for all items in the adapter + * + * @param b if to toggle true or false + */ + public void toggleChecked(boolean b) { + for (int i = 0; i < getItemsDigested().size(); i++) { + ListItem item = getItemsDigested().get(i); + if (b && item.getChecked() != ListItem.CHECKED) { + item.setChecked(true); + notifyItemChanged(i); + } else if (!b && item.getChecked() == ListItem.CHECKED) { + item.setChecked(false); + notifyItemChanged(i); + } + } + invalidateSelection(); + invalidateActionMode(); + } + + @NonNull + public ArrayList getCheckedItems() { + return mainFragment.getMainFragmentViewModel().getCheckedItems(); + } + + @Nullable + public ArrayList getItemsDigested() { + return mainFragment.getMainFragmentViewModel() != null + ? mainFragment.getMainFragmentViewModel().getAdapterListItems() + : null; + } + + public boolean isItemsDigestedNullOrEmpty() { + return getItemsDigested() == null || getItemsDigested().isEmpty(); + } + + public boolean areAllChecked(String path) { + int i = (path.equals("/") || !getBoolean(PREFERENCE_SHOW_GOBACK_BUTTON)) ? 0 : 1; + + for (; i < getItemsDigested().size(); i++) { + if (getItemsDigested().get(i).getChecked() == ListItem.NOT_CHECKED) { + return false; + } + } + return true; + } + + public ArrayList getCheckedItemsIndex() { + ArrayList checked = new ArrayList<>(); + + for (int i = 0; i < getItemsDigested().size(); i++) { + if (getItemsDigested().get(i).getChecked() == ListItem.CHECKED) { + checked.add(i); + } + } + + return checked; + } + + @Override + public void onViewDetachedFromWindow(@NonNull RecyclerView.ViewHolder holder) { + if (holder instanceof ItemViewHolder) { + ((ItemViewHolder) holder).baseItemView.clearAnimation(); + ((ItemViewHolder) holder).txtTitle.setSelected(false); + if (dragAndDropPreference != PreferencesConstants.PREFERENCE_DRAG_DEFAULT) { + ((ItemViewHolder) holder).baseItemView.setOnDragListener(null); + } + } + super.onViewDetachedFromWindow(holder); + } + + @Override + public void onViewAttachedToWindow(@NonNull RecyclerView.ViewHolder holder) { + super.onViewAttachedToWindow(holder); + boolean enableMarqueeFilename = + sharedPrefs.getBoolean(PreferencesConstants.PREFERENCE_ENABLE_MARQUEE_FILENAME, true); + if (enableMarqueeFilename && holder instanceof ItemViewHolder) { + AnimUtils.marqueeAfterDelay(2000, ((ItemViewHolder) holder).txtTitle); + } + super.onViewAttachedToWindow(holder); + } + + @Override + public boolean onFailedToRecycleView(@NonNull RecyclerView.ViewHolder holder) { + ((ItemViewHolder) holder).baseItemView.clearAnimation(); + ((ItemViewHolder) holder).txtTitle.setSelected(false); + return super.onFailedToRecycleView(holder); + } + + private void animate(ItemViewHolder holder) { + holder.baseItemView.clearAnimation(); + Animation localAnimation = AnimationUtils.loadAnimation(context, R.anim.fade_in_top); + localAnimation.setStartOffset(this.offset); + holder.baseItemView.startAnimation(localAnimation); + this.offset += 30; + } + + /** + * Adds item to the end of the list, don't use this unless you are dynamically loading the + * adapter, after you are finished you must call createHeaders + */ + public void addItem(@NonNull LayoutElementParcelable element) { + // TODO: simplify if condition + if (mainFragment.getMainFragmentViewModel() != null + && mainFragment.getMainFragmentViewModel().isList() + && getItemsDigested().size() > 0) { + getItemsDigested().add(getItemsDigested().size() - 1, new ListItem(element)); + } else if (mainFragment.getMainFragmentViewModel() != null + && mainFragment.getMainFragmentViewModel().isList()) { + getItemsDigested().add(new ListItem(element)); + getItemsDigested().add(new ListItem(EMPTY_LAST_ITEM)); + } else { + getItemsDigested().add(new ListItem(element)); + } + + notifyItemInserted(getItemCount()); + } + + public void setItems( + @NonNull RecyclerView recyclerView, @NonNull List elements) { + setItems(recyclerView, elements, true); + } + + private void setItems( + @NonNull RecyclerView recyclerView, + @NonNull List elements, + boolean invalidate) { + if (preloader != null) { + recyclerView.removeOnScrollListener(preloader); + preloader = null; + } + + if (getItemsDigested() != null && invalidate) { + getItemsDigested().clear(); + if (mainFragment.getMainFragmentViewModel().getIconList() != null) { + mainFragment.getMainFragmentViewModel().getIconList().clear(); + } + } + + offset = 0; + stoppedAnimation = false; + + ArrayList uris = new ArrayList<>(); + ArrayList listItems = new ArrayList<>(); + + for (LayoutElementParcelable e : elements) { + if (invalidate || isItemsDigestedNullOrEmpty()) { + if (e != null) { + listItems.add(new ListItem(e.isBack, e)); + } + uris.add(e != null ? e.iconData : null); + } + } + + if (mainFragment.getMainFragmentViewModel() != null + && mainFragment.getMainFragmentViewModel().isList() + && listItems.size() > 0 + && (invalidate || isItemsDigestedNullOrEmpty())) { + listItems.add(new ListItem(EMPTY_LAST_ITEM)); + uris.add(null); + } + + if (invalidate || isItemsDigestedNullOrEmpty()) { + mainFragment.getMainFragmentViewModel().setAdapterListItems(listItems); + mainFragment.getMainFragmentViewModel().setIconList(uris); + + if (getBoolean(PREFERENCE_SHOW_HEADERS)) { + createHeaders(invalidate, mainFragment.getMainFragmentViewModel().getIconList()); + } + } + + boolean isItemCircular = !isGrid; + + sizeProvider = new RecyclerPreloadSizeProvider(this); + modelProvider = + new RecyclerPreloadModelProvider( + mainFragment, mainFragment.getMainFragmentViewModel().getIconList(), isItemCircular); + + preloader = + new RecyclerViewPreloader<>( + Glide.with(mainFragment), + modelProvider, + sizeProvider, + GlideConstants.MAX_PRELOAD_FILES); + + recyclerView.addOnScrollListener(preloader); + } + + public void createHeaders(boolean invalidate, List uris) { + if ((mainFragment.getMainFragmentViewModel() != null + && mainFragment.getMainFragmentViewModel().getDsort() == DirSortBy.NONE_ON_TOP) + || getItemsDigested() == null + || getItemsDigested().isEmpty()) { + return; + } else { + boolean[] headers = new boolean[] {false, false}; + + for (int i = 0; i < getItemsDigested().size(); i++) { + + if (getItemsDigested().get(i).layoutElementParcelable != null) { + LayoutElementParcelable nextItem = getItemsDigested().get(i).layoutElementParcelable; + + if (nextItem != null) { + if (!headers[0] && nextItem.isDirectory) { + headers[0] = true; + getItemsDigested().add(i, new ListItem(TYPE_HEADER_FOLDERS)); + uris.add(i, null); + continue; + } + + if (!headers[1] + && !nextItem.isDirectory + && !nextItem.title.equals(".") + && !nextItem.title.equals("..")) { + headers[1] = true; + getItemsDigested().add(i, new ListItem(TYPE_HEADER_FILES)); + uris.add(i, null); + continue; // leave this continue for symmetry + } + } + } + } + + if (invalidate) { + notifyDataSetChanged(); + } + } + } + + @Override + public int getItemCount() { + return getItemsDigested().size(); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public int getItemViewType(int position) { + if (getItemsDigested().get(position).specialType != -1) { + return getItemsDigested().get(position).specialType; + } else { + return TYPE_ITEM; + } + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view; + + switch (viewType) { + case TYPE_HEADER_FOLDERS: + case TYPE_HEADER_FILES: + if (mainFragment.getMainFragmentViewModel() != null + && mainFragment.getMainFragmentViewModel().isList()) { + + view = mInflater.inflate(R.layout.list_header, parent, false); + } else { + + view = mInflater.inflate(R.layout.grid_header, parent, false); + } + + int type = + viewType == TYPE_HEADER_FOLDERS + ? SpecialViewHolder.HEADER_FOLDERS + : SpecialViewHolder.HEADER_FILES; + return new SpecialViewHolder(context, view, utilsProvider, type); + case TYPE_ITEM: + case TYPE_BACK: + if (mainFragment.getMainFragmentViewModel() != null + && mainFragment.getMainFragmentViewModel().isList()) { + view = mInflater.inflate(R.layout.rowlayout, parent, false); + sizeProvider.addView(VIEW_GENERIC, view.findViewById(R.id.generic_icon)); + sizeProvider.addView(VIEW_PICTURE, view.findViewById(R.id.picture_icon)); + sizeProvider.addView(VIEW_APK, view.findViewById(R.id.apk_icon)); + } else { + view = mInflater.inflate(R.layout.griditem, parent, false); + sizeProvider.addView(VIEW_GENERIC, view.findViewById(R.id.generic_icon)); + sizeProvider.addView(VIEW_THUMB, view.findViewById(R.id.icon_thumb)); + } + sizeProvider.closeOffAddition(); + + return new ItemViewHolder(view); + case EMPTY_LAST_ITEM: + int totalFabHeight = (int) context.getResources().getDimension(R.dimen.fab_height), + marginFab = (int) context.getResources().getDimension(R.dimen.fab_margin); + view = new View(context); + view.setMinimumHeight(totalFabHeight + marginFab); + return new EmptyViewHolder(view); + default: + throw new IllegalArgumentException("Illegal: " + viewType); + } + } + + @Override + public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder vholder, int position) { + if (!(vholder instanceof ItemViewHolder)) { + return; + } + + @NonNull final ItemViewHolder holder = (ItemViewHolder) vholder; + + holder.baseItemView.setOnFocusChangeListener( + (v, hasFocus) -> { + if (hasFocus) { + mainFragment.adjustListViewForTv(holder, mainFragment.getMainActivity()); + } + }); + holder.txtTitle.setEllipsize( + enableMarquee ? TextUtils.TruncateAt.MARQUEE : TextUtils.TruncateAt.MIDDLE); + + final boolean isBackButton = getItemsDigested().get(position).specialType == TYPE_BACK; + if (isBackButton) { + holder.about.setVisibility(View.GONE); + } + + if (!this.stoppedAnimation && !getItemsDigested().get(position).getAnimating()) { + animate(holder); + getItemsDigested().get(position).setAnimate(true); + } + + if (dragAndDropPreference != PreferencesConstants.PREFERENCE_DRAG_DEFAULT) { + holder.baseItemView.setOnDragListener( + new RecyclerAdapterDragListener(this, holder, dragAndDropPreference, mainFragment)); + } + + if (mainFragment.getMainFragmentViewModel().isList()) { + bindViewHolderList(holder, position); + } else { + bindViewHolderGrid(holder, position); + } + + invalidateActionMode(); + } + + private void bindViewHolderList(@NonNull final ItemViewHolder holder, int position) { + final boolean isBackButton = getItemsDigested().get(position).specialType == TYPE_BACK; + @Nullable + final LayoutElementParcelable rowItem = + getItemsDigested().get(position).layoutElementParcelable; + + if (mainFragment.getMainFragmentViewModel() != null && position == getItemCount() - 1) { + holder.baseItemView.setMinimumHeight((int) minRowHeight); + if (getItemsDigested().size() == (getBoolean(PREFERENCE_SHOW_GOBACK_BUTTON) ? 1 : 0)) + holder.txtTitle.setText(R.string.no_files); + else { + holder.txtTitle.setText(""); + } + return; + } + + holder.baseItemView.setOnLongClickListener( + p1 -> { + if (hasPendingPasteOperation()) return false; + if (!isBackButton) { + if (dragAndDropPreference == PreferencesConstants.PREFERENCE_DRAG_DEFAULT + || (dragAndDropPreference == PreferencesConstants.PREFERENCE_DRAG_TO_MOVE_COPY + && getItemsDigested().get(holder.getAdapterPosition()).getChecked() + != ListItem.CHECKED)) { + mainFragment.registerListItemChecked( + holder.getAdapterPosition(), holder.checkImageView); + } + initDragListener(position, p1, holder); + } + return true; + }); + + // clear previously cached icon + Glide.with(mainFragment).clear(holder.genericIcon); + Glide.with(mainFragment).clear(holder.pictureIcon); + Glide.with(mainFragment).clear(holder.apkIcon); + Glide.with(mainFragment).clear(holder.baseItemView); + + holder.baseItemView.setOnClickListener( + v -> { + mainFragment.onListItemClicked( + isBackButton, holder.getAdapterPosition(), rowItem, holder.checkImageView); + }); + + holder.about.setOnKeyListener( + (v, keyCode, event) -> { + if (event.getAction() == KeyEvent.ACTION_DOWN) { + if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_RIGHT) { + mainFragment.getMainActivity().getFAB().requestFocus(); + } else if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_CENTER) { + showPopup(v, rowItem); + } else if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) { + mainFragment.getMainActivity().onBackPressed(); + } else { + return false; + } + } + return true; + }); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + holder.checkImageView.setBackground( + new CircleGradientDrawable( + accentColor, + utilsProvider.getAppTheme(), + mainFragment.getResources().getDisplayMetrics())); + } else { + holder.checkImageView.setBackgroundDrawable( + new CircleGradientDrawable( + accentColor, + utilsProvider.getAppTheme(), + mainFragment.getResources().getDisplayMetrics())); + } + holder.txtTitle.setText(rowItem.title); + holder.genericText.setText(""); + + if (utilsProvider.getAppTheme().equals(AppTheme.LIGHT)) { + holder.about.setColorFilter(grey_color); + } + holder.about.setOnClickListener(v -> showPopup(v, rowItem)); + holder.genericIcon.setOnClickListener( + v -> { + int id = v.getId(); + if (id == R.id.generic_icon || id == R.id.picture_icon || id == R.id.apk_icon) { + // TODO: transform icon on press to the properties dialog with animation + if (!isBackButton) { + toggleChecked(holder.getAdapterPosition(), holder.checkImageView); + } else mainFragment.goBack(); + } + }); + + holder.pictureIcon.setOnClickListener( + view -> { + if (!isBackButton) { + toggleChecked(holder.getAdapterPosition(), holder.checkImageView); + } else mainFragment.goBack(); + }); + + holder.apkIcon.setOnClickListener( + view -> { + if (!isBackButton) { + toggleChecked(holder.getAdapterPosition(), holder.checkImageView); + } else mainFragment.goBack(); + }); + + // resetting icons visibility + holder.genericIcon.setVisibility(View.VISIBLE); + holder.pictureIcon.setVisibility(View.INVISIBLE); + holder.apkIcon.setVisibility(View.INVISIBLE); + holder.checkImageView.setVisibility(View.INVISIBLE); + + // setting icons for various cases + // apkIcon holder refers to square/non-circular drawable + // pictureIcon is circular drawable + switch (rowItem.filetype) { + case Icons.IMAGE: + case Icons.VIDEO: + if (getBoolean(PREFERENCE_SHOW_THUMB) && rowItem.getMode() != OpenMode.FTP) { + if (getBoolean(PREFERENCE_USE_CIRCULAR_IMAGES)) { + showThumbnailWithBackground( + holder, rowItem.iconData, holder.pictureIcon, rowItem.iconData::setImageBroken); + } else { + showThumbnailWithBackground( + holder, rowItem.iconData, holder.apkIcon, rowItem.iconData::setImageBroken); + } + } else { + holder.genericIcon.setImageResource( + rowItem.filetype == Icons.IMAGE + ? R.drawable.ic_doc_image + : R.drawable.ic_doc_video_am); + } + break; + case Icons.APK: + if (getBoolean(PREFERENCE_SHOW_THUMB)) { + showThumbnailWithBackground( + holder, rowItem.iconData, holder.apkIcon, rowItem.iconData::setImageBroken); + } else { + holder.genericIcon.setImageResource(R.drawable.ic_doc_apk_white); + } + break; + case Icons.NOT_KNOWN: + holder.genericIcon.setVisibility(View.VISIBLE); + // if the file type is any unknown variable + String ext = !rowItem.isDirectory ? MimeTypes.getExtension(rowItem.title) : null; + if (ext != null && ext.trim().length() != 0) { + holder.genericText.setText(ext); + holder.genericIcon.setImageDrawable(null); + holder.genericIcon.setVisibility(View.INVISIBLE); + } else { + // we could not find the extension, set a generic file type icon probably a directory + modelProvider.getPreloadRequestBuilder(rowItem.iconData).into(holder.genericIcon); + } + break; + case Icons.ENCRYPTED: + default: + holder.genericIcon.setVisibility(View.VISIBLE); + modelProvider.getPreloadRequestBuilder(rowItem.iconData).into(holder.genericIcon); + break; + } + + if (utilsProvider.getAppTheme().equals(AppTheme.LIGHT)) { + holder.baseItemView.setBackgroundResource(R.drawable.safr_ripple_white); + } else { + holder.baseItemView.setBackgroundResource(R.drawable.safr_ripple_black); + } + holder.baseItemView.setSelected(false); + if (getItemsDigested().get(position).getChecked() == ListItem.CHECKED) { + + if (holder.checkImageView.getVisibility() == View.INVISIBLE) + holder.checkImageView.setVisibility(View.VISIBLE); + // making sure the generic icon background color filter doesn't get changed + // to grey on picture/video/apk/generic text icons when checked + // so that user can still look at the thumbs even after selection + if ((rowItem.filetype != Icons.IMAGE + && rowItem.filetype != Icons.APK + && rowItem.filetype != Icons.VIDEO) + || !getBoolean(PREFERENCE_SHOW_THUMB)) { + holder.apkIcon.setVisibility(View.GONE); + holder.pictureIcon.setVisibility(View.GONE); + holder.genericIcon.setVisibility(View.VISIBLE); + GradientDrawable gradientDrawable = (GradientDrawable) holder.genericIcon.getBackground(); + gradientDrawable.setColor(goBackColor); + } + holder.baseItemView.setSelected(true); + // holder.genericText.setText(""); + } else { + holder.checkImageView.setVisibility(View.INVISIBLE); + if (!((rowItem.filetype == Icons.APK + || rowItem.filetype == Icons.IMAGE + || rowItem.filetype == Icons.VIDEO) + && getBoolean(PREFERENCE_SHOW_THUMB))) { + holder.genericIcon.setVisibility(View.VISIBLE); + GradientDrawable gradientDrawable = (GradientDrawable) holder.genericIcon.getBackground(); + + if (getBoolean(PREFERENCE_COLORIZE_ICONS)) { + if (rowItem.isDirectory) { + gradientDrawable.setColor(iconSkinColor); + } else { + ColorUtils.colorizeIcons(context, rowItem.filetype, gradientDrawable, iconSkinColor); + } + } else { + gradientDrawable.setColor(iconSkinColor); + } + + if (isBackButton) { + gradientDrawable.setColor(goBackColor); + } + } + } + if (getBoolean(PREFERENCE_SHOW_PERMISSIONS)) { + holder.perm.setText(rowItem.permissions); + } + if (getBoolean(PREFERENCE_SHOW_LAST_MODIFIED)) { + holder.date.setText(rowItem.dateModification); + } else { + holder.date.setVisibility(View.GONE); + } + if (isBackButton) { + holder.date.setText(rowItem.size); + holder.txtDesc.setText(""); + } else if (getBoolean(PREFERENCE_SHOW_FILE_SIZE)) { + holder.txtDesc.setText(rowItem.size); + } + } + + private void bindViewHolderGrid(@NonNull final ItemViewHolder holder, int position) { + final boolean isBackButton = getItemsDigested().get(position).specialType == TYPE_BACK; + @Nullable + final LayoutElementParcelable rowItem = + getItemsDigested().get(position).layoutElementParcelable; + + holder.baseItemView.setOnLongClickListener( + p1 -> { + if (hasPendingPasteOperation()) return false; + if (!isBackButton) { + if (dragAndDropPreference == PreferencesConstants.PREFERENCE_DRAG_DEFAULT + || (dragAndDropPreference == PreferencesConstants.PREFERENCE_DRAG_TO_MOVE_COPY + && getItemsDigested().get(holder.getAdapterPosition()).getChecked() + != ListItem.CHECKED)) { + mainFragment.registerListItemChecked( + holder.getAdapterPosition(), holder.checkImageViewGrid); + } + initDragListener(position, p1, holder); + } + return true; + }); + + // view is a grid view + // clear previously cached icon + Glide.with(mainFragment).clear(holder.genericIcon); + Glide.with(mainFragment).clear(holder.iconLayout); + Glide.with(mainFragment).clear(holder.imageView1); + Glide.with(mainFragment).clear(holder.baseItemView); + + holder.checkImageViewGrid.setColorFilter(accentColor); + holder.baseItemView.setOnClickListener( + v -> { + mainFragment.onListItemClicked( + isBackButton, holder.getAdapterPosition(), rowItem, holder.checkImageViewGrid); + }); + holder.txtTitle.setText(rowItem.title); + holder.imageView1.setVisibility(View.INVISIBLE); + holder.genericIcon.setVisibility(View.VISIBLE); + holder.checkImageViewGrid.setVisibility(View.INVISIBLE); + + if (rowItem.filetype == Icons.IMAGE || rowItem.filetype == Icons.VIDEO) { + if (getBoolean(PREFERENCE_SHOW_THUMB) && rowItem.getMode() != OpenMode.FTP) { + holder.imageView1.setVisibility(View.VISIBLE); + holder.imageView1.setImageDrawable(null); + if (utilsProvider.getAppTheme().equals(AppTheme.DARK) + || utilsProvider.getAppTheme().equals(AppTheme.BLACK)) + holder.imageView1.setBackgroundColor(Color.BLACK); + showRoundedThumbnail( + holder, rowItem.iconData, holder.imageView1, rowItem.iconData::setImageBroken); + } else { + if (rowItem.filetype == Icons.IMAGE) + holder.genericIcon.setImageResource(R.drawable.ic_doc_image); + else holder.genericIcon.setImageResource(R.drawable.ic_doc_video_am); + } + } else if (rowItem.filetype == Icons.APK) { + if (getBoolean(PREFERENCE_SHOW_THUMB)) + showRoundedThumbnail( + holder, rowItem.iconData, holder.genericIcon, rowItem.iconData::setImageBroken); + else { + holder.genericIcon.setImageResource(R.drawable.ic_doc_apk_white); + } + } else { + Glide.with(mainFragment).load(rowItem.iconData.image).into(holder.genericIcon); + } + + if (holder.genericIcon.getVisibility() == View.VISIBLE) { + View iconBackground = + getBoolean(PREFERENCE_USE_CIRCULAR_IMAGES) ? holder.genericIcon : holder.iconLayout; + if (rowItem.isDirectory) { + iconBackground.setBackgroundColor(iconSkinColor); + } else { + switch (rowItem.filetype) { + case Icons.VIDEO: + if (!getBoolean(PREFERENCE_SHOW_THUMB)) iconBackground.setBackgroundColor(videoColor); + break; + case Icons.AUDIO: + iconBackground.setBackgroundColor(audioColor); + break; + case Icons.PDF: + iconBackground.setBackgroundColor(pdfColor); + break; + case Icons.CODE: + iconBackground.setBackgroundColor(codeColor); + break; + case Icons.TEXT: + iconBackground.setBackgroundColor(textColor); + break; + case Icons.COMPRESSED: + iconBackground.setBackgroundColor(archiveColor); + break; + case Icons.NOT_KNOWN: + iconBackground.setBackgroundColor(genericColor); + break; + case Icons.APK: + if (!getBoolean(PREFERENCE_SHOW_THUMB)) iconBackground.setBackgroundColor(apkColor); + break; + case Icons.IMAGE: + if (!getBoolean(PREFERENCE_SHOW_THUMB)) iconBackground.setBackgroundColor(videoColor); + break; + default: + iconBackground.setBackgroundColor(iconSkinColor); + break; + } + } + + if (isBackButton) { + iconBackground.setBackgroundColor(goBackColor); + } + } + + if (getItemsDigested().get(position).getChecked() == ListItem.CHECKED) { + if (holder.genericIcon.getVisibility() == View.VISIBLE) { + + if ((rowItem.filetype != Icons.IMAGE + && rowItem.filetype != Icons.APK + && rowItem.filetype != Icons.VIDEO) + || !getBoolean(PREFERENCE_SHOW_THUMB)) { + View iconBackground = + getBoolean(PREFERENCE_USE_CIRCULAR_IMAGES) ? holder.genericIcon : holder.iconLayout; + iconBackground.setBackgroundColor(goBackColor); + } + } + + holder.checkImageViewGrid.setVisibility(View.VISIBLE); + holder.baseItemView.setBackgroundColor(Utils.getColor(context, R.color.item_background)); + } else { + holder.checkImageViewGrid.setVisibility(View.INVISIBLE); + if (utilsProvider.getAppTheme().equals(AppTheme.LIGHT)) { + holder.baseItemView.setBackgroundResource(R.drawable.item_doc_grid); + } else { + holder.baseItemView.setBackgroundResource(R.drawable.ic_grid_card_background_dark); + holder + .baseItemView + .findViewById(R.id.icon_frame_grid) + .setBackgroundColor(Utils.getColor(context, R.color.icon_background_dark)); + } + } + + if (utilsProvider.getAppTheme().equals(AppTheme.LIGHT)) { + holder.about.setColorFilter(grey_color); + } + holder.about.setOnClickListener(v -> showPopup(v, rowItem)); + + if (getBoolean(PREFERENCE_SHOW_LAST_MODIFIED)) { + holder.date.setText(rowItem.dateModification); + } + if (isBackButton) { + holder.date.setText(rowItem.size); + holder.txtDesc.setText(""); + } + if (getBoolean(PREFERENCE_SHOW_PERMISSIONS)) { + holder.perm.setText(rowItem.permissions); + } + } + + @Override + @ViewType + public int getCorrectView(IconDataParcelable item, int adapterPosition) { + int specialType = getItemsDigested().get(adapterPosition).specialType; + + if (specialType != TYPE_ITEM && specialType != TYPE_BACK) { // These have no icons + throw new IllegalStateException("Setting view type to wrong item"); + } + + if (mainFragment.getMainFragmentViewModel() != null + && mainFragment.getMainFragmentViewModel().isList()) { + if (getBoolean(PREFERENCE_SHOW_THUMB)) { + int filetype = + getItemsDigested().get(adapterPosition).requireLayoutElementParcelable().filetype; + + if (filetype == Icons.VIDEO || filetype == Icons.IMAGE) { + if (getBoolean(PREFERENCE_USE_CIRCULAR_IMAGES)) { + return VIEW_PICTURE; + } else { + return VIEW_APK; + } + } else if (filetype == Icons.APK) { + return VIEW_APK; + } + } + + return VIEW_GENERIC; + } else { + if (item.type == IconDataParcelable.IMAGE_FROMFILE) { + return VIEW_THUMB; + } else { + return VIEW_GENERIC; + } + } + } + + private void initDragListener(int position, View view, ItemViewHolder itemViewHolder) { + if (dragAndDropPreference != PreferencesConstants.PREFERENCE_DRAG_DEFAULT + && (getItemsDigested().get(position).getChecked() == ListItem.CHECKED + || dragAndDropPreference == PreferencesConstants.PREFERENCE_DRAG_TO_SELECT)) { + // toggle drag flag to true for list item due to the fact + // that we might have set it false in a previous drag event + if (!getItemsDigested().get(position).shouldToggleDragChecked) { + getItemsDigested().get(position).toggleShouldToggleDragChecked(); + } + + View shadowView = + dragAndDropPreference == PreferencesConstants.PREFERENCE_DRAG_TO_SELECT + ? itemViewHolder.dummyView + : getDragShadow(getCheckedItems().size()); + View.DragShadowBuilder dragShadowBuilder = new View.DragShadowBuilder(shadowView); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + view.startDragAndDrop(null, dragShadowBuilder, null, 0); + } else { + view.startDrag(null, dragShadowBuilder, null, 0); + } + mainFragment + .getMainActivity() + .initCornersDragListener( + false, dragAndDropPreference != PreferencesConstants.PREFERENCE_DRAG_TO_SELECT); + } + } + + private View getDragShadow(int selectionCount) { + mainFragment + .getMainActivity() + .getTabFragment() + .getDragPlaceholder() + .setVisibility(View.VISIBLE); + String rememberMovePreference = + sharedPrefs.getString(PreferencesConstants.PREFERENCE_DRAG_AND_DROP_REMEMBERED, ""); + AppCompatImageView icon = + mainFragment + .getMainActivity() + .getTabFragment() + .getDragPlaceholder() + .findViewById(R.id.icon); + View filesCountParent = + mainFragment + .getMainActivity() + .getTabFragment() + .getDragPlaceholder() + .findViewById(R.id.files_count_parent); + AppCompatTextView filesCount = + mainFragment + .getMainActivity() + .getTabFragment() + .getDragPlaceholder() + .findViewById(R.id.files_count); + icon.setImageDrawable( + context.getResources().getDrawable(getDragIconReference(rememberMovePreference))); + GradientDrawable gradientDrawable = (GradientDrawable) icon.getBackground(); + gradientDrawable.setColor(grey_color); + filesCount.setText(String.valueOf(selectionCount)); + filesCountParent.setBackgroundDrawable( + new CircleGradientDrawable( + accentColor, + utilsProvider.getAppTheme(), + mainFragment.getResources().getDisplayMetrics())); + return mainFragment.getMainActivity().getTabFragment().getDragPlaceholder(); + } + + private int getDragIconReference(String rememberMovePreference) { + int iconRef = R.drawable.ic_add_white_24dp; + if (rememberMovePreference.equalsIgnoreCase( + PreferencesConstants.PREFERENCE_DRAG_REMEMBER_MOVE)) { + iconRef = R.drawable.ic_content_cut_white_36dp; + } else if (rememberMovePreference.equalsIgnoreCase( + PreferencesConstants.PREFERENCE_DRAG_REMEMBER_COPY)) { + iconRef = R.drawable.ic_content_copy_white_24dp; + } + return iconRef; + } + + private void showThumbnailWithBackground( + ItemViewHolder viewHolder, + IconDataParcelable iconData, + AppCompatImageView view, + OnImageProcessed errorListener) { + if (iconData.isImageBroken()) { + viewHolder.genericIcon.setVisibility(View.VISIBLE); + Glide.with(mainFragment) + .load(R.drawable.ic_broken_image_white_24dp) + .into(viewHolder.genericIcon); + GradientDrawable gradientDrawable = (GradientDrawable) viewHolder.genericIcon.getBackground(); + gradientDrawable.setColor(grey_color); + + errorListener.onImageProcessed(true); + return; + } + + viewHolder.genericIcon.setVisibility(View.VISIBLE); + Glide.with(mainFragment).load(iconData.loadingImage).into(viewHolder.genericIcon); + GradientDrawable gradientDrawable = (GradientDrawable) viewHolder.genericIcon.getBackground(); + + RequestListener requestListener = + new RequestListener() { + + @Override + public boolean onLoadFailed( + @Nullable GlideException e, Object model, Target target, boolean isFirstResource) { + new Handler( + msg -> { + viewHolder.genericIcon.setVisibility(View.VISIBLE); + Glide.with(mainFragment) + .load(R.drawable.ic_broken_image_white_24dp) + .into(viewHolder.genericIcon); + return false; + }) + .obtainMessage() + .sendToTarget(); + gradientDrawable.setColor(grey_color); + + errorListener.onImageProcessed(true); + return true; + } + + @Override + public boolean onResourceReady( + Drawable resource, + Object model, + Target target, + DataSource dataSource, + boolean isFirstResource) { + viewHolder.genericIcon.setImageDrawable(null); + viewHolder.genericIcon.setVisibility(View.GONE); + gradientDrawable.setColor( + mainFragment.getResources().getColor(android.R.color.transparent)); + view.setVisibility(View.VISIBLE); + + errorListener.onImageProcessed(false); + return false; + } + }; + modelProvider.getPreloadRequestBuilder(iconData).listener(requestListener).into(view); + } + + private void showRoundedThumbnail( + ItemViewHolder viewHolder, + IconDataParcelable iconData, + AppCompatImageView view, + OnImageProcessed errorListener) { + if (iconData.isImageBroken()) { + View iconBackground = + getBoolean(PREFERENCE_USE_CIRCULAR_IMAGES) + ? viewHolder.genericIcon + : viewHolder.iconLayout; + + viewHolder.genericIcon.setVisibility(View.VISIBLE); + iconBackground.setBackgroundColor(grey_color); + Glide.with(mainFragment) + .load(R.drawable.ic_broken_image_white_24dp) + .into(viewHolder.genericIcon); + view.setVisibility(View.INVISIBLE); + + errorListener.onImageProcessed(true); + return; + } + + View iconBackground = + getBoolean(PREFERENCE_USE_CIRCULAR_IMAGES) ? viewHolder.genericIcon : viewHolder.iconLayout; + + viewHolder.genericIcon.setVisibility(View.VISIBLE); + Glide.with(mainFragment).load(iconData.loadingImage).into(viewHolder.genericIcon); + view.setVisibility(View.INVISIBLE); + + RequestListener requestListener = + new RequestListener() { + @Override + public boolean onLoadFailed( + @Nullable GlideException e, Object model, Target target, boolean isFirstResource) { + iconBackground.setBackgroundColor(grey_color); + new Handler( + msg -> { + Glide.with(mainFragment) + .load(R.drawable.ic_broken_image_white_24dp) + .into(viewHolder.genericIcon); + return false; + }) + .obtainMessage() + .sendToTarget(); + errorListener.onImageProcessed(true); + return true; + } + + @Override + public boolean onResourceReady( + Drawable resource, + Object model, + Target target, + DataSource dataSource, + boolean isFirstResource) { + viewHolder.genericIcon.setImageDrawable(null); + viewHolder.genericIcon.setVisibility(View.GONE); + view.setVisibility(View.VISIBLE); + iconBackground.setBackgroundColor( + mainFragment.getResources().getColor(android.R.color.transparent)); + errorListener.onImageProcessed(false); + return false; + } + }; + modelProvider.getPreloadRequestBuilder(iconData).listener(requestListener).into(view); + } + + private void showPopup(@NonNull View view, @NonNull final LayoutElementParcelable rowItem) { + if (hasPendingPasteOperation()) return; + Context currentContext = this.context; + if (mainFragment.getMainActivity().getAppTheme() == AppTheme.BLACK) { + currentContext = new ContextThemeWrapper(context, R.style.overflow_black); + } + PopupMenu popupMenu = + new ItemPopupMenu( + currentContext, + mainFragment.requireMainActivity(), + utilsProvider, + mainFragment, + rowItem, + view, + sharedPrefs); + popupMenu.inflate(R.menu.item_extras); + String description = rowItem.desc.toLowerCase(); + + if (rowItem.isDirectory) { + popupMenu.getMenu().findItem(R.id.open_with).setVisible(false); + popupMenu.getMenu().findItem(R.id.share).setVisible(false); + + if (mainFragment.getMainActivity().mReturnIntent) { + popupMenu.getMenu().findItem(R.id.return_select).setVisible(true); + } + } else { + popupMenu.getMenu().findItem(R.id.book).setVisible(false); + popupMenu.getMenu().findItem(R.id.compress).setVisible(true); + + if (description.endsWith(fileExtensionZip) + || description.endsWith(fileExtensionJar) + || description.endsWith(fileExtensionApk) + || description.endsWith(fileExtensionApks) + || description.endsWith(fileExtensionRar) + || description.endsWith(fileExtensionTar) + || description.endsWith(fileExtensionGzipTarLong) + || description.endsWith(fileExtensionGzipTarShort) + || description.endsWith(fileExtensionBzip2TarLong) + || description.endsWith(fileExtensionBzip2TarShort) + || description.endsWith(fileExtensionTarXz) + || description.endsWith(fileExtensionTarLzma) + || description.endsWith(fileExtension7zip) + || description.endsWith(fileExtensionGz) + || description.endsWith(fileExtensionBzip2) + || description.endsWith(fileExtensionLzma) + || description.endsWith(fileExtensionXz)) { + popupMenu.getMenu().findItem(R.id.ex).setVisible(true); + popupMenu.getMenu().findItem(R.id.compress).setVisible(false); + } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { + if (description.endsWith(CryptUtil.CRYPT_EXTENSION) + || description.endsWith(CryptUtil.AESCRYPT_EXTENSION)) { + popupMenu.getMenu().findItem(R.id.decrypt).setVisible(true); + } else { + popupMenu.getMenu().findItem(R.id.encrypt).setVisible(true); + } + } + if (rowItem.getMode() == OpenMode.TRASH_BIN) { + popupMenu.getMenu().findItem(R.id.return_select).setVisible(false); + popupMenu.getMenu().findItem(R.id.cut).setVisible(false); + popupMenu.getMenu().findItem(R.id.cpy).setVisible(false); + popupMenu.getMenu().findItem(R.id.rename).setVisible(false); + popupMenu.getMenu().findItem(R.id.encrypt).setVisible(false); + popupMenu.getMenu().findItem(R.id.decrypt).setVisible(false); + popupMenu.getMenu().findItem(R.id.about).setVisible(false); + popupMenu.getMenu().findItem(R.id.compress).setVisible(false); + popupMenu.getMenu().findItem(R.id.share).setVisible(false); + popupMenu.getMenu().findItem(R.id.ex).setVisible(false); + popupMenu.getMenu().findItem(R.id.book).setVisible(false); + popupMenu.getMenu().findItem(R.id.restore).setVisible(true); + popupMenu.getMenu().findItem(R.id.delete).setVisible(true); + } + + popupMenu.show(); + } + + /** + * Helps in deciding whether to allow file modification or not, depending on the state of the + * copy/paste operation. + * + * @return true if there is an unfinished copy/paste operation, false otherwise. + */ + private boolean hasPendingPasteOperation() { + MainActivity mainActivity = mainFragment.getMainActivity(); + if (mainActivity == null) return false; + MainActivityActionMode mainActivityActionMode = mainActivity.mainActivityActionMode; + PasteHelper pasteHelper = mainActivityActionMode.getPasteHelper(); + + if (pasteHelper != null + && pasteHelper.getSnackbar() != null + && pasteHelper.getSnackbar().isShown()) { + Toast.makeText( + mainFragment.requireContext(), + mainFragment.getString(R.string.complete_paste_warning), + Toast.LENGTH_LONG) + .show(); + return true; + } + return false; + } + + private boolean getBoolean(String key) { + return preferenceActivity.getBoolean(key); + } + + public static class ListItem { + public static final int CHECKED = 0, NOT_CHECKED = 1, UNCHECKABLE = 2; + + /** Not null if {@link ListItem#specialTypeHasFile()} returns true */ + @Nullable private final LayoutElementParcelable layoutElementParcelable; + + private final @ListElemType int specialType; + private boolean checked; + private boolean animate; + private boolean shouldToggleDragChecked = true; + + ListItem(@NonNull LayoutElementParcelable layoutElementParcelable) { + this(false, layoutElementParcelable); + } + + ListItem(boolean isBack, @NonNull LayoutElementParcelable layoutElementParcelable) { + this.layoutElementParcelable = layoutElementParcelable; + specialType = isBack ? TYPE_BACK : TYPE_ITEM; + } + + ListItem(@ListElemType int specialType) { + this.specialType = specialType; + this.layoutElementParcelable = null; + } + + public void setChecked(boolean checked) { + if (specialType == TYPE_ITEM) this.checked = checked; + } + + public int getChecked() { + if (checked) return CHECKED; + else if (specialType == TYPE_ITEM) return NOT_CHECKED; + else return UNCHECKABLE; + } + + @Nullable + public LayoutElementParcelable getLayoutElementParcelable() { + return layoutElementParcelable; + } + + /** + * Check that {@link ListItem#specialTypeHasFile()} returns true, if it does this method doesn't + * return null. + */ + @NonNull + public LayoutElementParcelable requireLayoutElementParcelable() { + if (!specialTypeHasFile()) { + throw new IllegalStateException( + "Type of item " + specialType + " has no LayoutElementParcelable!"); + } + return layoutElementParcelable; + } + + public int getSpecialType() { + return this.specialType; + } + + /** + * This method effectively has a contract that allows {@link + * ListItem#requireLayoutElementParcelable} afterwards without crashing. + */ + public boolean specialTypeHasFile() { + return specialType == TYPE_ITEM || specialType == TYPE_BACK; + } + + public boolean getShouldToggleDragChecked() { + return !checked && this.shouldToggleDragChecked; + } + + public void toggleShouldToggleDragChecked() { + this.shouldToggleDragChecked = !this.shouldToggleDragChecked; + } + + public void setAnimate(boolean animating) { + if (specialType == -1) this.animate = animating; + } + + public boolean getAnimating() { + return animate; + } + } + + public interface OnImageProcessed { + void onImageProcessed(boolean isImageBroken); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/adapters/SearchRecyclerViewAdapter.kt b/app/src/main/java/com/amaze/filemanager/adapters/SearchRecyclerViewAdapter.kt new file mode 100644 index 0000000..5c6afc3 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/adapters/SearchRecyclerViewAdapter.kt @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2014-2023 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters + +import android.content.Context +import android.text.Spannable +import android.text.SpannableString +import android.text.style.ForegroundColorSpan +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.AppCompatTextView +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.amaze.filemanager.R +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.asynchronous.asynctasks.searchfilesystem.SearchResult +import com.amaze.filemanager.ui.activities.MainActivity +import com.amaze.filemanager.ui.colors.ColorPreference +import java.util.Random + +class SearchRecyclerViewAdapter : + ListAdapter( + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: SearchResult, + newItem: SearchResult, + ): Boolean { + return oldItem.file.path == newItem.file.path && + oldItem.file.name == newItem.file.name + } + + override fun areContentsTheSame( + oldItem: SearchResult, + newItem: SearchResult, + ): Boolean { + return oldItem.file.path == newItem.file.path && + oldItem.file.name == newItem.file.name && + oldItem.matchRange == newItem.matchRange + } + }, + ) { + override fun onCreateViewHolder( + parent: ViewGroup, + type: Int, + ): ViewHolder { + val v: View = + LayoutInflater.from(parent.context) + .inflate(R.layout.search_row_item, parent, false) + return ViewHolder(v) + } + + override fun onBindViewHolder( + holder: SearchRecyclerViewAdapter.ViewHolder, + position: Int, + ) { + val (file, matchResult) = getItem(position) + + val colorPreference = + (AppConfig.getInstance().mainActivityContext as MainActivity).currentColorPreference + + val fileName = SpannableString(file.name) + fileName.setSpan( + ForegroundColorSpan(colorPreference.accent), + matchResult.first, + matchResult.last + 1, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + + holder.fileNameTV.text = fileName + holder.filePathTV.text = file.path.substring(0, file.path.lastIndexOf("/")) + + holder.colorView.setBackgroundColor(getRandomColor(holder.colorView.context)) + + if (file.isDirectory) { + holder.colorView.setBackgroundColor(colorPreference.primaryFirstTab) + } else { + holder.colorView.setBackgroundColor(colorPreference.accent) + } + } + + inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val fileNameTV: AppCompatTextView + val filePathTV: AppCompatTextView + val colorView: View + + init { + + fileNameTV = view.findViewById(R.id.searchItemFileNameTV) + filePathTV = view.findViewById(R.id.searchItemFilePathTV) + colorView = view.findViewById(R.id.searchItemSampleColorView) + + view.setOnClickListener { + + val (file, _) = getItem(adapterPosition) + + if (!file.isDirectory) { + file.openFile( + AppConfig.getInstance().mainActivityContext as MainActivity?, + false, + ) + } else { + (AppConfig.getInstance().mainActivityContext as MainActivity?) + ?.goToMain(file.path) + } + + (AppConfig.getInstance().mainActivityContext as MainActivity?) + ?.appbar?.searchView?.hideSearchView() + } + } + } + + private fun getRandomColor(context: Context): Int { + return ContextCompat.getColor( + context, + ColorPreference.availableColors[ + Random().nextInt( + ColorPreference.availableColors.size - 1, + ), + ], + ) + } +} diff --git a/app/src/main/java/com/amaze/filemanager/adapters/data/AppDataParcelable.kt b/app/src/main/java/com/amaze/filemanager/adapters/data/AppDataParcelable.kt new file mode 100644 index 0000000..2ea826f --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/adapters/data/AppDataParcelable.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters.data + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +@Suppress("LongParameterList") +class AppDataParcelable( + var label: String, + var path: String, + var splitPathList: List?, + var packageName: String, + var data: String, + var fileSize: String, + var size: Long, + var lastModification: Long, + var isSystemApp: Boolean, + var openFileParcelable: OpenFileParcelable?, +) : Parcelable diff --git a/app/src/main/java/com/amaze/filemanager/adapters/data/AppDataSorter.kt b/app/src/main/java/com/amaze/filemanager/adapters/data/AppDataSorter.kt new file mode 100644 index 0000000..93a8219 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/adapters/data/AppDataSorter.kt @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters.data + +import com.amaze.filemanager.utils.safeLet + +class AppDataSorter(var sort: Int, isAscending: Boolean) : + Comparator { + private val asc: Int = if (isAscending) 1 else -1 + + /** + * Compares two elements and return negative, zero and positive integer if first argument is + * less than, equal to or greater than second + */ + override fun compare( + file1: AppDataParcelable?, + file2: AppDataParcelable?, + ): Int { + safeLet(file1, file2) { + f1, f2 -> + if (f1.isSystemApp != f2.isSystemApp) { + return if (f1.isSystemApp) -1 else 1 + } + + when (sort) { + SORT_NAME -> { + // sort by name + return asc * f1.label.compareTo(f2.label, ignoreCase = true) + } + SORT_MODIF -> { + // sort by last modified + return asc * + java.lang.Long.valueOf(f1.lastModification) + .compareTo(f2.lastModification) + } + SORT_SIZE -> { + // sort by size + return asc * java.lang.Long.valueOf(f1.size).compareTo(f2.size) + } + else -> return 0 + } + } + return 0 + } + + companion object { + const val SORT_NAME = 0 + const val SORT_MODIF = 1 + const val SORT_SIZE = 2 + } +} diff --git a/app/src/main/java/com/amaze/filemanager/adapters/data/CompressedObjectParcelable.java b/app/src/main/java/com/amaze/filemanager/adapters/data/CompressedObjectParcelable.java new file mode 100644 index 0000000..c9332c1 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/adapters/data/CompressedObjectParcelable.java @@ -0,0 +1,164 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters.data; + +import java.util.Comparator; + +import com.amaze.filemanager.ui.icons.Icons; + +import android.os.Parcel; +import android.os.Parcelable; + +/** + * @author Emmanuel Messulam on 20/11/2017, at 15:26. + */ +public class CompressedObjectParcelable implements Parcelable { + public static final int TYPE_GOBACK = -1, TYPE_NORMAL = 0; + + public final boolean directory; + public final int type; + public final String path; + public final String name; + public final long date, size; + public final int filetype; + public final IconDataParcelable iconData; + + public CompressedObjectParcelable(String path, long date, long size, boolean directory) { + this.directory = directory; + this.type = TYPE_NORMAL; + this.path = path; + this.name = getNameForPath(path); + this.date = date; + this.size = size; + this.filetype = Icons.getTypeOfFile(path, directory); + this.iconData = + new IconDataParcelable(IconDataParcelable.IMAGE_RES, Icons.loadMimeIcon(path, directory)); + } + + /** TYPE_GOBACK instance */ + public CompressedObjectParcelable() { + this.directory = true; + this.type = TYPE_GOBACK; + this.path = null; + this.name = null; + this.date = 0; + this.size = 0; + this.filetype = -1; + this.iconData = null; + } + + @Override + public int describeContents() { + return 0; + } + + public void writeToParcel(Parcel p1, int p2) { + p1.writeInt(type); + if (type != TYPE_GOBACK) { + p1.writeInt(directory ? 1 : 0); + p1.writeString(path); + p1.writeString(name); + p1.writeLong(size); + p1.writeLong(date); + p1.writeInt(filetype); + p1.writeParcelable(iconData, 0); + } + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + public CompressedObjectParcelable createFromParcel(Parcel in) { + return new CompressedObjectParcelable(in); + } + + public CompressedObjectParcelable[] newArray(int size) { + return new CompressedObjectParcelable[size]; + } + }; + + private CompressedObjectParcelable(Parcel im) { + type = im.readInt(); + if (type == TYPE_GOBACK) { + directory = true; + path = null; + name = null; + date = 0; + size = 0; + filetype = -1; + iconData = null; + } else { + directory = im.readInt() == 1; + path = im.readString(); + name = im.readString(); + size = im.readLong(); + date = im.readLong(); + filetype = im.readInt(); + iconData = im.readParcelable(IconDataParcelable.class.getClassLoader()); + } + } + + public static class Sorter implements Comparator { + @Override + public int compare(CompressedObjectParcelable file1, CompressedObjectParcelable file2) { + if (file1.type == CompressedObjectParcelable.TYPE_GOBACK) return -1; + else if (file2.type == CompressedObjectParcelable.TYPE_GOBACK) return 1; + else if (file1.directory && !file2.directory) { + return -1; + } else if (file2.directory && !(file1).directory) { + return 1; + } else return file1.path.compareToIgnoreCase(file2.path); + } + } + + private String getNameForPath(String path) { + if (path.isEmpty()) return ""; + + final StringBuilder stringBuilder = new StringBuilder(path); + if (stringBuilder.charAt(path.length() - 1) == '/') + stringBuilder.deleteCharAt(path.length() - 1); + + try { + return stringBuilder.substring(stringBuilder.lastIndexOf("/") + 1); + } catch (StringIndexOutOfBoundsException e) { + return path.substring(0, path.lastIndexOf("/")); + } + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof CompressedObjectParcelable) { + CompressedObjectParcelable otherObj = (CompressedObjectParcelable) obj; + return name.equals(otherObj.name) + && type == otherObj.type + && directory == otherObj.directory + && size == otherObj.size; + } else return false; + } + + @Override + public int hashCode() { + int result = (directory ? 1 : 0); + result = 31 * result + type; + result = 31 * result + name.hashCode(); + result = 31 * result + (int) (size ^ (size >>> 32)); + return result; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/adapters/data/IconDataParcelable.java b/app/src/main/java/com/amaze/filemanager/adapters/data/IconDataParcelable.java new file mode 100644 index 0000000..4a34d93 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/adapters/data/IconDataParcelable.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters.data; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.DrawableRes; + +/** + * Saves data on what should be loaded as an icon for LayoutElementParcelable + * + * @author Emmanuel Messulam on 6/12/2017, at 17:52. + */ +public class IconDataParcelable implements Parcelable { + + public static final int IMAGE_RES = 0, IMAGE_FROMFILE = 1, IMAGE_FROMCLOUD = 2; + + public final int type; + public final String path; + public final @DrawableRes int image; + public final @DrawableRes int loadingImage; + private boolean isImageBroken = false; + + public IconDataParcelable(int type, @DrawableRes int img) { + if (type == IMAGE_FROMFILE) throw new IllegalArgumentException(); + this.type = type; + this.image = img; + this.loadingImage = -1; + this.path = null; + } + + public IconDataParcelable(int type, String path, @DrawableRes int loadingImages) { + if (type == IMAGE_RES) throw new IllegalArgumentException(); + this.type = type; + this.path = path; + this.loadingImage = loadingImages; + this.image = -1; + } + + public boolean isImageBroken() { + return isImageBroken; + } + + public void setImageBroken(boolean imageBroken) { + isImageBroken = imageBroken; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel parcel, int i) { + parcel.writeInt(type); + parcel.writeString(path); + parcel.writeInt(image); + parcel.writeInt(loadingImage); + parcel.writeInt(isImageBroken ? 1 : 0); + } + + public IconDataParcelable(Parcel im) { + type = im.readInt(); + path = im.readString(); + image = im.readInt(); + loadingImage = im.readInt(); + isImageBroken = im.readInt() == 1; + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + public IconDataParcelable createFromParcel(Parcel in) { + return new IconDataParcelable(in); + } + + public IconDataParcelable[] newArray(int size) { + return new IconDataParcelable[size]; + } + }; +} diff --git a/app/src/main/java/com/amaze/filemanager/adapters/data/LayoutElementParcelable.java b/app/src/main/java/com/amaze/filemanager/adapters/data/LayoutElementParcelable.java new file mode 100644 index 0000000..82539ce --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/adapters/data/LayoutElementParcelable.java @@ -0,0 +1,300 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters.data; + +import java.io.File; +import java.util.Calendar; + +import com.amaze.filemanager.fileoperations.filesystem.OpenMode; +import com.amaze.filemanager.filesystem.HybridFileParcelable; +import com.amaze.filemanager.filesystem.files.sort.ComparableParcelable; +import com.amaze.filemanager.ui.icons.Icons; +import com.amaze.filemanager.utils.Utils; + +import android.content.Context; +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; + +public class LayoutElementParcelable implements Parcelable, ComparableParcelable { + + private static final String CURRENT_YEAR = + String.valueOf(Calendar.getInstance().get(Calendar.YEAR)); + + public final boolean isBack; + public final int filetype; + public final IconDataParcelable iconData; + public final String title; + public final String desc; + public final String permissions; + public final String symlink; + public String size; + public boolean isDirectory; + public long date, longSize; + public String dateModification; + public final boolean header; + + // same as hfile.modes but different than openmode in Main.java + private OpenMode mode = OpenMode.FILE; + + public LayoutElementParcelable( + @NonNull Context c, boolean isBack, String goback, boolean showThumbs) { + this( + c, + true, + new File("..").getName(), + "..", + "", + "", + goback, + 0, + false, + "", + true, + showThumbs, + OpenMode.UNKNOWN); + } + + public LayoutElementParcelable( + @NonNull Context c, + String path, + String permissions, + String symlink, + String size, + long longSize, + boolean header, + String date, + boolean isDirectory, + boolean useThumbs, + OpenMode openMode) { + this( + c, + new File(path).getName(), + path, + permissions, + symlink, + size, + longSize, + header, + date, + isDirectory, + useThumbs, + openMode); + } + + public LayoutElementParcelable( + @NonNull Context c, + String title, + String path, + String permissions, + String symlink, + String size, + long longSize, + boolean header, + String date, + boolean isDirectory, + boolean useThumbs, + OpenMode openMode) { + this( + c, + false, + title, + path, + permissions, + symlink, + size, + longSize, + header, + date, + isDirectory, + useThumbs, + openMode); + } + + public LayoutElementParcelable( + @NonNull Context c, + boolean isBack, + String title, + String path, + String permissions, + String symlink, + String size, + long longSize, + boolean header, + String date, + boolean isDirectory, + boolean useThumbs, + OpenMode openMode) { + filetype = Icons.getTypeOfFile(path, isDirectory); + @DrawableRes int fallbackIcon = Icons.loadMimeIcon(path, isDirectory); + this.mode = openMode; + if (useThumbs) { + switch (mode) { + case SMB: + case SFTP: + case DROPBOX: + case GDRIVE: + case ONEDRIVE: + case BOX: + if (!isDirectory + && (filetype == Icons.IMAGE || filetype == Icons.VIDEO || filetype == Icons.APK)) { + this.iconData = + new IconDataParcelable(IconDataParcelable.IMAGE_FROMCLOUD, path, fallbackIcon); + } else { + this.iconData = new IconDataParcelable(IconDataParcelable.IMAGE_RES, fallbackIcon); + } + break; + // Until we find a way to properly handle threading issues with thread unsafe FTPClient, + // we refrain from loading any files via FTP as file thumbnail. - TranceLove + case FTP: + this.iconData = new IconDataParcelable(IconDataParcelable.IMAGE_RES, fallbackIcon); + break; + default: + if (filetype == Icons.IMAGE || filetype == Icons.VIDEO || filetype == Icons.APK) { + this.iconData = + new IconDataParcelable(IconDataParcelable.IMAGE_FROMFILE, path, fallbackIcon); + } else { + this.iconData = new IconDataParcelable(IconDataParcelable.IMAGE_RES, fallbackIcon); + } + } + } else { + this.iconData = new IconDataParcelable(IconDataParcelable.IMAGE_RES, fallbackIcon); + } + + this.title = title; + this.desc = path; + this.permissions = permissions.trim(); + this.symlink = symlink.trim(); + this.size = size; + this.header = header; + this.longSize = longSize; + this.isDirectory = isDirectory; + if (!date.trim().equals("")) { + this.date = Long.parseLong(date); + this.dateModification = Utils.getDate(c, this.date); + } else { + this.date = 0; + this.dateModification = ""; + } + this.isBack = isBack; + } + + public OpenMode getMode() { + return mode; + } + + public void setMode(OpenMode mode) { + this.mode = mode; + } + + public HybridFileParcelable generateBaseFile() { + HybridFileParcelable baseFile = + new HybridFileParcelable(desc, permissions, date, longSize, isDirectory); + baseFile.setMode(mode); + baseFile.setName(title); + return baseFile; + } + + public boolean hasSymlink() { + return symlink != null && symlink.length() != 0; + } + + @Override + public String toString() { + return title + "\n" + desc; + } + + // Hopefully it should be safe - nobody else is using this + public LayoutElementParcelable(Parcel im) { + filetype = im.readInt(); + iconData = im.readParcelable(IconDataParcelable.class.getClassLoader()); + title = im.readString(); + desc = im.readString(); + permissions = im.readString(); + symlink = im.readString(); + int j = im.readInt(); + date = im.readLong(); + int i = im.readInt(); + header = i != 0; + isDirectory = j != 0; + dateModification = im.readString(); + size = im.readString(); + longSize = im.readLong(); + isBack = im.readInt() != 0; + } + + @Override + public int describeContents() { + // TODO: Implement this method + return 0; + } + + @Override + public void writeToParcel(Parcel p1, int p2) { + p1.writeInt(filetype); + p1.writeParcelable(iconData, 0); + p1.writeString(title); + p1.writeString(desc); + p1.writeString(permissions); + p1.writeString(symlink); + p1.writeInt(isDirectory ? 1 : 0); + p1.writeLong(date); + p1.writeInt(header ? 1 : 0); + p1.writeString(dateModification); + p1.writeString(size); + p1.writeLong(longSize); + p1.writeInt(isBack ? 1 : 0); + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + public LayoutElementParcelable createFromParcel(Parcel in) { + return new LayoutElementParcelable(in); + } + + public LayoutElementParcelable[] newArray(int size) { + return new LayoutElementParcelable[size]; + } + }; + + @Override + public boolean isDirectory() { + return isDirectory; + } + + @NonNull + @Override + public String getParcelableName() { + return title; + } + + @Override + public long getDate() { + return date; + } + + @Override + public long getSize() { + return longSize; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/adapters/data/OpenFileParcelable.kt b/app/src/main/java/com/amaze/filemanager/adapters/data/OpenFileParcelable.kt new file mode 100644 index 0000000..8561815 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/adapters/data/OpenFileParcelable.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters.data + +import android.net.Uri +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class OpenFileParcelable( + var uri: Uri?, + var mimeType: String?, + var useNewStack: Boolean?, + var className: String?, + var packageName: String?, +) : Parcelable diff --git a/app/src/main/java/com/amaze/filemanager/adapters/data/StorageDirectoryParcelable.kt b/app/src/main/java/com/amaze/filemanager/adapters/data/StorageDirectoryParcelable.kt new file mode 100644 index 0000000..db94954 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/adapters/data/StorageDirectoryParcelable.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters.data + +import android.os.Parcel +import android.os.Parcelable +import androidx.annotation.DrawableRes + +/** Identifies a mounted volume */ +data class StorageDirectoryParcelable( + @JvmField + val path: String, + @JvmField + val name: String, + @JvmField + @DrawableRes + val iconRes: Int, +) : Parcelable { + constructor(im: Parcel) : this( + path = im.readString()!!, + name = im.readString()!!, + iconRes = im.readInt(), + ) + + override fun describeContents() = 0 + + override fun writeToParcel( + parcel: Parcel, + i: Int, + ) { + parcel.writeString(path) + parcel.writeString(name) + parcel.writeInt(iconRes) + } + + companion object { + @JvmField + val CREATOR = + object : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): StorageDirectoryParcelable { + return StorageDirectoryParcelable(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/adapters/glide/AppsAdapterPreloadModel.java b/app/src/main/java/com/amaze/filemanager/adapters/glide/AppsAdapterPreloadModel.java new file mode 100644 index 0000000..6651336 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/adapters/glide/AppsAdapterPreloadModel.java @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters.glide; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.amaze.filemanager.R; +import com.bumptech.glide.Glide; +import com.bumptech.glide.ListPreloader; +import com.bumptech.glide.RequestBuilder; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.graphics.drawable.Drawable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatImageView; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; + +/** + * @author Emmanuel Messulam on 10/12/2017, at 15:38. + */ +public class AppsAdapterPreloadModel implements ListPreloader.PreloadModelProvider { + + private final Logger LOG = LoggerFactory.getLogger(AppsAdapterPreloadModel.class); + + private Context mContext; + private RequestBuilder request; + private List items; + private boolean isBottomSheet; + + public AppsAdapterPreloadModel(Fragment f, boolean isBottomSheet) { + request = Glide.with(f).asDrawable().fitCenter(); + this.mContext = f.requireContext(); + this.isBottomSheet = isBottomSheet; + } + + public void addItem(String item) { + if (items == null) { + items = new ArrayList<>(); + } + items.add(item); + } + + @NonNull + @Override + public List getPreloadItems(int position) { + if (items == null) return Collections.emptyList(); + else return Collections.singletonList(items.get(position)); + } + + @Nullable + @Override + public RequestBuilder getPreloadRequestBuilder(String item) { + if (isBottomSheet) { + return request.clone().load(getApplicationIconFromPackageName(item)); + } else { + return request.clone().load(item); + } + } + + public void loadApkImage(String item, AppCompatImageView v) { + if (isBottomSheet) { + request.load(getApplicationIconFromPackageName(item)).into(v); + } else { + request.load(item).into(v); + } + } + + private Drawable getApplicationIconFromPackageName(String packageName) { + try { + return mContext.getPackageManager().getApplicationIcon(packageName); + } catch (PackageManager.NameNotFoundException e) { + LOG.warn(getClass().getSimpleName(), e); + return ContextCompat.getDrawable(mContext, R.drawable.ic_broken_image_white_24dp); + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/adapters/glide/RecyclerPreloadModelProvider.java b/app/src/main/java/com/amaze/filemanager/adapters/glide/RecyclerPreloadModelProvider.java new file mode 100644 index 0000000..a892be0 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/adapters/glide/RecyclerPreloadModelProvider.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters.glide; + +import java.util.Collections; +import java.util.List; + +import com.amaze.filemanager.adapters.data.IconDataParcelable; +import com.bumptech.glide.Glide; +import com.bumptech.glide.ListPreloader; +import com.bumptech.glide.RequestBuilder; +import com.bumptech.glide.load.engine.DiskCacheStrategy; + +import android.graphics.drawable.Drawable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +/** + * @author Emmanuel Messulam on 6/12/2017, at 15:15. + */ +public class RecyclerPreloadModelProvider + implements ListPreloader.PreloadModelProvider { + + private final List urisToLoad; + private final RequestBuilder request; + + public RecyclerPreloadModelProvider( + @NonNull Fragment fragment, @NonNull List uris, boolean isCircled) { + urisToLoad = uris; + RequestBuilder incompleteRequest = Glide.with(fragment).asDrawable(); + + if (isCircled) { + request = incompleteRequest.circleCrop(); + } else { + request = incompleteRequest.centerCrop(); + } + } + + @Override + @NonNull + public List getPreloadItems(int position) { + IconDataParcelable iconData = position < urisToLoad.size() ? urisToLoad.get(position) : null; + if (iconData == null) return Collections.emptyList(); + return Collections.singletonList(iconData); + } + + @Override + @Nullable + public RequestBuilder getPreloadRequestBuilder(IconDataParcelable iconData) { + RequestBuilder requestBuilder; + if (iconData.type == IconDataParcelable.IMAGE_FROMFILE) { + requestBuilder = request.load(iconData.path); + } else if (iconData.type == IconDataParcelable.IMAGE_FROMCLOUD) { + requestBuilder = request.load(iconData.path).diskCacheStrategy(DiskCacheStrategy.NONE); + } else { + requestBuilder = request.load(iconData.image); + } + return requestBuilder; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/adapters/glide/RecyclerPreloadSizeProvider.java b/app/src/main/java/com/amaze/filemanager/adapters/glide/RecyclerPreloadSizeProvider.java new file mode 100644 index 0000000..5bb868a --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/adapters/glide/RecyclerPreloadSizeProvider.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters.glide; + +import com.amaze.filemanager.adapters.data.IconDataParcelable; +import com.bumptech.glide.ListPreloader; +import com.bumptech.glide.request.target.SizeReadyCallback; +import com.bumptech.glide.request.target.ViewTarget; +import com.bumptech.glide.request.transition.Transition; + +import android.util.SparseArray; +import android.view.View; + +import androidx.annotation.Nullable; + +/** + * This uses a callback to know for each position what View is the one in which you're going to + * insert the image. + * + * @author Emmanuel Messulam on 10/12/2017, at 12:27. + */ +public class RecyclerPreloadSizeProvider + implements ListPreloader.PreloadSizeProvider { + + private RecyclerPreloadSizeProviderCallback callback; + private SparseArray viewSizes = new SparseArray<>(); + private boolean isAdditionClosed = false; + + public RecyclerPreloadSizeProvider(RecyclerPreloadSizeProviderCallback c) { + callback = c; + } + + /** + * Adds one of the views that can be used to put an image inside. If the id is already inserted + * the call will be ignored, but for performance you should call {@link #closeOffAddition()} once + * you are done. + * + * @param id a unique number for each view loaded to this object + * @param v the ciew to load + */ + public void addView(int id, View v) { + if (!isAdditionClosed && viewSizes.get(id, null) != null) return; + + final int viewNumber = id; + new SizeViewTarget( + v, (width, height) -> viewSizes.append(viewNumber, new int[] {width, height})); + } + + /** Calls to {@link #addView(int, View)} will be ignored */ + public void closeOffAddition() { + isAdditionClosed = true; + } + + @Nullable + @Override + public int[] getPreloadSize(IconDataParcelable item, int adapterPosition, int perItemPosition) { + return viewSizes.get(callback.getCorrectView(item, adapterPosition), null); + } + + public interface RecyclerPreloadSizeProviderCallback { + + /** + * Get the id for the view in which the image will be loaded. + * + * @return the view's id + */ + int getCorrectView(IconDataParcelable item, int adapterPosition); + } + + private static final class SizeViewTarget extends ViewTarget { + public SizeViewTarget(View view, SizeReadyCallback callback) { + super(view); + getSize(callback); + } + + @Override + public void onResourceReady(Object resource, Transition transition) { + // Do nothing + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/adapters/glide/apkimage/ApkImageDataFetcher.java b/app/src/main/java/com/amaze/filemanager/adapters/glide/apkimage/ApkImageDataFetcher.java new file mode 100644 index 0000000..1cb4ca2 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/adapters/glide/apkimage/ApkImageDataFetcher.java @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters.glide.apkimage; + +import com.amaze.filemanager.R; +import com.bumptech.glide.Priority; +import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.data.DataFetcher; + +import android.content.Context; +import android.content.pm.PackageInfo; +import android.graphics.drawable.Drawable; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; + +/** + * @author Emmanuel Messulam on 10/12/2017, at 16:12. + */ +public class ApkImageDataFetcher implements DataFetcher { + + private Context context; + private String model; + + public ApkImageDataFetcher(Context context, String model) { + this.context = context; + this.model = model; + } + + @Override + public void loadData(Priority priority, DataCallback callback) { + PackageInfo pi = context.getPackageManager().getPackageArchiveInfo(model, 0); + Drawable apkIcon; + if (pi != null) { + pi.applicationInfo.sourceDir = model; + pi.applicationInfo.publicSourceDir = model; + apkIcon = pi.applicationInfo.loadIcon(context.getPackageManager()); + } else { + apkIcon = ContextCompat.getDrawable(context, R.drawable.ic_android_white_24dp); + } + callback.onDataReady(apkIcon); + } + + @Override + public void cleanup() { + // Intentionally empty only because we're not opening an InputStream or another I/O resource! + } + + @Override + public void cancel() { + // No cancelation procedure + } + + @NonNull + @Override + public Class getDataClass() { + return Drawable.class; + } + + @NonNull + @Override + public DataSource getDataSource() { + return DataSource.LOCAL; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/adapters/glide/apkimage/ApkImageModelLoader.java b/app/src/main/java/com/amaze/filemanager/adapters/glide/apkimage/ApkImageModelLoader.java new file mode 100644 index 0000000..6cf0c89 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/adapters/glide/apkimage/ApkImageModelLoader.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters.glide.apkimage; + +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.model.ModelLoader; +import com.bumptech.glide.signature.ObjectKey; + +import android.content.Context; +import android.graphics.drawable.Drawable; + +import androidx.annotation.Nullable; + +/** + * @author Emmanuel Messulam on 10/12/2017, at 16:06. + */ +public class ApkImageModelLoader implements ModelLoader { + + private Context context; + + public ApkImageModelLoader(Context context) { + this.context = context; + } + + @Nullable + @Override + public LoadData buildLoadData(String s, int width, int height, Options options) { + return new LoadData<>(new ObjectKey(s), new ApkImageDataFetcher(context, s)); + } + + @Override + public boolean handles(String s) { + return s.substring(s.length() - 4, s.length()).toLowerCase().equals(".apk"); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/adapters/glide/apkimage/ApkImageModelLoaderFactory.java b/app/src/main/java/com/amaze/filemanager/adapters/glide/apkimage/ApkImageModelLoaderFactory.java new file mode 100644 index 0000000..45c3df7 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/adapters/glide/apkimage/ApkImageModelLoaderFactory.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters.glide.apkimage; + +import com.bumptech.glide.load.model.ModelLoader; +import com.bumptech.glide.load.model.ModelLoaderFactory; +import com.bumptech.glide.load.model.MultiModelLoaderFactory; + +import android.content.Context; +import android.graphics.drawable.Drawable; + +/** + * @author Emmanuel Messulam on 10/12/2017, at 16:21. + */ +public class ApkImageModelLoaderFactory implements ModelLoaderFactory { + + private Context context; + + public ApkImageModelLoaderFactory(Context context) { + this.context = context; + } + + @Override + public ModelLoader build(MultiModelLoaderFactory multiFactory) { + return new ApkImageModelLoader(context); + } + + @Override + public void teardown() {} +} diff --git a/app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconDataFetcher.kt b/app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconDataFetcher.kt new file mode 100644 index 0000000..8e95b1f --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconDataFetcher.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters.glide.cloudicon + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Log +import com.amaze.filemanager.filesystem.cloud.CloudUtil +import com.bumptech.glide.Priority +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.data.DataFetcher +import java.io.IOException +import java.io.InputStream + +class CloudIconDataFetcher( + private val context: Context, + private val path: String, + private val width: Int, + private val height: Int, +) : DataFetcher { + companion object { + private val TAG = CloudIconDataFetcher::class.java.simpleName + } + + private var inputStream: InputStream? = null + + override fun loadData( + priority: Priority, + callback: DataFetcher.DataCallback, + ) { + inputStream = CloudUtil.getThumbnailInputStreamForCloud(context, path) + val options = + BitmapFactory.Options().also { + it.outWidth = width + it.outHeight = height + } + val drawable = BitmapFactory.decodeStream(inputStream, null, options) + callback.onDataReady(drawable) + } + + override fun cleanup() { + try { + inputStream?.close() + } catch (e: IOException) { + Log.e(TAG, "Error cleaning up cloud icon fetch", e) + } + } + + override fun cancel() = Unit + + override fun getDataClass(): Class = Bitmap::class.java + + override fun getDataSource(): DataSource = DataSource.REMOTE +} diff --git a/app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelFactory.java b/app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelFactory.java new file mode 100644 index 0000000..caedb7d --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelFactory.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters.glide.cloudicon; + +import com.bumptech.glide.load.model.ModelLoader; +import com.bumptech.glide.load.model.ModelLoaderFactory; +import com.bumptech.glide.load.model.MultiModelLoaderFactory; + +import android.content.Context; +import android.graphics.Bitmap; + +/** Created by Vishal Nehra on 3/27/2018. */ +public class CloudIconModelFactory implements ModelLoaderFactory { + + private Context context; + + public CloudIconModelFactory(Context context) { + this.context = context; + } + + @Override + public ModelLoader build(MultiModelLoaderFactory multiFactory) { + return new CloudIconModelLoader(context); + } + + @Override + public void teardown() {} +} diff --git a/app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelLoader.java b/app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelLoader.java new file mode 100644 index 0000000..33edfb7 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/adapters/glide/cloudicon/CloudIconModelLoader.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters.glide.cloudicon; + +import static com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.SSH_URI_PREFIX; +import static com.amaze.filemanager.filesystem.smb.CifsContexts.SMB_URI_PREFIX; + +import com.amaze.filemanager.database.CloudHandler; +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.model.ModelLoader; +import com.bumptech.glide.signature.ObjectKey; + +import android.content.Context; +import android.graphics.Bitmap; + +import androidx.annotation.Nullable; + +/** Created by Vishal Nehra on 3/27/2018. */ +public class CloudIconModelLoader implements ModelLoader { + + private final Context context; + + public CloudIconModelLoader(Context context) { + this.context = context; + } + + @Nullable + @Override + public LoadData buildLoadData(String s, int width, int height, Options options) { + // we put key as current time since we're not disk caching the images for cloud, + // as there is no way to differentiate input streams returned by different cloud services + // for future instances and they don't expose concrete paths either + return new LoadData<>( + new ObjectKey(System.currentTimeMillis()), + new CloudIconDataFetcher(context, s, width, height)); + } + + @Override + public boolean handles(String s) { + return s.startsWith(CloudHandler.CLOUD_PREFIX_BOX) + || s.startsWith(CloudHandler.CLOUD_PREFIX_DROPBOX) + || s.startsWith(CloudHandler.CLOUD_PREFIX_GOOGLE_DRIVE) + || s.startsWith(CloudHandler.CLOUD_PREFIX_ONE_DRIVE) + || s.startsWith(SMB_URI_PREFIX) + || s.startsWith(SSH_URI_PREFIX); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/adapters/holders/AppHolder.kt b/app/src/main/java/com/amaze/filemanager/adapters/holders/AppHolder.kt new file mode 100644 index 0000000..84e85ab --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/adapters/holders/AppHolder.kt @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters.holders + +import android.view.View +import android.view.ViewGroup +import android.widget.RelativeLayout +import androidx.appcompat.widget.AppCompatImageButton +import androidx.appcompat.widget.AppCompatImageView +import androidx.appcompat.widget.AppCompatTextView +import androidx.core.view.marginBottom +import androidx.core.view.marginLeft +import androidx.core.view.marginTop +import androidx.recyclerview.widget.RecyclerView +import com.amaze.filemanager.R +import com.amaze.filemanager.ui.views.ThemedTextView +import com.amaze.filemanager.utils.Utils + +class AppHolder(view: View) : RecyclerView.ViewHolder(view) { + @JvmField + val apkIcon: AppCompatImageView = view.findViewById(R.id.apk_icon) + + @JvmField + val txtTitle: ThemedTextView = view.findViewById(R.id.firstline) + + @JvmField + val rl: RelativeLayout = view.findViewById(R.id.second) + + @JvmField + val txtDesc: AppCompatTextView = view.findViewById(R.id.date) + + @JvmField + val about: AppCompatImageButton = view.findViewById(R.id.properties) + + @JvmField + val summary: RelativeLayout = view.findViewById(R.id.summary) + + @JvmField + val packageName: AppCompatTextView = view.findViewById(R.id.appManagerPackageName) + + init { + apkIcon.visibility = View.VISIBLE + packageName.visibility = View.VISIBLE + + val layoutParams = txtDesc.layoutParams as ViewGroup.MarginLayoutParams + layoutParams.setMargins( + txtDesc.marginLeft, + txtDesc.marginTop, + Utils.dpToPx(view.context, 4), + txtDesc.marginBottom, + ) + txtDesc.layoutParams = layoutParams + + view.findViewById(R.id.picture_icon).visibility = View.GONE + view.findViewById(R.id.generic_icon).visibility = View.GONE + } +} diff --git a/app/src/main/java/com/amaze/filemanager/adapters/holders/CompressedItemViewHolder.kt b/app/src/main/java/com/amaze/filemanager/adapters/holders/CompressedItemViewHolder.kt new file mode 100644 index 0000000..bf1dc4d --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/adapters/holders/CompressedItemViewHolder.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters.holders + +import android.view.View +import androidx.appcompat.widget.AppCompatImageView +import androidx.appcompat.widget.AppCompatTextView +import androidx.recyclerview.widget.RecyclerView +import com.amaze.filemanager.R +import com.amaze.filemanager.ui.views.ThemedTextView + +class CompressedItemViewHolder(view: View) : RecyclerView.ViewHolder(view) { + // each data item is just a string in this case + @JvmField + val pictureIcon: AppCompatImageView = view.findViewById(R.id.picture_icon) + + @JvmField + val genericIcon: AppCompatImageView = view.findViewById(R.id.generic_icon) + + @JvmField + val apkIcon: AppCompatImageView = view.findViewById(R.id.apk_icon) + + @JvmField + val txtTitle: ThemedTextView = view.findViewById(R.id.firstline) + + @JvmField + val txtDesc: AppCompatTextView = view.findViewById(R.id.secondLine) + + @JvmField + val date: AppCompatTextView = view.findViewById(R.id.date) + + val perm: AppCompatTextView = view.findViewById(R.id.permis) + + @JvmField + val rl: View = view.findViewById(R.id.second) + + @JvmField + val checkImageView: AppCompatImageView = view.findViewById(R.id.check_icon) +} diff --git a/app/src/main/java/com/amaze/filemanager/adapters/holders/DonationViewHolder.kt b/app/src/main/java/com/amaze/filemanager/adapters/holders/DonationViewHolder.kt new file mode 100644 index 0000000..21a7a22 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/adapters/holders/DonationViewHolder.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters.holders + +import android.view.View +import android.widget.LinearLayout +import androidx.appcompat.widget.AppCompatTextView +import androidx.recyclerview.widget.RecyclerView +import com.amaze.filemanager.R + +class DonationViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + @JvmField + val ROOT_VIEW: LinearLayout = itemView.findViewById(R.id.adapter_donation_root) + + @JvmField + val TITLE: AppCompatTextView = itemView.findViewById(R.id.adapter_donation_title) + + @JvmField + val SUMMARY: AppCompatTextView = itemView.findViewById(R.id.adapter_donation_summary) + + @JvmField + val PRICE: AppCompatTextView = itemView.findViewById(R.id.adapter_donation_price) +} diff --git a/app/src/main/java/com/amaze/filemanager/adapters/holders/EmptyViewHolder.kt b/app/src/main/java/com/amaze/filemanager/adapters/holders/EmptyViewHolder.kt new file mode 100644 index 0000000..62e7365 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/adapters/holders/EmptyViewHolder.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters.holders + +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +/** + * Check RecyclerAdapter's doc. + */ +class EmptyViewHolder(view: View) : RecyclerView.ViewHolder(view) diff --git a/app/src/main/java/com/amaze/filemanager/adapters/holders/HiddenViewHolder.kt b/app/src/main/java/com/amaze/filemanager/adapters/holders/HiddenViewHolder.kt new file mode 100644 index 0000000..23cdc01 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/adapters/holders/HiddenViewHolder.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters.holders + +import android.view.View +import android.widget.LinearLayout +import androidx.appcompat.widget.AppCompatImageButton +import androidx.appcompat.widget.AppCompatTextView +import androidx.recyclerview.widget.RecyclerView +import com.amaze.filemanager.R + +/** + * This is the ViewHolder that formats the hidden files as defined in bookmarkrow.xml. + * + * @see com.amaze.filemanager.adapters.HiddenAdapter + */ +class HiddenViewHolder(view: View) : RecyclerView.ViewHolder(view) { + @JvmField + val deleteButton: AppCompatImageButton = view.findViewById(R.id.delete_button) + + @JvmField + val textTitle: AppCompatTextView = view.findViewById(R.id.filename) + + @JvmField + val textDescription: AppCompatTextView = view.findViewById(R.id.file_path) + + @JvmField + val row: LinearLayout = view.findViewById(R.id.bookmarkrow) +} diff --git a/app/src/main/java/com/amaze/filemanager/adapters/holders/ItemViewHolder.kt b/app/src/main/java/com/amaze/filemanager/adapters/holders/ItemViewHolder.kt new file mode 100644 index 0000000..a0e1685 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/adapters/holders/ItemViewHolder.kt @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters.holders + +import android.view.View +import android.widget.RelativeLayout +import androidx.appcompat.widget.AppCompatImageButton +import androidx.appcompat.widget.AppCompatImageView +import androidx.appcompat.widget.AppCompatTextView +import androidx.recyclerview.widget.RecyclerView +import com.amaze.filemanager.R +import com.amaze.filemanager.ui.views.ThemedTextView + +/** + * Check RecyclerAdapter's doc. TODO load everything related to this item here instead of in + * RecyclerAdapter. + */ +class ItemViewHolder(view: View) : RecyclerView.ViewHolder(view) { + // each data item is just a string in this case + @JvmField + val pictureIcon: AppCompatImageView? = view.findViewById(R.id.picture_icon) + + @JvmField + val genericIcon: AppCompatImageView = view.findViewById(R.id.generic_icon) + + @JvmField + val apkIcon: AppCompatImageView? = view.findViewById(R.id.apk_icon) + + @JvmField + val imageView1: AppCompatImageView? = view.findViewById(R.id.icon_thumb) + + @JvmField + val txtTitle: ThemedTextView = view.findViewById(R.id.firstline) + + @JvmField + val txtDesc: AppCompatTextView = view.findViewById(R.id.secondLine) + + @JvmField + val date: AppCompatTextView = view.findViewById(R.id.date) + + @JvmField + val perm: AppCompatTextView = view.findViewById(R.id.permis) + + @JvmField + val baseItemView: View = view.findViewById(R.id.second) + + @JvmField + val genericText: AppCompatTextView? = view.findViewById(R.id.generictext) + + @JvmField + val about: AppCompatImageButton = view.findViewById(R.id.properties) + + @JvmField + val checkImageView: AppCompatImageView? = view.findViewById(R.id.check_icon) + + @JvmField + val checkImageViewGrid: AppCompatImageView? = view.findViewById(R.id.check_icon_grid) + + @JvmField + val iconLayout: RelativeLayout? = view.findViewById(R.id.icon_frame_grid) + + @JvmField + val dummyView: View? = view.findViewById(R.id.dummy_view) +} diff --git a/app/src/main/java/com/amaze/filemanager/adapters/holders/SpecialViewHolder.kt b/app/src/main/java/com/amaze/filemanager/adapters/holders/SpecialViewHolder.kt new file mode 100644 index 0000000..f329e96 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/adapters/holders/SpecialViewHolder.kt @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.adapters.holders + +import android.content.Context +import android.view.View +import androidx.appcompat.widget.AppCompatTextView +import androidx.recyclerview.widget.RecyclerView +import com.amaze.filemanager.R +import com.amaze.filemanager.ui.provider.UtilitiesProvider +import com.amaze.filemanager.ui.theme.AppTheme +import com.amaze.filemanager.utils.Utils + +/** + * Check [com.amaze.filemanager.adapters.RecyclerAdapter]'s doc. + */ +class SpecialViewHolder( + c: Context, + view: View, + utilsProvider: UtilitiesProvider, + val type: Int, +) : RecyclerView.ViewHolder(view) { + // each data item is just a string in this case + private val txtTitle: AppCompatTextView = view.findViewById(R.id.text) + + companion object { + const val HEADER_FILES = 0 + const val HEADER_FOLDERS = 1 + const val HEADER_SYSTEM_APP = 2 + const val HEADER_USER_APP = 3 + } + + init { + when (type) { + HEADER_FILES -> txtTitle.setText(R.string.files) + HEADER_FOLDERS -> txtTitle.setText(R.string.folders) + HEADER_SYSTEM_APP -> txtTitle.setText(R.string.system_apps) + HEADER_USER_APP -> txtTitle.setText(R.string.user_apps) + else -> throw IllegalStateException(": $type") + } + + // if(utilsProvider.getAppTheme().equals(AppTheme.DARK)) + // view.setBackgroundResource(R.color.holo_dark_background); + if (utilsProvider.appTheme == AppTheme.LIGHT) { + txtTitle.setTextColor(Utils.getColor(c, R.color.text_light)) + } else { + txtTitle.setTextColor(Utils.getColor(c, R.color.text_dark)) + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/application/AppConfig.java b/app/src/main/java/com/amaze/filemanager/application/AppConfig.java new file mode 100644 index 0000000..06a7a2b --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/application/AppConfig.java @@ -0,0 +1,323 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.application; + +import java.io.File; +import java.lang.ref.WeakReference; +import java.util.concurrent.Callable; + +import org.acra.ACRA; +import org.acra.annotation.AcraCore; +import org.acra.config.ACRAConfigurationException; +import org.acra.config.CoreConfiguration; +import org.acra.config.CoreConfigurationBuilder; +import org.acra.data.StringFormat; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.amaze.filemanager.BuildConfig; +import com.amaze.filemanager.R; +import com.amaze.filemanager.crashreport.AcraReportSenderFactory; +import com.amaze.filemanager.crashreport.ErrorActivity; +import com.amaze.filemanager.database.ExplorerDatabase; +import com.amaze.filemanager.database.UtilitiesDatabase; +import com.amaze.filemanager.database.UtilsHandler; +import com.amaze.filemanager.fileoperations.exceptions.ShellNotRunningException; +import com.amaze.filemanager.fileoperations.filesystem.OpenMode; +import com.amaze.filemanager.filesystem.HybridFile; +import com.amaze.filemanager.filesystem.ssh.CustomSshJConfig; +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants; +import com.amaze.filemanager.ui.provider.UtilitiesProvider; +import com.amaze.filemanager.utils.ScreenUtils; +import com.amaze.trashbin.TrashBin; +import com.amaze.trashbin.TrashBinConfig; + +import android.app.Activity; +import android.app.Application; +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Environment; +import android.os.StrictMode; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.app.AppCompatDelegate; +import androidx.preference.PreferenceManager; + +import io.reactivex.Completable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.schedulers.Schedulers; +import jcifs.Config; +import jcifs.smb.SmbException; + +@AcraCore( + buildConfigClass = BuildConfig.class, + reportSenderFactoryClasses = AcraReportSenderFactory.class) +public class AppConfig extends GlideApplication { + + private Logger log = null; + + private UtilitiesProvider utilsProvider; + private UtilsHandler utilsHandler; + + private WeakReference mainActivityContext; + private static ScreenUtils screenUtils; + + private static AppConfig instance; + + private UtilitiesDatabase utilitiesDatabase; + + private ExplorerDatabase explorerDatabase; + + private TrashBinConfig trashBinConfig; + private TrashBin trashBin; + private static final String TRASH_BIN_BASE_PATH = + Environment.getExternalStorageDirectory().getPath() + File.separator + ".AmazeData"; + + public UtilitiesProvider getUtilsProvider() { + return utilsProvider; + } + + @Override + public void onCreate() { + super.onCreate(); + AppCompatDelegate.setCompatVectorFromResourcesEnabled( + true); // selector in srcCompat isn't supported without this + instance = this; + + CustomSshJConfig.init(); + explorerDatabase = ExplorerDatabase.initialize(this); + utilitiesDatabase = UtilitiesDatabase.initialize(this); + + utilsProvider = new UtilitiesProvider(this); + utilsHandler = new UtilsHandler(this, utilitiesDatabase); + + runInBackground(Config::registerSmbURLHandler); + + // disabling file exposure method check for api n+ + StrictMode.VmPolicy.Builder builder = new StrictMode.VmPolicy.Builder(); + StrictMode.setVmPolicy(builder.build()); + log = LoggerFactory.getLogger(AppConfig.class); + } + + @Override + protected void attachBaseContext(Context base) { + super.attachBaseContext(base); + initACRA(); + } + + @Override + public void onTerminate() { + super.onTerminate(); + } + + /** + * Post a runnable to handler. Use this in case we don't have any restriction to execute after + * this runnable is executed, and {@link #runInBackground(Runnable)} in case we need to execute + * something after execution in background + */ + public void runInBackground(Runnable runnable) { + Completable.fromRunnable(runnable).subscribeOn(Schedulers.io()).subscribe(); + } + + /** + * Shows a toast message + * + * @param context Any context belonging to this application + * @param message The message to show + */ + public static void toast(Context context, @StringRes int message) { + // this is a static method so it is easier to call, + // as the context checking and casting is done for you + + if (context == null) return; + + if (!(context instanceof Application)) { + context = context.getApplicationContext(); + } + + if (context instanceof Application) { + final Context c = context; + final @StringRes int m = message; + + getInstance().runInApplicationThread(() -> Toast.makeText(c, m, Toast.LENGTH_LONG).show()); + } + } + + /** + * Shows a toast message + * + * @param context Any context belonging to this application + * @param message The message to show + */ + public static void toast(Context context, String message) { + // this is a static method so it is easier to call, + // as the context checking and casting is done for you + + if (context == null) return; + + if (!(context instanceof Application)) { + context = context.getApplicationContext(); + } + + if (context instanceof Application) { + final Context c = context; + final String m = message; + + getInstance().runInApplicationThread(() -> Toast.makeText(c, m, Toast.LENGTH_LONG).show()); + } + } + + /** + * Run a {@link Runnable} in the main application thread + * + * @param r {@link Runnable} to run + */ + public void runInApplicationThread(@NonNull Runnable r) { + Completable.fromRunnable(r).subscribeOn(AndroidSchedulers.mainThread()).subscribe(); + } + + /** + * Convenience method to run a {@link Callable} in the main application thread. Use when the + * callable's return value is not processed. + * + * @param c {@link Callable} to run + */ + public void runInApplicationThread(@NonNull Callable c) { + Completable.fromCallable(c).subscribeOn(AndroidSchedulers.mainThread()).subscribe(); + } + + public static synchronized AppConfig getInstance() { + return instance; + } + + public UtilsHandler getUtilsHandler() { + return utilsHandler; + } + + public void setMainActivityContext(@NonNull Activity activity) { + mainActivityContext = new WeakReference<>(activity); + screenUtils = new ScreenUtils(activity); + } + + public ScreenUtils getScreenUtils() { + return screenUtils; + } + + @Nullable + public Context getMainActivityContext() { + return mainActivityContext.get(); + } + + public ExplorerDatabase getExplorerDatabase() { + return explorerDatabase; + } + + public UtilitiesDatabase getUtilitiesDatabase() { + return utilitiesDatabase; + } + + /** + * Called in {@link #attachBaseContext(Context)} after calling the {@code super} method. Should be + * overridden if MultiDex is enabled, since it has to be initialized before ACRA. + */ + protected void initACRA() { + if (ACRA.isACRASenderServiceProcess()) { + return; + } + + try { + final CoreConfiguration acraConfig = + new CoreConfigurationBuilder(this) + .setBuildConfigClass(BuildConfig.class) + .setReportFormat(StringFormat.JSON) + .setSendReportsInDevMode(true) + .setEnabled(true) + .build(); + ACRA.init(this, acraConfig); + } catch (final ACRAConfigurationException ace) { + if (log != null) { + log.warn("failed to initialize ACRA", ace); + } + ErrorActivity.reportError( + this, + ace, + null, + ErrorActivity.ErrorInfo.make( + ErrorActivity.ERROR_UNKNOWN, + "Could not initialize ACRA crash report", + R.string.app_ui_crash)); + } + } + + public TrashBin getTrashBinInstance() { + if (trashBin == null) { + trashBin = + new TrashBin( + getApplicationContext(), + true, + getTrashBinConfig(), + s -> { + runInBackground( + () -> { + HybridFile file = new HybridFile(OpenMode.TRASH_BIN, s); + try { + file.delete(getMainActivityContext(), false); + } catch (ShellNotRunningException | SmbException e) { + log.warn("failed to delete file in trash bin cleanup", e); + } + }); + return true; + }, + null); + } + return trashBin; + } + + private TrashBinConfig getTrashBinConfig() { + if (trashBinConfig == null) { + SharedPreferences sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this); + + int days = + sharedPrefs.getInt( + PreferencesConstants.KEY_TRASH_BIN_RETENTION_DAYS, + TrashBinConfig.RETENTION_DAYS_INFINITE); + long bytes = + sharedPrefs.getLong( + PreferencesConstants.KEY_TRASH_BIN_RETENTION_BYTES, + TrashBinConfig.RETENTION_BYTES_INFINITE); + int numOfFiles = + sharedPrefs.getInt( + PreferencesConstants.KEY_TRASH_BIN_RETENTION_NUM_OF_FILES, + TrashBinConfig.RETENTION_NUM_OF_FILES); + int intervalHours = + sharedPrefs.getInt( + PreferencesConstants.KEY_TRASH_BIN_CLEANUP_INTERVAL_HOURS, + TrashBinConfig.INTERVAL_CLEANUP_HOURS); + trashBinConfig = + new TrashBinConfig( + TRASH_BIN_BASE_PATH, days, bytes, numOfFiles, intervalHours, false, true); + } + return trashBinConfig; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/application/GlideApplication.java b/app/src/main/java/com/amaze/filemanager/application/GlideApplication.java new file mode 100644 index 0000000..8f234dc --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/application/GlideApplication.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.application; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.MemoryCategory; + +import androidx.multidex.MultiDexApplication; + +/** + * @author Emmanuel on 22/11/2017, at 17:18. + */ +public class GlideApplication extends MultiDexApplication { + @Override + public void onCreate() { + super.onCreate(); + Glide.get(this).setMemoryCategory(MemoryCategory.HIGH); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/AbstractRepeatingRunnable.java b/app/src/main/java/com/amaze/filemanager/asynchronous/AbstractRepeatingRunnable.java new file mode 100644 index 0000000..0fe3a8a --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/AbstractRepeatingRunnable.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import androidx.annotation.NonNull; + +public abstract class AbstractRepeatingRunnable implements Runnable { + + protected final ScheduledFuture handle; + + public AbstractRepeatingRunnable( + long initialDelay, long period, @NonNull TimeUnit unit, boolean startImmediately) { + if (!startImmediately) { + throw new UnsupportedOperationException("RepeatingRunnables are immediately executed!"); + } + + ScheduledExecutorService threadExcecutor = Executors.newScheduledThreadPool(0); + handle = threadExcecutor.scheduleAtFixedRate(this, initialDelay, period, unit); + } + + public boolean isAlive() { + return !handle.isDone(); + } + + /** + * @param immediately sets if the cancellation occurt right now, or after the run() function + * returns + */ + public void cancel(boolean immediately) { + handle.cancel(immediately); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/SaveOnDataUtilsChange.java b/app/src/main/java/com/amaze/filemanager/asynchronous/SaveOnDataUtilsChange.java new file mode 100644 index 0000000..d19282c --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/SaveOnDataUtilsChange.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous; + +import java.lang.ref.WeakReference; + +import com.amaze.filemanager.application.AppConfig; +import com.amaze.filemanager.database.UtilsHandler; +import com.amaze.filemanager.database.models.OperationData; +import com.amaze.filemanager.ui.views.drawer.Drawer; +import com.amaze.filemanager.utils.DataUtils; + +import androidx.annotation.NonNull; + +public class SaveOnDataUtilsChange implements DataUtils.DataChangeListener { + private final UtilsHandler utilsHandler = AppConfig.getInstance().getUtilsHandler(); + + private final WeakReference drawer; + + public SaveOnDataUtilsChange(@NonNull Drawer drawer) { + this.drawer = new WeakReference<>(drawer); + } + + @Override + public void onHiddenFileAdded(String path) { + utilsHandler.saveToDatabase(new OperationData(UtilsHandler.Operation.HIDDEN, path)); + } + + @Override + public void onHiddenFileRemoved(String path) { + utilsHandler.removeFromDatabase(new OperationData(UtilsHandler.Operation.HIDDEN, path)); + } + + @Override + public void onHistoryAdded(String path) { + utilsHandler.saveToDatabase(new OperationData(UtilsHandler.Operation.HISTORY, path)); + } + + @Override + public void onBookAdded(String[] path, boolean refreshdrawer) { + utilsHandler.saveToDatabase( + new OperationData(UtilsHandler.Operation.BOOKMARKS, path[0], path[1])); + if (refreshdrawer) { + final Drawer drawer = this.drawer.get(); + if (drawer != null) { + drawer.refreshDrawer(); + } + } + } + + @Override + public void onHistoryCleared() { + utilsHandler.clearTable(UtilsHandler.Operation.HISTORY); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/AsyncTaskResult.java b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/AsyncTaskResult.java new file mode 100644 index 0000000..a3fff01 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/AsyncTaskResult.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks; + +/** + * Container for AsyncTask results. Allow either result object or exception to be contained. + * + * @param Result type + */ +public class AsyncTaskResult { + public final T result; + public final Throwable exception; + + public AsyncTaskResult(T result) { + this.result = result; + this.exception = null; + } + + public AsyncTaskResult(Throwable exception) { + this.result = null; + this.exception = exception; + } + + /** Callback interface for use in {@link android.os.AsyncTask}. Think Promise callback in JS. */ + public interface Callback { + + /** Implement logic on what to do with the result here. */ + void onResult(T result); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/CountItemsOrAndSizeTask.java b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/CountItemsOrAndSizeTask.java new file mode 100644 index 0000000..78d2d58 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/CountItemsOrAndSizeTask.java @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks; + +import java.util.concurrent.atomic.AtomicInteger; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.filesystem.HybridFileParcelable; +import com.amaze.filemanager.filesystem.files.FileUtils; + +import android.content.Context; +import android.os.AsyncTask; +import android.text.format.Formatter; + +import androidx.appcompat.widget.AppCompatTextView; +import androidx.core.util.Pair; + +/** + * @author Emmanuel on 12/5/2017, at 19:40. + */ +public class CountItemsOrAndSizeTask extends AsyncTask, String> { + + private Context context; + private AppCompatTextView itemsText; + private HybridFileParcelable file; + private boolean isStorage; + + public CountItemsOrAndSizeTask( + Context c, AppCompatTextView itemsText, HybridFileParcelable f, boolean storage) { + this.context = c; + this.itemsText = itemsText; + file = f; + isStorage = storage; + } + + @Override + protected String doInBackground(Void[] params) { + String items = ""; + long fileLength = file.length(context); + + if (file.isDirectory(context)) { + final AtomicInteger x = new AtomicInteger(0); + file.forEachChildrenFile(context, false, file -> x.incrementAndGet()); + final int folderLength = x.intValue(); + long folderSize; + + if (isStorage) { + folderSize = file.getUsableSpace(); + } else { + folderSize = + FileUtils.folderSize(file, data -> publishProgress(new Pair<>(folderLength, data))); + } + + items = getText(folderLength, folderSize, false); + } else { + items = + Formatter.formatFileSize(context, fileLength) + + (" (" + + fileLength + + " " + + context + .getResources() + .getQuantityString( + R.plurals.bytes, (int) fileLength) // truncation is insignificant + + ")"); + } + + return items; + } + + @Override + protected void onProgressUpdate(Pair[] dataArr) { + Pair data = dataArr[0]; + + itemsText.setText(getText(data.first, data.second, true)); + } + + private String getText(int filesInFolder, long length, boolean loading) { + String numOfItems = + (filesInFolder != 0 ? filesInFolder + " " : "") + + context.getResources().getQuantityString(R.plurals.items, filesInFolder); + + return numOfItems + "; " + (loading ? ">" : "") + Formatter.formatFileSize(context, length); + } + + protected void onPostExecute(String items) { + itemsText.setText(items); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/DbViewerTask.java b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/DbViewerTask.java new file mode 100644 index 0000000..eea2b96 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/DbViewerTask.java @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks; + +import java.util.ArrayList; + +import com.amaze.filemanager.ui.fragments.DbViewerFragment; +import com.amaze.filemanager.ui.theme.AppTheme; + +import android.database.Cursor; +import android.os.AsyncTask; +import android.view.View; +import android.webkit.WebView; + +/** Created by Vishal on 20-03-2015. */ +public class DbViewerTask extends AsyncTask { + + Cursor schemaCursor, contentCursor; + ArrayList schemaList; + ArrayList contentList; + DbViewerFragment dbViewerFragment; + StringBuilder stringBuilder; + WebView webView; + String htmlInit; + + public DbViewerTask( + Cursor schemaCursor, + Cursor contentCursor, + WebView webView, + DbViewerFragment dbViewerFragment) { + this.schemaCursor = schemaCursor; + this.contentCursor = contentCursor; + this.webView = webView; + this.dbViewerFragment = dbViewerFragment; + stringBuilder = new StringBuilder(); + + this.webView.getSettings().setDefaultTextEncodingName("utf-8"); + } + + @Override + protected void onPreExecute() { + super.onPreExecute(); + + if (dbViewerFragment.databaseViewerActivity.getAppTheme().equals(AppTheme.DARK) + || dbViewerFragment.databaseViewerActivity.getAppTheme().equals(AppTheme.BLACK)) { + + htmlInit = ""; + } else { + + htmlInit = "
"; + } + stringBuilder.append(htmlInit); + dbViewerFragment.loadingText.setVisibility(View.VISIBLE); + } + + @Override + protected void onProgressUpdate(Integer... values) { + super.onProgressUpdate(values); + + dbViewerFragment.loadingText.setText(values[0] + " records loaded"); + } + + @Override + protected Void doInBackground(Void... params) { + schemaList = getDbTableSchema(schemaCursor); + contentList = getDbTableDetails(contentCursor); + return null; + } + + @Override + protected void onCancelled() { + super.onCancelled(); + dbViewerFragment.getActivity().onBackPressed(); + } + + @Override + protected void onPostExecute(Void aVoid) { + super.onPostExecute(aVoid); + + dbViewerFragment.loadingText.setVisibility(View.GONE); + + // init schema row + stringBuilder.append(""); + for (String s : schemaList) { + stringBuilder.append(""); + } + stringBuilder.append(""); + + for (String[] strings : contentList) { + // init content row + stringBuilder.append(""); + for (int i = 0; i < strings.length; i++) { + stringBuilder.append(""); + } + stringBuilder.append(""); + } + stringBuilder.append("
").append(s).append("
").append(strings[i]).append("
"); + webView.loadData(stringBuilder.toString(), "text/html;charset=utf-8", "utf-8"); + webView.setVisibility(View.VISIBLE); + } + + private ArrayList getDbTableDetails(Cursor c) { + ArrayList result = new ArrayList<>(); + int j = 0; + for (c.moveToFirst(); !c.isAfterLast(); c.moveToNext()) { + if (!isCancelled()) { + j++; + publishProgress(j); + String[] temp = new String[c.getColumnCount()]; + for (int i = 0; i < temp.length; i++) { + int dataType = c.getType(i); + switch (dataType) { + case 0: + // #FIELD_TYPE_NULL + temp[i] = null; + break; + case 1: + // #FIELD_TYPE_INTEGER + temp[i] = String.valueOf(c.getInt(i)); + break; + case 2: + // #FIELD_TYPE_FLOAT + temp[i] = String.valueOf(c.getFloat(i)); + break; + case 3: + // #FIELD_TYPE_STRING + temp[i] = c.getString(i); + break; + case 4: + // #FIELD_TYPE_BLOB + /*byte[] blob = c.getBlob(i); + blobString = new String(blob);*/ + temp[i] = "(BLOB)"; + break; + } + } + result.add(temp); + } else { + break; + } + } + return result; + } + + private ArrayList getDbTableSchema(Cursor c) { + ArrayList result = new ArrayList<>(); + for (c.moveToFirst(); !c.isAfterLast(); c.moveToNext()) { + if (!isCancelled()) { + + result.add(c.getString(1)); + } else break; + } + return result; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/DeleteTask.java b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/DeleteTask.java new file mode 100644 index 0000000..4f83f4b --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/DeleteTask.java @@ -0,0 +1,202 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks; + +import static com.amaze.filemanager.ui.activities.MainActivity.TAG_INTENT_FILTER_FAILED_OPS; +import static com.amaze.filemanager.ui.activities.MainActivity.TAG_INTENT_FILTER_GENERAL; + +import java.util.ArrayList; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.application.AppConfig; +import com.amaze.filemanager.database.CryptHandler; +import com.amaze.filemanager.fileoperations.exceptions.ShellNotRunningException; +import com.amaze.filemanager.fileoperations.filesystem.OpenMode; +import com.amaze.filemanager.filesystem.HybridFile; +import com.amaze.filemanager.filesystem.HybridFileParcelable; +import com.amaze.filemanager.filesystem.SafRootHolder; +import com.amaze.filemanager.filesystem.cloud.CloudUtil; +import com.amaze.filemanager.filesystem.files.CryptUtil; +import com.amaze.filemanager.filesystem.files.MediaConnectionUtils; +import com.amaze.filemanager.ui.activities.MainActivity; +import com.amaze.filemanager.ui.fragments.CompressedExplorerFragment; +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants; +import com.amaze.filemanager.ui.notifications.NotificationConstants; +import com.amaze.filemanager.utils.DataUtils; +import com.amaze.filemanager.utils.OTGUtil; +import com.cloudrail.si.interfaces.CloudStorage; + +import android.app.NotificationManager; +import android.content.Context; +import android.content.Intent; +import android.os.AsyncTask; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.documentfile.provider.DocumentFile; +import androidx.preference.PreferenceManager; + +import jcifs.smb.SmbException; + +public class DeleteTask + extends AsyncTask, String, AsyncTaskResult> { + + private static final Logger LOG = LoggerFactory.getLogger(DeleteTask.class); + + private ArrayList files; + private final Context applicationContext; + private final boolean rootMode; + private CompressedExplorerFragment compressedExplorerFragment; + + private boolean doDeletePermanently; + private final DataUtils dataUtils = DataUtils.getInstance(); + + public DeleteTask(@NonNull Context applicationContext, @NonNull boolean doDeletePermanently) { + this.applicationContext = applicationContext.getApplicationContext(); + this.doDeletePermanently = doDeletePermanently; + rootMode = + PreferenceManager.getDefaultSharedPreferences(applicationContext) + .getBoolean(PreferencesConstants.PREFERENCE_ROOTMODE, false); + } + + public DeleteTask( + @NonNull Context applicationContext, CompressedExplorerFragment compressedExplorerFragment) { + this.applicationContext = applicationContext.getApplicationContext(); + this.doDeletePermanently = false; + rootMode = + PreferenceManager.getDefaultSharedPreferences(applicationContext) + .getBoolean(PreferencesConstants.PREFERENCE_ROOTMODE, false); + this.compressedExplorerFragment = compressedExplorerFragment; + } + + @Override + protected void onProgressUpdate(String... values) { + super.onProgressUpdate(values); + Toast.makeText(applicationContext, values[0], Toast.LENGTH_SHORT).show(); + } + + @Override + @SafeVarargs + protected final AsyncTaskResult doInBackground( + final ArrayList... p1) { + files = p1[0]; + boolean wasDeleted = true; + if (files.size() == 0) return new AsyncTaskResult<>(true); + + for (HybridFileParcelable file : files) { + try { + wasDeleted = doDeleteFile(file); + if (!wasDeleted) break; + } catch (Exception e) { + return new AsyncTaskResult<>(e); + } + + // delete file from media database + if (!file.isSmb() && !file.isSftp()) + MediaConnectionUtils.scanFile( + applicationContext, files.toArray(new HybridFile[files.size()])); + + // delete file entry from encrypted database + if (file.getName(applicationContext).endsWith(CryptUtil.CRYPT_EXTENSION)) { + CryptHandler handler = CryptHandler.INSTANCE; + handler.clear(file.getPath()); + } + } + + return new AsyncTaskResult<>(wasDeleted); + } + + @Override + public void onPostExecute(AsyncTaskResult result) { + + Intent intent = new Intent(MainActivity.KEY_INTENT_LOAD_LIST); + if (files.size() > 0) { + String path = files.get(0).getParent(applicationContext); + intent.putExtra(MainActivity.KEY_INTENT_LOAD_LIST_FILE, path); + applicationContext.sendBroadcast(intent); + } + + if (result.result == null || !result.result) { + applicationContext.sendBroadcast( + new Intent(TAG_INTENT_FILTER_GENERAL) + .putParcelableArrayListExtra(TAG_INTENT_FILTER_FAILED_OPS, files)); + } else if (compressedExplorerFragment == null) { + AppConfig.toast(applicationContext, R.string.done); + } + + if (compressedExplorerFragment != null) { + compressedExplorerFragment.files.clear(); + } + + // cancel any processing notification because of cut/paste operation + NotificationManager notificationManager = + (NotificationManager) applicationContext.getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.cancel(NotificationConstants.COPY_ID); + } + + private boolean doDeleteFile(@NonNull HybridFileParcelable file) throws Exception { + switch (file.getMode()) { + case OTG: + DocumentFile documentFile = + OTGUtil.getDocumentFile(file.getPath(), applicationContext, false); + return documentFile.delete(); + case DOCUMENT_FILE: + documentFile = + OTGUtil.getDocumentFile( + file.getPath(), + SafRootHolder.getUriRoot(), + applicationContext, + OpenMode.DOCUMENT_FILE, + false); + return documentFile.delete(); + case DROPBOX: + case BOX: + case GDRIVE: + case ONEDRIVE: + CloudStorage cloudStorage = dataUtils.getAccount(file.getMode()); + try { + cloudStorage.delete(CloudUtil.stripPath(file.getMode(), file.getPath())); + return true; + } catch (Exception e) { + LOG.warn("failed to delete cloud files", e); + return false; + } + default: + try { + /* SMB and SFTP (or any remote files that may support in the future) should not be + * supported by recycle bin. - TranceLove + */ + if (!doDeletePermanently + && !OpenMode.SMB.equals(file.getMode()) + && !OpenMode.SFTP.equals(file.getMode())) { + return file.moveToBin(applicationContext); + } + return file.delete(applicationContext, rootMode); + } catch (ShellNotRunningException | SmbException e) { + LOG.warn("failed to delete files", e); + throw e; + } + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/LoadFilesListTask.java b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/LoadFilesListTask.java new file mode 100644 index 0000000..6de0985 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/LoadFilesListTask.java @@ -0,0 +1,865 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks; + +import static android.os.Build.VERSION.SDK_INT; +import static android.os.Build.VERSION_CODES.Q; + +import java.io.File; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedList; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.adapters.data.LayoutElementParcelable; +import com.amaze.filemanager.application.AppConfig; +import com.amaze.filemanager.database.SortHandler; +import com.amaze.filemanager.database.UtilsHandler; +import com.amaze.filemanager.fileoperations.exceptions.CloudPluginException; +import com.amaze.filemanager.fileoperations.filesystem.OpenMode; +import com.amaze.filemanager.filesystem.FileProperties; +import com.amaze.filemanager.filesystem.HybridFile; +import com.amaze.filemanager.filesystem.HybridFileParcelable; +import com.amaze.filemanager.filesystem.RootHelper; +import com.amaze.filemanager.filesystem.SafRootHolder; +import com.amaze.filemanager.filesystem.cloud.CloudUtil; +import com.amaze.filemanager.filesystem.files.FileListSorter; +import com.amaze.filemanager.filesystem.files.sort.SortType; +import com.amaze.filemanager.filesystem.root.ListFilesCommand; +import com.amaze.filemanager.ui.activities.MainActivityViewModel; +import com.amaze.filemanager.ui.fragments.CloudSheetFragment; +import com.amaze.filemanager.ui.fragments.MainFragment; +import com.amaze.filemanager.ui.fragments.data.MainFragmentViewModel; +import com.amaze.filemanager.utils.DataUtils; +import com.amaze.filemanager.utils.GenericExtKt; +import com.amaze.filemanager.utils.OTGUtil; +import com.amaze.filemanager.utils.OnAsyncTaskFinished; +import com.amaze.filemanager.utils.OnFileFound; +import com.amaze.filemanager.utils.Utils; +import com.amaze.trashbin.TrashBin; +import com.amaze.trashbin.TrashBinFile; +import com.cloudrail.si.interfaces.CloudStorage; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.database.Cursor; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.provider.MediaStore; +import android.text.format.Formatter; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.core.util.Pair; + +import jcifs.smb.SmbAuthException; +import jcifs.smb.SmbException; +import jcifs.smb.SmbFile; +import kotlin.collections.CollectionsKt; + +public class LoadFilesListTask + extends AsyncTask>> { + + private static final Logger LOG = LoggerFactory.getLogger(LoadFilesListTask.class); + + private String path; + private WeakReference mainFragmentReference; + private WeakReference context; + private OpenMode openmode; + private boolean showHiddenFiles, showThumbs; + private DataUtils dataUtils = DataUtils.getInstance(); + private OnAsyncTaskFinished>> listener; + private boolean forceReload; + + public LoadFilesListTask( + Context context, + String path, + MainFragment mainFragment, + OpenMode openmode, + boolean showThumbs, + boolean showHiddenFiles, + boolean forceReload, + OnAsyncTaskFinished>> l) { + this.path = path; + this.mainFragmentReference = new WeakReference<>(mainFragment); + this.openmode = openmode; + this.context = new WeakReference<>(context); + this.showThumbs = showThumbs; + this.showHiddenFiles = showHiddenFiles; + this.listener = l; + this.forceReload = forceReload; + } + + @Override + @SuppressWarnings({"PMD.NPathComplexity", "ComplexMethod", "LongMethod"}) + protected @Nullable Pair> doInBackground(Void... p) { + final MainFragment mainFragment = this.mainFragmentReference.get(); + final Context context = this.context.get(); + + if (mainFragment == null + || context == null + || mainFragment.getMainFragmentViewModel() == null + || mainFragment.getMainActivityViewModel() == null) { + cancel(true); + return null; + } + + HybridFile hFile = null; + MainFragmentViewModel mainFragmentViewModel = mainFragment.getMainFragmentViewModel(); + MainActivityViewModel mainActivityViewModel = mainFragment.getMainActivityViewModel(); + + if (OpenMode.UNKNOWN.equals(openmode) + || OpenMode.CUSTOM.equals(openmode) + || OpenMode.TRASH_BIN.equals(openmode)) { + hFile = new HybridFile(openmode, path); + hFile.generateMode(mainFragment.getActivity()); + openmode = hFile.getMode(); + + if (hFile.isSmb()) { + mainFragmentViewModel.setSmbPath(path); + } + } + + if (isCancelled()) return null; + + mainFragmentViewModel.setFolderCount(0); + mainFragmentViewModel.setFileCount(0); + final List list; + + switch (openmode) { + case SMB: + list = listSmb(hFile, mainActivityViewModel, mainFragment); + break; + case FTP: + case SFTP: + list = listSftp(mainActivityViewModel); + break; + case CUSTOM: + case TRASH_BIN: + list = getCachedMediaList(mainActivityViewModel); + break; + case OTG: + list = listOtg(); + openmode = OpenMode.OTG; + break; + case DOCUMENT_FILE: + list = listDocumentFiles(mainActivityViewModel); + openmode = OpenMode.DOCUMENT_FILE; + break; + case DROPBOX: + case BOX: + case GDRIVE: + case ONEDRIVE: + try { + list = listCloud(mainActivityViewModel); + } catch (CloudPluginException e) { + LOG.warn("failed to load cloud files", e); + AppConfig.toast(context, context.getResources().getString(R.string.failed_no_connection)); + return new Pair<>(openmode, Collections.emptyList()); + } + break; + case ANDROID_DATA: + list = listAppDataDirectories(path); + break; + default: + // we're neither in OTG not in SMB, load the list based on root/general filesystem + list = listDefault(mainActivityViewModel, mainFragment); + break; + } + + if (list != null + && !(openmode == OpenMode.CUSTOM + && (("5").equals(path) || ("6").equals(path) || ("7").equals(path)))) { + postListCustomPathProcess(list, mainFragment); + } + + return new Pair<>(openmode, list); + } + + @Override + protected void onCancelled() { + listener.onAsyncTaskFinished(null); + } + + @Override + protected void onProgressUpdate(Throwable... values) { + for (Throwable exception : values) { + if (exception instanceof SmbException) { + if ("/".equals(Uri.parse(path).getPath())) { + new AlertDialog.Builder(context.get()) + .setTitle(R.string.error_listfile_smb_title) + .setMessage( + context + .get() + .getString( + R.string.error_listfile_smb_noipcshare, + HybridFile.parseAndFormatUriForDisplay(path))) + .setPositiveButton( + android.R.string.ok, + (dialog, which) -> { + dialog.dismiss(); + }) + .show(); + } else { + Toast.makeText( + context.get(), + context + .get() + .getString( + R.string.error_listfile_smb, + HybridFile.parseAndFormatUriForDisplay(path), + exception.getMessage()), + Toast.LENGTH_LONG) + .show(); + } + } + } + } + + @Override + protected void onPostExecute(@Nullable Pair> list) { + listener.onAsyncTaskFinished(list); + } + + private List getCachedMediaList( + MainActivityViewModel mainActivityViewModel) throws IllegalStateException { + List list; + int mediaType = Integer.parseInt(path); + if (5 == mediaType + || 6 == mediaType + || 7 == mediaType + || mainActivityViewModel.getMediaCacheHash().get(mediaType) == null + || forceReload) { + switch (Integer.parseInt(path)) { + case 0: + list = listImages(); + break; + case 1: + list = listVideos(); + break; + case 2: + list = listaudio(); + break; + case 3: + list = listDocs(); + break; + case 4: + list = listApks(); + break; + case 5: + list = listRecent(); + break; + case 6: + list = listRecentFiles(); + break; + case 7: + list = listTrashBinFiles(); + break; + default: + throw new IllegalStateException(); + } + if (5 != mediaType && 6 != mediaType && 7 != mediaType) { + // not saving recent files in cache + mainActivityViewModel.getMediaCacheHash().set(mediaType, list); + } + } else { + list = mainActivityViewModel.getFromMediaFilesCache(mediaType); + } + return list; + } + + private void postListCustomPathProcess( + @NonNull List list, @NonNull MainFragment mainFragment) { + + SortType sortType = SortHandler.getSortType(context.get(), path); + + MainFragmentViewModel viewModel = mainFragment.getMainFragmentViewModel(); + + if (viewModel == null) { + LOG.error("MainFragmentViewModel is null, this is a bug"); + return; + } + + for (int i = 0; i < list.size(); i++) { + LayoutElementParcelable layoutElementParcelable = list.get(i); + + if (layoutElementParcelable == null) { + //noinspection SuspiciousListRemoveInLoop + list.remove(i); + continue; + } + + if (layoutElementParcelable.isDirectory) { + viewModel.incrementFolderCount(); + } else { + viewModel.incrementFileCount(); + } + } + + Collections.sort(list, new FileListSorter(viewModel.getDsort(), sortType)); + } + + private @Nullable LayoutElementParcelable createListParcelables(HybridFileParcelable baseFile) { + if (dataUtils.isFileHidden(baseFile.getPath())) { + return null; + } + + final MainFragment mainFragment = this.mainFragmentReference.get(); + final Context context = this.context.get(); + + if (mainFragment == null || context == null) { + cancel(true); + return null; + } + + String size = ""; + long longSize = 0; + + if (!baseFile.isDirectory()) { + if (baseFile.getSize() != -1) { + try { + longSize = baseFile.getSize(); + size = Formatter.formatFileSize(context, longSize); + } catch (NumberFormatException e) { + LOG.warn("failed to create list parcelables", e); + } + } + } + + LayoutElementParcelable layoutElement = + new LayoutElementParcelable( + context, + baseFile.getName(context), + baseFile.getPath(), + baseFile.getPermission(), + baseFile.getLink(), + size, + longSize, + false, + baseFile.getDate() + "", + baseFile.isDirectory(), + showThumbs, + baseFile.getMode()); + return layoutElement; + } + + private List listImages() { + final String[] projection = {MediaStore.Images.Media.DATA}; + return listMediaCommon(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, null); + } + + private List listVideos() { + final String[] projection = {MediaStore.Video.Media.DATA}; + return listMediaCommon(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, projection, null); + } + + private List listaudio() { + String selection = MediaStore.Audio.Media.IS_MUSIC + " != 0"; + String[] projection = {MediaStore.Audio.Media.DATA}; + return listMediaCommon(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, projection, selection); + } + + private @Nullable List listMediaCommon( + Uri contentUri, @NonNull String[] projection, @Nullable String selection) { + final Context context = this.context.get(); + + if (context == null) { + cancel(true); + return null; + } + + Cursor cursor = + context.getContentResolver().query(contentUri, projection, selection, null, null); + + ArrayList retval = new ArrayList<>(); + if (cursor == null) return retval; + else if (cursor.getCount() > 0 && cursor.moveToFirst()) { + do { + String path = cursor.getString(cursor.getColumnIndex(MediaStore.Files.FileColumns.DATA)); + HybridFileParcelable strings = RootHelper.generateBaseFile(new File(path), showHiddenFiles); + if (strings != null) { + LayoutElementParcelable parcelable = createListParcelables(strings); + if (parcelable != null) retval.add(parcelable); + } + } while (cursor.moveToNext()); + } + cursor.close(); + return retval; + } + + private @Nullable List listDocs() { + final Context context = this.context.get(); + + if (context == null) { + cancel(true); + return null; + } + + ArrayList docs = new ArrayList<>(); + final String[] projection = {MediaStore.Files.FileColumns.DATA}; + Cursor cursor = + context + .getContentResolver() + .query(MediaStore.Files.getContentUri("external"), projection, null, null, null); + + if (cursor == null) return docs; + else if (cursor.getCount() > 0 && cursor.moveToFirst()) { + do { + String path = cursor.getString(cursor.getColumnIndex(MediaStore.Files.FileColumns.DATA)); + + if (path != null + && (path.endsWith(".pdf") + || path.endsWith(".doc") + || path.endsWith(".docx") + || path.endsWith("txt") + || path.endsWith(".rtf") + || path.endsWith(".odt") + || path.endsWith(".html") + || path.endsWith(".xml") + || path.endsWith(".text/x-asm") + || path.endsWith(".def") + || path.endsWith(".in") + || path.endsWith(".rc") + || path.endsWith(".list") + || path.endsWith(".log") + || path.endsWith(".pl") + || path.endsWith(".prop") + || path.endsWith(".properties") + || path.endsWith(".msg") + || path.endsWith(".pages") + || path.endsWith(".wpd") + || path.endsWith(".wps"))) { + HybridFileParcelable strings = + RootHelper.generateBaseFile(new File(path), showHiddenFiles); + if (strings != null) { + LayoutElementParcelable parcelable = createListParcelables(strings); + if (parcelable != null) docs.add(parcelable); + } + } + } while (cursor.moveToNext()); + } + cursor.close(); + Collections.sort(docs, (lhs, rhs) -> -1 * Long.valueOf(lhs.date).compareTo(rhs.date)); + return docs; + } + + private @Nullable List listApks() { + final Context context = this.context.get(); + + if (context == null) { + cancel(true); + return null; + } + + ArrayList apks = new ArrayList<>(); + final String[] projection = {MediaStore.Files.FileColumns.DATA}; + + Cursor cursor = + context + .getContentResolver() + .query(MediaStore.Files.getContentUri("external"), projection, null, null, null); + if (cursor == null) return apks; + else if (cursor.getCount() > 0 && cursor.moveToFirst()) { + do { + String path = cursor.getString(cursor.getColumnIndex(MediaStore.Files.FileColumns.DATA)); + if (path != null && path.endsWith(".apk")) { + HybridFileParcelable strings = + RootHelper.generateBaseFile(new File(path), showHiddenFiles); + if (strings != null) { + LayoutElementParcelable parcelable = createListParcelables(strings); + if (parcelable != null) apks.add(parcelable); + } + } + } while (cursor.moveToNext()); + } + cursor.close(); + return apks; + } + + private @Nullable List listRecent() { + final MainFragment mainFragment = this.mainFragmentReference.get(); + if (mainFragment == null) { + cancel(true); + return null; + } + + UtilsHandler utilsHandler = AppConfig.getInstance().getUtilsHandler(); + final LinkedList paths = utilsHandler.getHistoryLinkedList(); + ArrayList songs = new ArrayList<>(); + for (String f : paths) { + if (!f.equals("/")) { + HybridFileParcelable hybridFileParcelable = + RootHelper.generateBaseFile(new File(f), showHiddenFiles); + if (hybridFileParcelable != null) { + hybridFileParcelable.generateMode(mainFragment.getActivity()); + if (hybridFileParcelable.isSimpleFile() + && !hybridFileParcelable.isDirectory() + && hybridFileParcelable.exists()) { + LayoutElementParcelable parcelable = createListParcelables(hybridFileParcelable); + if (parcelable != null) songs.add(parcelable); + } + } + } + } + return songs; + } + + private @Nullable List listRecentFiles() { + final Context context = this.context.get(); + + if (context == null) { + cancel(true); + return null; + } + + List recentFiles = new ArrayList<>(20); + final String[] projection = { + MediaStore.Files.FileColumns.DATA, MediaStore.Files.FileColumns.DATE_MODIFIED + }; + Calendar c = Calendar.getInstance(); + c.set(Calendar.DAY_OF_YEAR, c.get(Calendar.DAY_OF_YEAR) - 2); + Date d = c.getTime(); + Cursor cursor; + if (SDK_INT >= Q) { + Bundle queryArgs = new Bundle(); + queryArgs.putInt(ContentResolver.QUERY_ARG_LIMIT, 20); + queryArgs.putStringArray( + ContentResolver.QUERY_ARG_SORT_COLUMNS, + new String[] {MediaStore.Files.FileColumns.DATE_MODIFIED}); + queryArgs.putInt( + ContentResolver.QUERY_ARG_SORT_DIRECTION, + ContentResolver.QUERY_SORT_DIRECTION_DESCENDING); + cursor = + context + .getContentResolver() + .query(MediaStore.Files.getContentUri("external"), projection, queryArgs, null); + } else { + cursor = + context + .getContentResolver() + .query( + MediaStore.Files.getContentUri("external"), + projection, + null, + null, + MediaStore.Files.FileColumns.DATE_MODIFIED + " DESC LIMIT 20"); + } + if (cursor == null) return recentFiles; + if (cursor.getCount() > 0 && cursor.moveToFirst()) { + do { + String path = cursor.getString(cursor.getColumnIndex(MediaStore.Files.FileColumns.DATA)); + File f = new File(path); + if (d.compareTo(new Date(f.lastModified())) != 1 && !f.isDirectory()) { + HybridFileParcelable strings = + RootHelper.generateBaseFile(new File(path), showHiddenFiles); + if (strings != null) { + LayoutElementParcelable parcelable = createListParcelables(strings); + if (parcelable != null) recentFiles.add(parcelable); + } + } + } while (cursor.moveToNext()); + } + cursor.close(); + return recentFiles; + } + + private @Nullable List listTrashBinFiles() { + final Context context = this.context.get(); + + if (context == null) { + cancel(true); + return null; + } + + TrashBin trashBin = AppConfig.getInstance().getTrashBinInstance(); + List deletedFiles = new ArrayList<>(); + + if (trashBin == null) return deletedFiles; + + List filesInBin = trashBin.listFilesInBin(); + + if (filesInBin == null) return deletedFiles; + + for (TrashBinFile trashBinFile : filesInBin) { + HybridFile hybridFile = + new HybridFile( + OpenMode.TRASH_BIN, + trashBinFile.getDeletedPath( + AppConfig.getInstance().getTrashBinInstance().getConfig()), + trashBinFile.getFileName(), + trashBinFile.isDirectory()); + if (trashBinFile.getDeleteTime() != null) { + hybridFile.setLastModified(trashBinFile.getDeleteTime() * 1000); + } + LayoutElementParcelable element = hybridFile.generateLayoutElement(context, true); + element.date = trashBinFile.getDeleteTime(); + element.longSize = trashBinFile.getSizeBytes(); + element.size = Formatter.formatFileSize(context, trashBinFile.getSizeBytes()); + element.dateModification = Utils.getDate(context, trashBinFile.getDeleteTime() * 1000); + element.isDirectory = trashBinFile.isDirectory(); + deletedFiles.add(element); + } + return deletedFiles; + } + + private @NonNull List listAppDataDirectories(@NonNull String basePath) { + if (!GenericExtKt.containsPath(FileProperties.ANDROID_DEVICE_DATA_DIRS, basePath)) { + throw new IllegalArgumentException("Invalid base path: [" + basePath + "]"); + } + Context ctx = context.get(); + @Nullable PackageManager pm = ctx != null ? ctx.getPackageManager() : null; + List retval = new ArrayList<>(); + if (pm != null) { + Intent intent = new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER); + for (ResolveInfo app : + CollectionsKt.distinctBy( + pm.queryIntentActivities(intent, 0), + resolveInfo -> resolveInfo.activityInfo.packageName)) { + File dir = new File(new File(basePath), app.activityInfo.packageName); + if (dir.exists()) { + LayoutElementParcelable element = + new LayoutElementParcelable( + ctx, + dir.getAbsolutePath(), + "", + "", + Long.toString(dir.length()), + dir.length(), + false, + Long.toString(dir.lastModified()), + true, + false, + OpenMode.ANDROID_DATA); + retval.add(element); + } + } + } + return retval; + } + + private List listSmb( + @Nullable final HybridFile hFile, + @NonNull MainActivityViewModel mainActivityViewModel, + @NonNull MainFragment mainFragment) { + HybridFile _file = hFile; + if (_file == null) { + _file = new HybridFile(OpenMode.SMB, path); + } + if (!_file.getPath().endsWith("/")) { + _file.setPath(_file.getPath() + "/"); + } + @NonNull List list; + List smbCache = mainActivityViewModel.getFromListCache(path); + openmode = OpenMode.SMB; + if (smbCache != null && !forceReload) { + list = smbCache; + } else { + try { + SmbFile[] smbFile = _file.getSmbFile(5000).listFiles(); + list = mainFragment.addToSmb(smbFile, path, showHiddenFiles); + } catch (SmbAuthException e) { + if (!e.getMessage().toLowerCase().contains("denied")) { + mainFragment.reauthenticateSmb(); + } + LOG.warn("failed to load smb list, authentication issue: ", e); + publishProgress(e); + return null; + } catch (SmbException | NullPointerException e) { + LOG.warn("Failed to load smb files for path: " + path, e); + mainFragment.reauthenticateSmb(); + return null; + } + mainActivityViewModel.putInCache(path, list); + } + return list; + } + + private List listSftp( + @NonNull MainActivityViewModel mainActivityViewModel) { + HybridFile ftpHFile = new HybridFile(openmode, path); + List list; + List sftpCache = mainActivityViewModel.getFromListCache(path); + if (sftpCache != null && !forceReload) { + list = sftpCache; + } else { + list = new ArrayList<>(); + ftpHFile.forEachChildrenFile( + context.get(), + false, + file -> { + if (!(dataUtils.isFileHidden(file.getPath()) || file.isHidden() && !showHiddenFiles)) { + LayoutElementParcelable elem = createListParcelables(file); + if (elem != null) { + list.add(elem); + } + } + }); + mainActivityViewModel.putInCache(path, list); + } + return list; + } + + private List listOtg() { + List list = new ArrayList<>(); + listOtgInternal( + path, + file -> { + LayoutElementParcelable elem = createListParcelables(file); + if (elem != null) list.add(elem); + }); + return list; + } + + private List listDocumentFiles( + @NonNull MainActivityViewModel mainActivityViewModel) { + List list; + List cache = mainActivityViewModel.getFromListCache(path); + if (cache != null && !forceReload) { + list = cache; + } else { + list = new ArrayList<>(); + listDocumentFilesInternal( + file -> { + LayoutElementParcelable elem = createListParcelables(file); + if (elem != null) list.add(elem); + }); + mainActivityViewModel.putInCache(path, list); + } + return list; + } + + private List listCloud( + @NonNull MainActivityViewModel mainActivityViewModel) throws CloudPluginException { + List list; + List cloudCache = mainActivityViewModel.getFromListCache(path); + if (cloudCache != null && !forceReload) { + list = cloudCache; + } else { + CloudStorage cloudStorage = dataUtils.getAccount(openmode); + list = new ArrayList<>(); + listCloudInternal( + path, + cloudStorage, + openmode, + file -> { + LayoutElementParcelable elem = createListParcelables(file); + if (elem != null) list.add(elem); + }); + mainActivityViewModel.putInCache(path, list); + } + return list; + } + + private List listDefault( + @NonNull MainActivityViewModel mainActivityViewModel, @NonNull MainFragment mainFragment) { + List list; + List localCache = mainActivityViewModel.getFromListCache(path); + openmode = + ListFilesCommand.INSTANCE.getOpenMode( + path, mainFragment.requireMainActivity().isRootExplorer()); + if (localCache != null && !forceReload) { + list = localCache; + } else { + list = new ArrayList<>(); + final OpenMode[] currentOpenMode = new OpenMode[1]; + ListFilesCommand.INSTANCE.listFiles( + path, + mainFragment.requireMainActivity().isRootExplorer(), + showHiddenFiles, + mode -> { + currentOpenMode[0] = mode; + return null; + }, + hybridFileParcelable -> { + LayoutElementParcelable elem = createListParcelables(hybridFileParcelable); + if (elem != null) list.add(elem); + return null; + }); + if (list.size() > MainActivityViewModel.Companion.getCACHE_LOCAL_LIST_THRESHOLD()) { + mainActivityViewModel.putInCache(path, list); + } + if (null != currentOpenMode[0]) { + openmode = currentOpenMode[0]; + } + } + return list; + } + + /** + * Lists files from an OTG device + * + * @param path the path to the directory tree, starts with prefix {@link + * com.amaze.filemanager.utils.OTGUtil#PREFIX_OTG} Independent of URI (or mount point) for the + * OTG + */ + private void listOtgInternal(String path, OnFileFound fileFound) { + final Context context = this.context.get(); + + if (context == null) { + cancel(true); + return; + } + + OTGUtil.getDocumentFiles(path, context, fileFound); + } + + private void listDocumentFilesInternal(OnFileFound fileFound) { + final Context context = this.context.get(); + + if (context == null) { + cancel(true); + return; + } + + OTGUtil.getDocumentFiles( + SafRootHolder.getUriRoot(), path, context, OpenMode.DOCUMENT_FILE, fileFound); + } + + private void listCloudInternal( + String path, CloudStorage cloudStorage, OpenMode openMode, OnFileFound fileFoundCallback) + throws CloudPluginException { + final Context context = this.context.get(); + + if (context == null) { + cancel(true); + return; + } + + if (!CloudSheetFragment.isCloudProviderAvailable(context)) { + throw new CloudPluginException(); + } + + CloudUtil.getCloudFiles(path, cloudStorage, openMode, fileFoundCallback); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/LoadFolderSpaceDataTask.java b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/LoadFolderSpaceDataTask.java new file mode 100644 index 0000000..f0121c3 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/LoadFolderSpaceDataTask.java @@ -0,0 +1,158 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks; + +import static com.amaze.filemanager.utils.Utils.getColor; + +import java.util.ArrayList; +import java.util.List; + +import com.afollestad.materialdialogs.Theme; +import com.amaze.filemanager.R; +import com.amaze.filemanager.filesystem.HybridFileParcelable; +import com.amaze.filemanager.filesystem.files.FileUtils; +import com.amaze.filemanager.ui.dialogs.GeneralDialogCreation; +import com.amaze.filemanager.ui.theme.AppTheme; +import com.github.mikephil.charting.charts.PieChart; +import com.github.mikephil.charting.data.PieData; +import com.github.mikephil.charting.data.PieDataSet; +import com.github.mikephil.charting.data.PieEntry; + +import android.content.Context; +import android.graphics.Color; +import android.os.AsyncTask; +import android.text.SpannableString; +import android.text.format.Formatter; +import android.view.View; + +import androidx.core.util.Pair; + +/** + * Loads data for chart in FileUtils.showPropertiesDialog() + * + * @author Emmanuel Messulam on 12/5/2017, at 00:07. + */ +public class LoadFolderSpaceDataTask extends AsyncTask>> { + + private static int[] COLORS; + private static String[] LEGENDS; + + private Context context; + private AppTheme appTheme; + private PieChart chart; + private HybridFileParcelable file; + + public LoadFolderSpaceDataTask( + Context c, AppTheme appTheme, PieChart chart, HybridFileParcelable f) { + context = c; + this.appTheme = appTheme; + this.chart = chart; + file = f; + LEGENDS = + new String[] { + context.getString(R.string.size), + context.getString(R.string.used_by_others), + context.getString(R.string.free) + }; + COLORS = + new int[] { + getColor(c, R.color.piechart_red), + getColor(c, R.color.piechart_blue), + getColor(c, R.color.piechart_green) + }; + } + + @Override + protected Pair> doInBackground(Void... params) { + long[] dataArray = FileUtils.getSpaces(file, context, this::publishProgress); + + if (dataArray[0] != -1 && dataArray[0] != 0) { + long totalSpace = dataArray[0]; + + List entries = createEntriesFromArray(dataArray, false); + + return new Pair<>(Formatter.formatFileSize(context, totalSpace), entries); + } + + return null; + } + + @Override + protected void onProgressUpdate(Long[] dataArray) { + if (dataArray[0] != -1 && dataArray[0] != 0) { + long totalSpace = dataArray[0]; + + List entries = + createEntriesFromArray(new long[] {dataArray[0], dataArray[1], dataArray[2]}, true); + + updateChart(Formatter.formatFileSize(context, totalSpace), entries); + + chart.notifyDataSetChanged(); + chart.invalidate(); + } + } + + @Override + protected void onPostExecute(Pair> data) { + if (data == null) { + chart.setVisibility(View.GONE); + return; + } + + updateChart(data.first, data.second); + + chart.notifyDataSetChanged(); + chart.invalidate(); + } + + private List createEntriesFromArray(long[] dataArray, boolean loading) { + long usedByFolder = dataArray[2], + usedByOther = dataArray[0] - dataArray[1] - dataArray[2], + freeSpace = dataArray[1]; + + List entries = new ArrayList<>(); + entries.add(new PieEntry(usedByFolder, LEGENDS[0], loading ? ">" : null)); + entries.add(new PieEntry(usedByOther, LEGENDS[1], loading ? "<" : null)); + entries.add(new PieEntry(freeSpace, LEGENDS[2])); + + return entries; + } + + private void updateChart(String totalSpace, List entries) { + boolean isDarkTheme = appTheme.getMaterialDialogTheme() == Theme.DARK; + + PieDataSet set = new PieDataSet(entries, null); + set.setColors(COLORS); + set.setXValuePosition(PieDataSet.ValuePosition.OUTSIDE_SLICE); + set.setYValuePosition(PieDataSet.ValuePosition.OUTSIDE_SLICE); + set.setSliceSpace(5f); + set.setAutomaticallyDisableSliceSpacing(true); + set.setValueLinePart2Length(1.05f); + set.setSelectionShift(0f); + + PieData pieData = new PieData(set); + pieData.setValueFormatter(new GeneralDialogCreation.SizeFormatter(context)); + pieData.setValueTextColor(isDarkTheme ? Color.WHITE : Color.BLACK); + + chart.setCenterText(new SpannableString(context.getString(R.string.total) + "\n" + totalSpace)); + chart.setData(pieData); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/SearchTextTask.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/SearchTextTask.kt new file mode 100644 index 0000000..98ab446 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/SearchTextTask.kt @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks + +import android.os.AsyncTask +import android.text.TextUtils +import com.amaze.filemanager.ui.activities.texteditor.SearchResultIndex +import com.amaze.filemanager.utils.OnAsyncTaskFinished +import com.amaze.filemanager.utils.OnProgressUpdate +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.IOException +import java.io.LineNumberReader +import java.io.StringReader + +class SearchTextTask( + private val textToSearch: String, + private val searchedText: String, + private val updateListener: OnProgressUpdate, + private val listener: OnAsyncTaskFinished>, +) : AsyncTask>() { + private val lineNumberReader: LineNumberReader + + private val log: Logger = LoggerFactory.getLogger(SearchTextTask::class.java) + + override fun doInBackground(vararg params: Unit): List { + if (TextUtils.isEmpty(searchedText)) { + return emptyList() + } + + val searchResultIndices = ArrayList() + var charIndex = 0 + while (charIndex < textToSearch.length - searchedText.length) { + if (isCancelled) break + val nextPosition = textToSearch.indexOf(searchedText, charIndex) + if (nextPosition == -1) { + break + } + try { + lineNumberReader.skip((nextPosition - charIndex).toLong()) + } catch (e: IOException) { + log.warn("failed to search text", e) + } + charIndex = nextPosition + val index = + SearchResultIndex( + charIndex, + charIndex + searchedText.length, + lineNumberReader.lineNumber, + ) + searchResultIndices.add(index) + publishProgress(index) + charIndex++ + } + + return searchResultIndices + } + + override fun onProgressUpdate(vararg values: SearchResultIndex) { + updateListener.onUpdate(values[0]) + } + + override fun onPostExecute(searchResultIndices: List) { + listener.onAsyncTaskFinished(searchResultIndices) + } + + init { + val stringReader = StringReader(textToSearch) + lineNumberReader = LineNumberReader(stringReader) + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/StatefulAsyncTask.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/StatefulAsyncTask.kt new file mode 100644 index 0000000..89b2bbd --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/StatefulAsyncTask.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks + +/** + * Interface to define state to Asynctask + */ +interface StatefulAsyncTask { + /** + * Set callback to current async task. To be used to attach the context on + * orientation change of fragment / activity + * @param t callback + */ + fun setCallback(t: T) +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/Task.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/Task.kt new file mode 100644 index 0000000..ac1c980 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/Task.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks + +import androidx.annotation.MainThread +import io.reactivex.Flowable +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.Disposable +import io.reactivex.schedulers.Schedulers +import java.util.concurrent.Callable + +interface Task> { + /** + * This should return a callable to be run on a worker thread + * The [Callable] cannot return null + */ + fun getTask(): T + + /** + * This function will be called on main thread if an exception is thrown + */ + @MainThread + fun onError(error: Throwable) + + /** + * If the task does not return null, and doesn't throw an error this + * function will be called with the result of the operation on main thread + */ + @MainThread + fun onFinish(value: V) +} + +/** + * This creates and starts a [Flowable] from a [Task]. + */ +fun > fromTask(task: Task): Disposable { + return Flowable.fromCallable(task.getTask()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(task::onFinish, task::onError) +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/AbstractCommonsArchiveHelperCallable.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/AbstractCommonsArchiveHelperCallable.kt new file mode 100644 index 0000000..2aee262 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/AbstractCommonsArchiveHelperCallable.kt @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.compress + +import android.content.Context +import com.amaze.filemanager.R +import com.amaze.filemanager.adapters.data.CompressedObjectParcelable +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.filesystem.compressed.CompressedHelper +import org.apache.commons.compress.archivers.ArchiveEntry +import org.apache.commons.compress.archivers.ArchiveException +import org.apache.commons.compress.archivers.ArchiveInputStream +import java.io.FileInputStream +import java.io.IOException +import java.io.InputStream +import java.lang.ref.WeakReference + +abstract class AbstractCommonsArchiveHelperCallable( + context: Context, + private val filePath: String, + private val relativePath: String, + goBack: Boolean, +) : CompressedHelperCallable(goBack) { + private val context: WeakReference = WeakReference(context) + + /** + * Subclasses implement this method to create [ArchiveInputStream] instances with given archive + * as [InputStream]. + * + * @param inputStream archive as [InputStream] + */ + abstract fun createFrom(inputStream: InputStream): ArchiveInputStream + + @Throws(ArchiveException::class) + @Suppress("LabeledExpression") + public override fun addElements(elements: ArrayList) { + try { + createFrom(FileInputStream(filePath)).use { tarInputStream -> + var entry: ArchiveEntry? + while (tarInputStream.nextEntry.also { entry = it } != null) { + entry?.run { + var name = name + if (!CompressedHelper.isEntryPathValid(name)) { + AppConfig.toast( + context.get(), + context.get()!!.getString(R.string.multiple_invalid_archive_entries), + ) + return@run + } + if (name.endsWith(CompressedHelper.SEPARATOR)) { + name = name.substring(0, name.length - 1) + } + val isInBaseDir = + (relativePath == "" && !name.contains(CompressedHelper.SEPARATOR)) + val isInRelativeDir = ( + name.contains(CompressedHelper.SEPARATOR) && + name.substring(0, name.lastIndexOf(CompressedHelper.SEPARATOR)) + == relativePath + ) + if (isInBaseDir || isInRelativeDir) { + elements.add( + CompressedObjectParcelable( + name, + lastModifiedDate.time, + size, + isDirectory, + ), + ) + } + } + } + } + } catch (e: IOException) { + throw ArchiveException(String.format("Tarball archive %s is corrupt", filePath), e) + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/AbstractCompressedTarArchiveHelperCallable.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/AbstractCompressedTarArchiveHelperCallable.kt new file mode 100644 index 0000000..2ef831f --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/AbstractCompressedTarArchiveHelperCallable.kt @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.compress + +import android.content.Context +import com.amaze.filemanager.filesystem.compressed.extractcontents.Extractor +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream +import org.apache.commons.compress.compressors.CompressorInputStream +import java.io.InputStream +import java.lang.reflect.Constructor + +abstract class AbstractCompressedTarArchiveHelperCallable( + context: Context, + filePath: String, + relativePath: String, + goBack: Boolean, +) : + AbstractCommonsArchiveHelperCallable(context, filePath, relativePath, goBack) { + private val compressorInputStreamConstructor: Constructor + + init { + compressorInputStreamConstructor = + getCompressorInputStreamClass() + .getDeclaredConstructor(InputStream::class.java) + compressorInputStreamConstructor.isAccessible = true + } + + /** + * Subclasses implement this method to specify the [CompressorInputStream] class to be used. It + * will be used to create the backing inputstream beneath [TarArchiveInputStream] in + * [createFrom]. + * + * @return Class representing the implementation will be handling + */ + abstract fun getCompressorInputStreamClass(): Class + + override fun createFrom(inputStream: InputStream): TarArchiveInputStream { + return runCatching { + TarArchiveInputStream(compressorInputStreamConstructor.newInstance(inputStream)) + }.getOrElse { + throw Extractor.BadArchiveNotice(it) + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/CompressedHelperCallable.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/CompressedHelperCallable.kt new file mode 100644 index 0000000..5e01883 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/CompressedHelperCallable.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.compress + +import androidx.annotation.WorkerThread +import com.amaze.filemanager.adapters.data.CompressedObjectParcelable +import org.apache.commons.compress.archivers.ArchiveException +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.util.Collections +import java.util.concurrent.Callable + +abstract class CompressedHelperCallable internal constructor( + private val createBackItem: Boolean, +) : + Callable> { + protected val logger: Logger = LoggerFactory.getLogger(javaClass) + + @WorkerThread + @Throws(ArchiveException::class) + override fun call(): ArrayList { + val elements = ArrayList() + if (createBackItem) { + elements.add(0, CompressedObjectParcelable()) + } + + addElements(elements) + Collections.sort(elements, CompressedObjectParcelable.Sorter()) + return elements + } + + @Throws(ArchiveException::class) + protected abstract fun addElements(elements: ArrayList) + } diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/SevenZipHelperCallable.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/SevenZipHelperCallable.kt new file mode 100644 index 0000000..4579066 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/SevenZipHelperCallable.kt @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.compress + +import com.amaze.filemanager.adapters.data.CompressedObjectParcelable +import com.amaze.filemanager.fileoperations.filesystem.compressed.ArchivePasswordCache +import com.amaze.filemanager.filesystem.compressed.CompressedHelper +import com.amaze.filemanager.filesystem.compressed.CompressedHelper.SEPARATOR +import com.amaze.filemanager.filesystem.compressed.sevenz.SevenZFile +import org.apache.commons.compress.PasswordRequiredException +import org.apache.commons.compress.archivers.ArchiveException +import java.io.File +import java.io.IOException + +class SevenZipHelperCallable( + private val filePath: String, + private val relativePath: String, + goBack: Boolean, +) : + CompressedHelperCallable(goBack) { + @Throws(ArchiveException::class) + @Suppress("Detekt.RethrowCaughtException") + override fun addElements(elements: ArrayList) { + try { + val sevenzFile = + if (ArchivePasswordCache.getInstance().containsKey(filePath)) { + SevenZFile( + File(filePath), + ArchivePasswordCache.getInstance()[filePath]!!.toCharArray(), + ) + } else { + SevenZFile(File(filePath)) + } + + val entriesMap = sevenzFile.entries.associateBy { it.name } + val entries = HashSet() + + // Start filter out the paths we need to present based on relativePath + + entries.addAll( + consolidate( + entriesMap.keys.filter { + it.startsWith(relativePath) + }, + if (relativePath == "") { + 0 + } else if (relativePath.isNotBlank() && !relativePath.contains(SEPARATOR)) { + 1 + } else { + relativePath.count { it == CompressedHelper.SEPARATOR_CHAR } + 1 + }, + ), + ) + + entries.forEach { path -> + if (entriesMap.containsKey(path)) { + entriesMap[path]?.let { entry -> + elements.add( + CompressedObjectParcelable( + entry.name, + try { + entry.lastModifiedDate.time + } catch (e: UnsupportedOperationException) { + logger.warn("Unable to get modified date for 7zip file", e) + 0L + }, + entry.size, + entry.isDirectory, + ), + ) + } + } else { + elements.add( + CompressedObjectParcelable( + path, + 0L, + 0, + true, + ), + ) + } + } + } catch (e: PasswordRequiredException) { + // this is so that the caller can use onError to ask the user for the password + throw e + } catch (e: IOException) { + throw ArchiveException(String.format("7zip archive %s is corrupt", filePath)) + } + } + + internal fun consolidate( + paths: Collection, + level: Int = 0, + ): Set { + return paths.mapNotNull { path -> + when (level) { + 0 -> { + if (path.contains(SEPARATOR)) { + path.substringBefore(SEPARATOR) + } else { + path + } + } + else -> { + if (path.contains(SEPARATOR)) { + path.split(SEPARATOR).let { + if (it.size > level) { + it.subList(0, level + 1).joinToString(SEPARATOR) + } else { + null + } + } + } else { + null + } + } + } + }.toSet() + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/TarBzip2HelperCallable.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/TarBzip2HelperCallable.kt new file mode 100644 index 0000000..b5650ae --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/TarBzip2HelperCallable.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.compress + +import android.content.Context +import org.apache.commons.compress.compressors.CompressorInputStream +import org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream + +class TarBzip2HelperCallable( + context: Context, + filePath: String, + relativePath: String, + goBack: Boolean, +) : + AbstractCompressedTarArchiveHelperCallable(context, filePath, relativePath, goBack) { + override fun getCompressorInputStreamClass(): Class = BZip2CompressorInputStream::class.java +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/TarGzHelperCallable.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/TarGzHelperCallable.kt new file mode 100644 index 0000000..9de41dd --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/TarGzHelperCallable.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.compress + +import android.content.Context +import org.apache.commons.compress.compressors.CompressorInputStream +import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream + +class TarGzHelperCallable( + context: Context, + filePath: String, + relativePath: String, + goBack: Boolean, +) : + AbstractCompressedTarArchiveHelperCallable(context, filePath, relativePath, goBack) { + override fun getCompressorInputStreamClass(): Class = GzipCompressorInputStream::class.java +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/TarHelperCallable.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/TarHelperCallable.kt new file mode 100644 index 0000000..9c620f3 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/TarHelperCallable.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.compress + +import android.content.Context +import org.apache.commons.compress.archivers.ArchiveInputStream +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream +import java.io.InputStream + +class TarHelperCallable( + context: Context, + filePath: String, + relativePath: String, + goBack: Boolean, +) : + AbstractCommonsArchiveHelperCallable(context, filePath, relativePath, goBack) { + override fun createFrom(inputStream: InputStream): ArchiveInputStream = TarArchiveInputStream(inputStream) +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/TarLzmaHelperCallable.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/TarLzmaHelperCallable.kt new file mode 100644 index 0000000..41e302d --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/TarLzmaHelperCallable.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.compress + +import android.content.Context +import org.apache.commons.compress.compressors.CompressorInputStream +import org.apache.commons.compress.compressors.lzma.LZMACompressorInputStream + +class TarLzmaHelperCallable( + context: Context, + filePath: String, + relativePath: String, + goBack: Boolean, +) : + AbstractCompressedTarArchiveHelperCallable(context, filePath, relativePath, goBack) { + override fun getCompressorInputStreamClass(): Class = LZMACompressorInputStream::class.java +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/TarXzHelperCallable.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/TarXzHelperCallable.kt new file mode 100644 index 0000000..24fe3e3 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/TarXzHelperCallable.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.compress + +import android.content.Context +import org.apache.commons.compress.compressors.CompressorInputStream +import org.apache.commons.compress.compressors.xz.XZCompressorInputStream + +class TarXzHelperCallable( + context: Context, + filePath: String, + relativePath: String, + goBack: Boolean, +) : + AbstractCompressedTarArchiveHelperCallable(context, filePath, relativePath, goBack) { + override fun getCompressorInputStreamClass(): Class = XZCompressorInputStream::class.java +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/UnknownCompressedFileHelperCallable.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/UnknownCompressedFileHelperCallable.kt new file mode 100644 index 0000000..56c49e0 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/UnknownCompressedFileHelperCallable.kt @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.compress + +import com.amaze.filemanager.adapters.data.CompressedObjectParcelable + +/** + * For gzip, bz2, lzma and xz compressed files. + * + * These are only single file with compression, not to consider as archives. + * + * These files will only show uncompressed size = 0 and last modified date = 1 Jan 1970, reason - + * + * gzip stores uncompressed size at the last 4 bytes, which may be costly to obtain this value on + * mobile devices, and may be inaccurate for files larger than 4GB anyway. + * + * It does stores file last modified time, but it's optional. + * + * https://datatracker.ietf.org/doc/html/rfc1952 + * + * xz and lzma stores uncompressed size at header, but is optional. No file last modified date. + * + * https://svn.python.org/projects/external/xz-5.0.3/doc/lzma-file-format.txt + * https://tukaani.org/xz/xz-file-format.txt + * + * bzip2 does not store uncompressed size nor last modified time as current documentation shows. + * + * Therefore, we only use placeholder value of filesize = 0, last modified date = 0 + * for all of the above types. + * + * It is possible to implement uncompressed size for xz and lzma properly in the future, but are of + * lower priority. Any help would be appreciated. + */ +class UnknownCompressedFileHelperCallable( + private val filePath: String, + goBack: Boolean, +) : + CompressedHelperCallable(goBack) { + override fun addElements(elements: ArrayList) { + val entryName = filePath.substringAfterLast('/').substringBeforeLast('.') + elements.add( + CompressedObjectParcelable( + entryName, + 0L, + 0L, + false, + ), + ) + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/ZipHelperCallable.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/ZipHelperCallable.kt new file mode 100644 index 0000000..e8387d8 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/ZipHelperCallable.kt @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.compress + +import android.content.Context +import android.net.Uri +import com.amaze.filemanager.R +import com.amaze.filemanager.adapters.data.CompressedObjectParcelable +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.filesystem.compressed.CompressedHelper +import net.lingala.zip4j.ZipFile +import net.lingala.zip4j.exception.ZipException +import net.lingala.zip4j.model.FileHeader +import org.apache.commons.compress.archivers.ArchiveException +import java.io.File +import java.lang.ref.WeakReference + +class ZipHelperCallable( + c: Context, + realFileDirectory: String, + dir: String?, + goback: Boolean, +) : + CompressedHelperCallable(goback) { + private val context: WeakReference = WeakReference(c) + private val fileLocation: Uri = Uri.parse(realFileDirectory) + private val relativeDirectory: String? = dir + + @Throws(ArchiveException::class) + @Suppress("ComplexMethod", "LongMethod") + public override fun addElements(elements: ArrayList) = + try { + fileLocation.path?.run { + val zipfile = ZipFile(fileLocation.path) + val wholelist = filterValidEntryList(zipfile) + val strings = ArrayList() + for (entry in wholelist) { + val file = File(entry.path) + val y = + entry.path.let { + if (it.startsWith("/")) { + it.substring(1, it.length) + } else { + it + } + } + if (relativeDirectory == null || relativeDirectory.trim { it <= ' ' }.isEmpty()) { + var path: String + var zipObj: CompressedObjectParcelable + if (file.parent == null || file.parent!!.isEmpty() || file.parent == "/") { + path = y + zipObj = + CompressedObjectParcelable( + y, + entry.date, + entry.size, + entry.directory, + ) + } else { + path = y.substring(0, y.indexOf("/") + 1) + zipObj = + CompressedObjectParcelable( + path, + entry.date, + entry.size, + true, + ) + } + if (!strings.contains(path)) { + elements.add(zipObj) + strings.add(path) + } + } else { + if (file.parent != null && + ( + file.parent == relativeDirectory || + file.parent == "/$relativeDirectory" + ) + ) { + if (!strings.contains(y)) { + elements.add( + CompressedObjectParcelable( + y, + entry.date, + entry.size, + entry.directory, + ), + ) + strings.add(y) + } + } else if (y.startsWith("$relativeDirectory/") && + y.length > relativeDirectory.length + 1 + ) { + val path1 = y.substring(relativeDirectory.length + 1, y.length) + val index = relativeDirectory.length + 1 + path1.indexOf("/") + val path = y.substring(0, index + 1) + if (!strings.contains(path)) { + elements.add( + CompressedObjectParcelable( + y.substring(0, index + 1), + entry.date, + entry.size, + true, + ), + ) + strings.add(path) + } + } + } + } + } ?: throw ArchiveException(null) + } catch (e: ZipException) { + throw ArchiveException("Zip file is corrupt", e) + } + + private fun filterValidEntryList(zipFile: ZipFile): List { + val retval = ArrayList() + val headers: Iterator = zipFile.fileHeaders.iterator() + while (headers.hasNext()) { + val entry = headers.next() + if (!CompressedHelper.isEntryPathValid(entry.fileName)) { + AppConfig.toast( + context.get(), + context.get()!!.getString(R.string.multiple_invalid_archive_entries), + ) + continue + } + retval.add( + CompressedObjectParcelable( + entry.fileName, + entry.lastModifiedTimeEpoch, + entry.uncompressedSize, + entry.isDirectory, + ), + ) + } + return retval + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ftp/AbstractGetHostInfoTask.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ftp/AbstractGetHostInfoTask.kt new file mode 100644 index 0000000..de2cd9f --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ftp/AbstractGetHostInfoTask.kt @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.ftp + +import android.app.ProgressDialog +import android.widget.Toast +import androidx.annotation.MainThread +import com.amaze.filemanager.R +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.asynchronous.asynctasks.Task +import java.util.concurrent.Callable + +abstract class AbstractGetHostInfoTask>( + private val hostname: String, + private val port: Int, + private val callback: (V) -> Unit, +) : Task { + private lateinit var progressDialog: ProgressDialog + + /** + * Routine to run before passing control to worker thread, usually for UI related operations. + */ + @MainThread + open fun onPreExecute() { + AppConfig.getInstance().run { + progressDialog = + ProgressDialog.show( + this.mainActivityContext, + "", + this.resources.getString(R.string.processing), + ) + } + } + + @MainThread + override fun onError(error: Throwable) { + if (progressDialog.isShowing) { + progressDialog.dismiss() + } + Toast.makeText( + AppConfig.getInstance(), + AppConfig.getInstance() + .resources + .getString( + R.string.ssh_connect_failed, + hostname, + port, + error.localizedMessage, + ), + Toast.LENGTH_LONG, + ).show() + } + + @MainThread + override fun onFinish(value: V) { + callback(value) + if (progressDialog.isShowing) { + progressDialog.dismiss() + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ftp/auth/FtpAuthenticationTask.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ftp/auth/FtpAuthenticationTask.kt new file mode 100644 index 0000000..64983a3 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ftp/auth/FtpAuthenticationTask.kt @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.ftp.auth + +import androidx.annotation.MainThread +import com.amaze.filemanager.R +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.asynchronous.asynctasks.Task +import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.FTP_URI_PREFIX +import org.apache.commons.net.ftp.FTPClient +import org.json.JSONObject +import java.net.ConnectException +import java.net.SocketException +import java.net.SocketTimeoutException + +class FtpAuthenticationTask( + private val protocol: String, + private val host: String, + private val port: Int, + private val certInfo: JSONObject?, + private val username: String, + private val password: String?, + private val explicitTls: Boolean = false, +) : Task { + override fun getTask(): FtpAuthenticationTaskCallable { + return if (protocol == FTP_URI_PREFIX) { + FtpAuthenticationTaskCallable( + host, + port, + username, + password ?: "", + ) + } else { + FtpsAuthenticationTaskCallable( + host, + port, + certInfo!!, + username, + password ?: "", + explicitTls, + ) + } + } + + @MainThread + override fun onError(error: Throwable) { + if (error is SocketException || error is SocketTimeoutException || error is ConnectException + ) { + AppConfig.toast( + AppConfig.getInstance(), + AppConfig.getInstance() + .resources + .getString( + R.string.ssh_connect_failed, + host, + port, + error.localizedMessage ?: error.message, + ), + ) + } + } + + @MainThread + override fun onFinish(value: FTPClient) { + android.util.Log.d("TEST", value.toString()) + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ftp/auth/FtpAuthenticationTaskCallable.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ftp/auth/FtpAuthenticationTaskCallable.kt new file mode 100644 index 0000000..ae1cfe8 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ftp/auth/FtpAuthenticationTaskCallable.kt @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.ftp.auth + +import androidx.annotation.WorkerThread +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.filesystem.ftp.FTPClientImpl +import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool +import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.CONNECT_TIMEOUT +import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.FTP_URI_PREFIX +import com.amaze.filemanager.utils.PasswordUtil +import net.schmizz.sshj.userauth.UserAuthException +import org.apache.commons.net.ftp.FTPClient +import java.net.URLDecoder.decode +import java.util.concurrent.Callable +import kotlin.text.Charsets.UTF_8 + +open class FtpAuthenticationTaskCallable( + protected val hostname: String, + protected val port: Int, + protected val username: String, + protected val password: String, +) : Callable { + @WorkerThread + override fun call(): FTPClient { + val ftpClient = createFTPClient() + ftpClient.connectTimeout = CONNECT_TIMEOUT + ftpClient.controlEncoding = Charsets.UTF_8.name() + ftpClient.connect(hostname, port) + val loginSuccess = + if (username.isBlank() && password.isBlank()) { + ftpClient.login( + FTPClientImpl.ANONYMOUS, + FTPClientImpl.generateRandomEmailAddressForLogin(), + ) + } else { + ftpClient.login( + decode(username, UTF_8.name()), + decode( + PasswordUtil.decryptPassword(AppConfig.getInstance(), password), + UTF_8.name(), + ), + ) + } + return if (loginSuccess) { + ftpClient.enterLocalPassiveMode() + ftpClient + } else { + throw UserAuthException("Login failed") + } + } + + protected open fun createFTPClient(): FTPClient = NetCopyClientConnectionPool.ftpClientFactory.create(FTP_URI_PREFIX) +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ftp/auth/FtpsAuthenticationTaskCallable.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ftp/auth/FtpsAuthenticationTaskCallable.kt new file mode 100644 index 0000000..aa5e9b0 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ftp/auth/FtpsAuthenticationTaskCallable.kt @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.ftp.auth + +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.filesystem.ftp.FTPClientImpl +import com.amaze.filemanager.filesystem.ftp.FTPClientImpl.Companion.ARG_TLS +import com.amaze.filemanager.filesystem.ftp.FTPClientImpl.Companion.TLS_EXPLICIT +import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool +import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.FTPS_URI_PREFIX +import com.amaze.filemanager.utils.PasswordUtil +import com.amaze.filemanager.utils.X509CertificateUtil +import com.amaze.filemanager.utils.X509CertificateUtil.FINGERPRINT +import net.schmizz.sshj.userauth.UserAuthException +import org.apache.commons.net.ftp.FTPClient +import org.apache.commons.net.ftp.FTPSClient +import org.json.JSONObject +import javax.net.ssl.HostnameVerifier + +class FtpsAuthenticationTaskCallable( + hostname: String, + port: Int, + private val certInfo: JSONObject, + username: String, + password: String, + private val explicitTls: Boolean, +) : FtpAuthenticationTaskCallable(hostname, port, username, password) { + override fun call(): FTPClient { + val ftpClient = createFTPClient() as FTPSClient + ftpClient.connectTimeout = NetCopyClientConnectionPool.CONNECT_TIMEOUT + ftpClient.controlEncoding = Charsets.UTF_8.name() + ftpClient.connect(hostname, port) + val loginSuccess = + if (username.isBlank() && password.isBlank()) { + ftpClient.login( + FTPClientImpl.ANONYMOUS, + FTPClientImpl.generateRandomEmailAddressForLogin(), + ) + } else { + ftpClient.login( + username, + PasswordUtil.decryptPassword(AppConfig.getInstance(), password), + ) + } + return if (loginSuccess) { + // RFC 2228 set protection buffer size to 0 + ftpClient.execPBSZ(0) + // RFC 2228 set data protection level to PRIVATE + ftpClient.execPROT("P") + ftpClient.enterLocalPassiveMode() + ftpClient + } else { + throw UserAuthException("Login failed") + } + } + + @Suppress("LabeledExpression") + override fun createFTPClient(): FTPClient { + val uri = + buildString { + append(FTPS_URI_PREFIX) + if (explicitTls) { + append("?$ARG_TLS=$TLS_EXPLICIT") + } + } + return ( + NetCopyClientConnectionPool.ftpClientFactory.create(uri.toString()) + as FTPSClient + ).apply { + this.hostnameVerifier = + HostnameVerifier { _, session -> + return@HostnameVerifier if (session.peerCertificateChain.isNotEmpty()) { + X509CertificateUtil.parse( + session.peerCertificateChain.first(), + )[FINGERPRINT] == certInfo.get(FINGERPRINT) + } else { + false + } + } + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ftp/hostcert/FtpsGetHostCertificateTask.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ftp/hostcert/FtpsGetHostCertificateTask.kt new file mode 100644 index 0000000..b4c507f --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ftp/hostcert/FtpsGetHostCertificateTask.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.ftp.hostcert + +import android.content.Context +import com.amaze.filemanager.asynchronous.asynctasks.ftp.AbstractGetHostInfoTask +import org.json.JSONObject +import java.lang.ref.WeakReference + +class FtpsGetHostCertificateTask( + private val host: String, + private val port: Int, + private val explicitTls: Boolean = false, + context: Context, + callback: (JSONObject) -> Unit, +) : AbstractGetHostInfoTask(host, port, callback) { + val ctx: WeakReference = WeakReference(context) + + override fun getTask(): FtpsGetHostCertificateTaskCallable = FtpsGetHostCertificateTaskCallable(host, port, explicitTls) +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ftp/hostcert/FtpsGetHostCertificateTaskCallable.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ftp/hostcert/FtpsGetHostCertificateTaskCallable.kt new file mode 100644 index 0000000..42373ce --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ftp/hostcert/FtpsGetHostCertificateTaskCallable.kt @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.ftp.hostcert + +import androidx.annotation.WorkerThread +import com.amaze.filemanager.filesystem.ftp.FTPClientImpl.Companion.ARG_TLS +import com.amaze.filemanager.filesystem.ftp.FTPClientImpl.Companion.TLS_EXPLICIT +import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool +import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.CONNECT_TIMEOUT +import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.FTPS_URI_PREFIX +import com.amaze.filemanager.utils.X509CertificateUtil +import org.apache.commons.net.ftp.FTPSClient +import org.json.JSONObject +import java.util.concurrent.Callable +import java.util.concurrent.CountDownLatch +import javax.net.ssl.HostnameVerifier + +open class FtpsGetHostCertificateTaskCallable( + private val hostname: String, + private val port: Int, + private val explicitTls: Boolean = false, +) : Callable { + @WorkerThread + override fun call(): JSONObject? { + val latch = CountDownLatch(1) + var result: JSONObject? = null + val ftpClient = createFTPClient() + ftpClient.connectTimeout = CONNECT_TIMEOUT + ftpClient.controlEncoding = Charsets.UTF_8.name() + ftpClient.hostnameVerifier = + HostnameVerifier { _, session -> + if (session.peerCertificateChain.isNotEmpty()) { + val certinfo = X509CertificateUtil.parse(session.peerCertificateChain[0]) + result = JSONObject(certinfo) + } + latch.countDown() + true + } + ftpClient.connect(hostname, port) + latch.await() + ftpClient.disconnect() + return result + } + + protected open fun createFTPClient(): FTPSClient = + NetCopyClientConnectionPool.ftpClientFactory.create( + if (explicitTls) { + "$FTPS_URI_PREFIX?$ARG_TLS=$TLS_EXPLICIT" + } else { + FTPS_URI_PREFIX + }, + ) as FTPSClient +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/hashcalculator/CalculateHashCallback.java b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/hashcalculator/CalculateHashCallback.java new file mode 100644 index 0000000..67a2612 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/hashcalculator/CalculateHashCallback.java @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.hashcalculator; + +import java.io.IOException; +import java.io.InputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Objects; +import java.util.concurrent.Callable; + +import com.amaze.filemanager.filesystem.HybridFileParcelable; +import com.amaze.filemanager.filesystem.files.GenericCopyUtil; + +import android.content.Context; + +import androidx.annotation.WorkerThread; + +/** Generates hashes from files (MD5 and SHA256) */ +public class CalculateHashCallback implements Callable { + + private InputStream inputStreamMd5; + private InputStream inputStreamSha; + private final HybridFileParcelable file; + private final Context context; + + public CalculateHashCallback(HybridFileParcelable file, final Context context) { + if (file.isSftp()) { + throw new IllegalArgumentException("Use CalculateHashSftpCallback"); + } + this.context = context; + this.file = file; + } + + @WorkerThread + @Override + public Hash call() throws Exception { + boolean isNotADirectory = !file.isDirectory(context); + this.inputStreamMd5 = file.getInputStream(context); + this.inputStreamSha = file.getInputStream(context); + + String md5 = null; + String sha256 = null; + + if (isNotADirectory) { + md5 = getMD5Checksum(); + sha256 = getSHA256Checksum(); + } + + Objects.requireNonNull(md5); + Objects.requireNonNull(sha256); + + return new Hash(md5, sha256); + } + + // see this How-to for a faster way to convert a byte array to a HEX string + + private String getMD5Checksum() throws Exception { + byte[] b = createChecksum(); + String result = ""; + + for (byte aB : b) { + result += Integer.toString((aB & 0xff) + 0x100, 16).substring(1); + } + return result; + } + + private String getSHA256Checksum() throws NoSuchAlgorithmException, IOException { + MessageDigest messageDigest = MessageDigest.getInstance("SHA-256"); + byte[] input = new byte[GenericCopyUtil.DEFAULT_BUFFER_SIZE]; + int length; + InputStream inputStream = inputStreamMd5; + while ((length = inputStream.read(input)) != -1) { + if (length > 0) messageDigest.update(input, 0, length); + } + + byte[] hash = messageDigest.digest(); + + StringBuilder hexString = new StringBuilder(); + + for (byte aHash : hash) { + // convert hash to base 16 + String hex = Integer.toHexString(0xff & aHash); + if (hex.length() == 1) hexString.append('0'); + hexString.append(hex); + } + inputStream.close(); + return hexString.toString(); + } + + private byte[] createChecksum() throws Exception { + InputStream fis = inputStreamSha; + + byte[] buffer = new byte[8192]; + MessageDigest complete = MessageDigest.getInstance("MD5"); + int numRead; + + do { + numRead = fis.read(buffer); + if (numRead > 0) { + complete.update(buffer, 0, numRead); + } + } while (numRead != -1); + + fis.close(); + return complete.digest(); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/hashcalculator/CalculateHashSftpCallback.java b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/hashcalculator/CalculateHashSftpCallback.java new file mode 100644 index 0000000..8cd43cc --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/hashcalculator/CalculateHashSftpCallback.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.hashcalculator; + +import java.io.IOException; +import java.util.Objects; +import java.util.concurrent.Callable; + +import com.amaze.filemanager.filesystem.HybridFileParcelable; +import com.amaze.filemanager.filesystem.ftp.NetCopyClientUtils; +import com.amaze.filemanager.filesystem.ssh.SshClientSessionTemplate; +import com.amaze.filemanager.filesystem.ssh.SshClientUtils; + +import androidx.annotation.WorkerThread; + +import net.schmizz.sshj.common.IOUtils; +import net.schmizz.sshj.connection.channel.direct.Session; + +public class CalculateHashSftpCallback implements Callable { + private final HybridFileParcelable file; + + public CalculateHashSftpCallback(HybridFileParcelable file) { + if (!file.isSftp()) { + throw new IllegalArgumentException("Use CalculateHashCallback"); + } + + this.file = file; + } + + @WorkerThread + @Override + public Hash call() throws Exception { + String md5Command = "md5sum -b \"%s\" | cut -c -32"; + String shaCommand = "sha256sum -b \"%s\" | cut -c -64"; + + String md5 = SshClientUtils.execute(getHash(md5Command)); + String sha256 = SshClientUtils.execute(getHash(shaCommand)); + + Objects.requireNonNull(md5); + Objects.requireNonNull(sha256); + + return new Hash(md5, sha256); + } + + private SshClientSessionTemplate getHash(String command) { + return new SshClientSessionTemplate(file.getPath()) { + @Override + public String execute(Session session) throws IOException { + String path = NetCopyClientUtils.INSTANCE.extractRemotePathFrom(file.getPath()); + String fullCommand = String.format(command, path); + Session.Command cmd = session.exec(fullCommand); + String result = new String(IOUtils.readFully(cmd.getInputStream()).toByteArray()); + cmd.close(); + if (cmd.getExitStatus() == 0) { + return result; + } else { + return null; + } + } + }; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/hashcalculator/CalculateHashTask.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/hashcalculator/CalculateHashTask.kt new file mode 100644 index 0000000..aaac1a2 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/hashcalculator/CalculateHashTask.kt @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.hashcalculator + +import android.content.Context +import android.view.View +import android.widget.LinearLayout +import android.widget.Toast +import androidx.appcompat.widget.AppCompatTextView +import com.amaze.filemanager.R +import com.amaze.filemanager.asynchronous.asynctasks.Task +import com.amaze.filemanager.filesystem.HybridFileParcelable +import com.amaze.filemanager.filesystem.files.FileUtils +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.lang.ref.WeakReference +import java.util.Locale +import java.util.concurrent.Callable + +data class Hash(val md5: String, val sha: String) + +class CalculateHashTask( + private val file: HybridFileParcelable, + context: Context, + view: View, +) : Task> { + private val log: Logger = LoggerFactory.getLogger(CalculateHashTask::class.java) + + private val task: Callable = + if (file.isSftp && !file.isDirectory(context)) { + CalculateHashSftpCallback(file) + } else if (file.isFtp || file.isDirectory(context)) { + // Don't do this. Especially when FTPClient requires thread safety. + DoNothingCalculateHashCallback() + } else { + CalculateHashCallback(file, context) + } + + private val context = WeakReference(context) + private val view = WeakReference(view) + + override fun getTask(): Callable = task + + override fun onError(error: Throwable) { + log.error("Error on calculate hash", error) + updateView(null) + } + + override fun onFinish(value: Hash) { + updateView(value) + } + + private fun updateView(hashes: Hash?) { + val context = context.get() + context ?: return + + val view = view.get() + view ?: return + + val md5Text = hashes?.md5 ?: context.getString(R.string.unavailable) + val shaText = hashes?.sha ?: context.getString(R.string.unavailable) + + val md5HashText = view.findViewById(R.id.t9) + val sha256Text = view.findViewById(R.id.t10) + + val mMD5LinearLayout = view.findViewById(R.id.properties_dialog_md5) + val mSHA256LinearLayout = view.findViewById(R.id.properties_dialog_sha256) + + if (!file.isDirectory(context) && file.getSize() != 0L) { + md5HashText.text = md5Text + sha256Text.text = shaText + mMD5LinearLayout.setOnLongClickListener { + FileUtils.copyToClipboard(context, md5Text) + Toast.makeText( + context, + context.resources.getString(R.string.md5).uppercase(Locale.getDefault()) + + " " + + context.resources.getString(R.string.properties_copied_clipboard), + Toast.LENGTH_SHORT, + ) + .show() + false + } + mSHA256LinearLayout.setOnLongClickListener { + FileUtils.copyToClipboard(context, shaText) + Toast.makeText( + context, + context.resources.getString(R.string.hash_sha256) + " " + + context.resources.getString(R.string.properties_copied_clipboard), + Toast.LENGTH_SHORT, + ) + .show() + false + } + } else { + mMD5LinearLayout.visibility = View.GONE + mSHA256LinearLayout.visibility = View.GONE + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/hashcalculator/DoNothingCalculateHashCallback.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/hashcalculator/DoNothingCalculateHashCallback.kt new file mode 100644 index 0000000..1c5b19b --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/hashcalculator/DoNothingCalculateHashCallback.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.hashcalculator + +import java.util.concurrent.Callable + +/** + * A do-nothing callback that will not perform calculations on file hashes. + */ +class DoNothingCalculateHashCallback : Callable { + override fun call(): Hash = Hash("", "") +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/movecopy/MoveFiles.java b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/movecopy/MoveFiles.java new file mode 100644 index 0000000..394a66b --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/movecopy/MoveFiles.java @@ -0,0 +1,179 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.movecopy; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.concurrent.Callable; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.amaze.filemanager.fileoperations.exceptions.ShellNotRunningException; +import com.amaze.filemanager.fileoperations.filesystem.OpenMode; +import com.amaze.filemanager.filesystem.HybridFile; +import com.amaze.filemanager.filesystem.HybridFileParcelable; +import com.amaze.filemanager.filesystem.Operations; +import com.amaze.filemanager.filesystem.cloud.CloudUtil; +import com.amaze.filemanager.filesystem.files.FileUtils; +import com.amaze.filemanager.filesystem.root.RenameFileCommand; +import com.amaze.filemanager.utils.DataUtils; +import com.cloudrail.si.interfaces.CloudStorage; + +import android.content.Context; + +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +/** + * AsyncTask that moves files from source to destination by trying to rename files first, if they're + * in the same filesystem, else starting the copy service. Be advised - do not start this AsyncTask + * directly but use {@link PreparePasteTask} instead + */ +public class MoveFiles implements Callable { + + private final Logger LOG = LoggerFactory.getLogger(MoveFiles.class); + + private final ArrayList> files; + private final ArrayList paths; + private final Context context; + private final OpenMode mode; + private long totalBytes = 0L; + private final boolean isRootExplorer; + + public MoveFiles( + ArrayList> files, + boolean isRootExplorer, + Context context, + OpenMode mode, + ArrayList paths) { + this.context = context; + this.files = files; + this.mode = mode; + this.isRootExplorer = isRootExplorer; + this.paths = paths; + } + + @WorkerThread + @Override + public MoveFilesReturn call() { + if (files.size() == 0) { + return new MoveFilesReturn(true, false, 0, 0); + } + + for (ArrayList filesCurrent : files) { + totalBytes += FileUtils.getTotalBytes(filesCurrent, context); + } + HybridFile destination = new HybridFile(mode, paths.get(0)); + long destinationSize = destination.getUsableSpace(); + + for (int i = 0; i < paths.size(); i++) { + for (HybridFileParcelable baseFile : files.get(i)) { + final MoveFilesReturn r = processFile(baseFile, paths.get(i), destinationSize); + if (r != null) { + return r; + } + } + } + return new MoveFilesReturn(true, false, destinationSize, totalBytes); + } + + @Nullable + private MoveFilesReturn processFile( + HybridFileParcelable baseFile, String path, long destinationSize) { + String destPath = path + "/" + baseFile.getName(context); + if (baseFile.getPath().indexOf('?') > 0) + destPath += baseFile.getPath().substring(baseFile.getPath().indexOf('?')); + if (!isMoveOperationValid(baseFile, new HybridFile(mode, path))) { + // TODO: 30/06/20 Replace runtime exception with generic exception + LOG.warn("Some files failed to be moved", new RuntimeException()); + return new MoveFilesReturn(false, true, destinationSize, totalBytes); + } + switch (mode) { + case FILE: + File dest = new File(destPath); + File source = new File(baseFile.getPath()); + if (!source.renameTo(dest)) { + + // check if we have root + if (isRootExplorer) { + try { + if (!RenameFileCommand.INSTANCE.renameFile(baseFile.getPath(), destPath)) { + return new MoveFilesReturn(false, false, destinationSize, totalBytes); + } + } catch (ShellNotRunningException e) { + LOG.warn("failed to move file in local filesystem", e); + return new MoveFilesReturn(false, false, destinationSize, totalBytes); + } + } else { + return new MoveFilesReturn(false, false, destinationSize, totalBytes); + } + } + break; + case DROPBOX: + case BOX: + case ONEDRIVE: + case GDRIVE: + DataUtils dataUtils = DataUtils.getInstance(); + + CloudStorage cloudStorage = dataUtils.getAccount(mode); + if (baseFile.getMode() == mode) { + // source and target both in same filesystem, use API method + try { + cloudStorage.move( + CloudUtil.stripPath(mode, baseFile.getPath()), CloudUtil.stripPath(mode, destPath)); + } catch (RuntimeException e) { + LOG.warn("failed to move file in cloud filesystem", e); + return new MoveFilesReturn(false, false, destinationSize, totalBytes); + } + } else { + // not in same filesystem, execute service + return new MoveFilesReturn(false, false, destinationSize, totalBytes); + } + default: + return new MoveFilesReturn(false, false, destinationSize, totalBytes); + } + + return null; + } + + private boolean isMoveOperationValid(HybridFileParcelable sourceFile, HybridFile targetFile) { + return !Operations.isCopyLoopPossible(sourceFile, targetFile) && sourceFile.exists(context); + } + + /** + * Maintains a list of filesystems supporting the move/rename implementation. Please update to + * return your {@link OpenMode} type if it is supported here + * + * @return + */ + public static HashSet getOperationSupportedFileSystem() { + HashSet hashSet = new HashSet<>(); + hashSet.add(OpenMode.SMB); + hashSet.add(OpenMode.FILE); + hashSet.add(OpenMode.DROPBOX); + hashSet.add(OpenMode.BOX); + hashSet.add(OpenMode.GDRIVE); + hashSet.add(OpenMode.ONEDRIVE); + return hashSet; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/movecopy/MoveFilesTask.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/movecopy/MoveFilesTask.kt new file mode 100644 index 0000000..9d9a3b6 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/movecopy/MoveFilesTask.kt @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.movecopy + +import android.content.Context +import android.content.Intent +import android.widget.Toast +import com.amaze.filemanager.R +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.asynchronous.asynctasks.Task +import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil +import com.amaze.filemanager.asynchronous.services.CopyService +import com.amaze.filemanager.database.CryptHandler +import com.amaze.filemanager.database.models.explorer.EncryptedEntry +import com.amaze.filemanager.fileoperations.filesystem.OpenMode +import com.amaze.filemanager.filesystem.HybridFile +import com.amaze.filemanager.filesystem.HybridFileParcelable +import com.amaze.filemanager.filesystem.files.CryptUtil +import com.amaze.filemanager.filesystem.files.MediaConnectionUtils +import com.amaze.filemanager.ui.activities.MainActivity +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +data class MoveFilesReturn( + val movedCorrectly: Boolean, + val invalidOperation: Boolean, + val destinationSize: Long, + val totalSize: Long, +) + +class MoveFilesTask( + val files: ArrayList>, + val isRootExplorer: Boolean, + val currentPath: String, + context: Context, + val mode: OpenMode, + val paths: ArrayList, +) : Task { + private val log: Logger = LoggerFactory.getLogger(MoveFilesTask::class.java) + + private val task: MoveFiles = MoveFiles(files, isRootExplorer, context, mode, paths) + private val applicationContext: Context = context.applicationContext + + override fun getTask(): MoveFiles = task + + override fun onError(error: Throwable) { + log.error("Unexpected error on file move: ", error) + } + + override fun onFinish(value: MoveFilesReturn) { + val (movedCorrectly, invalidOperation, destinationSize, totalBytes) = value + + if (movedCorrectly) { + onMovedCorrectly(invalidOperation) + } else { + onMovedFail(destinationSize, totalBytes) + } + } + + private fun onMovedCorrectly(invalidOperation: Boolean) { + if (currentPath == paths[0]) { + // mainFrag.updateList(); + val intent = Intent(MainActivity.KEY_INTENT_LOAD_LIST) + intent.putExtra(MainActivity.KEY_INTENT_LOAD_LIST_FILE, paths[0]) + applicationContext.sendBroadcast(intent) + } + + if (invalidOperation) { + Toast.makeText( + applicationContext, + R.string.some_files_failed_invalid_operation, + Toast.LENGTH_LONG, + ) + .show() + } + + for (i in paths.indices) { + val targetFiles: MutableList = ArrayList() + val sourcesFiles: MutableList = ArrayList() + for (f in files[i]) { + val file = + HybridFile( + OpenMode.FILE, + paths[i] + "/" + f.getName(applicationContext), + ) + targetFiles.add(file) + } + for (hybridFileParcelables in files) { + sourcesFiles.addAll(hybridFileParcelables) + } + MediaConnectionUtils.scanFile(applicationContext, sourcesFiles.toTypedArray()) + MediaConnectionUtils.scanFile(applicationContext, targetFiles.toTypedArray()) + } + + // updating encrypted db entry if any encrypted file was moved + AppConfig.getInstance() + .runInBackground { + for (i in paths.indices) { + for (file in files[i]) { + if (file.getName(applicationContext).endsWith(CryptUtil.CRYPT_EXTENSION)) { + val oldEntry = CryptHandler.findEntry(file.path) + if (oldEntry != null) { + val newEntry = EncryptedEntry() + newEntry.id = oldEntry.id + newEntry.password = oldEntry.password + newEntry.path = paths[i] + "/" + file.getName(applicationContext) + CryptHandler.updateEntry(oldEntry, newEntry) + } + } + } + } + } + } + + private fun onMovedFail( + destinationSize: Long, + totalBytes: Long, + ) { + if (totalBytes > 0 && destinationSize < totalBytes) { + // destination don't have enough space; return + Toast.makeText( + applicationContext, + applicationContext.resources.getString(R.string.in_safe), + Toast.LENGTH_LONG, + ) + .show() + return + } + for (i in paths.indices) { + val intent = Intent(applicationContext, CopyService::class.java) + intent.putExtra(CopyService.TAG_COPY_SOURCES, files[i]) + intent.putExtra(CopyService.TAG_COPY_TARGET, paths[i]) + intent.putExtra(CopyService.TAG_COPY_MOVE, true) + intent.putExtra(CopyService.TAG_COPY_OPEN_MODE, mode.ordinal) + intent.putExtra(CopyService.TAG_IS_ROOT_EXPLORER, isRootExplorer) + ServiceWatcherUtil.runService(applicationContext, intent) + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/movecopy/PreparePasteTask.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/movecopy/PreparePasteTask.kt new file mode 100644 index 0000000..e8e3531 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/movecopy/PreparePasteTask.kt @@ -0,0 +1,490 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.movecopy + +import android.app.ProgressDialog +import android.content.Intent +import android.view.LayoutInflater +import android.widget.Toast +import androidx.appcompat.widget.AppCompatCheckBox +import com.afollestad.materialdialogs.DialogAction +import com.afollestad.materialdialogs.MaterialDialog +import com.amaze.filemanager.R +import com.amaze.filemanager.asynchronous.asynctasks.fromTask +import com.amaze.filemanager.asynchronous.asynctasks.movecopy.PreparePasteTask.CopyNode +import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil +import com.amaze.filemanager.asynchronous.services.CopyService +import com.amaze.filemanager.databinding.CopyDialogBinding +import com.amaze.filemanager.fileoperations.filesystem.CAN_CREATE_FILES +import com.amaze.filemanager.fileoperations.filesystem.COPY +import com.amaze.filemanager.fileoperations.filesystem.FolderState +import com.amaze.filemanager.fileoperations.filesystem.MOVE +import com.amaze.filemanager.fileoperations.filesystem.OpenMode +import com.amaze.filemanager.filesystem.FilenameHelper +import com.amaze.filemanager.filesystem.HybridFile +import com.amaze.filemanager.filesystem.HybridFileParcelable +import com.amaze.filemanager.filesystem.MakeDirectoryOperation +import com.amaze.filemanager.filesystem.files.FileUtils +import com.amaze.filemanager.ui.activities.MainActivity +import com.amaze.filemanager.utils.OnFileFound +import com.amaze.filemanager.utils.Utils +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.lang.ref.WeakReference +import java.util.LinkedList + +/** + * This helper class works by checking the conflicts during paste operation. After checking + * conflicts [MaterialDialog] is shown to user for each conflicting file. If the conflicting file + * is a directory, the conflicts are resolved by inserting a node in [CopyNode] tree and then doing + * BFS on this tree. + */ +class PreparePasteTask(strongRefMain: MainActivity) { + private lateinit var targetPath: String + private var isMove = false + private var isRootMode = false + private lateinit var openMode: OpenMode + private lateinit var filesToCopy: MutableList + + private val pathsList = ArrayList() + private val filesToCopyPerFolder = ArrayList>() + + private val context = WeakReference(strongRefMain) + + @Suppress("DEPRECATION") + private var progressDialog: ProgressDialog? = null + private val coroutineScope = CoroutineScope(Job() + Dispatchers.Default) + + private lateinit var destination: HybridFile + private val conflictingFiles: MutableList = mutableListOf() + private val conflictingDirActionMap = HashMap() + + private var skipAll = false + private var renameAll = false + private var overwriteAll = false + + private fun startService( + sourceFiles: ArrayList, + target: String, + openMode: OpenMode, + isMove: Boolean, + isRootMode: Boolean, + ) { + val intent = Intent(context.get(), CopyService::class.java) + intent.putParcelableArrayListExtra(CopyService.TAG_COPY_SOURCES, sourceFiles) + intent.putExtra(CopyService.TAG_COPY_TARGET, target) + intent.putExtra(CopyService.TAG_COPY_OPEN_MODE, openMode.ordinal) + intent.putExtra(CopyService.TAG_COPY_MOVE, isMove) + intent.putExtra(CopyService.TAG_IS_ROOT_EXPLORER, isRootMode) + ServiceWatcherUtil.runService(context.get(), intent) + } + + /** + * Starts execution of [PreparePasteTask] class. + */ + fun execute( + targetPath: String, + isMove: Boolean, + isRootMode: Boolean, + openMode: OpenMode, + filesToCopy: ArrayList, + ) { + this.targetPath = targetPath + this.isMove = isMove + this.isRootMode = isRootMode + this.openMode = openMode + this.filesToCopy = filesToCopy + + val isCloudOrRootMode = + openMode == OpenMode.OTG || + openMode == OpenMode.GDRIVE || + openMode == OpenMode.DROPBOX || + openMode == OpenMode.BOX || + openMode == OpenMode.ONEDRIVE || + openMode == OpenMode.ROOT + + if (isCloudOrRootMode) { + startService(filesToCopy, targetPath, openMode, isMove, isRootMode) + return + } + + val totalBytes = FileUtils.getTotalBytes(filesToCopy, context.get()) + destination = HybridFile(openMode, targetPath) + destination.generateMode(context.get()) + + if (filesToCopy.isNotEmpty() && + isMove && + filesToCopy[0].getParent(context.get()) == targetPath + ) { + Toast.makeText(context.get(), R.string.same_dir_move_error, Toast.LENGTH_SHORT).show() + return + } + + val isMoveSupported = + isMove && + destination.mode == openMode && + MoveFiles.getOperationSupportedFileSystem().contains(openMode) + + if (destination.usableSpace < totalBytes && + !isMoveSupported + ) { + Toast.makeText(context.get(), R.string.in_safe, Toast.LENGTH_SHORT).show() + return + } + @Suppress("DEPRECATION") + progressDialog = + ProgressDialog.show( + context.get(), + "", + context.get()?.getString(R.string.checking_conflicts), + ) + checkConflicts( + isRootMode, + filesToCopy, + destination, + conflictingFiles, + conflictingDirActionMap, + ) + } + + private fun checkConflicts( + isRootMode: Boolean, + filesToCopy: ArrayList, + destination: HybridFile, + conflictingFiles: MutableList, + conflictingDirActionMap: HashMap, + ) { + coroutineScope.launch { + destination.forEachChildrenFile( + context.get(), + isRootMode, + object : OnFileFound { + override fun onFileFound(file: HybridFileParcelable) { + for (fileToCopy in filesToCopy) { + if (file.getName(context.get()) == fileToCopy.getName(context.get())) { + conflictingFiles.add(fileToCopy) + } + } + } + }, + ) + withContext(Dispatchers.Main) { + prepareDialog(conflictingFiles, conflictingDirActionMap) + @Suppress("DEPRECATION") + progressDialog?.setMessage(context.get()?.getString(R.string.copying)) + } + resolveConflict(conflictingFiles, conflictingDirActionMap, filesToCopy) + } + } + + private suspend fun prepareDialog( + conflictingFiles: MutableList, + conflictingDirActionMap: HashMap, + ) { + if (conflictingFiles.isEmpty()) return + + val contextRef = context.get() ?: return + val accentColor = contextRef.accent + val dialogBuilder = MaterialDialog.Builder(contextRef) + val copyDialogBinding: CopyDialogBinding = + CopyDialogBinding.inflate(LayoutInflater.from(contextRef)) + dialogBuilder.customView(copyDialogBinding.root, true) + val checkBox: AppCompatCheckBox = copyDialogBinding.checkBox + + Utils.setTint(contextRef, checkBox, accentColor) + dialogBuilder.theme(contextRef.appTheme.getMaterialDialogTheme()) + dialogBuilder.title(contextRef.resources.getString(R.string.paste)) + dialogBuilder.positiveText(R.string.rename) + dialogBuilder.neutralText(R.string.skip) + dialogBuilder.positiveColor(accentColor) + dialogBuilder.negativeColor(accentColor) + dialogBuilder.neutralColor(accentColor) + dialogBuilder.negativeText(R.string.overwrite) + dialogBuilder.cancelable(false) + showDialog( + conflictingFiles, + conflictingDirActionMap, + copyDialogBinding, + dialogBuilder, + checkBox, + ) + } + + private suspend fun showDialog( + conflictingFiles: MutableList, + conflictingDirActionMap: HashMap, + copyDialogBinding: CopyDialogBinding, + dialogBuilder: MaterialDialog.Builder, + checkBox: AppCompatCheckBox, + ) { + val iterator = conflictingFiles.iterator() + while (iterator.hasNext()) { + val hybridFileParcelable = iterator.next() + copyDialogBinding.fileNameText.text = hybridFileParcelable.name + val dialog = dialogBuilder.build() + if (hybridFileParcelable.getParent(context.get()) == targetPath) { + dialog.getActionButton(DialogAction.NEGATIVE) + .isEnabled = false + } + val resultDeferred = CompletableDeferred() + dialogBuilder.onPositive { _, _ -> + resultDeferred.complete(DialogAction.POSITIVE) + } + dialogBuilder.onNegative { _, _ -> + resultDeferred.complete(DialogAction.NEGATIVE) + } + dialogBuilder.onNeutral { _, _ -> + resultDeferred.complete(DialogAction.NEUTRAL) + } + dialog.show() + when (resultDeferred.await()) { + DialogAction.POSITIVE -> { + if (checkBox.isChecked) { + renameAll = true + return + } + conflictingDirActionMap[hybridFileParcelable] = Action.RENAME + } + DialogAction.NEGATIVE -> { + if (checkBox.isChecked) { + overwriteAll = true + return + } + conflictingDirActionMap[hybridFileParcelable] = Action.OVERWRITE + } + DialogAction.NEUTRAL -> { + if (checkBox.isChecked) { + skipAll = true + return + } + conflictingDirActionMap[hybridFileParcelable] = Action.SKIP + } + } + iterator.remove() + } + } + + private fun resolveConflict( + conflictingFiles: MutableList, + conflictingDirActionMap: HashMap, + filesToCopy: ArrayList, + ) = coroutineScope.launch { + var index = conflictingFiles.size - 1 + if (renameAll) { + while (conflictingFiles.isNotEmpty()) { + conflictingDirActionMap[conflictingFiles[index]] = Action.RENAME + conflictingFiles.removeAt(index) + index-- + } + } else if (overwriteAll) { + while (conflictingFiles.isNotEmpty()) { + conflictingDirActionMap[conflictingFiles[index]] = Action.OVERWRITE + conflictingFiles.removeAt(index) + index-- + } + } else if (skipAll) { + while (conflictingFiles.isNotEmpty()) { + filesToCopy.remove(conflictingFiles.removeAt(index)) + index-- + } + } + + val rootNode = CopyNode(targetPath, ArrayList(filesToCopy)) + var currentNode: CopyNode? = rootNode.startCopy() + + while (currentNode != null) { + pathsList.add(currentNode.path) + filesToCopyPerFolder.add(currentNode.filesToCopy) + currentNode = rootNode.goToNextNode() + } + finishCopying() + } + + private suspend fun finishCopying() { + var index = 0 + while (index < filesToCopyPerFolder.size) { + if (filesToCopyPerFolder[index].size == 0) { + filesToCopyPerFolder.removeAt(index) + pathsList.removeAt(index) + index-- + } + index++ + } + if (filesToCopyPerFolder.isNotEmpty()) { + @FolderState + val mode: Int = + context.get()?.mainActivityHelper!! + .checkFolder(targetPath, openMode, context.get()) + if (mode == CAN_CREATE_FILES && !targetPath.contains("otg:/")) { + // This is used because in newer devices the user has to accept a permission, + // see MainActivity.onActivityResult() + context.get()?.oparrayListList = filesToCopyPerFolder + context.get()?.oparrayList = null + context.get()?.operation = if (isMove) MOVE else COPY + context.get()?.oppatheList = pathsList + } else { + if (!isMove) { + for (foldersIndex in filesToCopyPerFolder.indices) + startService( + filesToCopyPerFolder[foldersIndex], + pathsList[foldersIndex], + openMode, + isMove, + isRootMode, + ) + } else { + fromTask( + MoveFilesTask( + filesToCopyPerFolder, + isRootMode, + targetPath, + context.get()!!, + openMode, + pathsList, + ), + ) + } + } + } else { + withContext(Dispatchers.Main) { + Toast.makeText( + context.get(), + context.get()!!.resources.getString(R.string.no_file_overwrite), + Toast.LENGTH_SHORT, + ).show() + } + } + withContext(Dispatchers.Main) { + progressDialog?.dismiss() + } + coroutineScope.cancel() + } + + private inner class CopyNode( + val path: String, + val filesToCopy: ArrayList, + ) { + private val nextNodes: MutableList = mutableListOf() + private var queue: LinkedList? = null + private var visited: HashSet? = null + + init { + val iterator = filesToCopy.iterator() + while (iterator.hasNext()) { + val hybridFileParcelable = iterator.next() + if (conflictingDirActionMap.contains(hybridFileParcelable)) { + val fileAtTarget = + HybridFile( + hybridFileParcelable.mode, + path, + hybridFileParcelable.name, + hybridFileParcelable.isDirectory, + ) + when (conflictingDirActionMap[hybridFileParcelable]) { + Action.RENAME -> { + if (hybridFileParcelable.isDirectory) { + val newName = + FilenameHelper.increment(fileAtTarget).getName(context.get()) + val newPath = "$path/$newName" + val newDir = HybridFile(hybridFileParcelable.mode, newPath) + MakeDirectoryOperation.mkdirs(context.get()!!, newDir) + @Suppress("DEPRECATION") + nextNodes.add( + CopyNode( + newPath, + hybridFileParcelable.listFiles(context.get(), isRootMode), + ), + ) + iterator.remove() + } else { + filesToCopy[filesToCopy.indexOf(hybridFileParcelable)].name = + FilenameHelper.increment( + fileAtTarget, + ).getName(context.get()) + } + } + + Action.SKIP -> iterator.remove() + } + } + } + } + + /** + * Starts BFS traversal of tree. + * + * @return Root node + */ + fun startCopy(): CopyNode { + queue = LinkedList() + visited = HashSet() + queue!!.add(this) + visited!!.add(this) + return this + } + + /** + * Moves to the next unvisited node in tree. + * + * @return The next unvisited node if available, otherwise returns null. + */ + fun goToNextNode(): CopyNode? = + if (queue.isNullOrEmpty()) { + null + } else { + val node = queue!!.element() + val child = getUnvisitedChildNode(visited!!, node) + if (child != null) { + visited!!.add(child) + queue!!.add(child) + child + } else { + queue!!.remove() + goToNextNode() + } + } + + private fun getUnvisitedChildNode( + visited: Set, + node: CopyNode, + ): CopyNode? { + for (currentNode in node.nextNodes) { + if (!visited.contains(currentNode)) { + return currentNode + } + } + return null + } + } + + private class Action { + companion object { + const val SKIP = "skip" + const val RENAME = "rename" + const val OVERWRITE = "overwrite" + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/BasicSearch.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/BasicSearch.kt new file mode 100644 index 0000000..46bf493 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/BasicSearch.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.searchfilesystem + +import android.content.Context +import com.amaze.filemanager.filesystem.HybridFileParcelable +import com.amaze.filemanager.filesystem.root.ListFilesCommand.listFiles + +class BasicSearch( + query: String, + path: String, + searchParameters: SearchParameters, + context: Context, +) : FileSearch(query, path, searchParameters) { + private val applicationContext = context.applicationContext + + override suspend fun search(filter: SearchFilter) { + listFiles( + path, + SearchParameter.ROOT in searchParameters, + SearchParameter.SHOW_HIDDEN_FILES in searchParameters, + { }, + ) { hybridFileParcelable: HybridFileParcelable -> + if (SearchParameter.SHOW_HIDDEN_FILES in searchParameters || + !hybridFileParcelable.isHidden + ) { + val resultRange = + filter.searchFilter(hybridFileParcelable.getName(applicationContext)) + if (resultRange != null) { + publishProgress(hybridFileParcelable, resultRange) + } + } + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/DeepSearch.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/DeepSearch.kt new file mode 100644 index 0000000..227626b --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/DeepSearch.kt @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.searchfilesystem + +import android.content.Context +import com.amaze.filemanager.fileoperations.filesystem.OpenMode +import com.amaze.filemanager.filesystem.HybridFile +import kotlinx.coroutines.isActive +import org.slf4j.LoggerFactory +import kotlin.coroutines.coroutineContext + +class DeepSearch( + query: String, + path: String, + searchParameters: SearchParameters, + context: Context, + private val openMode: OpenMode, +) : FileSearch(query, path, searchParameters) { + private val LOG = LoggerFactory.getLogger(DeepSearch::class.java) + + private val applicationContext: Context + + init { + applicationContext = context.applicationContext + } + + /** + * Search for occurrences of a given text in file names and publish the result + * + * @param directory the current path + */ + override suspend fun search(filter: SearchFilter) { + val directory = HybridFile(openMode, path) + if (directory.isSmb) return + + if (directory.isDirectory(applicationContext)) { + // you have permission to read this directory + val worklist = ArrayDeque() + worklist.add(directory) + while (coroutineContext.isActive && worklist.isNotEmpty()) { + val nextFile = worklist.removeFirst() + nextFile.forEachChildrenFile( + applicationContext, + SearchParameter.ROOT in searchParameters, + ) { file -> + if (!file.isHidden || SearchParameter.SHOW_HIDDEN_FILES in searchParameters) { + val resultRange = filter.searchFilter(file.getName(applicationContext)) + if (resultRange != null) { + publishProgress(file, resultRange) + } + if (file.isDirectory(applicationContext)) { + worklist.add(file) + } + } + } + } + } else { + LOG.warn("Cannot search " + directory.path + ": Permission Denied") + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/FileSearch.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/FileSearch.kt new file mode 100644 index 0000000..a920e70 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/FileSearch.kt @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.searchfilesystem + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.amaze.filemanager.asynchronous.asynctasks.searchfilesystem.FileSearch.SearchFilter +import com.amaze.filemanager.filesystem.HybridFileParcelable +import java.util.Locale +import java.util.regex.Pattern + +abstract class FileSearch( + protected val query: String, + protected val path: String, + protected val searchParameters: SearchParameters, +) { + private val mutableFoundFilesLiveData: MutableLiveData> = + MutableLiveData() + val foundFilesLiveData: LiveData> = mutableFoundFilesLiveData + private val foundFilesList: MutableList = mutableListOf() + + /** + * Search for files, whose names match [query], starting from [path] and add them to + * [foundFilesLiveData] + */ + suspend fun search() { + if (SearchParameter.REGEX !in searchParameters) { + // regex not turned on so we use simpleFilter + this.search(simpleFilter(query)) + } else { + if (SearchParameter.REGEX_MATCHES !in searchParameters) { + // only regex turned on so we use regexFilter + this.search(regexFilter(query)) + } else { + // regex turned on and names must match pattern so use regexMatchFilter + this.search(regexMatchFilter(query)) + } + } + } + + /** + * Search for files, whose names fulfill [filter], starting from [path] and add them to + * [foundFilesLiveData]. + */ + protected abstract suspend fun search(filter: SearchFilter) + + /** + * Add [file] to list of found files and post it to [foundFilesLiveData] + */ + protected fun publishProgress( + file: HybridFileParcelable, + matchRange: MatchRange, + ) { + foundFilesList.add(SearchResult(file, matchRange)) + mutableFoundFilesLiveData.postValue(foundFilesList) + } + + private fun simpleFilter(query: String): SearchFilter = + SearchFilter { fileName -> + // check case-insensitively if query is contained in fileName + val start = + fileName.lowercase(Locale.getDefault()).indexOf( + query.lowercase( + Locale.getDefault(), + ), + ) + if (start >= 0) { + start until start + query.length + } else { + null + } + } + + private fun regexFilter(query: String): SearchFilter { + val pattern = regexPattern(query) + return SearchFilter { fileName -> + // check case-insensitively if the pattern compiled from query can be found in fileName + val matcher = pattern.matcher(fileName) + if (matcher.find()) { + matcher.start() until matcher.end() + } else { + null + } + } + } + + private fun regexMatchFilter(query: String): SearchFilter { + val pattern = regexPattern(query) + return SearchFilter { fileName -> + // check case-insensitively if the pattern compiled from query matches fileName + if (pattern.matcher(fileName).matches()) { + fileName.indices + } else { + null + } + } + } + + private fun regexPattern(query: String): Pattern = + // compiles the given query into a Pattern + Pattern.compile( + bashRegexToJava(query), + Pattern.CASE_INSENSITIVE, + ) + + /** + * method converts bash style regular expression to java. See [Pattern] + * + * @return converted string + */ + private fun bashRegexToJava(originalString: String): String { + val stringBuilder = StringBuilder() + for (i in originalString.indices) { + when (originalString[i].toString() + "") { + "*" -> stringBuilder.append("\\w*") + "?" -> stringBuilder.append("\\w") + else -> stringBuilder.append(originalString[i]) + } + } + return stringBuilder.toString() + } + + fun interface SearchFilter { + /** + * If the file with the given [fileName] fulfills some predicate, returns the part that fulfills the predicate. + * Otherwise returns null. + */ + fun searchFilter(fileName: String): MatchRange? + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/IndexedSearch.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/IndexedSearch.kt new file mode 100644 index 0000000..56a1cc6 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/IndexedSearch.kt @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.searchfilesystem + +import android.database.Cursor +import android.provider.MediaStore +import com.amaze.filemanager.filesystem.RootHelper +import kotlinx.coroutines.isActive +import java.io.File +import kotlin.coroutines.coroutineContext + +class IndexedSearch( + query: String, + path: String, + searchParameters: SearchParameters, + private val cursor: Cursor, +) : FileSearch(query, path, searchParameters) { + override suspend fun search(filter: SearchFilter) { + if (cursor.count > 0 && cursor.moveToFirst()) { + do { + val nextPath = + cursor.getString( + cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATA), + ) + val displayName = + cursor.getString( + cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DISPLAY_NAME), + ) + if (nextPath != null && displayName != null && nextPath.contains(path)) { + val resultRange = filter.searchFilter(displayName) + if (resultRange != null) { + val hybridFileParcelable = + RootHelper.generateBaseFile( + File(nextPath), + SearchParameter.SHOW_HIDDEN_FILES in searchParameters, + ) + if (hybridFileParcelable != null) { + publishProgress(hybridFileParcelable, resultRange) + } + } + } + } while (cursor.moveToNext() && coroutineContext.isActive) + } + + cursor.close() + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/SearchParameter.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/SearchParameter.kt new file mode 100644 index 0000000..ec52862 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/SearchParameter.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.searchfilesystem + +enum class SearchParameter { + ROOT, + REGEX, + REGEX_MATCHES, + SHOW_HIDDEN_FILES, + ; + + /** + * Returns [SearchParameters] containing [this] and [other] + */ + infix fun and(other: SearchParameter): SearchParameters = SearchParameters.of(this, other) + + /** + * Returns [SearchParameters] containing [this] and [other] + */ + operator fun plus(other: SearchParameter): SearchParameters = this and other +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/SearchParameters.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/SearchParameters.kt new file mode 100644 index 0000000..23337c5 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/SearchParameters.kt @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.searchfilesystem + +import java.util.EnumSet + +typealias SearchParameters = EnumSet + +/** + * Returns [SearchParameters] extended by [other] + */ +infix fun SearchParameters.and(other: SearchParameter): SearchParameters = + SearchParameters.of( + other, + *this.toTypedArray(), + ) + +/** + * Returns [SearchParameters] extended by [other] + */ +operator fun SearchParameters.plus(other: SearchParameter): SearchParameters = this and other + +/** + * Returns [SearchParameters] that reflect the given Booleans + */ +fun searchParametersFromBoolean( + showHiddenFiles: Boolean = false, + isRegexEnabled: Boolean = false, + isRegexMatchesEnabled: Boolean = false, + isRoot: Boolean = false, +): SearchParameters { + val searchParameterList = mutableListOf() + + if (showHiddenFiles) searchParameterList.add(SearchParameter.SHOW_HIDDEN_FILES) + if (isRegexEnabled) searchParameterList.add(SearchParameter.REGEX) + if (isRegexMatchesEnabled) searchParameterList.add(SearchParameter.REGEX_MATCHES) + if (isRoot) searchParameterList.add(SearchParameter.ROOT) + + return if (searchParameterList.isEmpty()) { + SearchParameters.noneOf(SearchParameter::class.java) + } else { + SearchParameters.copyOf(searchParameterList) + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/SearchResult.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/SearchResult.kt new file mode 100644 index 0000000..e9aa24d --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/SearchResult.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.searchfilesystem + +import com.amaze.filemanager.filesystem.HybridFileParcelable + +data class SearchResult(val file: HybridFileParcelable, val matchRange: MatchRange) + +typealias MatchRange = IntProgression + +/** Returns the size of the [MatchRange] which means how many characters were matched */ +fun MatchRange.size(): Int = this.last - this.first diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/SearchResultListSorter.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/SearchResultListSorter.kt new file mode 100644 index 0000000..7dfa2b5 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/SearchResultListSorter.kt @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.searchfilesystem + +import com.amaze.filemanager.filesystem.files.FileListSorter +import com.amaze.filemanager.filesystem.files.sort.DirSortBy +import com.amaze.filemanager.filesystem.files.sort.SortBy +import com.amaze.filemanager.filesystem.files.sort.SortType +import java.util.Date +import java.util.concurrent.TimeUnit + +class SearchResultListSorter( + private val dirArg: DirSortBy, + private val sortType: SortType, + private val searchTerm: String, +) : Comparator { + private val fileListSorter: FileListSorter by lazy { FileListSorter(dirArg, sortType) } + + private val relevanceComparator: Comparator by lazy { + Comparator { o1, o2 -> + val currentTime = Date().time + val comparator = + compareBy { (item, matchRange) -> + // the match percentage of the search term in the name + val matchPercentageScore = + matchRange.size().toDouble() / item.getParcelableName().length.toDouble() + + // if the name starts with the search term + val startScore = (matchRange.first == 0).toInt() + + // if the search term is surrounded by separators + // e.g. "my-cat" more relevant than "mysterious" for search term "my" + val wordScore = + item.getParcelableName().split('-', '_', '.', ' ').any { + it.contentEquals( + searchTerm, + ignoreCase = true, + ) + }.toInt() + + val modificationDate = item.getDate() + // the time difference as minutes + val timeDiff = + TimeUnit.MILLISECONDS.toMinutes(currentTime - modificationDate) + // 30 days as minutes + val relevantModificationPeriod = TimeUnit.DAYS.toMinutes(30) + val timeScore = + if (timeDiff < relevantModificationPeriod) { + // if the file was modified within the last 30 days, the recency is normalized + (relevantModificationPeriod - timeDiff) / + relevantModificationPeriod.toDouble() + } else { + // for all older modification time, the recency doesn't change the relevancy + 0.0 + } + + 1.2 * matchPercentageScore + + 0.7 * startScore + + 0.7 * wordScore + + 0.6 * timeScore + } + // Reverts the sorting to make most relevant first + comparator.compare(o1, o2) * -1 + } + } + + private fun Boolean.toInt() = if (this) 1 else 0 + + override fun compare( + result1: SearchResult, + result2: SearchResult, + ): Int { + return when (sortType.sortBy) { + SortBy.RELEVANCE -> relevanceComparator.compare(result1, result2) + SortBy.SIZE, SortBy.TYPE, SortBy.LAST_MODIFIED, SortBy.NAME -> + fileListSorter.compare(result1.file, result2.file) + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ssh/GetSshHostFingerprintTask.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ssh/GetSshHostFingerprintTask.kt new file mode 100644 index 0000000..b30834d --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ssh/GetSshHostFingerprintTask.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.ssh + +import com.amaze.filemanager.asynchronous.asynctasks.ftp.AbstractGetHostInfoTask +import java.security.PublicKey + +class GetSshHostFingerprintTask( + private val hostname: String, + private val port: Int, + private val firstContact: Boolean, + callback: (PublicKey) -> Unit, +) : AbstractGetHostInfoTask( + hostname, + port, + callback, + ) { + override fun getTask(): GetSshHostFingerprintTaskCallable = GetSshHostFingerprintTaskCallable(hostname, port, firstContact) +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ssh/GetSshHostFingerprintTaskCallable.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ssh/GetSshHostFingerprintTaskCallable.kt new file mode 100644 index 0000000..fe3567d --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ssh/GetSshHostFingerprintTaskCallable.kt @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.ssh + +import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool +import com.amaze.filemanager.filesystem.ssh.CustomSshJConfig +import com.amaze.filemanager.filesystem.ssh.SshClientUtils +import net.schmizz.sshj.transport.verification.HostKeyVerifier +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.security.PublicKey +import java.util.Collections +import java.util.concurrent.Callable +import java.util.concurrent.CountDownLatch + +class GetSshHostFingerprintTaskCallable( + private val hostname: String, + private val port: Int, + private val firstContact: Boolean = false, +) : Callable { + companion object { + @JvmStatic + private val logger: Logger = + LoggerFactory.getLogger( + GetSshHostFingerprintTaskCallable::class.java, + ) + } + + override fun call(): PublicKey { + var holder: PublicKey? = null + val latch = CountDownLatch(1) + val sshClient = + NetCopyClientConnectionPool.sshClientFactory + .create(CustomSshJConfig()).also { + it.connectTimeout = NetCopyClientConnectionPool.CONNECT_TIMEOUT + it.addHostKeyVerifier( + object : HostKeyVerifier { + override fun verify( + hostname: String?, + port: Int, + key: PublicKey?, + ): Boolean { + holder = key + latch.countDown() + return true + } + + override fun findExistingAlgorithms( + hostname: String?, + port: Int, + ): MutableList = Collections.emptyList() + }, + ) + } + return runCatching { + sshClient.connect(hostname, port) + latch.await() + holder!! + }.onFailure { + if (!firstContact) { + logger.error("Unable to connect to [$hostname:$port]", it) + } + latch.countDown() + }.getOrThrow().also { + SshClientUtils.tryDisconnect(sshClient) + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ssh/PemToKeyPairObservable.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ssh/PemToKeyPairObservable.kt new file mode 100644 index 0000000..b4af758 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ssh/PemToKeyPairObservable.kt @@ -0,0 +1,214 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.ssh + +import android.text.InputType +import android.view.LayoutInflater +import android.widget.Toast +import com.afollestad.materialdialogs.DialogAction +import com.afollestad.materialdialogs.MaterialDialog +import com.amaze.filemanager.R +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.databinding.DialogSingleedittextBinding +import com.amaze.filemanager.ui.views.WarnableTextInputLayout +import com.amaze.filemanager.ui.views.WarnableTextInputValidator +import com.hierynomus.sshj.userauth.keyprovider.OpenSSHKeyV1KeyFile +import io.reactivex.ObservableEmitter +import io.reactivex.ObservableOnSubscribe +import net.schmizz.sshj.userauth.keyprovider.OpenSSHKeyFile +import net.schmizz.sshj.userauth.keyprovider.PuTTYKeyFile +import net.schmizz.sshj.userauth.password.PasswordFinder +import net.schmizz.sshj.userauth.password.Resource +import org.bouncycastle.openssl.PEMKeyPair +import org.bouncycastle.openssl.PEMParser +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.IOException +import java.io.InputStream +import java.io.StringReader +import java.security.KeyPair + +class PemToKeyPairObservable(private val pemFile: ByteArray) : ObservableOnSubscribe { + private val converters = + arrayOf( + JcaPemToKeyPairConverter(), + OpenSshPemToKeyPairConverter(), + OpenSshV1PemToKeyPairConverter(), + PuttyPrivateKeyToKeyPairConverter(), + ) + private var passwordFinder: PasswordFinder? = null + private var errorMessage: String? = null + + companion object { + private val log: Logger = LoggerFactory.getLogger(PemToKeyPairObservable::class.java) + } + + constructor(pemFile: InputStream) : this(pemFile.readBytes()) + constructor(pemContent: String) : this(pemContent.toByteArray()) + + override fun subscribe(emitter: ObservableEmitter) { + for (converter in converters) { + val keyPair = converter.convert(String(pemFile)) + if (keyPair != null) { + emitter.onNext(keyPair) + emitter.onComplete() + return + } + } + if (passwordFinder != null) { + errorMessage = + AppConfig + .getInstance() + .getString(R.string.ssh_key_invalid_passphrase) + } else { + errorMessage = + AppConfig + .getInstance() + .getString(R.string.ssh_key_no_decoder_decrypt) + } + emitter.onError(IOException(errorMessage)) + } + + /** + * For generating the callback when decoding the PEM failed. Opens dialog and prompt for + * password. + */ + fun displayPassphraseDialog( + exception: Throwable, + positiveCallback: (() -> Unit), + negativeCallback: (() -> Unit), + ) { + val builder = + MaterialDialog.Builder( + AppConfig.getInstance().mainActivityContext!!, + ) + val dialogLayout = + DialogSingleedittextBinding.inflate( + LayoutInflater.from(AppConfig.getInstance().mainActivityContext), + ) + val wilTextfield: WarnableTextInputLayout = + dialogLayout.singleedittextWarnabletextinputlayout + val textfield = dialogLayout.singleedittextInput + textfield.inputType = InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_VARIATION_PASSWORD + builder + .customView(dialogLayout.root, false) + .autoDismiss(false) + .title(R.string.ssh_key_prompt_passphrase) + .positiveText(R.string.ok) + .onPositive { dialog: MaterialDialog, which: DialogAction? -> + passwordFinder = + object : PasswordFinder { + override fun reqPassword(resource: Resource<*>?): CharArray { + return textfield.text.toString().toCharArray() + } + + override fun shouldRetry(resource: Resource<*>?): Boolean { + return false + } + } + dialog.dismiss() + positiveCallback.invoke() + } + .negativeText(R.string.cancel) + .onNegative { dialog: MaterialDialog, which: DialogAction? -> + dialog.dismiss() + toastOnParseError(exception) + negativeCallback.invoke() + } + val dialog = builder.show() + WarnableTextInputValidator( + AppConfig.getInstance().mainActivityContext, + textfield, + wilTextfield, + dialog.getActionButton(DialogAction.POSITIVE), + ) { text: String -> + if (text.isEmpty()) { + WarnableTextInputValidator.ReturnState( + WarnableTextInputValidator.ReturnState.STATE_ERROR, + R.string.field_empty, + ) + } + WarnableTextInputValidator.ReturnState() + } + if (errorMessage != null) { + wilTextfield.error = errorMessage + textfield.selectAll() + } + } + + private fun toastOnParseError(result: Throwable) { + Toast.makeText( + AppConfig.getInstance().mainActivityContext, + AppConfig.getInstance() + .resources + .getString(R.string.ssh_pem_key_parse_error, result.localizedMessage), + Toast.LENGTH_LONG, + ) + .show() + } + + private abstract inner class PemToKeyPairConverter { + fun convert(source: String): KeyPair? = + runCatching { + throwingConvert(source) + }.onFailure { + log.warn("failed to convert pem to keypair", it) + }.getOrNull() + + protected abstract fun throwingConvert(source: String?): KeyPair? + } + + private inner class JcaPemToKeyPairConverter : PemToKeyPairConverter() { + override fun throwingConvert(source: String?): KeyPair? { + val pemParser = PEMParser(StringReader(source)) + val keyPair = pemParser.readObject() as PEMKeyPair? + val converter = JcaPEMKeyConverter() + return converter.getKeyPair(keyPair) + } + } + + private inner class OpenSshPemToKeyPairConverter : PemToKeyPairConverter() { + override fun throwingConvert(source: String?): KeyPair { + val converter = OpenSSHKeyFile() + converter.init(StringReader(source), passwordFinder) + return KeyPair(converter.public, converter.private) + } + } + + private inner class OpenSshV1PemToKeyPairConverter : PemToKeyPairConverter() { + override fun throwingConvert(source: String?): KeyPair { + val converter = OpenSSHKeyV1KeyFile() + converter.init(StringReader(source), passwordFinder) + return KeyPair(converter.public, converter.private) + } + } + + private inner class PuttyPrivateKeyToKeyPairConverter : PemToKeyPairConverter() { + @Throws(Exception::class) + public override fun throwingConvert(source: String?): KeyPair { + val converter = PuTTYKeyFile() + converter.init(StringReader(source), passwordFinder) + return KeyPair(converter.public, converter.private) + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ssh/SshAuthenticationTask.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ssh/SshAuthenticationTask.kt new file mode 100644 index 0000000..b2a401f --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ssh/SshAuthenticationTask.kt @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.ssh + +import android.app.AlertDialog +import androidx.annotation.MainThread +import com.amaze.filemanager.R +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.asynchronous.asynctasks.Task +import net.schmizz.sshj.SSHClient +import net.schmizz.sshj.common.DisconnectReason +import net.schmizz.sshj.transport.TransportException +import java.net.ConnectException +import java.net.SocketException +import java.net.SocketTimeoutException +import java.security.KeyPair + +class SshAuthenticationTask( + private val hostname: String, + private val port: Int, + private val hostKey: String, + private val username: String, + private val password: String? = null, + private val privateKey: KeyPair? = null, +) : Task { + override fun getTask(): SshAuthenticationTaskCallable = + SshAuthenticationTaskCallable(hostname, port, hostKey, username, password, privateKey) + + @MainThread + override fun onError(error: Throwable) { + if (SocketException::class.java.isAssignableFrom(error.javaClass) || + ConnectException::class.java.isAssignableFrom(error.javaClass) || + SocketTimeoutException::class.java + .isAssignableFrom(error.javaClass) + ) { + AppConfig.toast( + AppConfig.getInstance(), + AppConfig.getInstance() + .resources + .getString( + R.string.ssh_connect_failed, + hostname, + port, + error.localizedMessage ?: error.message, + ), + ) + } else if (TransportException::class.java + .isAssignableFrom(error.javaClass) + ) { + val disconnectReason = + TransportException::class.java.cast(error)!!.disconnectReason + if (DisconnectReason.HOST_KEY_NOT_VERIFIABLE == disconnectReason) { + AlertDialog.Builder(AppConfig.getInstance().mainActivityContext) + .setTitle(R.string.ssh_connect_failed_host_key_changed_title) + .setMessage(R.string.ssh_connect_failed_host_key_changed_message) + .setPositiveButton(R.string.ok) { dialog, _ -> + dialog.dismiss() + } + .show() + } + } else if (password != null) { + AppConfig.toast( + AppConfig.getInstance(), + R.string.ssh_authentication_failure_password, + ) + } else if (privateKey != null) { + AppConfig.toast( + AppConfig.getInstance(), + R.string.ssh_authentication_failure_key, + ) + } + } + + @MainThread + override fun onFinish(value: SSHClient) = Unit +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ssh/SshAuthenticationTaskCallable.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ssh/SshAuthenticationTaskCallable.kt new file mode 100644 index 0000000..a67c2fd --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/ssh/SshAuthenticationTaskCallable.kt @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.ssh + +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool +import com.amaze.filemanager.filesystem.ssh.CustomSshJConfig +import com.amaze.filemanager.utils.PasswordUtil +import net.schmizz.sshj.SSHClient +import net.schmizz.sshj.common.KeyType +import net.schmizz.sshj.userauth.keyprovider.KeyProvider +import java.net.URLDecoder.decode +import java.security.KeyPair +import java.security.PrivateKey +import java.security.PublicKey +import java.util.concurrent.Callable +import kotlin.text.Charsets.UTF_8 + +class SshAuthenticationTaskCallable( + private val hostname: String, + private val port: Int, + private val hostKey: String, + private val username: String, + private val password: String? = null, + private val privateKey: KeyPair? = null, +) : Callable { + init { + require( + true == password?.isNotEmpty() || privateKey != null, + ) { + "Must provide either password or privateKey" + } + } + + override fun call(): SSHClient { + val sshClient = + NetCopyClientConnectionPool.sshClientFactory + .create(CustomSshJConfig()).also { + it.addHostKeyVerifier(hostKey) + it.connectTimeout = NetCopyClientConnectionPool.CONNECT_TIMEOUT + } + return run { + sshClient.connect(hostname, port) + if (privateKey != null) { + sshClient.authPublickey( + decode(username, UTF_8.name()), + object : KeyProvider { + override fun getPrivate(): PrivateKey = privateKey.private + + override fun getPublic(): PublicKey = privateKey.public + + override fun getType(): KeyType = KeyType.fromKey(public) + }, + ) + sshClient + } else { + sshClient.authPassword( + decode(username, UTF_8.name()), + decode( + PasswordUtil.decryptPassword( + AppConfig.getInstance(), + password!!, + ), + UTF_8.name(), + ), + ) + sshClient + } + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/ReadTextFileCallable.java b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/ReadTextFileCallable.java new file mode 100644 index 0000000..3611b68 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/ReadTextFileCallable.java @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.texteditor.read; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.Objects; +import java.util.concurrent.Callable; + +import com.amaze.filemanager.application.AppConfig; +import com.amaze.filemanager.fileoperations.exceptions.ShellNotRunningException; +import com.amaze.filemanager.fileoperations.exceptions.StreamNotFoundException; +import com.amaze.filemanager.filesystem.EditableFileAbstraction; +import com.amaze.filemanager.filesystem.HybridFileParcelable; +import com.amaze.filemanager.filesystem.files.FileUtils; +import com.amaze.filemanager.filesystem.root.CopyFilesCommand; +import com.amaze.filemanager.ui.activities.texteditor.ReturnedValueOnReadFile; + +import android.content.ContentResolver; + +import androidx.annotation.WorkerThread; +import androidx.documentfile.provider.DocumentFile; + +public class ReadTextFileCallable implements Callable { + + public static final int MAX_FILE_SIZE_CHARS = 50 * 1024; + + private final ContentResolver contentResolver; + private final EditableFileAbstraction fileAbstraction; + private final File externalCacheDir; + private final boolean isRootExplorer; + + private File cachedFile = null; + + public ReadTextFileCallable( + ContentResolver contentResolver, + EditableFileAbstraction file, + File cacheDir, + boolean isRootExplorer) { + this.contentResolver = contentResolver; + this.fileAbstraction = file; + this.externalCacheDir = cacheDir; + this.isRootExplorer = isRootExplorer; + } + + @WorkerThread + @Override + public ReturnedValueOnReadFile call() + throws StreamNotFoundException, IOException, OutOfMemoryError, ShellNotRunningException { + InputStream inputStream; + + switch (fileAbstraction.scheme) { + case CONTENT: + Objects.requireNonNull(fileAbstraction.uri); + + final AppConfig appConfig = AppConfig.getInstance(); + + if (fileAbstraction.uri.getAuthority().equals(appConfig.getPackageName())) { + DocumentFile documentFile = DocumentFile.fromSingleUri(appConfig, fileAbstraction.uri); + + if (documentFile != null && documentFile.exists() && documentFile.canWrite()) { + inputStream = contentResolver.openInputStream(documentFile.getUri()); + } else { + inputStream = loadFile(FileUtils.fromContentUri(fileAbstraction.uri)); + } + } else { + inputStream = contentResolver.openInputStream(fileAbstraction.uri); + } + break; + case FILE: + final HybridFileParcelable hybridFileParcelable = fileAbstraction.hybridFileParcelable; + Objects.requireNonNull(hybridFileParcelable); + + File file = hybridFileParcelable.getFile(); + inputStream = loadFile(file); + + break; + default: + throw new IllegalArgumentException( + "The scheme for '" + fileAbstraction.scheme + "' cannot be processed!"); + } + + Objects.requireNonNull(inputStream); + + InputStreamReader inputStreamReader = new InputStreamReader(inputStream); + + char[] buffer = new char[MAX_FILE_SIZE_CHARS]; + + final int readChars = inputStreamReader.read(buffer); + boolean tooLong = -1 != inputStream.read(); + + inputStreamReader.close(); + + final String fileContents; + + if (readChars == -1) { + fileContents = ""; + } else { + fileContents = String.valueOf(buffer, 0, readChars); + } + + return new ReturnedValueOnReadFile(fileContents, cachedFile, tooLong); + } + + private InputStream loadFile(File file) throws ShellNotRunningException, IOException { + InputStream inputStream; + + if (!file.canWrite() && isRootExplorer) { + // try loading stream associated using root + cachedFile = new File(externalCacheDir, file.getName()); + // Scrap previously cached file if exist + if (cachedFile.exists()) { + cachedFile.delete(); + } + cachedFile.createNewFile(); + cachedFile.deleteOnExit(); + // creating a cache file + CopyFilesCommand.INSTANCE.copyFiles(file.getAbsolutePath(), cachedFile.getPath()); + + inputStream = new FileInputStream(cachedFile); + } else if (file.canRead()) { + // readable file in filesystem + try { + inputStream = new FileInputStream(file.getAbsolutePath()); + } catch (FileNotFoundException e) { + throw new FileNotFoundException( + "Unable to open file [" + file.getAbsolutePath() + "] for reading"); + } + } else { + throw new IOException("Cannot read or write text file!"); + } + + return inputStream; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/ReadTextFileTask.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/ReadTextFileTask.kt new file mode 100644 index 0000000..361e541 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/ReadTextFileTask.kt @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.texteditor.read + +import android.content.Context +import android.widget.Toast +import androidx.activity.viewModels +import androidx.annotation.MainThread +import androidx.annotation.StringRes +import com.amaze.filemanager.R +import com.amaze.filemanager.asynchronous.asynctasks.Task +import com.amaze.filemanager.fileoperations.exceptions.StreamNotFoundException +import com.amaze.filemanager.filesystem.EditableFileAbstraction +import com.amaze.filemanager.ui.activities.texteditor.ReturnedValueOnReadFile +import com.amaze.filemanager.ui.activities.texteditor.TextEditorActivity +import com.amaze.filemanager.ui.activities.texteditor.TextEditorActivityViewModel +import com.google.android.material.snackbar.Snackbar +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.IOException +import java.lang.ref.WeakReference +import java.util.Locale + +class ReadTextFileTask( + activity: TextEditorActivity, + private val textEditorActivityWR: WeakReference, + private val appContextWR: WeakReference, +) : Task { + private val log: Logger = LoggerFactory.getLogger(ReadTextFileTask::class.java) + + private val task: ReadTextFileCallable + + init { + val viewModel: TextEditorActivityViewModel by activity.viewModels() + task = + ReadTextFileCallable( + activity.contentResolver, + viewModel.file, + activity.externalCacheDir, + activity.isRootExplorer, + ) + } + + override fun getTask(): ReadTextFileCallable = task + + @MainThread + override fun onError(error: Throwable) { + log.error("Error on text read", error) + val applicationContext = appContextWR.get() ?: return + + @StringRes val errorMessage: Int = + when (error) { + is StreamNotFoundException -> { + R.string.error_file_not_found + } + is IOException -> { + R.string.error_io + } + is OutOfMemoryError -> { + R.string.error_file_too_large + } + else -> { + R.string.error + } + } + Toast.makeText(applicationContext, errorMessage, Toast.LENGTH_SHORT).show() + val textEditorActivity = textEditorActivityWR.get() ?: return + textEditorActivity.dismissLoadingSnackbar() + textEditorActivity.finish() + } + + @MainThread + override fun onFinish(value: ReturnedValueOnReadFile) { + val textEditorActivity = textEditorActivityWR.get() ?: return + val viewModel: TextEditorActivityViewModel by textEditorActivity.viewModels() + textEditorActivity.dismissLoadingSnackbar() + viewModel.cacheFile = value.cachedFile + viewModel.original = value.fileContents + val file = viewModel.file ?: return + val externalCacheDir = textEditorActivity.externalCacheDir + + textEditorActivity.mainTextView.setText(value.fileContents) + + // file in cache, and not a root temporary file + val isFileInCacheAndNotRoot = + file.scheme == EditableFileAbstraction.Scheme.FILE && + externalCacheDir != null && + file.hybridFileParcelable.path.contains(externalCacheDir.path) && + viewModel.cacheFile == null + + if (isFileInCacheAndNotRoot) { + textEditorActivity.setReadOnly() + val snackbar = + Snackbar.make( + textEditorActivity.mainTextView, + R.string.file_read_only, + Snackbar.LENGTH_INDEFINITE, + ) + snackbar.setAction( + textEditorActivity.resources.getString(R.string.got_it) + .uppercase(Locale.getDefault()), + ) { snackbar.dismiss() } + snackbar.show() + } + + if (value.fileContents.isEmpty()) { + textEditorActivity.mainTextView.setHint(R.string.file_empty) + } else { + textEditorActivity.mainTextView.hint = null + } + + if (value.fileIsTooLong) { + textEditorActivity.setReadOnly() + val snackbar = + Snackbar.make( + textEditorActivity.mainTextView, + textEditorActivity.resources + .getString(R.string.file_too_long, ReadTextFileCallable.MAX_FILE_SIZE_CHARS), + Snackbar.LENGTH_INDEFINITE, + ) + snackbar.setAction( + textEditorActivity.resources.getString(R.string.got_it) + .uppercase(Locale.getDefault()), + ) { snackbar.dismiss() } + snackbar.show() + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/write/WriteTextFileCallable.java b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/write/WriteTextFileCallable.java new file mode 100644 index 0000000..06dcc7d --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/write/WriteTextFileCallable.java @@ -0,0 +1,142 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.texteditor.write; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.lang.ref.WeakReference; +import java.util.Objects; +import java.util.concurrent.Callable; + +import com.amaze.filemanager.application.AppConfig; +import com.amaze.filemanager.fileoperations.exceptions.ShellNotRunningException; +import com.amaze.filemanager.fileoperations.exceptions.StreamNotFoundException; +import com.amaze.filemanager.filesystem.EditableFileAbstraction; +import com.amaze.filemanager.filesystem.FileUtil; +import com.amaze.filemanager.filesystem.HybridFileParcelable; +import com.amaze.filemanager.filesystem.files.FileUtils; +import com.amaze.filemanager.filesystem.root.ConcatenateFileCommand; + +import android.content.ContentResolver; +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; +import androidx.documentfile.provider.DocumentFile; + +import kotlin.Unit; + +public class WriteTextFileCallable implements Callable { + private final WeakReference context; + private final ContentResolver contentResolver; + private final EditableFileAbstraction fileAbstraction; + private final File cachedFile; + private final boolean isRootExplorer; + private final String dataToSave; + + public WriteTextFileCallable( + Context context, + ContentResolver contentResolver, + EditableFileAbstraction file, + String dataToSave, + File cachedFile, + boolean isRootExplorer) { + this.context = new WeakReference<>(context); + this.contentResolver = contentResolver; + this.fileAbstraction = file; + this.cachedFile = cachedFile; + this.dataToSave = dataToSave; + this.isRootExplorer = isRootExplorer; + } + + @WorkerThread + @Override + public Unit call() + throws IOException, + StreamNotFoundException, + ShellNotRunningException, + IllegalArgumentException { + OutputStream outputStream; + File destFile = null; + + switch (fileAbstraction.scheme) { + case CONTENT: + Objects.requireNonNull(fileAbstraction.uri); + if (fileAbstraction.uri.getAuthority().equals(context.get().getPackageName())) { + DocumentFile documentFile = + DocumentFile.fromSingleUri(AppConfig.getInstance(), fileAbstraction.uri); + if (documentFile != null && documentFile.exists() && documentFile.canWrite()) { + outputStream = contentResolver.openOutputStream(fileAbstraction.uri, "wt"); + } else { + destFile = FileUtils.fromContentUri(fileAbstraction.uri); + outputStream = openFile(destFile, context.get()); + } + } else { + outputStream = contentResolver.openOutputStream(fileAbstraction.uri, "wt"); + } + break; + case FILE: + final HybridFileParcelable hybridFileParcelable = fileAbstraction.hybridFileParcelable; + Objects.requireNonNull(hybridFileParcelable); + + Context context = this.context.get(); + if (context == null) { + return null; + } + outputStream = openFile(hybridFileParcelable.getFile(), context); + destFile = fileAbstraction.hybridFileParcelable.getFile(); + break; + default: + throw new IllegalArgumentException( + "The scheme for '" + fileAbstraction.scheme + "' cannot be processed!"); + } + + Objects.requireNonNull(outputStream); + + outputStream.write(dataToSave.getBytes()); + outputStream.close(); + + if (cachedFile != null && cachedFile.exists() && destFile != null) { + // cat cache content to original file and delete cache file + ConcatenateFileCommand.INSTANCE.concatenateFile(cachedFile.getPath(), destFile.getPath()); + cachedFile.delete(); + } + return Unit.INSTANCE; + } + + private OutputStream openFile(@NonNull File file, @NonNull Context context) + throws IOException, StreamNotFoundException { + OutputStream outputStream = FileUtil.getOutputStream(file, context); + + // try loading stream associated using root + if (isRootExplorer && outputStream == null && cachedFile != null && cachedFile.exists()) { + outputStream = new FileOutputStream(cachedFile); + } + + if (outputStream == null) { + throw new StreamNotFoundException("Cannot read or write text file!"); + } + + return outputStream; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/write/WriteTextFileTask.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/write/WriteTextFileTask.kt new file mode 100644 index 0000000..8345092 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/write/WriteTextFileTask.kt @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.texteditor.write + +import android.content.Context +import android.widget.Toast +import androidx.activity.viewModels +import androidx.annotation.MainThread +import androidx.annotation.StringRes +import com.amaze.filemanager.R +import com.amaze.filemanager.asynchronous.asynctasks.Task +import com.amaze.filemanager.fileoperations.exceptions.ShellNotRunningException +import com.amaze.filemanager.fileoperations.exceptions.StreamNotFoundException +import com.amaze.filemanager.ui.activities.texteditor.TextEditorActivity +import com.amaze.filemanager.ui.activities.texteditor.TextEditorActivityViewModel +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.IOException +import java.lang.ref.WeakReference + +class WriteTextFileTask( + activity: TextEditorActivity, + private val editTextString: String, + private val textEditorActivityWR: WeakReference, + private val appContextWR: WeakReference, +) : Task { + private var log: Logger = LoggerFactory.getLogger(WriteTextFileTask::class.java) + + private val task: WriteTextFileCallable + + init { + val viewModel: TextEditorActivityViewModel by activity.viewModels() + task = + WriteTextFileCallable( + activity, + activity.contentResolver, + viewModel.file, + editTextString, + viewModel.cacheFile, + activity.isRootExplorer, + ) + } + + override fun getTask(): WriteTextFileCallable = task + + @MainThread + override fun onError(error: Throwable) { + log.error("Error on text write", error) + val applicationContext = appContextWR.get() ?: return + + @StringRes val errorMessage: Int = + when (error) { + is StreamNotFoundException -> { + R.string.error_file_not_found + } + is IOException -> { + R.string.error_io + } + is ShellNotRunningException -> { + R.string.root_failure + } + else -> { + R.string.error + } + } + Toast.makeText(applicationContext, errorMessage, Toast.LENGTH_SHORT).show() + } + + @MainThread + override fun onFinish(value: Unit) { + val applicationContext = appContextWR.get() ?: return + Toast.makeText(applicationContext, R.string.done, Toast.LENGTH_SHORT).show() + val textEditorActivity = textEditorActivityWR.get() ?: return + val viewModel: TextEditorActivityViewModel by textEditorActivity.viewModels() + + viewModel.original = editTextString + viewModel.modified = false + textEditorActivity.invalidateOptionsMenu() + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/broadcast_receivers/PackageReceiver.java b/app/src/main/java/com/amaze/filemanager/asynchronous/broadcast_receivers/PackageReceiver.java new file mode 100644 index 0000000..9128b78 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/broadcast_receivers/PackageReceiver.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.broadcast_receivers; + +import com.amaze.filemanager.asynchronous.loaders.AppListLoader; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; + +/** + * Created by vishal on 23/2/17. + * + *

A broadcast receiver that watches over app installation and removal and notifies {@link + * AppListLoader} for the same + */ +public class PackageReceiver extends BroadcastReceiver { + + private AppListLoader listLoader; + + public PackageReceiver(AppListLoader listLoader) { + + this.listLoader = listLoader; + + IntentFilter filter = new IntentFilter(Intent.ACTION_PACKAGE_ADDED); + filter.addAction(Intent.ACTION_PACKAGE_REMOVED); + filter.addAction(Intent.ACTION_PACKAGE_CHANGED); + filter.addDataScheme("package"); + listLoader.getContext().registerReceiver(this, filter); + + // Register for events related to SD card installation + IntentFilter sdcardFilter = new IntentFilter(Intent.ACTION_EXTERNAL_APPLICATIONS_AVAILABLE); + sdcardFilter.addAction(Intent.ACTION_EXTERNAL_APPLICATIONS_UNAVAILABLE); + listLoader.getContext().registerReceiver(this, sdcardFilter); + } + + @Override + public void onReceive(Context context, Intent intent) { + listLoader.onContentChanged(); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/handlers/FileHandler.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/handlers/FileHandler.kt new file mode 100644 index 0000000..f3132bf --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/handlers/FileHandler.kt @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.handlers + +import android.os.Handler +import android.os.Looper +import android.os.Message +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import com.amaze.filemanager.adapters.RecyclerAdapter +import com.amaze.filemanager.filesystem.CustomFileObserver +import com.amaze.filemanager.filesystem.HybridFile +import com.amaze.filemanager.ui.fragments.MainFragment +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.File +import java.lang.ref.WeakReference + +class FileHandler( + mainFragment: MainFragment, + private val listView: RecyclerView, + private val useThumbs: Boolean, +) : Handler( + Looper.getMainLooper(), + ) { + private val mainFragment: WeakReference = WeakReference(mainFragment) + private val log: Logger = LoggerFactory.getLogger(FileHandler::class.java) + + override fun handleMessage(msg: Message) { + super.handleMessage(msg) + val main = mainFragment.get() ?: return + val mainFragmentViewModel = main.mainFragmentViewModel ?: return + val elementsList = main.elementsList ?: return + if (main.activity == null) { + return + } + + val path = msg.obj as? String + when (msg.what) { + CustomFileObserver.GOBACK -> { + main.goBack() + } + CustomFileObserver.NEW_ITEM -> { + if (path == null) { + log.error("Path is empty for file") + return + } + val fileCreated = + HybridFile( + mainFragmentViewModel.openMode, + "${main.currentPath}/$path", + ) + val newElement = fileCreated.generateLayoutElement(main.requireContext(), useThumbs) + main.elementsList?.add(newElement) + } + CustomFileObserver.DELETED_ITEM -> { + val index = + elementsList.withIndex().find { + File(it.value.desc).name == path + }?.index + + if (index != null) { + main.elementsList?.removeAt(index) + } + } + else -> { + super.handleMessage(msg) + return + } + } + if (listView.visibility == View.VISIBLE) { + if (elementsList.size == 0) { + // no item left in list, recreate views + main.reloadListElements( + true, + !mainFragmentViewModel.isList, + ) + } else { + listView.adapter?.let { + val itemList = main.elementsList ?: listOf() + // we already have some elements in list view, invalidate the adapter + (listView.adapter as RecyclerAdapter).setItems(listView, itemList) + } + } + } else { + // there was no list view, means the directory was empty + main.loadlist(main.currentPath, true, mainFragmentViewModel.openMode, true) + } + main.currentPath?.let { + main.mainActivityViewModel?.evictPathFromListCache(it) + } + main.computeScroll() + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/loaders/AppListLoader.java b/app/src/main/java/com/amaze/filemanager/asynchronous/loaders/AppListLoader.java new file mode 100644 index 0000000..57f064f --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/loaders/AppListLoader.java @@ -0,0 +1,227 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.loaders; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.amaze.filemanager.adapters.data.AppDataParcelable; +import com.amaze.filemanager.adapters.data.AppDataSorter; +import com.amaze.filemanager.asynchronous.broadcast_receivers.PackageReceiver; +import com.amaze.filemanager.utils.InterestingConfigChange; + +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.os.Build; +import android.text.format.Formatter; + +import androidx.loader.content.AsyncTaskLoader; + +/** + * Created by vishal on 23/2/17. + * + *

Class loads all the packages installed + */ +public class AppListLoader extends AsyncTaskLoader> { + + private final Logger LOG = LoggerFactory.getLogger(AppListLoader.class); + + private PackageManager packageManager; + private PackageReceiver packageReceiver; + private List mApps; + private final int sortBy; + private final boolean isAscending; + + public AppListLoader(Context context, int sortBy, boolean isAscending) { + super(context); + + this.sortBy = sortBy; + this.isAscending = isAscending; + + /* + * using global context because of the fact that loaders are supposed to be used + * across fragments and activities + */ + packageManager = getContext().getPackageManager(); + } + + @Override + public List loadInBackground() { + List apps = + packageManager.getInstalledApplications( + PackageManager.MATCH_UNINSTALLED_PACKAGES + | PackageManager.MATCH_DISABLED_UNTIL_USED_COMPONENTS); + + if (apps == null) return Collections.emptyList(); + mApps = new ArrayList<>(apps.size()); + PackageInfo androidInfo = null; + try { + androidInfo = packageManager.getPackageInfo("android", PackageManager.GET_SIGNATURES); + } catch (PackageManager.NameNotFoundException e) { + LOG.warn("failed to find android package name while loading apps list", e); + } + + for (ApplicationInfo object : apps) { + if (object.sourceDir == null) { + continue; + } + File sourceDir = new File(object.sourceDir); + + String label = object.loadLabel(packageManager).toString(); + PackageInfo info; + + try { + info = packageManager.getPackageInfo(object.packageName, PackageManager.GET_SIGNATURES); + } catch (PackageManager.NameNotFoundException e) { + LOG.warn("failed to find package name {} while loading apps list", object.packageName, e); + info = null; + } + boolean isSystemApp = isAppInSystemPartition(object) || isSignedBySystem(info, androidInfo); + + List splitPathList = null; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + && object.splitPublicSourceDirs != null) { + splitPathList = Arrays.asList(object.splitPublicSourceDirs); + } + + AppDataParcelable elem = + new AppDataParcelable( + label == null ? object.packageName : label, + object.sourceDir, + splitPathList, + object.packageName, + object.flags + "_" + (info != null ? info.versionName : ""), + Formatter.formatFileSize(getContext(), sourceDir.length()), + sourceDir.length(), + sourceDir.lastModified(), + isSystemApp, + null); + + mApps.add(elem); + } + + Collections.sort(mApps, new AppDataSorter(sortBy, isAscending)); + return mApps; + } + + @Override + public void deliverResult(List data) { + if (isReset()) { + + if (data != null) onReleaseResources(data); // TODO onReleaseResources() is empty + } + + // preserving old data for it to be closed + List oldData = mApps; + mApps = data; + if (isStarted()) { + // loader has been started, if we have data, return immediately + super.deliverResult(mApps); + } + + // releasing older resources as we don't need them now + if (oldData != null) { + onReleaseResources(oldData); // TODO onReleaseResources() is empty + } + } + + @Override + protected void onStartLoading() { + + if (mApps != null) { + // we already have the results, load immediately + deliverResult(mApps); + } + + if (packageReceiver != null) { + packageReceiver = new PackageReceiver(this); + } + + boolean didConfigChange = InterestingConfigChange.isConfigChanged(getContext().getResources()); + + if (takeContentChanged() || mApps == null || didConfigChange) { + forceLoad(); + } + } + + @Override + protected void onStopLoading() { + cancelLoad(); + } + + @Override + public void onCanceled(List data) { + super.onCanceled(data); + + onReleaseResources(data); // TODO onReleaseResources() is empty + } + + @Override + protected void onReset() { + super.onReset(); + + onStopLoading(); + + // we're free to clear resources + if (mApps != null) { + onReleaseResources(mApps); // TODO onReleaseResources() is empty + mApps = null; + } + + if (packageReceiver != null) { + getContext().unregisterReceiver(packageReceiver); + + packageReceiver = null; + } + + InterestingConfigChange.recycle(); + } + + /** We would want to release resources here List is nothing we would want to close */ + // TODO do something + private void onReleaseResources(List layoutElementList) {} + + /** + * Check if an App is under /system or has been installed as an update to a built-in system + * application. + */ + public static boolean isAppInSystemPartition(ApplicationInfo applicationInfo) { + return ((applicationInfo.flags + & (ApplicationInfo.FLAG_SYSTEM | ApplicationInfo.FLAG_UPDATED_SYSTEM_APP)) + != 0); + } + + /** Check if an App is signed by system or not. */ + public boolean isSignedBySystem(PackageInfo piApp, PackageInfo piSys) { + return (piApp != null + && piSys != null + && piApp.signatures != null + && piSys.signatures[0].equals(piApp.signatures[0])); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/management/ServiceWatcherUtil.java b/app/src/main/java/com/amaze/filemanager/asynchronous/management/ServiceWatcherUtil.java new file mode 100644 index 0000000..5cc17c1 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/management/ServiceWatcherUtil.java @@ -0,0 +1,286 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.management; + +/** + * Created by vishal on 4/1/17. + * + *

Helper class providing helper methods to manage Service startup and it's progress Be advised - + * this class can only handle progress with one object at a time. Hence, class also provides + * convenience methods to serialize the service startup. + */ +import static com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil.ServiceStatusCallbacks.STATE_HALTED; +import static com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil.ServiceStatusCallbacks.STATE_RESUMED; +import static com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil.ServiceStatusCallbacks.STATE_UNSET; + +import java.lang.ref.WeakReference; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.TimeUnit; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.asynchronous.AbstractRepeatingRunnable; +import com.amaze.filemanager.asynchronous.services.AbstractProgressiveService; +import com.amaze.filemanager.fileoperations.utils.UpdatePosition; +import com.amaze.filemanager.ui.notifications.NotificationConstants; +import com.amaze.filemanager.utils.ProgressHandler; + +import android.app.NotificationManager; +import android.content.Context; +import android.content.Intent; +import android.text.format.Formatter; + +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; + +public class ServiceWatcherUtil { + + public static final UpdatePosition UPDATE_POSITION = + (toAdd -> ServiceWatcherUtil.position += toAdd); + + public static int state = STATE_UNSET; + + /** + * Position of byte in total byte size to be copied. This variable CANNOT be updated from more + * than one thread simultaneously. This variable should only be updated from an {@link + * AbstractProgressiveService}'s background thread. + * + * @see #postWaiting(Context) + */ + public static volatile long position = 0L; + + private ProgressHandler progressHandler; + + private static AbstractRepeatingRunnable watcherRepeatingRunnable; + private static NotificationManager notificationManager; + private static NotificationCompat.Builder builder; + + private static ConcurrentLinkedQueue pendingIntents = new ConcurrentLinkedQueue<>(); + + private static int haltCounter = -1; + + /** + * @param progressHandler to publish progress after certain delay + */ + public ServiceWatcherUtil(ProgressHandler progressHandler) { + this.progressHandler = progressHandler; + position = 0L; + haltCounter = -1; + } + + /** + * Watches over the service progress without interrupting the worker thread in respective services + * Method frees up all the resources and handlers after operation completes. + */ + public void watch(ServiceStatusCallbacks serviceStatusCallbacks) { + watcherRepeatingRunnable = + new ServiceWatcherRepeatingRunnable(true, serviceStatusCallbacks, progressHandler); + } + + private static final class ServiceWatcherRepeatingRunnable extends AbstractRepeatingRunnable { + private final WeakReference serviceStatusCallbacks; + private final ProgressHandler progressHandler; + + public ServiceWatcherRepeatingRunnable( + boolean startImmediately, + ServiceStatusCallbacks serviceStatusCallbacks, + ProgressHandler progressHandler) { + super(1, 1, TimeUnit.SECONDS, startImmediately); + + this.serviceStatusCallbacks = new WeakReference<>(serviceStatusCallbacks); + this.progressHandler = progressHandler; + } + + @Override + public void run() { + final ServiceStatusCallbacks serviceStatusCallbacks = this.serviceStatusCallbacks.get(); + if (serviceStatusCallbacks == null) { + // the service was destroyed, clean up + cancel(false); + return; + } + + // we don't have a file name yet, wait for service to set + if (progressHandler.getFileName() == null) { + return; + } + + if (position == progressHandler.getWrittenSize() + && (state != STATE_HALTED && ++haltCounter > 5)) { + // new position is same as the last second position, and halt counter is past threshold + + String writtenSize = + Formatter.formatShortFileSize( + serviceStatusCallbacks.getApplicationContext(), progressHandler.getWrittenSize()); + String totalSize = + Formatter.formatShortFileSize( + serviceStatusCallbacks.getApplicationContext(), progressHandler.getTotalSize()); + + if (serviceStatusCallbacks.isDecryptService() && writtenSize.equals(totalSize)) { + // workaround for decryption when we have a length retrieved by + // CipherInputStream less than the original stream, and hence the total size + // we passed at the beginning is never reached + // we try to get a less precise size and make our decision based on that + progressHandler.addWrittenLength(progressHandler.getTotalSize()); + if (!pendingIntents.isEmpty()) pendingIntents.remove(); + cancel(false); + return; + } + + haltCounter = 0; + state = STATE_HALTED; + serviceStatusCallbacks.progressHalted(); + } else if (position != progressHandler.getWrittenSize()) { + + if (state == STATE_HALTED) { + + state = STATE_RESUMED; + haltCounter = 0; + serviceStatusCallbacks.progressResumed(); + } else { + + // reset the halt counter everytime there is a progress + // so that it increments only when + // progress was halted for consecutive time period + state = STATE_UNSET; + haltCounter = 0; + } + } + + progressHandler.addWrittenLength(position); + + if (position == progressHandler.getTotalSize() || progressHandler.getCancelled()) { + // process complete, free up resources + // we've finished the work or process cancelled + if (!pendingIntents.isEmpty()) pendingIntents.remove(); + cancel(false); + } + } + } + + /** + * Manually call runnable, before the delay. Fixes race condition which can arise when service has + * finished execution and stopping self, but the runnable is yet scheduled to be posted. Thus + * avoids posting any callback after service has stopped. + */ + public void stopWatch() { + if (watcherRepeatingRunnable != null && watcherRepeatingRunnable.isAlive()) { + watcherRepeatingRunnable.cancel(true); + } + } + + /** + * Convenience method to check whether another service is working in background If a service is + * found working (by checking {@link #watcherRepeatingRunnable} for it's state) then we wait for + * an interval of 5 secs, before checking on it again. + * + *

Be advised - this method is not sure to start a new service, especially when app has been + * closed as there are higher chances for android system to GC the thread when it is running low + * on memory + */ + public static synchronized void runService(@Nullable final Context context, final Intent intent) { + switch (pendingIntents.size()) { + case 0: + if (context != null) context.startService(intent); + break; + case 1: + // initialize waiting handlers + pendingIntents.add(intent); + if (context != null) postWaiting(context); + break; + case 2: + // to avoid notifying repeatedly + pendingIntents.add(intent); + notificationManager.notify(NotificationConstants.WAIT_ID, builder.build()); + break; + default: + pendingIntents.add(intent); + break; + } + } + + /** + * Helper method to {@link #runService(Context, Intent)} Starts the wait watcher thread if not + * already started. Halting condition depends on the state of {@link #watcherRepeatingRunnable} + */ + private static synchronized void postWaiting(final Context context) { + notificationManager = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + builder = + new NotificationCompat.Builder(context, NotificationConstants.CHANNEL_NORMAL_ID) + .setContentTitle(context.getString(R.string.waiting_title)) + .setContentText(context.getString(R.string.waiting_content)) + .setAutoCancel(false) + .setSmallIcon(R.drawable.ic_all_inclusive_white_36dp) + .setProgress(0, 0, true); + + NotificationConstants.setMetadata(context, builder, NotificationConstants.TYPE_NORMAL); + + new WaitNotificationThread(context, true); + } + + private static final class WaitNotificationThread extends AbstractRepeatingRunnable { + private final WeakReference context; + + private WaitNotificationThread(Context context, boolean startImmediately) { + super(0, 1, TimeUnit.SECONDS, startImmediately); + this.context = new WeakReference<>(context); + } + + @Override + public void run() { + if (watcherRepeatingRunnable == null || !watcherRepeatingRunnable.isAlive()) { + if (pendingIntents.size() == 0) { + cancel(false); + return; + } else { + if (pendingIntents.size() == 1) { + notificationManager.cancel(NotificationConstants.WAIT_ID); + } + + final Context context = this.context.get(); + if (context != null) { + context.startService(pendingIntents.element()); + cancel(true); + return; + } + } + } + } + } + + public interface ServiceStatusCallbacks { + + int STATE_UNSET = -1; + int STATE_HALTED = 0; + int STATE_RESUMED = 1; + + /** Progress has been halted for some reason */ + void progressHalted(); + + /** Future extension for possible implementation of pause/resume of services */ + void progressResumed(); + + Context getApplicationContext(); + + /** This is for a hack, read about it where it's used */ + boolean isDecryptService(); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/AbstractProgressiveService.java b/app/src/main/java/com/amaze/filemanager/asynchronous/services/AbstractProgressiveService.java new file mode 100644 index 0000000..c952fe8 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/AbstractProgressiveService.java @@ -0,0 +1,407 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.services; + +import static android.app.PendingIntent.FLAG_IMMUTABLE; +import static android.app.PendingIntent.FLAG_UPDATE_CURRENT; +import static android.os.Build.VERSION.SDK_INT; +import static android.os.Build.VERSION_CODES.S; + +import java.util.ArrayList; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil; +import com.amaze.filemanager.filesystem.HybridFile; +import com.amaze.filemanager.ui.activities.MainActivity; +import com.amaze.filemanager.ui.fragments.ProcessViewerFragment; +import com.amaze.filemanager.ui.notifications.NotificationConstants; +import com.amaze.filemanager.utils.DatapointParcelable; +import com.amaze.filemanager.utils.ProgressHandler; +import com.amaze.filemanager.utils.Utils; + +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.PowerManager; +import android.text.format.Formatter; +import android.widget.RemoteViews; + +import androidx.annotation.CallSuper; +import androidx.annotation.StringRes; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; + +/** + * @author Emmanuel Messulam on 28/11/2017, at 19:32. + */ +public abstract class AbstractProgressiveService extends Service + implements ServiceWatcherUtil.ServiceStatusCallbacks { + private final Logger LOG = LoggerFactory.getLogger(AbstractProgressiveService.class); + + private boolean isNotificationTitleSet = false; + private PowerManager.WakeLock wakeLock; + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + return super.onStartCommand(intent, flags, startId); + } + + @Override + @CallSuper + public void onCreate() { + super.onCreate(); + final PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE); + wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, getClass().getName()); + wakeLock.setReferenceCounted(false); + } + + protected abstract NotificationManagerCompat getNotificationManager(); + + protected abstract NotificationCompat.Builder getNotificationBuilder(); + + protected abstract int getNotificationId(); + + protected abstract @StringRes int getTitle(boolean move); + + protected abstract RemoteViews getNotificationCustomViewSmall(); + + protected abstract RemoteViews getNotificationCustomViewBig(); + + public abstract ProgressListener getProgressListener(); + + public abstract void setProgressListener(ProgressListener progressListener); + + /** + * @return list of data packages, to initiate chart in process viewer fragment + */ + protected abstract ArrayList getDataPackages(); + + protected abstract ProgressHandler getProgressHandler(); + + protected abstract void clearDataPackages(); + + @Override + public void progressHalted() { + // set notification to indeterminate unless progress resumes + getNotificationCustomViewSmall() + .setProgressBar(R.id.notification_service_progressBar_small, 0, 0, true); + getNotificationCustomViewBig() + .setProgressBar(R.id.notification_service_progressBar_big, 0, 0, true); + getNotificationCustomViewBig() + .setTextViewText( + R.id.notification_service_textView_timeRemaining_big, getString(R.string.unknown)); + getNotificationCustomViewBig() + .setTextViewText( + R.id.notification_service_textView_transferRate_big, getString(R.string.unknown)); + getNotificationManager().notify(getNotificationId(), getNotificationBuilder().build()); + } + + @Override + public void progressResumed() { + // set notification to indeterminate unless progress resumes + getNotificationCustomViewSmall() + .setProgressBar( + R.id.notification_service_progressBar_small, + 100, + Math.round(getProgressHandler().getPercentProgress()), + false); + getNotificationCustomViewBig() + .setProgressBar( + R.id.notification_service_progressBar_big, + 100, + Math.round(getProgressHandler().getPercentProgress()), + false); + getNotificationManager().notify(getNotificationId(), getNotificationBuilder().build()); + } + + @Override + @CallSuper + public void onDestroy() { + super.onDestroy(); + // remove the listener on destruction to prevent + // implicit AbstractProgressiveService instance from leaking (as "this") + getProgressHandler().setProgressListener(null); + wakeLock.release(); + clearDataPackages(); + } + + /** + * Publish the results of the progress to notification and {@link DatapointParcelable} and + * eventually to {@link ProcessViewerFragment} + * + * @param speed number of bytes being copied per sec + * @param isComplete whether operation completed or ongoing (not supported at the moment) + * @param move if the files are to be moved + */ + public final void publishResults(long speed, boolean isComplete, boolean move) { + if (!getProgressHandler().getCancelled()) { + String fileName = getProgressHandler().getFileName(); + long totalSize = getProgressHandler().getTotalSize(); + long writtenSize = getProgressHandler().getWrittenSize(); + + if (!isNotificationTitleSet) { + getNotificationBuilder().setSubText(getString(getTitle(move))); + isNotificationTitleSet = true; + } + + if (ServiceWatcherUtil.state != ServiceWatcherUtil.ServiceStatusCallbacks.STATE_HALTED) { + + String written = + Formatter.formatFileSize(this, writtenSize) + + "/" + + Formatter.formatFileSize(this, totalSize); + getNotificationCustomViewBig() + .setTextViewText(R.id.notification_service_textView_filename_big, fileName); + getNotificationCustomViewSmall() + .setTextViewText(R.id.notification_service_textView_filename_small, fileName); + getNotificationCustomViewBig() + .setTextViewText(R.id.notification_service_textView_written_big, written); + getNotificationCustomViewSmall() + .setTextViewText(R.id.notification_service_textView_written_small, written); + getNotificationCustomViewBig() + .setTextViewText( + R.id.notification_service_textView_transferRate_big, + Formatter.formatFileSize(this, speed) + "/s"); + + String remainingTime; + if (speed != 0) { + remainingTime = Utils.formatTimer(Math.round((totalSize - writtenSize) / speed)); + } else { + remainingTime = getString(R.string.unknown); + } + getNotificationCustomViewBig() + .setTextViewText(R.id.notification_service_textView_timeRemaining_big, remainingTime); + getNotificationCustomViewSmall() + .setProgressBar( + R.id.notification_service_progressBar_small, + 100, + Math.round(getProgressHandler().getPercentProgress()), + false); + getNotificationCustomViewBig() + .setProgressBar( + R.id.notification_service_progressBar_big, + 100, + Math.round(getProgressHandler().getPercentProgress()), + false); + getNotificationManager().notify(getNotificationId(), getNotificationBuilder().build()); + } + + if (writtenSize == totalSize || totalSize == 0) { + if (move && getNotificationId() == NotificationConstants.COPY_ID) { + + // mBuilder.setContentTitle(getString(R.string.move_complete)); + // set progress to indeterminate as deletion might still be going on from source + // while moving the file + getNotificationCustomViewSmall() + .setProgressBar(R.id.notification_service_progressBar_small, 0, 0, true); + getNotificationCustomViewBig() + .setProgressBar(R.id.notification_service_progressBar_big, 0, 0, true); + + getNotificationCustomViewBig() + .setTextViewText( + R.id.notification_service_textView_filename_big, getString(R.string.processing)); + getNotificationCustomViewSmall() + .setTextViewText( + R.id.notification_service_textView_filename_small, + getString(R.string.processing)); + getNotificationCustomViewBig() + .setTextViewText( + R.id.notification_service_textView_timeRemaining_big, + getString(R.string.unknown)); + getNotificationCustomViewBig() + .setTextViewText( + R.id.notification_service_textView_transferRate_big, getString(R.string.unknown)); + + getNotificationBuilder().setOngoing(false); + getNotificationBuilder().setAutoCancel(true); + getNotificationManager().notify(getNotificationId(), getNotificationBuilder().build()); + } else { + publishCompletedResult(getNotificationId()); + } + } + + // for processviewer + DatapointParcelable intent = + new DatapointParcelable( + fileName, + getProgressHandler().getSourceSize(), + getProgressHandler().getSourceFilesProcessed(), + totalSize, + writtenSize, + speed, + move, + isComplete); + // putDataPackage(intent); + addDatapoint(intent); + } else publishCompletedResult(getNotificationId()); + } + + private void publishCompletedResult(int id1) { + try { + getNotificationManager().cancel(id1); + } catch (Exception e) { + LOG.warn("failed to publish results", e); + } + } + + protected void addFirstDatapoint(String name, int amountOfFiles, long totalBytes, boolean move) { + if (!getDataPackages().isEmpty()) { + LOG.error("This is not the first datapoint!"); + getDataPackages().clear(); + } + DatapointParcelable intent1 = + DatapointParcelable.Companion.buildDatapointParcelable( + name, amountOfFiles, totalBytes, move); + putDataPackage(intent1); + } + + protected void addDatapoint(DatapointParcelable datapoint) { + if (getDataPackages().isEmpty()) { + LOG.error("This is the first datapoint!"); + } + + putDataPackage(datapoint); + if (getProgressListener() != null) { + getProgressListener().onUpdate(datapoint); + if (datapoint.getCompleted()) getProgressListener().refresh(); + } + } + + /** + * Returns the {@link #getDataPackages()} list which contains data to be transferred to {@link + * ProcessViewerFragment} Method call is synchronized so as to avoid modifying the list by {@link + * ServiceWatcherUtil#handlerThread} while {@link MainActivity#runOnUiThread(Runnable)} is + * executing the callbacks in {@link ProcessViewerFragment} + */ + public final synchronized DatapointParcelable getDataPackage(int index) { + return getDataPackages().get(index); + } + + public final synchronized int getDataPackageSize() { + return getDataPackages().size(); + } + + /** + * Puts a {@link DatapointParcelable} into a list Method call is synchronized so as to avoid + * modifying the list by {@link ServiceWatcherUtil#handlerThread} while {@link + * MainActivity#runOnUiThread(Runnable)} is executing the callbacks in {@link + * ProcessViewerFragment} + */ + private synchronized void putDataPackage(DatapointParcelable dataPackage) { + getDataPackages().add(dataPackage); + } + + public interface ProgressListener { + void onUpdate(DatapointParcelable dataPackage); + + void refresh(); + } + + @Override + public boolean isDecryptService() { + return false; + } + + /** Displays a notification, sends intent and cancels progress if there were some failures */ + void finalizeNotification(ArrayList failedOps, boolean move) { + clearDataPackages(); + + if (!move) getNotificationManager().cancelAll(); + + if (failedOps.size() == 0) return; + NotificationCompat.Builder mBuilder = + new NotificationCompat.Builder( + getApplicationContext(), NotificationConstants.CHANNEL_NORMAL_ID); + mBuilder.setContentTitle(getString(R.string.operation_unsuccesful)); + + mBuilder.setContentText( + getString(R.string.copy_error, getString(getTitle(move)).toLowerCase())); + mBuilder.setAutoCancel(true); + + getProgressHandler().setCancelled(true); + + Intent intent = new Intent(this, MainActivity.class); + intent.putExtra(MainActivity.TAG_INTENT_FILTER_FAILED_OPS, failedOps); + intent.putExtra("move", move); + + PendingIntent pIntent = + PendingIntent.getActivity(this, 101, intent, getPendingIntentFlag(FLAG_UPDATE_CURRENT)); + + mBuilder.setContentIntent(pIntent); + mBuilder.setSmallIcon(R.drawable.ic_folder_lock_open_white_36dp); + + getNotificationManager().notify(NotificationConstants.FAILED_ID, mBuilder.build()); + + intent = new Intent(MainActivity.TAG_INTENT_FILTER_GENERAL); + intent.putExtra(MainActivity.TAG_INTENT_FILTER_FAILED_OPS, failedOps); + + sendBroadcast(intent); + } + + /** Initializes notification views to initial (processing..) state */ + public void initNotificationViews() { + getNotificationCustomViewBig() + .setTextViewText( + R.id.notification_service_textView_filename_big, getString(R.string.processing)); + getNotificationCustomViewSmall() + .setTextViewText( + R.id.notification_service_textView_filename_small, getString(R.string.processing)); + + String zeroBytesFormat = Formatter.formatFileSize(this, 0l); + + getNotificationCustomViewBig() + .setTextViewText(R.id.notification_service_textView_written_big, zeroBytesFormat); + getNotificationCustomViewSmall() + .setTextViewText(R.id.notification_service_textView_written_small, zeroBytesFormat); + getNotificationCustomViewBig() + .setTextViewText( + R.id.notification_service_textView_transferRate_big, zeroBytesFormat + "/s"); + + getNotificationCustomViewBig() + .setTextViewText( + R.id.notification_service_textView_timeRemaining_big, getString(R.string.unknown)); + getNotificationCustomViewSmall() + .setProgressBar(R.id.notification_service_progressBar_small, 0, 0, true); + getNotificationCustomViewBig() + .setProgressBar(R.id.notification_service_progressBar_big, 0, 0, true); + getNotificationManager().notify(getNotificationId(), getNotificationBuilder().build()); + } + + /** + * For compatibility purposes. Wraps the pending intent flag, return with FLAG_IMMUTABLE if device + * SDK >= 32. + * + * @see PendingIntent.FLAG_IMMUTABLE + * @param pendingIntentFlag proposed PendingIntent flag + * @return original PendingIntent flag if SDK < 32, otherwise adding FLAG_IMMUTABLE flag. + */ + public static int getPendingIntentFlag(final int pendingIntentFlag) { + if (SDK_INT < S) { + return pendingIntentFlag; + } else { + return pendingIntentFlag | FLAG_IMMUTABLE; + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/CopyService.java b/app/src/main/java/com/amaze/filemanager/asynchronous/services/CopyService.java new file mode 100644 index 0000000..0e03e0d --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/CopyService.java @@ -0,0 +1,546 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.services; + +import static android.app.PendingIntent.FLAG_UPDATE_CURRENT; + +import java.io.IOException; +import java.util.ArrayList; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.application.AppConfig; +import com.amaze.filemanager.asynchronous.asynctasks.DeleteTask; +import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil; +import com.amaze.filemanager.database.CryptHandler; +import com.amaze.filemanager.database.models.explorer.EncryptedEntry; +import com.amaze.filemanager.fileoperations.exceptions.ShellNotRunningException; +import com.amaze.filemanager.fileoperations.filesystem.OpenMode; +import com.amaze.filemanager.filesystem.FileProperties; +import com.amaze.filemanager.filesystem.HybridFile; +import com.amaze.filemanager.filesystem.HybridFileParcelable; +import com.amaze.filemanager.filesystem.Operations; +import com.amaze.filemanager.filesystem.files.CryptUtil; +import com.amaze.filemanager.filesystem.files.FileUtils; +import com.amaze.filemanager.filesystem.files.GenericCopyUtil; +import com.amaze.filemanager.filesystem.files.MediaConnectionUtils; +import com.amaze.filemanager.filesystem.root.CopyFilesCommand; +import com.amaze.filemanager.filesystem.root.MoveFileCommand; +import com.amaze.filemanager.ui.activities.MainActivity; +import com.amaze.filemanager.ui.notifications.NotificationConstants; +import com.amaze.filemanager.utils.DatapointParcelable; +import com.amaze.filemanager.utils.ObtainableServiceBinder; +import com.amaze.filemanager.utils.ProgressHandler; + +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.os.AsyncTask; +import android.os.Bundle; +import android.os.IBinder; +import android.widget.RemoteViews; +import android.widget.Toast; + +import androidx.annotation.StringRes; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; +import androidx.preference.PreferenceManager; + +public class CopyService extends AbstractProgressiveService { + private static final Logger LOG = LoggerFactory.getLogger(CopyService.class); + public static final String TAG_IS_ROOT_EXPLORER = "is_root"; + public static final String TAG_COPY_TARGET = "COPY_DIRECTORY"; + public static final String TAG_COPY_SOURCES = "FILE_PATHS"; + public static final String TAG_COPY_OPEN_MODE = "MODE"; // target open mode + public static final String TAG_COPY_MOVE = "move"; + private static final String TAG_COPY_START_ID = "id"; + + public static final String TAG_BROADCAST_COPY_CANCEL = "copycancel"; + + private NotificationManagerCompat mNotifyManager; + private NotificationCompat.Builder mBuilder; + private Context c; + + private final IBinder mBinder = new ObtainableServiceBinder<>(this); + private ServiceWatcherUtil watcherUtil; + private final ProgressHandler progressHandler = new ProgressHandler(); + private ProgressListener progressListener; + // list of data packages, to initiate chart in process viewer fragment + private final ArrayList dataPackages = new ArrayList<>(); + private RemoteViews customSmallContentViews, customBigContentViews; + + @Override + public void onCreate() { + super.onCreate(); + c = getApplicationContext(); + registerReceiver(receiver3, new IntentFilter(TAG_BROADCAST_COPY_CANCEL)); + } + + @Override + public int onStartCommand(Intent intent, int flags, final int startId) { + Bundle b = new Bundle(); + boolean isRootExplorer = intent.getBooleanExtra(TAG_IS_ROOT_EXPLORER, false); + ArrayList files = intent.getParcelableArrayListExtra(TAG_COPY_SOURCES); + String targetPath = intent.getStringExtra(TAG_COPY_TARGET); + int mode = intent.getIntExtra(TAG_COPY_OPEN_MODE, OpenMode.UNKNOWN.ordinal()); + final boolean move = intent.getBooleanExtra(TAG_COPY_MOVE, false); + SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(c); + int accentColor = + ((AppConfig) getApplication()) + .getUtilsProvider() + .getColorPreference() + .getCurrentUserColorPreferences(this, sharedPreferences) + .getAccent(); + + mNotifyManager = NotificationManagerCompat.from(getApplicationContext()); + b.putInt(TAG_COPY_START_ID, startId); + + Intent notificationIntent = new Intent(this, MainActivity.class); + notificationIntent.setAction(Intent.ACTION_MAIN); + notificationIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + notificationIntent.putExtra(MainActivity.KEY_INTENT_PROCESS_VIEWER, true); + PendingIntent pendingIntent = + PendingIntent.getActivity(this, 0, notificationIntent, getPendingIntentFlag(0)); + + customSmallContentViews = + new RemoteViews(getPackageName(), R.layout.notification_service_small); + customBigContentViews = new RemoteViews(getPackageName(), R.layout.notification_service_big); + + Intent stopIntent = new Intent(TAG_BROADCAST_COPY_CANCEL); + PendingIntent stopPendingIntent = + PendingIntent.getBroadcast(c, 1234, stopIntent, getPendingIntentFlag(FLAG_UPDATE_CURRENT)); + NotificationCompat.Action action = + new NotificationCompat.Action( + R.drawable.ic_content_copy_white_36dp, getString(R.string.stop_ftp), stopPendingIntent); + + mBuilder = + new NotificationCompat.Builder(c, NotificationConstants.CHANNEL_NORMAL_ID) + .setContentIntent(pendingIntent) + .setSmallIcon(R.drawable.ic_content_copy_white_36dp) + .setCustomContentView(customSmallContentViews) + .setCustomBigContentView(customBigContentViews) + .setCustomHeadsUpContentView(customSmallContentViews) + .setStyle(new NotificationCompat.DecoratedCustomViewStyle()) + .addAction(action) + .setOngoing(true) + .setColor(accentColor); + + // set default notification views text + + NotificationConstants.setMetadata(c, mBuilder, NotificationConstants.TYPE_NORMAL); + + startForeground(NotificationConstants.COPY_ID, mBuilder.build()); + initNotificationViews(); + + b.putBoolean(TAG_COPY_MOVE, move); + b.putString(TAG_COPY_TARGET, targetPath); + b.putInt(TAG_COPY_OPEN_MODE, mode); + b.putParcelableArrayList(TAG_COPY_SOURCES, files); + + super.onStartCommand(intent, flags, startId); + super.progressHalted(); + // going async + new DoInBackground(isRootExplorer).execute(b); + + // If we get killed, after returning from here, restart + return START_NOT_STICKY; + } + + @Override + protected NotificationManagerCompat getNotificationManager() { + return mNotifyManager; + } + + @Override + protected NotificationCompat.Builder getNotificationBuilder() { + return mBuilder; + } + + @Override + protected int getNotificationId() { + return NotificationConstants.COPY_ID; + } + + @Override + protected RemoteViews getNotificationCustomViewSmall() { + return customSmallContentViews; + } + + @Override + protected RemoteViews getNotificationCustomViewBig() { + return customBigContentViews; + } + + @Override + @StringRes + protected int getTitle(boolean move) { + return move ? R.string.moving : R.string.copying; + } + + public ProgressListener getProgressListener() { + return progressListener; + } + + @Override + public void setProgressListener(ProgressListener progressListener) { + this.progressListener = progressListener; + } + + @Override + protected ArrayList getDataPackages() { + return dataPackages; + } + + @Override + protected ProgressHandler getProgressHandler() { + return progressHandler; + } + + @Override + protected void clearDataPackages() { + dataPackages.clear(); + } + + public void onDestroy() { + super.onDestroy(); + unregisterReceiver(receiver3); + } + + private class DoInBackground extends AsyncTask { + boolean move; + private Copy copy; + private String targetPath; + private final boolean isRootExplorer; + private int sourceProgress = 0; + + private DoInBackground(boolean isRootExplorer) { + this.isRootExplorer = isRootExplorer; + } + + protected Void doInBackground(Bundle... p1) { + + ArrayList sourceFiles = p1[0].getParcelableArrayList(TAG_COPY_SOURCES); + + // setting up service watchers and initial data packages + // finding total size on background thread (this is necessary condition for SMB!) + long totalSize = FileUtils.getTotalBytes(sourceFiles, c); + int totalSourceFiles = sourceFiles.size(); + + progressHandler.setSourceSize(totalSourceFiles); + progressHandler.setTotalSize(totalSize); + + progressHandler.setProgressListener((speed) -> publishResults(speed, false, move)); + + watcherUtil = new ServiceWatcherUtil(progressHandler); + + addFirstDatapoint(sourceFiles.get(0).getName(c), sourceFiles.size(), totalSize, move); + + targetPath = p1[0].getString(TAG_COPY_TARGET); + move = p1[0].getBoolean(TAG_COPY_MOVE); + OpenMode openMode = OpenMode.getOpenMode(p1[0].getInt(TAG_COPY_OPEN_MODE)); + copy = new Copy(); + copy.execute(sourceFiles, targetPath, move, openMode); + + if (copy.failedFOps.size() == 0) { + + // adding/updating new encrypted db entry if any encrypted file was copied/moved + for (HybridFileParcelable sourceFile : sourceFiles) { + try { + findAndReplaceEncryptedEntry(sourceFile); + } catch (Exception e) { + // unable to modify encrypted entry in database + Toast.makeText(c, getString(R.string.encryption_fail_copy), Toast.LENGTH_SHORT).show(); + } + } + } + return null; + } + + @Override + public void onPostExecute(Void b) { + + super.onPostExecute(b); + // publishResults(b, "", totalSourceFiles, totalSourceFiles, totalSize, totalSize, 0, true, + // move); + // stopping watcher if not yet finished + watcherUtil.stopWatch(); + finalizeNotification(copy.failedFOps, move); + + Intent intent = new Intent(MainActivity.KEY_INTENT_LOAD_LIST); + intent.putExtra(MainActivity.KEY_INTENT_LOAD_LIST_FILE, targetPath); + sendBroadcast(intent); + stopSelf(); + } + + /** + * Iterates through every file to find an encrypted file and update/add a new entry about it's + * metadata in the database + * + * @param sourceFile the file which is to be iterated + */ + private void findAndReplaceEncryptedEntry(HybridFileParcelable sourceFile) { + + // even directories can end with CRYPT_EXTENSION + if (sourceFile.isDirectory() && !sourceFile.getName(c).endsWith(CryptUtil.CRYPT_EXTENSION)) { + sourceFile.forEachChildrenFile( + getApplicationContext(), + isRootExplorer, + file -> { + // iterating each file inside source files which were copied to find instance of + // any copied / moved encrypted file + findAndReplaceEncryptedEntry(file); + }); + } else { + + if (sourceFile.getName(c).endsWith(CryptUtil.CRYPT_EXTENSION)) { + try { + + CryptHandler cryptHandler = CryptHandler.INSTANCE; + EncryptedEntry oldEntry = cryptHandler.findEntry(sourceFile.getPath()); + EncryptedEntry newEntry = new EncryptedEntry(); + + newEntry.setPassword(oldEntry.getPassword()); + newEntry.setPath(targetPath + "/" + sourceFile.getName(c)); + + if (move) { + + // file was been moved, update the existing entry + newEntry.setId(oldEntry.getId()); + cryptHandler.updateEntry(oldEntry, newEntry); + } else { + // file was copied, create a new entry with same data + cryptHandler.addEntry(newEntry); + } + } catch (Exception e) { + LOG.warn("failed to find and replace encrypted entry after copy", e); + // couldn't change the entry, leave it alone + } + } + } + } + + class Copy { + + ArrayList failedFOps; + ArrayList toDelete; + + Copy() { + failedFOps = new ArrayList<>(); + toDelete = new ArrayList<>(); + } + + /** + * Method iterate through files to be copied + * + * @param mode target file open mode (current path's open mode) + */ + public void execute( + final ArrayList sourceFiles, + final String targetPath, + final boolean move, + OpenMode mode) { + + // initial start of copy, initiate the watcher + watcherUtil.watch(CopyService.this); + + if (FileProperties.checkFolder((targetPath), c) == 1) { + for (int i = 0; i < sourceFiles.size(); i++) { + sourceProgress = i; + HybridFileParcelable f1 = (sourceFiles.get(i)); + + try { + + HybridFile hFile; + if (targetPath.contains(getExternalCacheDir().getPath())) { + // the target open mode is not the one we're currently in! + // we're processing the file for cache + hFile = + new HybridFile( + OpenMode.FILE, targetPath, sourceFiles.get(i).getName(c), f1.isDirectory()); + } else { + + // the target open mode is where we're currently at + hFile = + new HybridFile( + mode, targetPath, sourceFiles.get(i).getName(c), f1.isDirectory()); + } + + if (!progressHandler.getCancelled()) { + + if ((f1.getMode() == OpenMode.ROOT || mode == OpenMode.ROOT) && isRootExplorer) { + // either source or target are in root + LOG.debug("either source or target are in root"); + progressHandler.setSourceFilesProcessed(++sourceProgress); + copyRoot(f1, hFile, move); + continue; + } + progressHandler.setSourceFilesProcessed(++sourceProgress); + copyFiles((f1), hFile, progressHandler); + } else { + break; + } + } catch (Exception e) { + LOG.error("Got exception checkout: " + f1.getPath(), e); + + failedFOps.add(sourceFiles.get(i)); + for (int j = i + 1; j < sourceFiles.size(); j++) failedFOps.add(sourceFiles.get(j)); + break; + } + } + + } else if (isRootExplorer) { + for (int i = 0; i < sourceFiles.size(); i++) { + if (!progressHandler.getCancelled()) { + HybridFile hFile = + new HybridFile( + mode, + targetPath, + sourceFiles.get(i).getName(c), + sourceFiles.get(i).isDirectory()); + progressHandler.setSourceFilesProcessed(++sourceProgress); + progressHandler.setFileName(sourceFiles.get(i).getName(c)); + copyRoot(sourceFiles.get(i), hFile, move); + /*if(checkFiles(new HybridFile(sourceFiles.get(i).getMode(),path), + new HybridFile(OpenMode.ROOT,targetPath+"/"+name))){ + failedFOps.add(sourceFiles.get(i)); + }*/ + } + } + } else { + failedFOps.addAll(sourceFiles); + return; + } + + // making sure to delete files after copy operation is done + // and not if the copy was cancelled + if (move && !progressHandler.getCancelled()) { + ArrayList toDelete = new ArrayList<>(); + for (HybridFileParcelable a : sourceFiles) { + if (!failedFOps.contains(a)) toDelete.add(a); + } + new DeleteTask(c, true).execute((toDelete)); + } + } + + void copyRoot(HybridFileParcelable sourceFile, HybridFile targetFile, boolean move) { + + try { + if (!move) { + CopyFilesCommand.INSTANCE.copyFiles(sourceFile.getPath(), targetFile.getPath()); + } else { + MoveFileCommand.INSTANCE.moveFile(sourceFile.getPath(), targetFile.getPath()); + } + ServiceWatcherUtil.position += sourceFile.getSize(); + } catch (ShellNotRunningException e) { + LOG.warn( + "failed to copy root file source: {} dest: {}", + sourceFile.getPath(), + targetFile.getPath(), + e); + failedFOps.add(sourceFile); + } + MediaConnectionUtils.scanFile(c, new HybridFile[] {targetFile}); + } + + private void copyFiles( + final HybridFileParcelable sourceFile, + final HybridFile targetFile, + final ProgressHandler progressHandler) + throws IOException { + + if (progressHandler.getCancelled()) return; + if (sourceFile.isDirectory()) { + + if (!targetFile.exists()) { + targetFile.mkdir(c); + } + + // various checks + // 1. source file and target file doesn't end up in loop + // 2. source file has a valid name or not + if (!Operations.isFileNameValid(sourceFile.getName(c)) + || Operations.isCopyLoopPossible(sourceFile, targetFile)) { + failedFOps.add(sourceFile); + return; + } + targetFile.setLastModified(sourceFile.lastModified()); + + if (progressHandler.getCancelled()) return; + sourceFile.forEachChildrenFile( + c, + false, + file -> { + HybridFile destFile = + new HybridFile( + targetFile.getMode(), + targetFile.getPath(), + file.getName(c), + file.isDirectory()); + try { + copyFiles(file, destFile, progressHandler); + destFile.setLastModified(file.lastModified()); + } catch (IOException e) { + throw new IllegalStateException(e); // throw unchecked exception, no throws needed + } + }); + } else { + if (!Operations.isFileNameValid(sourceFile.getName(c))) { + failedFOps.add(sourceFile); + return; + } + + GenericCopyUtil copyUtil = new GenericCopyUtil(c, progressHandler); + + progressHandler.setFileName(sourceFile.getName(c)); + copyUtil.copy( + sourceFile, + targetFile, + () -> { + // we ran out of memory to map the whole channel, let's switch to streams + AppConfig.toast(c, c.getString(R.string.copy_low_memory)); + }, + ServiceWatcherUtil.UPDATE_POSITION); + targetFile.setLastModified(sourceFile.lastModified()); + } + } + } + } + + private final BroadcastReceiver receiver3 = + new BroadcastReceiver() { + + @Override + public void onReceive(Context context, Intent intent) { + // cancel operation + progressHandler.setCancelled(true); + } + }; + + @Override + public IBinder onBind(Intent arg0) { + return mBinder; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/DecryptService.java b/app/src/main/java/com/amaze/filemanager/asynchronous/services/DecryptService.java new file mode 100644 index 0000000..494d245 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/DecryptService.java @@ -0,0 +1,304 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.services; + +import static android.app.PendingIntent.FLAG_UPDATE_CURRENT; +import static com.amaze.filemanager.asynchronous.services.EncryptService.TAG_PASSWORD; + +import java.util.ArrayList; +import java.util.concurrent.Callable; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.application.AppConfig; +import com.amaze.filemanager.asynchronous.asynctasks.Task; +import com.amaze.filemanager.asynchronous.asynctasks.TaskKt; +import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil; +import com.amaze.filemanager.filesystem.FileProperties; +import com.amaze.filemanager.filesystem.HybridFile; +import com.amaze.filemanager.filesystem.HybridFileParcelable; +import com.amaze.filemanager.filesystem.files.CryptUtil; +import com.amaze.filemanager.filesystem.files.EncryptDecryptUtils; +import com.amaze.filemanager.ui.activities.MainActivity; +import com.amaze.filemanager.ui.notifications.NotificationConstants; +import com.amaze.filemanager.utils.AESCrypt; +import com.amaze.filemanager.utils.DatapointParcelable; +import com.amaze.filemanager.utils.ObtainableServiceBinder; +import com.amaze.filemanager.utils.ProgressHandler; + +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.os.IBinder; +import android.widget.RemoteViews; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; +import androidx.preference.PreferenceManager; + +/** + * @author Emmanuel Messulam on 28/11/2017, at 20:59. + */ +public class DecryptService extends AbstractProgressiveService { + + public static final String TAG_SOURCE = "crypt_source"; // source file to encrypt or decrypt + public static final String TAG_DECRYPT_PATH = "decrypt_path"; + public static final String TAG_OPEN_MODE = "open_mode"; + + public static final String TAG_BROADCAST_CRYPT_CANCEL = "crypt_cancel"; + private final Logger LOG = LoggerFactory.getLogger(DecryptService.class); + + private NotificationManagerCompat notificationManager; + private NotificationCompat.Builder notificationBuilder; + private Context context; + private final IBinder mBinder = new ObtainableServiceBinder<>(this); + private final ProgressHandler progressHandler = new ProgressHandler(); + private ProgressListener progressListener; + // list of data packages, to initiate chart in process viewer fragment + private final ArrayList dataPackages = new ArrayList<>(); + private ServiceWatcherUtil serviceWatcherUtil; + private long totalSize = 0L; + private String decryptPath; + private HybridFileParcelable baseFile; + private final ArrayList failedOps = new ArrayList<>(); + private String password; + private RemoteViews customSmallContentViews, customBigContentViews; + + @Override + public void onCreate() { + super.onCreate(); + + context = getApplicationContext(); + registerReceiver(cancelReceiver, new IntentFilter(TAG_BROADCAST_CRYPT_CANCEL)); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + + baseFile = intent.getParcelableExtra(TAG_SOURCE); + password = intent.getStringExtra(TAG_PASSWORD); + decryptPath = intent.getStringExtra(TAG_DECRYPT_PATH); + + SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); + int accentColor = + ((AppConfig) getApplication()) + .getUtilsProvider() + .getColorPreference() + .getCurrentUserColorPreferences(this, sharedPreferences) + .getAccent(); + + notificationManager = NotificationManagerCompat.from(getApplicationContext()); + Intent notificationIntent = new Intent(this, MainActivity.class); + notificationIntent.setAction(Intent.ACTION_MAIN); + notificationIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + notificationIntent.putExtra(MainActivity.KEY_INTENT_PROCESS_VIEWER, true); + PendingIntent pendingIntent = + PendingIntent.getActivity(this, 0, notificationIntent, getPendingIntentFlag(0)); + + customSmallContentViews = + new RemoteViews(getPackageName(), R.layout.notification_service_small); + customBigContentViews = new RemoteViews(getPackageName(), R.layout.notification_service_big); + + Intent stopIntent = new Intent(TAG_BROADCAST_CRYPT_CANCEL); + PendingIntent stopPendingIntent = + PendingIntent.getBroadcast( + context, 1234, stopIntent, getPendingIntentFlag(FLAG_UPDATE_CURRENT)); + NotificationCompat.Action action = + new NotificationCompat.Action( + R.drawable.ic_folder_lock_open_white_36dp, + getString(R.string.stop_ftp), + stopPendingIntent); + + notificationBuilder = + new NotificationCompat.Builder(this, NotificationConstants.CHANNEL_NORMAL_ID); + notificationBuilder + .setContentIntent(pendingIntent) + .setCustomContentView(customSmallContentViews) + .setCustomBigContentView(customBigContentViews) + .setCustomHeadsUpContentView(customSmallContentViews) + .setStyle(new NotificationCompat.DecoratedCustomViewStyle()) + .addAction(action) + .setColor(accentColor) + .setOngoing(true) + .setSmallIcon(R.drawable.ic_folder_lock_open_white_36dp); + + NotificationConstants.setMetadata( + getApplicationContext(), notificationBuilder, NotificationConstants.TYPE_NORMAL); + + startForeground(getNotificationId(), notificationBuilder.build()); + initNotificationViews(); + + super.onStartCommand(intent, flags, startId); + super.progressHalted(); + TaskKt.fromTask(new BackgroundTask()); + + return START_NOT_STICKY; + } + + @Override + protected NotificationManagerCompat getNotificationManager() { + return notificationManager; + } + + @Override + protected NotificationCompat.Builder getNotificationBuilder() { + return notificationBuilder; + } + + @Override + protected int getNotificationId() { + return NotificationConstants.DECRYPT_ID; + } + + @Override + @StringRes + protected int getTitle(boolean move) { + return R.string.crypt_decrypting; + } + + @Override + protected RemoteViews getNotificationCustomViewSmall() { + return customSmallContentViews; + } + + @Override + protected RemoteViews getNotificationCustomViewBig() { + return customBigContentViews; + } + + public ProgressListener getProgressListener() { + return progressListener; + } + + @Override + public void setProgressListener(ProgressListener progressListener) { + this.progressListener = progressListener; + } + + @Override + protected ArrayList getDataPackages() { + return dataPackages; + } + + @Override + protected ProgressHandler getProgressHandler() { + return progressHandler; + } + + @Override + protected void clearDataPackages() { + dataPackages.clear(); + } + + class BackgroundTask implements Task> { + + @Override + public void onError(@NonNull Throwable error) { + LOG.warn("failed to decrypt", error); + } + + @Override + public void onFinish(Long value) { + serviceWatcherUtil.stopWatch(); + finalizeNotification(failedOps, false); + + Intent intent = new Intent(EncryptDecryptUtils.DECRYPT_BROADCAST); + intent.putExtra(MainActivity.KEY_INTENT_LOAD_LIST_FILE, ""); + sendBroadcast(intent); + stopSelf(); + } + + @NonNull + @Override + public Callable getTask() { + return () -> { + String baseFileFolder = + baseFile.isDirectory() + ? baseFile.getPath() + : baseFile.getPath().substring(0, baseFile.getPath().lastIndexOf('/')); + + if (baseFile.isDirectory()) totalSize = baseFile.folderSize(context); + else totalSize = baseFile.length(context); + + progressHandler.setSourceSize(1); + progressHandler.setTotalSize(totalSize); + progressHandler.setProgressListener((speed) -> publishResults(speed, false, false)); + serviceWatcherUtil = new ServiceWatcherUtil(progressHandler); + + addFirstDatapoint( + baseFile.getName(context), + 1, + totalSize, + false); // we're using encrypt as move flag false + + if (FileProperties.checkFolder(baseFileFolder, context) == 1) { + serviceWatcherUtil.watch(DecryptService.this); + + // we're here to decrypt, we'll decrypt at a custom path. + // the path is to the same directory as in encrypted one in normal case + // and the cache directory in case we're here because of the viewer + try { + new CryptUtil(context, baseFile, decryptPath, progressHandler, failedOps, password); + } catch (AESCrypt.DecryptFailureException ignored) { + + } catch (Exception e) { + LOG.error("Error decrypting " + baseFile.getPath(), e); + failedOps.add(baseFile); + } + } + return totalSize; + }; + } + } + + @Override + public boolean isDecryptService() { + return true; + } + + @Override + public IBinder onBind(Intent intent) { + return mBinder; + } + + @Override + public void onDestroy() { + super.onDestroy(); + this.unregisterReceiver(cancelReceiver); + } + + private final BroadcastReceiver cancelReceiver = + new BroadcastReceiver() { + + @Override + public void onReceive(Context context, Intent intent) { + // cancel operation + progressHandler.setCancelled(true); + } + }; +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/EncryptService.java b/app/src/main/java/com/amaze/filemanager/asynchronous/services/EncryptService.java new file mode 100644 index 0000000..71980ec --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/EncryptService.java @@ -0,0 +1,300 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.services; + +import static android.app.PendingIntent.FLAG_UPDATE_CURRENT; + +import java.util.ArrayList; +import java.util.concurrent.Callable; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.application.AppConfig; +import com.amaze.filemanager.asynchronous.asynctasks.Task; +import com.amaze.filemanager.asynchronous.asynctasks.TaskKt; +import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil; +import com.amaze.filemanager.filesystem.FileProperties; +import com.amaze.filemanager.filesystem.HybridFile; +import com.amaze.filemanager.filesystem.HybridFileParcelable; +import com.amaze.filemanager.filesystem.files.CryptUtil; +import com.amaze.filemanager.ui.activities.MainActivity; +import com.amaze.filemanager.ui.notifications.NotificationConstants; +import com.amaze.filemanager.utils.DatapointParcelable; +import com.amaze.filemanager.utils.ObtainableServiceBinder; +import com.amaze.filemanager.utils.ProgressHandler; + +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.os.IBinder; +import android.widget.RemoteViews; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; +import androidx.preference.PreferenceManager; + +/** Created by vishal on 8/4/17 edited by Emmanuel Messulam */ +public class EncryptService extends AbstractProgressiveService { + + private final Logger LOG = LoggerFactory.getLogger(EncryptService.class); + + public static final String TAG_SOURCE = "crypt_source"; // source file to encrypt or decrypt + public static final String TAG_ENCRYPT_TARGET = "crypt_target"; // name of encrypted file + public static final String TAG_DECRYPT_PATH = "decrypt_path"; + public static final String TAG_OPEN_MODE = "open_mode"; + public static final String TAG_AESCRYPT = "use_aescrypt"; + public static final String TAG_PASSWORD = "password"; + + public static final String TAG_BROADCAST_CRYPT_CANCEL = "crypt_cancel"; + + private NotificationManagerCompat notificationManager; + private NotificationCompat.Builder notificationBuilder; + private Context context; + private final IBinder binder = new ObtainableServiceBinder<>(this); + private final ProgressHandler progressHandler = new ProgressHandler(); + private ProgressListener progressListener; + // list of data packages, to initiate chart in process viewer fragment + private final ArrayList dataPackages = new ArrayList<>(); + private ServiceWatcherUtil serviceWatcherUtil; + private long totalSize = 0L; + private HybridFileParcelable baseFile; + private final ArrayList failedOps = new ArrayList<>(); + private String targetFilename; + private boolean useAesCrypt; + private String password; + private RemoteViews customSmallContentViews, customBigContentViews; + + @Override + public void onCreate() { + super.onCreate(); + + context = getApplicationContext(); + registerReceiver(cancelReceiver, new IntentFilter(TAG_BROADCAST_CRYPT_CANCEL)); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + + baseFile = intent.getParcelableExtra(TAG_SOURCE); + targetFilename = intent.getStringExtra(TAG_ENCRYPT_TARGET); + useAesCrypt = intent.getBooleanExtra(TAG_AESCRYPT, false); + if (useAesCrypt) { + password = intent.getStringExtra(TAG_PASSWORD); + } + SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); + int accentColor = + ((AppConfig) getApplication()) + .getUtilsProvider() + .getColorPreference() + .getCurrentUserColorPreferences(this, sharedPreferences) + .getAccent(); + + notificationManager = NotificationManagerCompat.from(getApplicationContext()); + Intent notificationIntent = new Intent(this, MainActivity.class); + notificationIntent.setAction(Intent.ACTION_MAIN); + notificationIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + notificationIntent.putExtra(MainActivity.KEY_INTENT_PROCESS_VIEWER, true); + PendingIntent pendingIntent = + PendingIntent.getActivity(this, 0, notificationIntent, getPendingIntentFlag(0)); + + customSmallContentViews = + new RemoteViews(getPackageName(), R.layout.notification_service_small); + customBigContentViews = new RemoteViews(getPackageName(), R.layout.notification_service_big); + + Intent stopIntent = new Intent(TAG_BROADCAST_CRYPT_CANCEL); + PendingIntent stopPendingIntent = + PendingIntent.getBroadcast( + context, 1234, stopIntent, getPendingIntentFlag(FLAG_UPDATE_CURRENT)); + NotificationCompat.Action action = + new NotificationCompat.Action( + getSmallIcon(), getString(R.string.stop_ftp), stopPendingIntent); + + notificationBuilder = + new NotificationCompat.Builder(this, NotificationConstants.CHANNEL_NORMAL_ID); + notificationBuilder + .setContentIntent(pendingIntent) + .setCustomContentView(customSmallContentViews) + .setCustomBigContentView(customBigContentViews) + .setCustomHeadsUpContentView(customSmallContentViews) + .setStyle(new NotificationCompat.DecoratedCustomViewStyle()) + .addAction(action) + .setColor(accentColor) + .setOngoing(true) + .setSmallIcon(getSmallIcon()); + + NotificationConstants.setMetadata( + getApplicationContext(), notificationBuilder, NotificationConstants.TYPE_NORMAL); + + startForeground(getNotificationId(), notificationBuilder.build()); + initNotificationViews(); + + super.onStartCommand(intent, flags, startId); + super.progressHalted(); + TaskKt.fromTask(new BackgroundTask()); + + return START_NOT_STICKY; + } + + protected @DrawableRes int getSmallIcon() { + return R.drawable.ic_folder_lock_white_36dp; + } + + @Override + protected NotificationManagerCompat getNotificationManager() { + return notificationManager; + } + + @Override + protected NotificationCompat.Builder getNotificationBuilder() { + return notificationBuilder; + } + + @Override + protected int getNotificationId() { + return NotificationConstants.ENCRYPT_ID; + } + + @Override + @StringRes + protected int getTitle(boolean move) { + return R.string.crypt_encrypting; + } + + @Override + protected RemoteViews getNotificationCustomViewSmall() { + return customSmallContentViews; + } + + @Override + protected RemoteViews getNotificationCustomViewBig() { + return customBigContentViews; + } + + public ProgressListener getProgressListener() { + return progressListener; + } + + @Override + public void setProgressListener(ProgressListener progressListener) { + this.progressListener = progressListener; + } + + @Override + protected ArrayList getDataPackages() { + return dataPackages; + } + + @Override + protected ProgressHandler getProgressHandler() { + return progressHandler; + } + + @Override + protected void clearDataPackages() { + dataPackages.clear(); + } + + class BackgroundTask implements Task> { + + @Override + public void onError(@NonNull Throwable error) {} + + @Override + public void onFinish(Long value) { + serviceWatcherUtil.stopWatch(); + finalizeNotification(failedOps, false); + + Intent intent = new Intent(MainActivity.KEY_INTENT_LOAD_LIST); + intent.putExtra(MainActivity.KEY_INTENT_LOAD_LIST_FILE, ""); + sendBroadcast(intent); + stopSelf(); + } + + @NonNull + @Override + public Callable getTask() { + return () -> { + if (baseFile.isDirectory()) totalSize = baseFile.folderSize(context); + else totalSize = baseFile.length(context); + + progressHandler.setSourceSize(1); + progressHandler.setTotalSize(totalSize); + progressHandler.setProgressListener((speed) -> publishResults(speed, false, false)); + serviceWatcherUtil = new ServiceWatcherUtil(progressHandler); + + addFirstDatapoint( + baseFile.getName(context), + 1, + totalSize, + true); // we're using encrypt as move flag false + + if (FileProperties.checkFolder(baseFile.getPath(), context) == 1) { + serviceWatcherUtil.watch(EncryptService.this); + + // we're here to encrypt + try { + new CryptUtil( + context, + baseFile, + progressHandler, + failedOps, + targetFilename, + useAesCrypt, + password); + } catch (Exception e) { + LOG.warn("failed to get crypt util instance", e); + failedOps.add(baseFile); + } + } + return totalSize; + }; + } + } + + @Override + public IBinder onBind(Intent intent) { + return binder; + } + + @Override + public void onDestroy() { + super.onDestroy(); + this.unregisterReceiver(cancelReceiver); + } + + private final BroadcastReceiver cancelReceiver = + new BroadcastReceiver() { + + @Override + public void onReceive(Context context, Intent intent) { + // cancel operation + progressHandler.setCancelled(true); + } + }; +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ExtractService.java b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ExtractService.java new file mode 100644 index 0000000..28d1cdc --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ExtractService.java @@ -0,0 +1,464 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.services; + +import static android.app.PendingIntent.FLAG_UPDATE_CURRENT; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; + +import org.apache.commons.compress.PasswordRequiredException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.tukaani.xz.CorruptedInputException; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.application.AppConfig; +import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil; +import com.amaze.filemanager.fileoperations.filesystem.compressed.ArchivePasswordCache; +import com.amaze.filemanager.filesystem.compressed.CompressedHelper; +import com.amaze.filemanager.filesystem.compressed.extractcontents.Extractor; +import com.amaze.filemanager.ui.activities.MainActivity; +import com.amaze.filemanager.ui.dialogs.GeneralDialogCreation; +import com.amaze.filemanager.ui.notifications.NotificationConstants; +import com.amaze.filemanager.utils.DatapointParcelable; +import com.amaze.filemanager.utils.ObtainableServiceBinder; +import com.amaze.filemanager.utils.ProgressHandler; +import com.github.junrar.exception.UnsupportedRarV5Exception; + +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.os.AsyncTask; +import android.os.IBinder; +import android.text.TextUtils; +import android.widget.RemoteViews; +import android.widget.Toast; + +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.widget.AppCompatEditText; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; +import androidx.preference.PreferenceManager; + +public class ExtractService extends AbstractProgressiveService { + + private final Logger LOG = LoggerFactory.getLogger(ExtractService.class); + private final IBinder mBinder = new ObtainableServiceBinder<>(this); + + // list of data packages,// to initiate chart in process viewer fragment + private final ArrayList dataPackages = new ArrayList<>(); + + private NotificationManagerCompat mNotifyManager; + private NotificationCompat.Builder mBuilder; + private final ProgressHandler progressHandler = new ProgressHandler(); + private ProgressListener progressListener; + private RemoteViews customSmallContentViews, customBigContentViews; + private @Nullable DoWork extractingAsyncTask; + + public static final String KEY_PATH_ZIP = "zip"; + public static final String KEY_ENTRIES_ZIP = "entries"; + public static final String TAG_BROADCAST_EXTRACT_CANCEL = "excancel"; + public static final String KEY_PATH_EXTRACT = "extractpath"; + + @Override + public void onCreate() { + super.onCreate(); + registerReceiver(receiver1, new IntentFilter(TAG_BROADCAST_EXTRACT_CANCEL)); + } + + @Override + public int onStartCommand(Intent intent, int flags, final int startId) { + String file = intent.getStringExtra(KEY_PATH_ZIP); + String extractPath = intent.getStringExtra(KEY_PATH_EXTRACT); + String[] entries = intent.getStringArrayExtra(KEY_ENTRIES_ZIP); + + mNotifyManager = NotificationManagerCompat.from(getApplicationContext()); + SharedPreferences sharedPreferences = + PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); + int accentColor = + ((AppConfig) getApplication()) + .getUtilsProvider() + .getColorPreference() + .getCurrentUserColorPreferences(this, sharedPreferences) + .getAccent(); + + Intent notificationIntent = new Intent(this, MainActivity.class); + notificationIntent.setAction(Intent.ACTION_MAIN); + notificationIntent.putExtra(MainActivity.KEY_INTENT_PROCESS_VIEWER, true); + PendingIntent pendingIntent = + PendingIntent.getActivity(this, 0, notificationIntent, getPendingIntentFlag(0)); + + customSmallContentViews = + new RemoteViews(getPackageName(), R.layout.notification_service_small); + customBigContentViews = new RemoteViews(getPackageName(), R.layout.notification_service_big); + + Intent stopIntent = new Intent(TAG_BROADCAST_EXTRACT_CANCEL); + PendingIntent stopPendingIntent = + PendingIntent.getBroadcast( + getApplicationContext(), 1234, stopIntent, getPendingIntentFlag(FLAG_UPDATE_CURRENT)); + NotificationCompat.Action action = + new NotificationCompat.Action( + R.drawable.ic_zip_box_grey, getString(R.string.stop_ftp), stopPendingIntent); + + mBuilder = + new NotificationCompat.Builder( + getApplicationContext(), NotificationConstants.CHANNEL_NORMAL_ID); + mBuilder + .setContentIntent(pendingIntent) + .setSmallIcon(R.drawable.ic_zip_box_grey) + .setContentIntent(pendingIntent) + .setCustomContentView(customSmallContentViews) + .setCustomBigContentView(customBigContentViews) + .setCustomHeadsUpContentView(customSmallContentViews) + .setStyle(new NotificationCompat.DecoratedCustomViewStyle()) + .addAction(action) + .setAutoCancel(true) + .setOngoing(true) + .setColor(accentColor); + + NotificationConstants.setMetadata( + getApplicationContext(), mBuilder, NotificationConstants.TYPE_NORMAL); + startForeground(NotificationConstants.EXTRACT_ID, mBuilder.build()); + initNotificationViews(); + + long totalSize = getTotalSize(file); + + progressHandler.setSourceSize(1); + progressHandler.setTotalSize(totalSize); + progressHandler.setProgressListener((speed) -> publishResults(speed, false, false)); + + super.onStartCommand(intent, flags, startId); + super.progressHalted(); + extractingAsyncTask = new DoWork(progressHandler, file, extractPath, entries); + extractingAsyncTask.execute(); + + return START_NOT_STICKY; + } + + @Override + protected NotificationManagerCompat getNotificationManager() { + return mNotifyManager; + } + + @Override + protected NotificationCompat.Builder getNotificationBuilder() { + return mBuilder; + } + + @Override + protected int getNotificationId() { + return NotificationConstants.EXTRACT_ID; + } + + @Override + @StringRes + protected int getTitle(boolean move) { + return R.string.extracting; + } + + @Override + protected RemoteViews getNotificationCustomViewSmall() { + return customSmallContentViews; + } + + @Override + protected RemoteViews getNotificationCustomViewBig() { + return customBigContentViews; + } + + public ProgressListener getProgressListener() { + return progressListener; + } + + @Override + public void setProgressListener(ProgressListener progressListener) { + this.progressListener = progressListener; + } + + @Override + protected ArrayList getDataPackages() { + return dataPackages; + } + + @Override + protected ProgressHandler getProgressHandler() { + return progressHandler; + } + + @Override + protected void clearDataPackages() { + dataPackages.clear(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (extractingAsyncTask != null) { + extractingAsyncTask.cancel(true); + } + unregisterReceiver(receiver1); + } + + /** + * Method calculates zip file size to initiate progress Supporting local file extraction progress + * for now + */ + private long getTotalSize(String filePath) { + return new File(filePath).length(); + } + + public class DoWork extends AsyncTask { + private String[] entriesToExtract; + private String extractionPath; + private final String compressedPath; + private final ProgressHandler progressHandler; + private ServiceWatcherUtil watcherUtil; + private boolean paused = false; + private boolean passwordProtected = false; + + private DoWork(ProgressHandler progressHandler, String cpath, String epath, String[] entries) { + this.progressHandler = progressHandler; + compressedPath = cpath; + extractionPath = epath; + entriesToExtract = entries; + } + + @Override + protected Boolean doInBackground(Void... p) { + while (!isCancelled()) { + if (paused) continue; + + final ExtractService extractService = ExtractService.this; + + File f = new File(compressedPath); + String extractDirName = CompressedHelper.getFileName(f.getName()); + + if (compressedPath.equals(extractionPath)) { + // custom extraction path not set, extract at default path + extractionPath = f.getParent() + "/" + extractDirName; + } else { + if (extractionPath.endsWith("/")) { + extractionPath = extractionPath + extractDirName; + } else if (!passwordProtected) { + extractionPath = extractionPath + "/" + extractDirName; + } + } + + if (entriesToExtract != null && entriesToExtract.length == 0) entriesToExtract = null; + + final Extractor extractor = + CompressedHelper.getExtractorInstance( + extractService.getApplicationContext(), + f, + extractionPath, + new Extractor.OnUpdate() { + private int sourceFilesProcessed = 0; + + @Override + public void onStart(long totalBytes, String firstEntryName) { + // setting total bytes calculated from zip entries + progressHandler.setTotalSize(totalBytes); + + extractService.addFirstDatapoint(firstEntryName, 1, totalBytes, false); + + watcherUtil = new ServiceWatcherUtil(progressHandler); + watcherUtil.watch(ExtractService.this); + } + + @Override + public void onUpdate(String entryPath) { + progressHandler.setFileName(entryPath); + if (entriesToExtract != null) { + progressHandler.setSourceFilesProcessed(sourceFilesProcessed++); + } + } + + @Override + public void onFinish() { + if (entriesToExtract == null) { + progressHandler.setSourceFilesProcessed(1); + } + } + + @Override + public boolean isCancelled() { + return progressHandler.getCancelled(); + } + }, + ServiceWatcherUtil.UPDATE_POSITION); + + if (extractor == null) { + Toast.makeText( + getApplicationContext(), + R.string.error_cant_decompress_that_file, + Toast.LENGTH_LONG) + .show(); + return false; + } + + try { + if (entriesToExtract != null) { + extractor.extractFiles(entriesToExtract); + } else { + extractor.extractEverything(); + } + return (extractor.getInvalidArchiveEntries().size() == 0); + } catch (Extractor.EmptyArchiveNotice e) { + LOG.error("Archive " + compressedPath + " is an empty archive"); + AppConfig.toast( + getApplicationContext(), + extractService.getString(R.string.error_empty_archive, compressedPath)); + return true; + } catch (Extractor.BadArchiveNotice e) { + LOG.error("Archive " + compressedPath + " is a corrupted archive.", e); + AppConfig.toast( + getApplicationContext(), + e.getCause() != null && TextUtils.isEmpty(e.getCause().getMessage()) + ? getString(R.string.error_bad_archive_without_info, compressedPath) + : getString( + R.string.error_bad_archive_with_info, compressedPath, e.getMessage())); + return true; + } catch (CorruptedInputException e) { + LOG.debug("Corrupted LZMA input", e); + return false; + } catch (IOException e) { + if (PasswordRequiredException.class.isAssignableFrom(e.getClass())) { + LOG.debug("Archive is password protected.", e); + if (ArchivePasswordCache.getInstance().containsKey(compressedPath)) { + ArchivePasswordCache.getInstance().remove(compressedPath); + AppConfig.toast( + getApplicationContext(), + extractService.getString(R.string.error_archive_password_incorrect)); + } + passwordProtected = true; + paused = true; + publishProgress(e); + } else if (e.getCause() != null + && UnsupportedRarV5Exception.class.isAssignableFrom(e.getCause().getClass())) { + LOG.error("RAR " + compressedPath + " is unsupported V5 archive", e); + AppConfig.toast( + getApplicationContext(), + extractService.getString(R.string.error_unsupported_v5_rar, compressedPath)); + return false; + } else { + LOG.error("Error while extracting file " + compressedPath, e); + AppConfig.toast(getApplicationContext(), extractService.getString(R.string.error)); + paused = true; + publishProgress(e); + } + } catch (Throwable unhandledException) { + LOG.error("Unhandled exception thrown", unhandledException); + } + } + return false; + } + + @Override + protected void onProgressUpdate(IOException... values) { + super.onProgressUpdate(values); + if (values.length < 1 || !passwordProtected) return; + + IOException result = values[0]; + ArchivePasswordCache.getInstance().remove(compressedPath); + GeneralDialogCreation.showPasswordDialog( + AppConfig.getInstance().getMainActivityContext(), + (MainActivity) AppConfig.getInstance().getMainActivityContext(), + AppConfig.getInstance().getUtilsProvider().getAppTheme(), + R.string.archive_password_prompt, + R.string.authenticate_password, + (dialog, which) -> { + AppCompatEditText editText = dialog.getView().findViewById(R.id.singleedittext_input); + ArchivePasswordCache.getInstance().put(compressedPath, editText.getText().toString()); + ExtractService.this.getDataPackages().clear(); + this.paused = false; + dialog.dismiss(); + }, + ((dialog, which) -> { + dialog.dismiss(); + toastOnParseError(result); + cancel(true); // This cancels the AsyncTask... + progressHandler.setCancelled(true); + stopSelf(); // and this stops the ExtractService altogether. + this.paused = false; + })); + } + + @Override + public void onPostExecute(Boolean hasInvalidEntries) { + ArchivePasswordCache.getInstance().remove(compressedPath); + final ExtractService extractService = ExtractService.this; + + // check whether watcherutil was initialized. It was not initialized when we got exception + // in extracting the file + if (watcherUtil != null) watcherUtil.stopWatch(); + Intent intent = new Intent(MainActivity.KEY_INTENT_LOAD_LIST); + intent.putExtra(MainActivity.KEY_INTENT_LOAD_LIST_FILE, extractionPath); + extractService.sendBroadcast(intent); + extractService.stopSelf(); + + if (!hasInvalidEntries) + AppConfig.toast( + getApplicationContext(), getString(R.string.multiple_invalid_archive_entries)); + } + + @Override + protected void onCancelled() { + super.onCancelled(); + ArchivePasswordCache.getInstance().remove(compressedPath); + } + + private void toastOnParseError(IOException result) { + Toast.makeText( + getApplicationContext(), + AppConfig.getInstance() + .getResources() + .getString( + R.string.cannot_extract_archive, + compressedPath, + result.getLocalizedMessage()), + Toast.LENGTH_LONG) + .show(); + } + } + + /** + * Class used for the client Binder. Because we know this service always runs in the same process + * as its clients, we don't need to deal with IPC. + */ + private final BroadcastReceiver receiver1 = + new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + progressHandler.setCancelled(true); + } + }; + + @Override + public IBinder onBind(Intent intent) { + return mBinder; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ZipService.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ZipService.kt new file mode 100644 index 0000000..d081643 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ZipService.kt @@ -0,0 +1,371 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.services + +import android.app.PendingIntent +import android.app.PendingIntent.FLAG_UPDATE_CURRENT +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.SharedPreferences +import android.net.Uri +import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES.O +import android.os.IBinder +import android.widget.RemoteViews +import androidx.annotation.StringRes +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.preference.PreferenceManager +import com.amaze.filemanager.R +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil +import com.amaze.filemanager.filesystem.FileUtil +import com.amaze.filemanager.filesystem.HybridFileParcelable +import com.amaze.filemanager.filesystem.files.FileUtils +import com.amaze.filemanager.filesystem.files.GenericCopyUtil +import com.amaze.filemanager.ui.activities.MainActivity +import com.amaze.filemanager.ui.notifications.NotificationConstants +import com.amaze.filemanager.utils.DatapointParcelable +import com.amaze.filemanager.utils.ObtainableServiceBinder +import com.amaze.filemanager.utils.ProgressHandler +import io.reactivex.Completable +import io.reactivex.CompletableEmitter +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.disposables.Disposable +import io.reactivex.schedulers.Schedulers +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.IOException +import java.io.OutputStream +import java.nio.file.Files +import java.nio.file.Paths +import java.nio.file.attribute.BasicFileAttributes +import java.util.zip.ZipEntry +import java.util.zip.ZipException +import java.util.zip.ZipOutputStream + +@Suppress("TooManyFunctions") // Hack. +class ZipService : AbstractProgressiveService() { + private val log: Logger = LoggerFactory.getLogger(ZipService::class.java) + + private val mBinder: IBinder = ObtainableServiceBinder(this) + private val disposables = CompositeDisposable() + private lateinit var mNotifyManager: NotificationManagerCompat + private lateinit var mBuilder: NotificationCompat.Builder + private var progressListener: ProgressListener? = null + private val progressHandler = ProgressHandler() + + // list of data packages, to initiate chart in process viewer fragment + private val dataPackages = ArrayList() + private var accentColor = 0 + private var sharedPreferences: SharedPreferences? = null + private var customSmallContentViews: RemoteViews? = null + private var customBigContentViews: RemoteViews? = null + + override fun onCreate() { + super.onCreate() + registerReceiver(receiver1, IntentFilter(KEY_COMPRESS_BROADCAST_CANCEL)) + } + + override fun onStartCommand( + intent: Intent, + flags: Int, + startId: Int, + ): Int { + val mZipPath = intent.getStringExtra(KEY_COMPRESS_PATH) + val baseFiles: ArrayList = + intent.getParcelableArrayListExtra(KEY_COMPRESS_FILES)!! + val zipFile = File(mZipPath) + mNotifyManager = NotificationManagerCompat.from(applicationContext) + if (!zipFile.exists()) { + try { + zipFile.createNewFile() + } catch (e: IOException) { + log.warn("failed to create zip file", e) + } + } + sharedPreferences = PreferenceManager.getDefaultSharedPreferences(applicationContext) + accentColor = + (application as AppConfig) + .utilsProvider + .colorPreference + .getCurrentUserColorPreferences(this, sharedPreferences).accent + val notificationIntent = + Intent(this, MainActivity::class.java) + .putExtra(MainActivity.KEY_INTENT_PROCESS_VIEWER, true) + val pendingIntent = + PendingIntent.getActivity( + this, + 0, + notificationIntent, + getPendingIntentFlag(0), + ) + customSmallContentViews = RemoteViews(packageName, R.layout.notification_service_small) + customBigContentViews = RemoteViews(packageName, R.layout.notification_service_big) + val stopIntent = Intent(KEY_COMPRESS_BROADCAST_CANCEL) + val stopPendingIntent = + PendingIntent.getBroadcast( + applicationContext, + 1234, + stopIntent, + getPendingIntentFlag(FLAG_UPDATE_CURRENT), + ) + val action = + NotificationCompat.Action( + R.drawable.ic_zip_box_grey, + getString(R.string.stop_ftp), + stopPendingIntent, + ) + mBuilder = + NotificationCompat.Builder(this, NotificationConstants.CHANNEL_NORMAL_ID) + .setSmallIcon(R.drawable.ic_zip_box_grey) + .setContentIntent(pendingIntent) + .setCustomContentView(customSmallContentViews) + .setCustomBigContentView(customBigContentViews) + .setCustomHeadsUpContentView(customSmallContentViews) + .setStyle(NotificationCompat.DecoratedCustomViewStyle()) + .addAction(action) + .setOngoing(true) + .setColor(accentColor) + NotificationConstants.setMetadata(this, mBuilder, NotificationConstants.TYPE_NORMAL) + startForeground(NotificationConstants.ZIP_ID, mBuilder.build()) + initNotificationViews() + super.onStartCommand(intent, flags, startId) + super.progressHalted() + val zipTask = CompressTask(this, baseFiles, zipFile.absolutePath) + disposables.add(zipTask.compress()) + // If we get killed, after returning from here, restart + return START_NOT_STICKY + } + + override fun getNotificationManager(): NotificationManagerCompat = mNotifyManager + + override fun getNotificationBuilder(): NotificationCompat.Builder = mBuilder + + override fun getNotificationId(): Int = NotificationConstants.ZIP_ID + + @StringRes + override fun getTitle(move: Boolean): Int = R.string.compressing + + override fun getNotificationCustomViewSmall(): RemoteViews = customSmallContentViews!! + + override fun getNotificationCustomViewBig(): RemoteViews = customBigContentViews!! + + override fun getProgressListener(): ProgressListener? = progressListener + + override fun setProgressListener(progressListener: ProgressListener?) { + this.progressListener = progressListener + } + + override fun getDataPackages(): ArrayList = dataPackages + + override fun getProgressHandler(): ProgressHandler = progressHandler + + override fun clearDataPackages() = dataPackages.clear() + + inner class CompressTask( + private val zipService: ZipService, + private val baseFiles: ArrayList, + private val zipPath: String, + ) { + private lateinit var zos: ZipOutputStream + private lateinit var watcherUtil: ServiceWatcherUtil + + /** + * Main use case for executing zipping task by given [zipPath] + */ + fun compress(): Disposable { + return Completable.create { emitter -> + // setting up service watchers and initial data packages + // finding total size on background thread (this is necessary condition for SMB!) + val totalBytes = FileUtils.getTotalBytes(baseFiles, zipService.applicationContext) + progressHandler.sourceSize = baseFiles.size + progressHandler.totalSize = totalBytes + + progressHandler.setProgressListener { speed: Long -> + publishResults(speed, false, false) + } + zipService.addFirstDatapoint( + baseFiles[0].getName(applicationContext), + baseFiles.size, + totalBytes, + false, + ) + execute( + emitter, + zipService.applicationContext, + FileUtils.hybridListToFileArrayList(baseFiles), + zipPath, + ) + + emitter.onComplete() + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + watcherUtil.stopWatch() + val intent = + Intent(MainActivity.KEY_INTENT_LOAD_LIST) + .putExtra(MainActivity.KEY_INTENT_LOAD_LIST_FILE, zipPath) + zipService.sendBroadcast(intent) + zipService.stopSelf() + }, + { log.error(it.message ?: "ZipService.CompressAsyncTask.compress failed") }, + ) + } + + /** + * Deletes the destination file zip file if exists + */ + fun cancel() { + progressHandler.cancelled = true + val zipFile = File(zipPath) + if (zipFile.exists()) zipFile.delete() + } + + /** + * Main logic for zipping specified files. + */ + fun execute( + emitter: CompletableEmitter, + context: Context, + baseFiles: ArrayList, + zipPath: String, + ) { + val out: OutputStream? + val zipDirectory = File(zipPath) + watcherUtil = ServiceWatcherUtil(progressHandler) + watcherUtil.watch(this@ZipService) + try { + out = FileUtil.getOutputStream(zipDirectory, context) + zos = ZipOutputStream(BufferedOutputStream(out)) + for ((fileProgress, file) in baseFiles.withIndex()) { + if (emitter.isDisposed) return + progressHandler.fileName = file.name + progressHandler.sourceFilesProcessed = fileProgress + 1 + compressFile(file, "") + } + } catch (e: IOException) { + log.warn("failed to zip file", e) + } finally { + try { + zos.flush() + zos.close() + context.sendBroadcast( + Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE) + .setData(Uri.fromFile(zipDirectory)), + ) + } catch (e: IOException) { + log.warn("failed to close zip streams", e) + } + } + } + + @Throws(IOException::class, NullPointerException::class, ZipException::class) + private fun compressFile( + file: File, + path: String, + ) { + if (progressHandler.cancelled) return + if (!file.isDirectory) { + zos.putNextEntry(createZipEntry(file, path)) + val buf = ByteArray(GenericCopyUtil.DEFAULT_BUFFER_SIZE) + var len: Int + BufferedInputStream(FileInputStream(file)).use { bufferedInputStream -> + while (bufferedInputStream.read(buf).also { len = it } > 0) { + if (!progressHandler.cancelled) { + zos.write(buf, 0, len) + ServiceWatcherUtil.position += len.toLong() + } else { + break + } + } + } + return + } + file.listFiles()?.forEach { + compressFile(it, "${createZipEntryPrefixWith(path)}${file.name}") + } + } + } + + private fun createZipEntryPrefixWith(path: String): String = + if (path.isEmpty()) { + path + } else { + "$path/" + } + + private fun createZipEntry( + file: File, + path: String, + ): ZipEntry = + ZipEntry("${createZipEntryPrefixWith(path)}${file.name}").apply { + if (SDK_INT >= O) { + val attrs = + Files.readAttributes( + Paths.get(file.absolutePath), + BasicFileAttributes::class.java, + ) + setCreationTime(attrs.creationTime()) + .setLastAccessTime(attrs.lastAccessTime()) + .lastModifiedTime = attrs.lastModifiedTime() + } else { + time = file.lastModified() + } + } + + /* + * Class used for the client Binder. Because we know this service always runs in the same process + * as its clients, we don't need to deal with IPC. + */ + private val receiver1: BroadcastReceiver = + object : BroadcastReceiver() { + override fun onReceive( + context: Context, + intent: Intent, + ) { + progressHandler.cancelled = true + } + } + + override fun onBind(arg0: Intent): IBinder = mBinder + + override fun onDestroy() { + super.onDestroy() + unregisterReceiver(receiver1) + disposables.dispose() + } + + companion object { + const val KEY_COMPRESS_PATH = "zip_path" + const val KEY_COMPRESS_FILES = "zip_files" + const val KEY_COMPRESS_BROADCAST_CANCEL = "zip_cancel" + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/CommandFactoryFactory.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/CommandFactoryFactory.kt new file mode 100644 index 0000000..ef13285 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/CommandFactoryFactory.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.services.ftp + +import com.amaze.filemanager.filesystem.ftpserver.AndroidFtpFileSystemView +import com.amaze.filemanager.filesystem.ftpserver.commands.AVBL +import com.amaze.filemanager.filesystem.ftpserver.commands.FEAT +import com.amaze.filemanager.filesystem.ftpserver.commands.PWD +import org.apache.ftpserver.command.CommandFactory +import org.apache.ftpserver.command.CommandFactoryFactory + +/** + * Custom [CommandFactory] factory with custom commands. + */ +object CommandFactoryFactory { + /** + * Encapsulate custom [CommandFactory] construction logic. Append custom AVBL and PWD command, + * as well as feature flag in FEAT command if not using [AndroidFtpFileSystemView]. + */ + fun create(useAndroidFileSystem: Boolean): CommandFactory { + val cf = CommandFactoryFactory() + if (!useAndroidFileSystem) { + cf.addCommand("AVBL", AVBL()) + cf.addCommand("FEAT", FEAT()) + cf.addCommand("PWD", PWD()) + } + return cf.createCommandFactory() + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpReceiver.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpReceiver.kt new file mode 100644 index 0000000..9d71e40 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpReceiver.kt @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.services.ftp + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.core.content.ContextCompat +import com.amaze.filemanager.BuildConfig.DEBUG +import com.amaze.filemanager.asynchronous.services.ftp.FtpService.Companion.isRunning + +/** Created by yashwanthreddyg on 09-06-2016. */ +class FtpReceiver : BroadcastReceiver() { + private val TAG = FtpReceiver::class.java.simpleName + + override fun onReceive( + context: Context, + intent: Intent, + ) { + if (DEBUG) { + Log.v(TAG, "Received: ${intent.action}") + } + val service = Intent(context, FtpService::class.java) + service.putExtras(intent) + runCatching { + if (intent.action == FtpService.ACTION_START_FTPSERVER && !isRunning()) { + ContextCompat.startForegroundService(context, service) + } else if (intent.action == FtpService.ACTION_STOP_FTPSERVER) { + context.stopService(service) + } else { + Unit + } + }.onFailure { + Log.e(TAG, "Failed to start/stop on intent ${it.message}") + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpService.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpService.kt new file mode 100644 index 0000000..d8003ce --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpService.kt @@ -0,0 +1,385 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.services.ftp + +import android.app.AlarmManager +import android.app.PendingIntent +import android.app.PendingIntent.FLAG_ONE_SHOT +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES.KITKAT +import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M +import android.os.Build.VERSION_CODES.N +import android.os.Build.VERSION_CODES.Q +import android.os.Environment +import android.os.IBinder +import android.os.PowerManager +import android.os.SystemClock +import android.provider.DocumentsContract +import androidx.preference.PreferenceManager +import com.amaze.filemanager.BuildConfig +import com.amaze.filemanager.R +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.asynchronous.services.AbstractProgressiveService.getPendingIntentFlag +import com.amaze.filemanager.filesystem.ftpserver.AndroidFileSystemFactory +import com.amaze.filemanager.filesystem.ftpserver.RootFileSystemFactory +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_ROOTMODE +import com.amaze.filemanager.ui.notifications.FtpNotification +import com.amaze.filemanager.ui.notifications.NotificationConstants +import com.amaze.filemanager.utils.ObtainableServiceBinder +import com.amaze.filemanager.utils.PasswordUtil +import org.apache.ftpserver.ConnectionConfigFactory +import org.apache.ftpserver.FtpServer +import org.apache.ftpserver.FtpServerFactory +import org.apache.ftpserver.filesystem.nativefs.NativeFileSystemFactory +import org.apache.ftpserver.listener.ListenerFactory +import org.apache.ftpserver.ssl.ClientAuth +import org.apache.ftpserver.ssl.impl.DefaultSslConfiguration +import org.apache.ftpserver.usermanager.impl.BaseUser +import org.apache.ftpserver.usermanager.impl.WritePermission +import org.greenrobot.eventbus.EventBus +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.IOException +import java.security.GeneralSecurityException +import java.security.KeyStore +import java.util.LinkedList +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.TrustManagerFactory +import kotlin.concurrent.thread + +/** + * Created by yashwanthreddyg on 09-06-2016. + * + * + * Edited by zent-co on 30-07-2019 Edited by bowiechen on 2019-10-19. + */ +class FtpService : Service(), Runnable { + private val binder: IBinder = ObtainableServiceBinder(this) + + // Service will broadcast via event bus when server start/stop + enum class FtpReceiverActions { + STARTED, + STARTED_FROM_TILE, + STOPPED, + FAILED_TO_START, + } + + private var username: String? = null + private var password: String? = null + private var isPasswordProtected = false + private var isStartedByTile = false + private lateinit var wakeLock: PowerManager.WakeLock + + override fun onStartCommand( + intent: Intent?, + flags: Int, + startId: Int, + ): Int { + isStartedByTile = true == intent?.getBooleanExtra(TAG_STARTED_BY_TILE, false) + var attempts = 10 + while (serverThread != null) { + if (attempts > 0) { + attempts-- + try { + Thread.sleep(1000) + } catch (ignored: InterruptedException) { + } + } else { + return START_STICKY + } + } + + serverThread = thread(block = this::run) + val notification = FtpNotification.startNotification(applicationContext, isStartedByTile) + startForeground(NotificationConstants.FTP_ID, notification) + return START_NOT_STICKY + } + + override fun onCreate() { + super.onCreate() + + val powerManager = getSystemService(POWER_SERVICE) as PowerManager + wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, javaClass.name) + wakeLock.setReferenceCounted(false) + } + + override fun onBind(intent: Intent): IBinder { + return binder + } + + @Suppress("LongMethod") + override fun run() { + val preferences = PreferenceManager.getDefaultSharedPreferences(this) + FtpServerFactory().run { + val connectionConfigFactory = ConnectionConfigFactory() + val shouldUseAndroidFileSystem = + preferences.getBoolean(KEY_PREFERENCE_SAF_FILESYSTEM, false) + if (SDK_INT >= KITKAT && shouldUseAndroidFileSystem) { + fileSystem = AndroidFileSystemFactory(applicationContext) + } else if (preferences.getBoolean(PREFERENCE_ROOTMODE, false)) { + fileSystem = RootFileSystemFactory() + } else { + fileSystem = NativeFileSystemFactory() + } + + commandFactory = CommandFactoryFactory.create(shouldUseAndroidFileSystem) + + val usernamePreference = + preferences.getString( + KEY_PREFERENCE_USERNAME, + DEFAULT_USERNAME, + ) + if (usernamePreference != DEFAULT_USERNAME) { + username = usernamePreference + runCatching { + password = + PasswordUtil.decryptPassword( + applicationContext, + preferences.getString(KEY_PREFERENCE_PASSWORD, "")!!, + ) + isPasswordProtected = true + }.onFailure { + log.warn("failed to decrypt password in ftp service", it) + AppConfig.toast(applicationContext, R.string.error) + preferences.edit().putString(KEY_PREFERENCE_PASSWORD, "").apply() + isPasswordProtected = false + } + } + val user = BaseUser() + if (!isPasswordProtected) { + user.name = "anonymous" + connectionConfigFactory.isAnonymousLoginEnabled = true + } else { + user.name = username + user.password = password + } + user.homeDirectory = + preferences.getString( + KEY_PREFERENCE_PATH, + defaultPath(this@FtpService), + ) + if (!preferences.getBoolean(KEY_PREFERENCE_READONLY, false)) { + user.authorities = listOf(WritePermission()) + } + + connectionConfig = connectionConfigFactory.createConnectionConfig() + userManager.save(user) + + val fac = ListenerFactory() + if (preferences.getBoolean(KEY_PREFERENCE_SECURE, DEFAULT_SECURE)) { + try { + val keyStore = KeyStore.getInstance("BKS") + val keyStorePassword = BuildConfig.FTP_SERVER_KEYSTORE_PASSWORD.toCharArray() + keyStore.load(resources.openRawResource(R.raw.key), keyStorePassword) + val keyManagerFactory = + KeyManagerFactory + .getInstance(KeyManagerFactory.getDefaultAlgorithm()) + keyManagerFactory.init(keyStore, keyStorePassword) + val trustManagerFactory = + TrustManagerFactory + .getInstance(TrustManagerFactory.getDefaultAlgorithm()) + trustManagerFactory.init(keyStore) + fac.sslConfiguration = + DefaultSslConfiguration( + keyManagerFactory, + trustManagerFactory, + ClientAuth.WANT, + "TLS", + enabledCipherSuites, + "ftpserver", + ) + fac.isImplicitSsl = true + } catch (e: GeneralSecurityException) { + preferences.edit().putBoolean(KEY_PREFERENCE_SECURE, false).apply() + } catch (e: IOException) { + preferences.edit().putBoolean(KEY_PREFERENCE_SECURE, false).apply() + } + } + fac.port = getPort(preferences) + fac.idleTimeout = preferences.getInt(KEY_PREFERENCE_TIMEOUT, DEFAULT_TIMEOUT) + + addListener("default", fac.createListener()) + runCatching { + server = + createServer().apply { + start() + EventBus.getDefault() + .post( + if (isStartedByTile) { + FtpReceiverActions.STARTED_FROM_TILE + } else { + FtpReceiverActions.STARTED + }, + ) + } + }.onFailure { + EventBus.getDefault().post(FtpReceiverActions.FAILED_TO_START) + } + } + } + + override fun onDestroy() { + wakeLock.release() + serverThread?.let { serverThread -> + serverThread.interrupt() + // wait 10 sec for server thread to finish + serverThread.join(10000) + + if (!serverThread.isAlive) { + Companion.serverThread = null + } + server?.stop().also { + EventBus.getDefault().post(FtpReceiverActions.STOPPED) + } + } + } + + // Restart the service if the app is closed from the recent list + override fun onTaskRemoved(rootIntent: Intent) { + super.onTaskRemoved(rootIntent) + val restartService = Intent(applicationContext, this.javaClass).setPackage(packageName) + val flag = getPendingIntentFlag(FLAG_ONE_SHOT) + val restartServicePI = + PendingIntent.getService( + applicationContext, + 1, + restartService, + flag, + ) + val alarmService = applicationContext.getSystemService(ALARM_SERVICE) as AlarmManager + alarmService[AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + 2000] = + restartServicePI + } + + companion object { + private val log: Logger = LoggerFactory.getLogger(FtpService::class.java) + + const val DEFAULT_PORT = 2211 + const val DEFAULT_USERNAME = "" + const val DEFAULT_TIMEOUT = 600 // default timeout, in sec + const val DEFAULT_SECURE = true + const val PORT_PREFERENCE_KEY = "ftpPort" + const val KEY_PREFERENCE_PATH = "ftp_path" + const val KEY_PREFERENCE_USERNAME = "ftp_username" + const val KEY_PREFERENCE_PASSWORD = "ftp_password_encrypted" + const val KEY_PREFERENCE_TIMEOUT = "ftp_timeout" + const val KEY_PREFERENCE_SECURE = "ftp_secure" + const val KEY_PREFERENCE_READONLY = "ftp_readonly" + const val KEY_PREFERENCE_SAF_FILESYSTEM = "ftp_saf_filesystem" + const val KEY_PREFERENCE_ROOT_FILESYSTEM = "ftp_root_filesystem" + const val INITIALS_HOST_FTP = "ftp://" + const val INITIALS_HOST_SFTP = "ftps://" + + // RequestStartStopReceiver listens for these actions to start/stop this server + const val ACTION_START_FTPSERVER = + "com.amaze.filemanager.services.ftpservice.FTPReceiver.ACTION_START_FTPSERVER" + const val ACTION_STOP_FTPSERVER = + "com.amaze.filemanager.services.ftpservice.FTPReceiver.ACTION_STOP_FTPSERVER" + const val TAG_STARTED_BY_TILE = "started_by_tile" + // attribute of action_started, used by notification + + private lateinit var _enabledCipherSuites: Array + + init { + _enabledCipherSuites = + LinkedList().apply { + if (SDK_INT >= Q) { + add("TLS_AES_128_GCM_SHA256") + add("TLS_AES_256_GCM_SHA384") + add("TLS_CHACHA20_POLY1305_SHA256") + } + if (SDK_INT >= N) { + add("TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256") + add("TLS_ECDHE_PSK_WITH_CHACHA20_POLY1305_SHA256") + } + if (SDK_INT >= LOLLIPOP) { + add("TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA") + add("TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256") + add("TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA") + add("TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384") + add("TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA") + add("TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256") + add("TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA") + add("TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384") + add("TLS_RSA_WITH_AES_128_GCM_SHA256") + add("TLS_RSA_WITH_AES_256_GCM_SHA384") + } + if (SDK_INT < LOLLIPOP) { + add("TLS_RSA_WITH_AES_128_CBC_SHA") + add("TLS_RSA_WITH_AES_256_CBC_SHA") + } + }.toTypedArray() + } + + /** + * Return a list of available ciphers for ftpserver. + * + * Added SDK detection since some ciphers are available only on higher versions, and they + * have to be on top of the list to make a more secure SSL + * + * @see [org.apache.ftpserver.ssl.SslConfiguration] + * @see [javax.net.ssl.SSLEngine] + */ + @JvmStatic + val enabledCipherSuites = _enabledCipherSuites + + private var serverThread: Thread? = null + private var server: FtpServer? = null + + /** + * Derive the FTP server's default share path, depending the user's Android version. + * + * Default it's the internal storage's root as java.io.File; otherwise it's content:// + * based URI if it's running on Android 7.0 or above. + */ + @JvmStatic + fun defaultPath(context: Context): String { + return if (PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(KEY_PREFERENCE_SAF_FILESYSTEM, false) && SDK_INT > M + ) { + DocumentsContract.buildTreeDocumentUri( + "com.android.externalstorage.documents", + "primary:", + ).toString() + } else { + Environment.getExternalStorageDirectory().absolutePath + } + } + + /** + * Indicator whether FTP service is running + */ + @JvmStatic + fun isRunning(): Boolean { + val server = server ?: return false + return !server.isStopped + } + + private fun getPort(preferences: SharedPreferences): Int { + return preferences.getInt(FtpService.PORT_PREFERENCE_KEY, FtpService.DEFAULT_PORT) + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpTileService.java b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpTileService.java new file mode 100644 index 0000000..d0de754 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpTileService.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.services.ftp; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.utils.NetworkUtil; + +import android.annotation.TargetApi; +import android.content.Intent; +import android.graphics.drawable.Icon; +import android.os.Build; +import android.service.quicksettings.Tile; +import android.service.quicksettings.TileService; +import android.widget.Toast; + +/** Created by vishal on 1/1/17. */ +@TargetApi(Build.VERSION_CODES.N) +public class FtpTileService extends TileService { + + @Subscribe + public void onFtpReceiverActions(FtpService.FtpReceiverActions signal) { + updateTileState(); + } + + @Override + public void onStartListening() { + super.onStartListening(); + EventBus.getDefault().register(this); + updateTileState(); + } + + @Override + public void onStopListening() { + super.onStopListening(); + EventBus.getDefault().unregister(this); + } + + @Override + public void onClick() { + unlockAndRun( + () -> { + if (FtpService.isRunning()) { + getApplicationContext() + .sendBroadcast( + new Intent(FtpService.ACTION_STOP_FTPSERVER).setPackage(getPackageName())); + } else { + if (NetworkUtil.isConnectedToWifi(getApplicationContext()) + || NetworkUtil.isConnectedToLocalNetwork(getApplicationContext())) { + Intent i = new Intent(FtpService.ACTION_START_FTPSERVER).setPackage(getPackageName()); + i.putExtra(FtpService.TAG_STARTED_BY_TILE, true); + getApplicationContext().sendBroadcast(i); + } else { + Toast.makeText( + getApplicationContext(), getString(R.string.ftp_no_wifi), Toast.LENGTH_LONG) + .show(); + } + } + }); + } + + private void updateTileState() { + Tile tile = getQsTile(); + if (FtpService.isRunning()) { + tile.setState(Tile.STATE_ACTIVE); + tile.setIcon(Icon.createWithResource(this, R.drawable.ic_ftp_dark)); + } else { + tile.setState(Tile.STATE_INACTIVE); + tile.setIcon(Icon.createWithResource(this, R.drawable.ic_ftp_light)); + } + tile.updateTile(); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/crashreport/AcraReportSender.kt b/app/src/main/java/com/amaze/filemanager/crashreport/AcraReportSender.kt new file mode 100644 index 0000000..0b59911 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/crashreport/AcraReportSender.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.crashreport + +import android.content.Context +import com.amaze.filemanager.R +import org.acra.data.CrashReportData +import org.acra.sender.ReportSender + +class AcraReportSender : ReportSender { + override fun send( + context: Context, + errorContent: CrashReportData, + ) { + ErrorActivity.reportError( + context, + errorContent, + ErrorActivity.ErrorInfo.make( + ErrorActivity.ERROR_UI_ERROR, + "Application crash", + R.string.app_ui_crash, + ), + ) + } + + override fun requiresForeground(): Boolean { + return true + } +} diff --git a/app/src/main/java/com/amaze/filemanager/crashreport/AcraReportSenderFactory.kt b/app/src/main/java/com/amaze/filemanager/crashreport/AcraReportSenderFactory.kt new file mode 100644 index 0000000..6fa9872 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/crashreport/AcraReportSenderFactory.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.crashreport + +import android.content.Context +import org.acra.config.CoreConfiguration +import org.acra.sender.ReportSender +import org.acra.sender.ReportSenderFactory + +class AcraReportSenderFactory : ReportSenderFactory { + override fun create( + context: Context, + config: CoreConfiguration, + ): ReportSender { + return AcraReportSender() + } + + override fun enabled(config: CoreConfiguration): Boolean { + return true + } +} diff --git a/app/src/main/java/com/amaze/filemanager/crashreport/ErrorActivity.java b/app/src/main/java/com/amaze/filemanager/crashreport/ErrorActivity.java new file mode 100644 index 0000000..331a00d --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/crashreport/ErrorActivity.java @@ -0,0 +1,508 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.crashreport; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TimeZone; +import java.util.Vector; + +import org.acra.ReportField; +import org.acra.data.CrashReportData; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.amaze.filemanager.BuildConfig; +import com.amaze.filemanager.R; +import com.amaze.filemanager.filesystem.files.FileUtils; +import com.amaze.filemanager.ui.activities.MainActivity; +import com.amaze.filemanager.ui.activities.superclasses.ThemedActivity; +import com.amaze.filemanager.utils.Utils; +import com.google.android.material.snackbar.Snackbar; + +import android.content.Context; +import android.content.Intent; +import android.graphics.Color; +import android.os.Build; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; +import android.util.Log; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.widget.Toast; + +import androidx.annotation.StringRes; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.widget.AppCompatButton; +import androidx.appcompat.widget.AppCompatEditText; +import androidx.appcompat.widget.AppCompatTextView; +import androidx.appcompat.widget.Toolbar; +import androidx.core.app.NavUtils; + +/* + * Created by Christian Schabesberger on 24.10.15. + * + * Copyright (C) Christian Schabesberger 2016 + * ErrorActivity.java is part of NewPipe. + * + * NewPipe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * < + * NewPipe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * < + * You should have received a copy of the GNU General Public License + * along with NewPipe. If not, see . + */ + +public class ErrorActivity extends ThemedActivity { + // LOG TAGS + private static final Logger LOG = LoggerFactory.getLogger(ErrorActivity.class); + public static final String TAG = ErrorActivity.class.toString(); + + // BUNDLE TAGS + public static final String ERROR_INFO = "error_info"; + public static final String ERROR_LIST = "error_list"; + + // Error codes + public static final String ERROR_UI_ERROR = "UI Error"; + public static final String ERROR_USER_REPORT = "User report"; + public static final String ERROR_UNKNOWN = "Unknown"; + + public static final String ERROR_GITHUB_ISSUE_URL = + "https://github.com/TeamAmaze/AmazeFileManager/issues"; + + private String[] errorList; + private ErrorInfo errorInfo; + private Class returnActivity; + private String currentTimeStamp; + private AppCompatEditText userCommentBox; + + public static void reportError( + final Context context, + final List el, + final View rootView, + final ErrorInfo errorInfo) { + if (rootView != null) { + Snackbar.make(rootView, R.string.error_snackbar_message, 3 * 1000) + .setActionTextColor(Color.YELLOW) + .setAction( + context.getString(R.string.error_snackbar_action).toUpperCase(), + v -> startErrorActivity(context, errorInfo, el)) + .show(); + } else { + startErrorActivity(context, errorInfo, el); + } + } + + private static void startErrorActivity( + final Context context, final ErrorInfo errorInfo, final List el) { + final Intent intent = new Intent(context, ErrorActivity.class); + intent.putExtra(ERROR_INFO, errorInfo); + intent.putExtra(ERROR_LIST, elToSl(el)); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + } + + public static void reportError( + final Context context, final Throwable e, final View rootView, final ErrorInfo errorInfo) { + List el = null; + if (e != null) { + el = new Vector<>(); + el.add(e); + } + reportError(context, el, rootView, errorInfo); + } + + public static void reportError( + final Context context, final CrashReportData report, final ErrorInfo errorInfo) { + System.out.println("ErrorActivity reportError"); + final String[] el = new String[] {report.getString(ReportField.STACK_TRACE)}; + // Add this to try figure out what happened when stacktrace is sent to acra. + // Hope this will be useful for build failures... + if (BuildConfig.DEBUG) { + for (String line : el) { + System.out.println(line); + } + } + final Intent intent = new Intent(context, ErrorActivity.class); + intent.putExtra(ERROR_INFO, errorInfo); + intent.putExtra(ERROR_LIST, el); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + } + + private static String getStackTrace(final Throwable throwable) { + final StringWriter sw = new StringWriter(); + final PrintWriter pw = new PrintWriter(sw, true); + throwable.printStackTrace(pw); + return sw.getBuffer().toString(); + } + + // errorList to StringList + private static String[] elToSl(final List stackTraces) { + final String[] out = new String[stackTraces.size()]; + for (int i = 0; i < stackTraces.size(); i++) { + out[i] = getStackTrace(stackTraces.get(i)); + } + return out; + } + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_error); + final Toolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + + final Intent intent = getIntent(); + + final ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setTitle(R.string.error_report_title); + actionBar.setDisplayShowTitleEnabled(true); + } + + final AppCompatButton reportEmailButton = findViewById(R.id.errorReportEmailButton); + final AppCompatButton reportTelegramButton = findViewById(R.id.errorReportTelegramButton); + final AppCompatButton copyButton = findViewById(R.id.errorReportCopyButton); + final AppCompatButton reportGithubButton = findViewById(R.id.errorReportGitHubButton); + + userCommentBox = findViewById(R.id.errorCommentBox); + final AppCompatTextView errorView = findViewById(R.id.errorView); + final AppCompatTextView errorMessageView = findViewById(R.id.errorMessageView); + + returnActivity = MainActivity.class; + errorInfo = intent.getParcelableExtra(ERROR_INFO); + errorList = intent.getStringArrayExtra(ERROR_LIST); + + // important add guru meditation + addGuruMeditation(); + currentTimeStamp = getCurrentTimeStamp(); + + reportEmailButton.setOnClickListener((View v) -> sendReportEmail()); + + reportTelegramButton.setOnClickListener( + (View v) -> { + FileUtils.copyToClipboard(this, buildMarkdown()); + Toast.makeText(this, R.string.crash_report_copied, Toast.LENGTH_SHORT).show(); + Utils.openTelegramURL(this); + }); + + copyButton.setOnClickListener( + (View v) -> { + FileUtils.copyToClipboard(this, buildMarkdown()); + Toast.makeText(this, R.string.crash_report_copied, Toast.LENGTH_SHORT).show(); + }); + + reportGithubButton.setOnClickListener( + (View v) -> { + FileUtils.copyToClipboard(this, buildMarkdown()); + Toast.makeText(this, R.string.crash_report_copied, Toast.LENGTH_SHORT).show(); + Utils.openURL(ERROR_GITHUB_ISSUE_URL, this); + }); + + // normal bugreport + buildInfo(errorInfo); + if (errorInfo.message != 0) { + errorMessageView.setText(errorInfo.message); + } else { + errorMessageView.setVisibility(View.GONE); + findViewById(R.id.messageWhatHappenedView).setVisibility(View.GONE); + } + + errorView.setText(formErrorText(errorList)); + + // print stack trace once again for debugging: + for (final String e : errorList) { + Log.e(TAG, e); + } + initStatusBarResources(findViewById(R.id.parent_view)); + } + + @Override + public boolean onCreateOptionsMenu(final Menu menu) { + final MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.error_menu, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + final int id = item.getItemId(); + switch (id) { + case android.R.id.home: + goToReturnActivity(); + break; + case R.id.menu_item_share_error: + final Intent intent = new Intent(); + intent.setAction(Intent.ACTION_SEND); + intent.putExtra(Intent.EXTRA_TEXT, buildMarkdown()); + intent.setType("text/plain"); + startActivity(Intent.createChooser(intent, getString(R.string.share))); + break; + default: + break; + } + return false; + } + + private void sendReportEmail() { + final Intent i = Utils.buildEmailIntent(this, buildMarkdown(), Utils.EMAIL_NOREPLY_REPORTS); + if (i.resolveActivity(getPackageManager()) != null) { + startActivity(i); + } + } + + private String formErrorText(final String[] el) { + final StringBuilder text = new StringBuilder(); + if (el != null) { + for (final String e : el) { + text.append("-------------------------------------\n").append(e); + } + } + text.append("-------------------------------------"); + return text.toString(); + } + + private void goToReturnActivity() { + final Intent intent = new Intent(this, returnActivity); + NavUtils.navigateUpTo(this, intent); + startActivity(intent); + } + + private void buildInfo(final ErrorInfo info) { + final AppCompatTextView infoLabelView = findViewById(R.id.errorInfoLabelsView); + final AppCompatTextView infoView = findViewById(R.id.errorInfosView); + String text = ""; + + infoLabelView.setText(getString(R.string.info_labels).replace("\\n", "\n")); + + text += + errorInfo.userAction + + "\n" + + info.request + + "\n" + + currentTimeStamp + + "\n" + + getPackageName() + + "\n" + + BuildConfig.VERSION_NAME + + "\n" + + getOsString() + + "\n" + + Build.DEVICE + + "\n" + + Build.MODEL + + "\n" + + Build.PRODUCT; + + infoView.setText(text); + } + + private String buildJson() { + try { + Map jsonMap = new HashMap<>(); + jsonMap.put("user_action", errorInfo.userAction); + jsonMap.put("request", errorInfo.request); + jsonMap.put("package", getPackageName()); + jsonMap.put("version", BuildConfig.VERSION_NAME); + jsonMap.put("os", getOsString()); + jsonMap.put("device", Build.DEVICE); + jsonMap.put("model", Build.MODEL); + jsonMap.put("product", Build.PRODUCT); + jsonMap.put("time", currentTimeStamp); + jsonMap.put("exceptions", Arrays.asList(errorList).toString()); + jsonMap.put("user_comment", userCommentBox.getText().toString()); + return new JSONObject(jsonMap).toString(); + } catch (final Throwable e) { + LOG.warn("failed to build json", e); + } + + return ""; + } + + private String buildMarkdown() { + try { + final StringBuilder htmlErrorReport = new StringBuilder(); + + String userComment = ""; + if (!TextUtils.isEmpty(userCommentBox.getText())) { + userComment = userCommentBox.getText().toString(); + } + + // basic error info + htmlErrorReport + .append( + String.format("## Issue explanation (write below this line)\n\n%s\n\n", userComment)) + .append("## Exception") + .append("\n* __App Name:__ ") + .append(getString(R.string.app_name)) + .append("\n* __Package:__ ") + .append(BuildConfig.APPLICATION_ID) + .append("\n* __Version:__ ") + .append(BuildConfig.VERSION_NAME) + .append("\n* __User Action:__ ") + .append(errorInfo.userAction) + .append("\n* __Request:__ ") + .append(errorInfo.request) + .append("\n* __OS:__ ") + .append(getOsString()) + .append("\n* __Device:__ ") + .append(Build.DEVICE) + .append("\n* __Model:__ ") + .append(Build.MODEL) + .append("\n* __Product:__ ") + .append(Build.PRODUCT) + .append("\n"); + + // Collapse all logs to a single paragraph when there are more than one + // to keep the GitHub issue clean. + if (errorList.length > 1) { + htmlErrorReport + .append("

Exceptions (") + .append(errorList.length) + .append(")

\n"); + } + + // add the logs + for (int i = 0; i < errorList.length; i++) { + htmlErrorReport.append("

Crash log "); + if (errorList.length > 1) { + htmlErrorReport.append(i + 1); + } + htmlErrorReport + .append("") + .append("

\n") + .append("\n```\n") + .append(errorList[i]) + .append("\n```\n") + .append("

\n"); + } + + // make sure to close everything + if (errorList.length > 1) { + htmlErrorReport.append("

\n"); + } + htmlErrorReport.append("
\n"); + return htmlErrorReport.toString(); + } catch (final Throwable e) { + LOG.warn("error while building markdown", e); + return ""; + } + } + + private String getOsString() { + final String osBase = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? Build.VERSION.BASE_OS : "Android"; + return System.getProperty("os.name") + + " " + + (osBase.isEmpty() ? "Android" : osBase) + + " " + + Build.VERSION.RELEASE + + " - " + + Build.VERSION.SDK_INT; + } + + private void addGuruMeditation() { + // just an easter egg + final AppCompatTextView sorryView = findViewById(R.id.errorSorryView); + String text = sorryView.getText().toString(); + text += "\n" + getString(R.string.guru_meditation); + sorryView.setText(text); + } + + @Override + public void onBackPressed() { + super.onBackPressed(); + goToReturnActivity(); + } + + public String getCurrentTimeStamp() { + final SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm"); + df.setTimeZone(TimeZone.getTimeZone("GMT")); + return df.format(new Date()); + } + + public static class ErrorInfo implements Parcelable { + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + @Override + public ErrorInfo createFromParcel(final Parcel source) { + return new ErrorInfo(source); + } + + @Override + public ErrorInfo[] newArray(final int size) { + return new ErrorInfo[size]; + } + }; + + private final String userAction; + private final String request; + @StringRes public final int message; + + private ErrorInfo(final String userAction, final String request, @StringRes final int message) { + this.userAction = userAction; + this.request = request; + this.message = message; + } + + protected ErrorInfo(final Parcel in) { + this.userAction = in.readString(); + this.request = in.readString(); + this.message = in.readInt(); + } + + public static ErrorInfo make( + final String userAction, final String request, @StringRes final int message) { + return new ErrorInfo(userAction, request, message); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(final Parcel dest, final int flags) { + dest.writeString(this.userAction); + dest.writeString(this.request); + dest.writeInt(this.message); + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/database/CloudContract.java b/app/src/main/java/com/amaze/filemanager/database/CloudContract.java new file mode 100644 index 0000000..533f1b2 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/CloudContract.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database; + +/** Created by vishal on 19/4/17. */ +public class CloudContract { + + public static final String APP_PACKAGE_NAME = "com.filemanager.amazecloud"; + + public static final String PROVIDER_AUTHORITY = "com.amaze.cloud.provider"; + + public static final String PERMISSION_PROVIDER = "com.amaze.cloud.permission.ACCESS_PROVIDER"; + + public static final String DATABASE_NAME = "keys.db"; + public static final String TABLE_NAME = "secret_keys"; + public static final String COLUMN_ID = "_id"; + public static final String COLUMN_CLIENT_ID = "client_id"; + public static final String COLUMN_CLIENT_SECRET_KEY = "client_secret"; + public static final int DATABASE_VERSION = 1; +} diff --git a/app/src/main/java/com/amaze/filemanager/database/CloudHandler.java b/app/src/main/java/com/amaze/filemanager/database/CloudHandler.java new file mode 100644 index 0000000..ad82667 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/CloudHandler.java @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database; + +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.amaze.filemanager.database.models.explorer.CloudEntry; +import com.amaze.filemanager.fileoperations.exceptions.CloudPluginException; +import com.amaze.filemanager.fileoperations.filesystem.OpenMode; +import com.amaze.filemanager.ui.fragments.CloudSheetFragment; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import io.reactivex.schedulers.Schedulers; + +/** Created by vishal on 18/4/17. */ +public class CloudHandler { + + public static final String CLOUD_PREFIX_BOX = "box:/"; + public static final String CLOUD_PREFIX_DROPBOX = "dropbox:/"; + public static final String CLOUD_PREFIX_GOOGLE_DRIVE = "gdrive:/"; + public static final String CLOUD_PREFIX_ONE_DRIVE = "onedrive:/"; + + public static final String CLOUD_NAME_GOOGLE_DRIVE = "Google Drive™"; + public static final String CLOUD_NAME_DROPBOX = "Dropbox"; + public static final String CLOUD_NAME_ONE_DRIVE = "One Drive"; + public static final String CLOUD_NAME_BOX = "Box"; + private final Logger LOG = LoggerFactory.getLogger(CloudHandler.class); + + private final ExplorerDatabase database; + private final Context context; + + public CloudHandler(@NonNull Context context, @NonNull ExplorerDatabase explorerDatabase) { + this.context = context; + this.database = explorerDatabase; + } + + public void addEntry(CloudEntry cloudEntry) throws CloudPluginException { + + if (!CloudSheetFragment.isCloudProviderAvailable(context)) throw new CloudPluginException(); + + database.cloudEntryDao().insert(cloudEntry).subscribeOn(Schedulers.io()).subscribe(); + } + + public void clear(OpenMode serviceType) { + database + .cloudEntryDao() + .findByServiceType(serviceType.ordinal()) + .subscribeOn(Schedulers.io()) + .subscribe( + cloudEntry -> + database + .cloudEntryDao() + .delete(cloudEntry) + .subscribeOn(Schedulers.io()) + .subscribe(), + throwable -> LOG.warn("failed to delete cloud connection", throwable)); + } + + public void clearAllCloudConnections() { + database.cloudEntryDao().clear().subscribeOn(Schedulers.io()).blockingGet(); + } + + public void updateEntry(OpenMode serviceType, CloudEntry newCloudEntry) + throws CloudPluginException { + + if (!CloudSheetFragment.isCloudProviderAvailable(context)) throw new CloudPluginException(); + + database.cloudEntryDao().update(newCloudEntry).subscribeOn(Schedulers.io()).subscribe(); + } + + public CloudEntry findEntry(OpenMode serviceType) throws CloudPluginException { + + if (!CloudSheetFragment.isCloudProviderAvailable(context)) throw new CloudPluginException(); + + try { + return database + .cloudEntryDao() + .findByServiceType(serviceType.ordinal()) + .subscribeOn(Schedulers.io()) + .blockingGet(); + } catch (Exception e) { + // catch error to handle Single#onError for blockingGet + LOG.error(getClass().getSimpleName(), e); + return null; + } + } + + public List getAllEntries() throws CloudPluginException { + + if (!CloudSheetFragment.isCloudProviderAvailable(context)) throw new CloudPluginException(); + return database.cloudEntryDao().list().subscribeOn(Schedulers.io()).blockingGet(); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/database/CryptHandler.kt b/app/src/main/java/com/amaze/filemanager/database/CryptHandler.kt new file mode 100644 index 0000000..82cb9cf --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/CryptHandler.kt @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database + +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.database.models.explorer.EncryptedEntry +import io.reactivex.schedulers.Schedulers +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +/** Created by vishal on 15/4/17. */ +object CryptHandler { + private val log: Logger = LoggerFactory.getLogger(CryptHandler::class.java) + private val database: ExplorerDatabase = AppConfig.getInstance().explorerDatabase + + /** + * Add [EncryptedEntry] to database. + */ + fun addEntry(encryptedEntry: EncryptedEntry) { + database.encryptedEntryDao().insert(encryptedEntry).subscribeOn(Schedulers.io()).subscribe() + } + + /** + * Remove [EncryptedEntry] of specified path. + */ + fun clear(path: String) { + database.encryptedEntryDao().delete(path).subscribeOn(Schedulers.io()).subscribe() + } + + /** + * Update specified new [EncryptedEntry] in database. + */ + fun updateEntry( + oldEncryptedEntry: EncryptedEntry, + newEncryptedEntry: EncryptedEntry, + ) { + database.encryptedEntryDao().update(newEncryptedEntry).subscribeOn(Schedulers.io()) + .subscribe() + } + + /** + * Find [EncryptedEntry] of specified path. Returns null if not exist. + */ + fun findEntry(path: String): EncryptedEntry? { + return runCatching { + database.encryptedEntryDao().select(path).subscribeOn(Schedulers.io()).blockingGet() + }.onFailure { + log.error(it.message!!) + }.getOrNull() + } + + val allEntries: Array + get() { + val encryptedEntryList = + database.encryptedEntryDao().list().subscribeOn(Schedulers.io()).blockingGet() + return encryptedEntryList.toTypedArray() + } +} diff --git a/app/src/main/java/com/amaze/filemanager/database/ExplorerDatabase.kt b/app/src/main/java/com/amaze/filemanager/database/ExplorerDatabase.kt new file mode 100644 index 0000000..52b6fea --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/ExplorerDatabase.kt @@ -0,0 +1,361 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database + +import android.content.Context +import androidx.annotation.VisibleForTesting +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import com.amaze.filemanager.database.daos.CloudEntryDao +import com.amaze.filemanager.database.daos.EncryptedEntryDao +import com.amaze.filemanager.database.daos.SortDao +import com.amaze.filemanager.database.daos.TabDao +import com.amaze.filemanager.database.models.explorer.CloudEntry +import com.amaze.filemanager.database.models.explorer.EncryptedEntry +import com.amaze.filemanager.database.models.explorer.Sort +import com.amaze.filemanager.database.models.explorer.Tab + +/** + * Repository for [Tab], [Sort], [EncryptedEntry], [CloudEntry] in + * explorer.db in Amaze. + * + * @see RoomDatabase + */ +@Database( + entities = [Tab::class, Sort::class, EncryptedEntry::class, CloudEntry::class], + version = ExplorerDatabase.DATABASE_VERSION, +) +@Suppress("StringLiteralDuplication", "ComplexMethod", "LongMethod") +abstract class ExplorerDatabase : RoomDatabase() { + /** + * Returns DAO for [Tab] objects. + */ + abstract fun tabDao(): TabDao + + /** + * Returns DAO for [Sort] objects. + */ + abstract fun sortDao(): SortDao + + /** + * Returns DAO for [EncryptedEntry] objects. + */ + abstract fun encryptedEntryDao(): EncryptedEntryDao + + /** + * Returns DAO for [CloudEntry] objects. + */ + abstract fun cloudEntryDao(): CloudEntryDao + + companion object { + private const val DATABASE_NAME = "explorer.db" + const val DATABASE_VERSION = 11 + const val TABLE_TAB = "tab" + const val TABLE_CLOUD_PERSIST = "cloud" + const val TABLE_ENCRYPTED = "encrypted" + const val TABLE_SORT = "sort" + const val COLUMN_TAB_NO = "tab_no" + const val COLUMN_PATH = "path" + const val COLUMN_HOME = "home" + const val COLUMN_ENCRYPTED_ID = "_id" + const val COLUMN_ENCRYPTED_PATH = "path" + const val COLUMN_ENCRYPTED_PASSWORD = "password" + const val COLUMN_CLOUD_ID = "_id" + const val COLUMN_CLOUD_SERVICE = "service" + const val COLUMN_CLOUD_PERSIST = "persist" + const val COLUMN_SORT_PATH = "path" + const val COLUMN_SORT_TYPE = "type" + + @VisibleForTesting + var overrideDatabaseBuilder: ((Context) -> Builder)? = null + + private const val TEMP_TABLE_PREFIX = "temp_" + + // 1->2: add encrypted table (66f08f34) + internal val MIGRATION_1_2: Migration = + object : Migration(1, 2) { + override fun migrate(database: SupportSQLiteDatabase) { + val CREATE_TABLE_ENCRYPTED = ( + "CREATE TABLE " + + TABLE_ENCRYPTED + + "(" + + COLUMN_ENCRYPTED_ID + + " INTEGER PRIMARY KEY," + + COLUMN_ENCRYPTED_PATH + + " TEXT," + + COLUMN_ENCRYPTED_PASSWORD + + " TEXT" + + ")" + ) + database.execSQL(CREATE_TABLE_ENCRYPTED) + } + } + + // 2->3: add cloud table (8a5ced1b) + internal val MIGRATION_2_3: Migration = + object : Migration(2, 3) { + override fun migrate(database: SupportSQLiteDatabase) { + val CREATE_TABLE_CLOUD = ( + "CREATE TABLE " + + TABLE_CLOUD_PERSIST + + "(" + + COLUMN_CLOUD_ID + + " INTEGER PRIMARY KEY," + + COLUMN_CLOUD_SERVICE + + " INTEGER," + + COLUMN_CLOUD_PERSIST + + " TEXT" + + ")" + ) + database.execSQL(CREATE_TABLE_CLOUD) + } + } + + // 3->4: same as 2->3 (765140f6) + internal val MIGRATION_3_4: Migration = + object : Migration(3, 4) { + override fun migrate(database: SupportSQLiteDatabase) = Unit + } + + // 4->5: same as 3->4, same as 2->3 (37357436) + internal val MIGRATION_4_5: Migration = + object : Migration(4, 5) { + override fun migrate(database: SupportSQLiteDatabase) = Unit + } + + // 5->6: add sort table (fe7c0aba) + internal val MIGRATION_5_6: Migration = + object : Migration(5, 6) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + "CREATE TABLE " + + TABLE_SORT + + "(" + + COLUMN_SORT_PATH + + " TEXT PRIMARY KEY," + + COLUMN_SORT_TYPE + + " INTEGER" + + ")", + ) + } + } + internal val MIGRATION_6_7: Migration = + object : Migration(6, 7) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + "CREATE TABLE " + + TEMP_TABLE_PREFIX + + TABLE_TAB + + "(" + + COLUMN_TAB_NO + + " INTEGER PRIMARY KEY NOT NULL, " + + COLUMN_PATH + + " TEXT, " + + COLUMN_HOME + + " TEXT)", + ) + database.execSQL( + "INSERT INTO " + + TEMP_TABLE_PREFIX + + TABLE_TAB + + "(" + + COLUMN_TAB_NO + + "," + + COLUMN_PATH + + "," + + COLUMN_HOME + + ")" + + " SELECT " + + COLUMN_TAB_NO + + "," + + COLUMN_PATH + + "," + + COLUMN_HOME + + " FROM " + + TABLE_TAB, + ) + database.execSQL("DROP TABLE $TABLE_TAB") + database.execSQL( + "ALTER TABLE $TEMP_TABLE_PREFIX$TABLE_TAB RENAME TO $TABLE_TAB", + ) + database.execSQL( + "CREATE TABLE " + + TEMP_TABLE_PREFIX + + TABLE_SORT + + "(" + + COLUMN_SORT_PATH + + " TEXT PRIMARY KEY NOT NULL, " + + COLUMN_SORT_TYPE + + " INTEGER NOT NULL)", + ) + database.execSQL( + "INSERT INTO $TEMP_TABLE_PREFIX$TABLE_SORT SELECT * FROM $TABLE_SORT", + ) + database.execSQL("DROP TABLE $TABLE_SORT") + database.execSQL( + "ALTER TABLE $TEMP_TABLE_PREFIX$TABLE_SORT RENAME TO $TABLE_SORT", + ) + database.execSQL( + "CREATE TABLE " + + TEMP_TABLE_PREFIX + + TABLE_ENCRYPTED + + "(" + + COLUMN_ENCRYPTED_ID + + " INTEGER PRIMARY KEY NOT NULL," + + COLUMN_ENCRYPTED_PATH + + " TEXT," + + COLUMN_ENCRYPTED_PASSWORD + + " TEXT)", + ) + database.execSQL( + "INSERT INTO " + + TEMP_TABLE_PREFIX + + TABLE_ENCRYPTED + + " SELECT * FROM " + + TABLE_ENCRYPTED, + ) + database.execSQL("DROP TABLE $TABLE_ENCRYPTED") + database.execSQL( + "ALTER TABLE " + + TEMP_TABLE_PREFIX + + TABLE_ENCRYPTED + + " RENAME TO " + + TABLE_ENCRYPTED, + ) + database.execSQL( + "CREATE TABLE " + + TEMP_TABLE_PREFIX + + TABLE_CLOUD_PERSIST + + "(" + + COLUMN_CLOUD_ID + + " INTEGER PRIMARY KEY NOT NULL," + + COLUMN_CLOUD_SERVICE + + " INTEGER," + + COLUMN_CLOUD_PERSIST + + " TEXT)", + ) + database.execSQL( + "INSERT INTO " + + TEMP_TABLE_PREFIX + + TABLE_CLOUD_PERSIST + + " SELECT * FROM " + + TABLE_CLOUD_PERSIST, + ) + database.execSQL("DROP TABLE $TABLE_CLOUD_PERSIST") + database.execSQL( + "ALTER TABLE " + + TEMP_TABLE_PREFIX + + TABLE_CLOUD_PERSIST + + " RENAME TO " + + TABLE_CLOUD_PERSIST, + ) + } + } + internal val MIGRATION_7_8: Migration = + object : Migration(7, 8) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + "UPDATE " + + TABLE_CLOUD_PERSIST + + " SET " + + COLUMN_CLOUD_SERVICE + + " = " + + COLUMN_CLOUD_SERVICE + + "+1", + ) + } + } + internal val MIGRATION_8_9: Migration = + object : Migration(8, 9) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + "UPDATE " + + TABLE_CLOUD_PERSIST + + " SET " + + COLUMN_CLOUD_SERVICE + + " = " + + COLUMN_CLOUD_SERVICE + + "+1", + ) + } + } + + internal val MIGRATION_9_10: Migration = + object : Migration(9, 10) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + "UPDATE " + + TABLE_CLOUD_PERSIST + + " SET " + + COLUMN_CLOUD_SERVICE + + " = " + + COLUMN_CLOUD_SERVICE + + "+1", + ) + } + } + + internal val MIGRATION_10_11: Migration = + object : Migration(10, DATABASE_VERSION) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + "UPDATE " + + TABLE_CLOUD_PERSIST + + " SET " + + COLUMN_CLOUD_SERVICE + + " = " + + COLUMN_CLOUD_SERVICE + + "-2", + ) + } + } + + /** + * Initialize the database. Optionally, may provide a custom way to create the database + * with supplied [Context]. + */ + @JvmStatic + fun initialize(context: Context): ExplorerDatabase { + val builder = + overrideDatabaseBuilder?.invoke(context) ?: Room.databaseBuilder( + context, + ExplorerDatabase::class.java, + DATABASE_NAME, + ) + return builder + .addMigrations(MIGRATION_1_2) + .addMigrations(MIGRATION_2_3) + .addMigrations(MIGRATION_3_4) + .addMigrations(MIGRATION_4_5) + .addMigrations(MIGRATION_5_6) + .addMigrations(MIGRATION_6_7) + .addMigrations(MIGRATION_7_8) + .addMigrations(MIGRATION_8_9) + .addMigrations(MIGRATION_9_10) + .addMigrations(MIGRATION_10_11) + .allowMainThreadQueries() + .build() + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/database/SortHandler.java b/app/src/main/java/com/amaze/filemanager/database/SortHandler.java new file mode 100644 index 0000000..9313add --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/SortHandler.java @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database; + +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SORTBY_ONLY_THIS; + +import java.util.HashSet; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.amaze.filemanager.application.AppConfig; +import com.amaze.filemanager.database.models.explorer.Sort; +import com.amaze.filemanager.filesystem.files.sort.SortType; + +import android.content.Context; +import android.content.SharedPreferences; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.preference.PreferenceManager; + +import io.reactivex.schedulers.Schedulers; + +/** Created by Ning on 5/28/2018. */ +public class SortHandler { + + private final Logger LOG = LoggerFactory.getLogger(SortHandler.class); + + private final ExplorerDatabase database; + + private SortHandler(@NonNull ExplorerDatabase explorerDatabase) { + database = explorerDatabase; + } + + private static class SortHandlerHolder { + private static final SortHandler INSTANCE = + new SortHandler(AppConfig.getInstance().getExplorerDatabase()); + } + + public static SortHandler getInstance() { + return SortHandlerHolder.INSTANCE; + } + + public static SortType getSortType(Context context, String path) { + SharedPreferences sharedPref = PreferenceManager.getDefaultSharedPreferences(context); + final Set onlyThisFloders = + sharedPref.getStringSet(PREFERENCE_SORTBY_ONLY_THIS, new HashSet<>()); + final boolean onlyThis = onlyThisFloders.contains(path); + final int globalSortby = Integer.parseInt(sharedPref.getString("sortby", "0")); + SortType globalSortType = SortType.getDirectorySortType(globalSortby); + if (!onlyThis) { + return globalSortType; + } + Sort sort = SortHandler.getInstance().findEntry(path); + if (sort == null) { + return globalSortType; + } + return SortType.getDirectorySortType(sort.type); + } + + public void addEntry(String path, SortType sortType) { + Sort sort = new Sort(path, sortType.toDirectorySortInt()); + database.sortDao().insert(sort).subscribeOn(Schedulers.io()).subscribe(); + } + + public void clear(String path) { + database.sortDao().clear(path).subscribeOn(Schedulers.io()).subscribe(); + } + + public void updateEntry(Sort oldSort, String newPath, SortType newSortType) { + Sort newSort = new Sort(newPath, newSortType.toDirectorySortInt()); + database.sortDao().update(newSort).subscribeOn(Schedulers.io()).subscribe(); + } + + @Nullable + public Sort findEntry(String path) { + try { + return database.sortDao().find(path).subscribeOn(Schedulers.io()).blockingGet(); + } catch (Exception e) { + // catch error to handle Single#onError for blockingGet + LOG.error(getClass().getSimpleName(), e); + return null; + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/database/TabHandler.java b/app/src/main/java/com/amaze/filemanager/database/TabHandler.java new file mode 100644 index 0000000..122a963 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/TabHandler.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database; + +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.amaze.filemanager.application.AppConfig; +import com.amaze.filemanager.database.models.explorer.Tab; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import io.reactivex.Completable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.schedulers.Schedulers; + +/** Created by Vishal on 9/17/2014. */ +public class TabHandler { + private static final Logger LOG = LoggerFactory.getLogger(TabHandler.class); + + private final ExplorerDatabase database; + + private TabHandler(@NonNull ExplorerDatabase explorerDatabase) { + this.database = explorerDatabase; + } + + private static class TabHandlerHolder { + private static final TabHandler INSTANCE = + new TabHandler(AppConfig.getInstance().getExplorerDatabase()); + } + + public static TabHandler getInstance() { + return TabHandlerHolder.INSTANCE; + } + + public Completable addTab(@NonNull Tab tab) { + return database.tabDao().insertTab(tab).subscribeOn(Schedulers.io()); + } + + public void update(Tab tab) { + database.tabDao().update(tab); + } + + public Completable clear() { + return database + .tabDao() + .clear() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()); + } + + @Nullable + public Tab findTab(int tabNo) { + try { + return database.tabDao().find(tabNo).subscribeOn(Schedulers.io()).blockingGet(); + } catch (Exception e) { + // catch error to handle Single#onError for blockingGet + LOG.error(e.getMessage()); + return null; + } + } + + public Tab[] getAllTabs() { + List tabList = database.tabDao().list().subscribeOn(Schedulers.io()).blockingGet(); + Tab[] tabs = new Tab[tabList.size()]; + return tabList.toArray(tabs); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/database/UtilitiesDatabase.kt b/app/src/main/java/com/amaze/filemanager/database/UtilitiesDatabase.kt new file mode 100644 index 0000000..f2df627 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/UtilitiesDatabase.kt @@ -0,0 +1,519 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database + +import android.content.Context +import android.text.TextUtils +import android.util.Base64 +import androidx.annotation.VisibleForTesting +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.database.daos.BookmarkEntryDao +import com.amaze.filemanager.database.daos.GridEntryDao +import com.amaze.filemanager.database.daos.HiddenEntryDao +import com.amaze.filemanager.database.daos.HistoryEntryDao +import com.amaze.filemanager.database.daos.ListEntryDao +import com.amaze.filemanager.database.daos.SftpEntryDao +import com.amaze.filemanager.database.daos.SmbEntryDao +import com.amaze.filemanager.database.models.utilities.Bookmark +import com.amaze.filemanager.database.models.utilities.Grid +import com.amaze.filemanager.database.models.utilities.Hidden +import com.amaze.filemanager.database.models.utilities.History +import com.amaze.filemanager.database.models.utilities.SftpEntry +import com.amaze.filemanager.database.models.utilities.SmbEntry +import com.amaze.filemanager.filesystem.ftp.NetCopyConnectionInfo.Companion.AT +import com.amaze.filemanager.filesystem.ftp.NetCopyConnectionInfo.Companion.COLON +import com.amaze.filemanager.utils.PasswordUtil.decryptPassword +import com.amaze.filemanager.utils.PasswordUtil.encryptPassword +import org.slf4j.LoggerFactory +import java.io.IOException +import java.security.GeneralSecurityException + +/** + * Repository for [Bookmark], [Grid], [Hidden], [History], [List], + * [SmbEntry], [SftpEntry] objects in utilities.db in Amaze. + * + * @see RoomDatabase + */ +@Database( + entities = [ + Bookmark::class, + Grid::class, + Hidden::class, + History::class, + com.amaze.filemanager.database.models.utilities.List::class, + SmbEntry::class, + SftpEntry::class, + ], + version = UtilitiesDatabase.DATABASE_VERSION, + exportSchema = false, +) +@Suppress("StringLiteralDuplication", "ComplexMethod", "LongMethod") +abstract class UtilitiesDatabase : RoomDatabase() { + /** + * Returns DAO for [Hidden] objects. + */ + abstract fun hiddenEntryDao(): HiddenEntryDao + + /** + * Returns DAO for [Grid] objects. + */ + abstract fun gridEntryDao(): GridEntryDao + + /** + * Returns DAO for [com.amaze.filemanager.database.models.utilities.List] objects. + */ + abstract fun listEntryDao(): ListEntryDao + + /** + * Returns DAO for [History] objects. + */ + abstract fun historyEntryDao(): HistoryEntryDao + + /** + * Returns DAO for [Bookmark] objects. + */ + abstract fun bookmarkEntryDao(): BookmarkEntryDao + + /** + * Returns DAO for [SmbEntry] objects. + */ + abstract fun smbEntryDao(): SmbEntryDao + + /** + * Returns DAO for [SftpEntry] objects. + */ + abstract fun sftpEntryDao(): SftpEntryDao + + companion object { + private val logger = LoggerFactory.getLogger(UtilitiesDatabase::class.java) + private const val DATABASE_NAME = "utilities.db" + const val DATABASE_VERSION = 6 + const val TABLE_HISTORY = "history" + const val TABLE_HIDDEN = "hidden" + const val TABLE_LIST = "list" + const val TABLE_GRID = "grid" + const val TABLE_BOOKMARKS = "bookmarks" + const val TABLE_SMB = "smb" + const val TABLE_SFTP = "sftp" + const val COLUMN_ID = "_id" + const val COLUMN_PATH = "path" + const val COLUMN_NAME = "name" + const val COLUMN_HOST_PUBKEY = "pub_key" + const val COLUMN_PRIVATE_KEY_NAME = "ssh_key_name" + const val COLUMN_PRIVATE_KEY = "ssh_key" + + @VisibleForTesting + var overrideDatabaseBuilder: ((Context) -> Builder)? = null + + private const val TEMP_TABLE_PREFIX = "temp_" + private const val queryHistory = ( + "CREATE TABLE IF NOT EXISTS " + + TABLE_HISTORY + + " (" + + COLUMN_ID + + " INTEGER PRIMARY KEY," + + COLUMN_PATH + + " TEXT UNIQUE" + + ");" + ) + private const val queryHidden = ( + "CREATE TABLE IF NOT EXISTS " + + TABLE_HIDDEN + + " (" + + COLUMN_ID + + " INTEGER PRIMARY KEY," + + COLUMN_PATH + + " TEXT UNIQUE" + + ");" + ) + private const val queryList = ( + "CREATE TABLE IF NOT EXISTS " + + TABLE_LIST + + " (" + + COLUMN_ID + + " INTEGER PRIMARY KEY," + + COLUMN_PATH + + " TEXT UNIQUE" + + ");" + ) + private const val queryGrid = ( + "CREATE TABLE IF NOT EXISTS " + + TABLE_GRID + + " (" + + COLUMN_ID + + " INTEGER PRIMARY KEY," + + COLUMN_PATH + + " TEXT UNIQUE" + + ");" + ) + private const val queryBookmarks = ( + "CREATE TABLE IF NOT EXISTS " + + TABLE_BOOKMARKS + + " (" + + COLUMN_ID + + " INTEGER PRIMARY KEY," + + COLUMN_NAME + + " TEXT," + + COLUMN_PATH + + " TEXT UNIQUE" + + ");" + ) + private const val querySmb = ( + "CREATE TABLE IF NOT EXISTS " + + TABLE_SMB + + " (" + + COLUMN_ID + + " INTEGER PRIMARY KEY," + + COLUMN_NAME + + " TEXT," + + COLUMN_PATH + + " TEXT UNIQUE" + + ");" + ) + private const val querySftp = ( + "CREATE TABLE IF NOT EXISTS " + + TABLE_SFTP + + " (" + + COLUMN_ID + + " INTEGER PRIMARY KEY," + + COLUMN_NAME + + " TEXT," + + COLUMN_PATH + + " TEXT UNIQUE," + + COLUMN_HOST_PUBKEY + + " TEXT," + + COLUMN_PRIVATE_KEY_NAME + + " TEXT," + + COLUMN_PRIVATE_KEY + + " TEXT" + + ");" + ) + + internal val MIGRATION_1_2: Migration = + object : Migration(1, 2) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + "CREATE TABLE IF NOT EXISTS " + + TABLE_SFTP + + " (" + + COLUMN_ID + + " INTEGER PRIMARY KEY," + + COLUMN_NAME + + " TEXT," + + COLUMN_PATH + + " TEXT UNIQUE," + + COLUMN_HOST_PUBKEY + + " TEXT," + + COLUMN_PRIVATE_KEY_NAME + + " TEXT," + + COLUMN_PRIVATE_KEY + + " TEXT" + + ");", + ) + } + } + + internal val MIGRATION_2_3: Migration = + object : Migration(2, 3) { + override fun migrate(database: SupportSQLiteDatabase) { + var backupTable = TEMP_TABLE_PREFIX + TABLE_HISTORY + database.execSQL(queryHistory.replace(TABLE_HISTORY, backupTable)) + database.execSQL( + "INSERT INTO $backupTable SELECT * FROM $TABLE_HISTORY group by path;", + ) + database.execSQL("DROP TABLE $TABLE_HISTORY;") + database.execSQL("ALTER TABLE $backupTable RENAME TO $TABLE_HISTORY;") + backupTable = TEMP_TABLE_PREFIX + TABLE_HIDDEN + database.execSQL(queryHidden.replace(TABLE_HIDDEN, backupTable)) + database.execSQL( + "INSERT INTO $backupTable SELECT * FROM $TABLE_HIDDEN group by path;", + ) + database.execSQL("DROP TABLE $TABLE_HIDDEN;") + database.execSQL("ALTER TABLE $backupTable RENAME TO $TABLE_HIDDEN;") + backupTable = TEMP_TABLE_PREFIX + TABLE_LIST + database.execSQL(queryList.replace(TABLE_LIST, backupTable)) + database.execSQL( + "INSERT INTO $backupTable SELECT * FROM $TABLE_LIST group by path;", + ) + database.execSQL("DROP TABLE $TABLE_LIST;") + database.execSQL("ALTER TABLE $backupTable RENAME TO $TABLE_LIST;") + backupTable = TEMP_TABLE_PREFIX + TABLE_GRID + database.execSQL(queryGrid.replace(TABLE_GRID, backupTable)) + database.execSQL( + "INSERT INTO $backupTable SELECT * FROM $TABLE_GRID group by path;", + ) + database.execSQL("DROP TABLE $TABLE_GRID;") + database.execSQL("ALTER TABLE $backupTable RENAME TO $TABLE_GRID;") + backupTable = TEMP_TABLE_PREFIX + TABLE_BOOKMARKS + database.execSQL(queryBookmarks.replace(TABLE_BOOKMARKS, backupTable)) + database.execSQL( + "INSERT INTO " + + backupTable + + " SELECT * FROM " + + TABLE_BOOKMARKS + + " group by path;", + ) + database.execSQL("DROP TABLE $TABLE_BOOKMARKS;") + database.execSQL("ALTER TABLE $backupTable RENAME TO $TABLE_BOOKMARKS;") + backupTable = TEMP_TABLE_PREFIX + TABLE_SMB + database.execSQL(querySmb.replace(TABLE_SMB, backupTable)) + database.execSQL( + "INSERT INTO $backupTable SELECT * FROM $TABLE_SMB group by path;", + ) + database.execSQL("DROP TABLE $TABLE_SMB;") + database.execSQL("ALTER TABLE $backupTable RENAME TO $TABLE_SMB;") + backupTable = TEMP_TABLE_PREFIX + TABLE_SFTP + database.execSQL(querySftp.replace(TABLE_SFTP, backupTable)) + database.execSQL( + "INSERT INTO $backupTable SELECT * FROM $TABLE_SFTP group by path;", + ) + database.execSQL("DROP TABLE $TABLE_SFTP;") + database.execSQL("ALTER TABLE $backupTable RENAME TO $TABLE_SFTP;") + } + } + + internal val MIGRATION_3_4: Migration = + object : Migration(3, 4) { + override fun migrate(database: SupportSQLiteDatabase) { + var backupTable = TEMP_TABLE_PREFIX + TABLE_HISTORY + database.execSQL( + queryHistory + .replace(TABLE_HISTORY, backupTable) + .replace("PRIMARY KEY,", "PRIMARY KEY NOT NULL,"), + ) + database.execSQL( + "INSERT INTO $backupTable SELECT * FROM $TABLE_HISTORY group by path;", + ) + database.execSQL("DROP TABLE $TABLE_HISTORY;") + database.execSQL("ALTER TABLE $backupTable RENAME TO $TABLE_HISTORY;") + backupTable = TEMP_TABLE_PREFIX + TABLE_HIDDEN + database.execSQL( + queryHidden + .replace(TABLE_HIDDEN, backupTable) + .replace("PRIMARY KEY,", "PRIMARY KEY NOT NULL,"), + ) + database.execSQL( + "INSERT INTO $backupTable SELECT * FROM $TABLE_HIDDEN group by path;", + ) + database.execSQL("DROP TABLE $TABLE_HIDDEN;") + database.execSQL("ALTER TABLE $backupTable RENAME TO $TABLE_HIDDEN;") + backupTable = TEMP_TABLE_PREFIX + TABLE_LIST + database.execSQL( + queryList + .replace(TABLE_LIST, backupTable) + .replace("PRIMARY KEY,", "PRIMARY KEY NOT NULL,"), + ) + database.execSQL( + "INSERT INTO $backupTable SELECT * FROM $TABLE_LIST group by path;", + ) + database.execSQL("DROP TABLE $TABLE_LIST;") + database.execSQL("ALTER TABLE $backupTable RENAME TO $TABLE_LIST;") + backupTable = TEMP_TABLE_PREFIX + TABLE_GRID + database.execSQL( + queryGrid + .replace(TABLE_GRID, backupTable) + .replace("PRIMARY KEY,", "PRIMARY KEY NOT NULL,"), + ) + database.execSQL( + "INSERT INTO $backupTable SELECT * FROM $TABLE_GRID group by path;", + ) + database.execSQL("DROP TABLE $TABLE_GRID;") + database.execSQL("ALTER TABLE $backupTable RENAME TO $TABLE_GRID;") + backupTable = TEMP_TABLE_PREFIX + TABLE_BOOKMARKS + database.execSQL( + queryBookmarks + .replace(TABLE_BOOKMARKS, backupTable) + .replace("PRIMARY KEY,", "PRIMARY KEY NOT NULL,"), + ) + database.execSQL( + "INSERT INTO " + + backupTable + + " SELECT * FROM " + + TABLE_BOOKMARKS + + " group by path;", + ) + database.execSQL("DROP TABLE $TABLE_BOOKMARKS;") + database.execSQL("ALTER TABLE $backupTable RENAME TO $TABLE_BOOKMARKS;") + backupTable = TEMP_TABLE_PREFIX + TABLE_SMB + database.execSQL( + querySmb + .replace(TABLE_SMB, backupTable) + .replace("PRIMARY KEY,", "PRIMARY KEY NOT NULL,"), + ) + database.execSQL( + "INSERT INTO $backupTable SELECT * FROM $TABLE_SMB group by path;", + ) + database.execSQL("DROP TABLE $TABLE_SMB;") + database.execSQL("ALTER TABLE $backupTable RENAME TO $TABLE_SMB;") + backupTable = TEMP_TABLE_PREFIX + TABLE_SFTP + database.execSQL( + querySftp + .replace(TABLE_SFTP, backupTable) + .replace("PRIMARY KEY,", "PRIMARY KEY NOT NULL,"), + ) + database.execSQL( + "INSERT INTO $backupTable SELECT * FROM $TABLE_SFTP group by path;", + ) + database.execSQL("DROP TABLE $TABLE_SFTP;") + database.execSQL("ALTER TABLE $backupTable RENAME TO $TABLE_SFTP;") + } + } + + internal val MIGRATION_4_5: Migration = + object : Migration(4, 5) { + override fun migrate(database: SupportSQLiteDatabase) { + val backupTable = TEMP_TABLE_PREFIX + TABLE_BOOKMARKS + database.execSQL( + queryBookmarks + .replace(TABLE_BOOKMARKS, backupTable) + .replace("PRIMARY KEY,", "PRIMARY KEY NOT NULL,"), + ) + database.execSQL( + "INSERT INTO " + + backupTable + + "(" + + COLUMN_NAME + + "," + + COLUMN_PATH + + ") SELECT DISTINCT(" + + COLUMN_NAME + + "), " + + COLUMN_PATH + + " FROM " + + TABLE_BOOKMARKS, + ) + database.execSQL("DROP TABLE $TABLE_BOOKMARKS;") + database.execSQL("ALTER TABLE $backupTable RENAME TO $TABLE_BOOKMARKS;") + database.execSQL( + "CREATE UNIQUE INDEX 'bookmarks_idx' ON " + + TABLE_BOOKMARKS + + "(" + + COLUMN_NAME + + ", " + + COLUMN_PATH + + ");", + ) + } + } + + private fun migratePasswordInUris( + database: SupportSQLiteDatabase, + tableName: String, + ): List { + val updateSqls: MutableList = ArrayList() + val cursor = + database.query("SELECT $COLUMN_NAME, $COLUMN_PATH FROM $tableName") + while (cursor.moveToNext()) { + val name = cursor.getString(0) + val oldPath = cursor.getString(1) + if (oldPath.contains(AT)) { + val userCredentials = + oldPath.substring(oldPath.indexOf("://") + 3, oldPath.lastIndexOf(AT)) + if (userCredentials.contains(":")) { + val password = + userCredentials.substring( + userCredentials.lastIndexOf(COLON) + 1, + ) + if (!TextUtils.isEmpty(password)) { + try { + val oldPassword = + decryptPassword( + AppConfig.getInstance(), + password, + Base64.DEFAULT, + ) + val newPassword = + encryptPassword( + AppConfig.getInstance(), + oldPassword, + Base64.URL_SAFE, + ) + val newPath = oldPath.replace(password, newPassword!!) + updateSqls.add( + "UPDATE " + + tableName + + " SET PATH = '" + + newPath + + "' WHERE " + + COLUMN_NAME + + "='" + + name + + "' AND " + + COLUMN_PATH + + "='" + + oldPath + + "'", + ) + } catch (e: GeneralSecurityException) { + logger.error("Error migrating database records") + } catch (e: IOException) { + logger.error("Error migrating database records") + } + } + } + } + } + cursor.close() + return updateSqls + } + + internal val MIGRATION_5_6: Migration = + object : Migration(5, DATABASE_VERSION) { + override fun migrate(database: SupportSQLiteDatabase) { + val updateSqls: MutableList = ArrayList() + updateSqls.addAll(migratePasswordInUris(database, TABLE_SMB)) + updateSqls.addAll(migratePasswordInUris(database, TABLE_SFTP)) + for (updateSql in updateSqls) { + database.execSQL(updateSql) + } + } + } + + /** + * Initialize the database. Optionally, may provide a custom way to create the database + * with supplied [Context]. + */ + @JvmStatic + fun initialize(context: Context): UtilitiesDatabase { + val builder: Builder = + overrideDatabaseBuilder?.invoke(context) ?: Room.databaseBuilder( + context, + UtilitiesDatabase::class.java, + DATABASE_NAME, + ) + return builder + .allowMainThreadQueries() + .addMigrations( + MIGRATION_1_2, + MIGRATION_2_3, + MIGRATION_3_4, + MIGRATION_4_5, + MIGRATION_5_6, + ) + .build() + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/database/UtilsHandler.kt b/app/src/main/java/com/amaze/filemanager/database/UtilsHandler.kt new file mode 100644 index 0000000..ecb8706 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/UtilsHandler.kt @@ -0,0 +1,512 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database + +import android.content.Context +import android.os.Environment +import android.widget.Toast +import com.amaze.filemanager.BuildConfig +import com.amaze.filemanager.R +import com.amaze.filemanager.database.models.OperationData +import com.amaze.filemanager.database.models.utilities.Bookmark +import com.amaze.filemanager.database.models.utilities.Grid +import com.amaze.filemanager.database.models.utilities.Hidden +import com.amaze.filemanager.database.models.utilities.History +import com.amaze.filemanager.database.models.utilities.SftpEntry +import com.amaze.filemanager.database.models.utilities.SmbEntry +import com.googlecode.concurrenttrees.radix.ConcurrentRadixTree +import com.googlecode.concurrenttrees.radix.node.concrete.DefaultCharArrayNodeFactory +import com.googlecode.concurrenttrees.radix.node.concrete.voidvalue.VoidValue +import io.reactivex.schedulers.Schedulers +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.File +import java.io.IOException +import java.security.GeneralSecurityException +import java.util.LinkedList + +/** + * Created by Vishal on 29-05-2017. Class handles database with tables having list of various + * utilities like history, hidden files, list paths, grid paths, bookmarks, SMB entry + * + * + * Try to use these functions from a background thread + */ +class UtilsHandler( + private val context: Context, + private val utilitiesDatabase: UtilitiesDatabase, +) { + private val log: Logger = LoggerFactory.getLogger(UtilsHandler::class.java) + + enum class Operation { + HISTORY, + HIDDEN, + LIST, + GRID, + BOOKMARKS, + SMB, + SFTP, + } + + /** + * Main save method. + */ + @Suppress("ComplexMethod", "LongMethod") + fun saveToDatabase(operationData: OperationData) { + when (operationData.type) { + Operation.HIDDEN -> + utilitiesDatabase + .hiddenEntryDao() + .insert(Hidden(operationData.path)) + .subscribeOn(Schedulers.io()) + .subscribe() + Operation.HISTORY -> + utilitiesDatabase.historyEntryDao().run { + deleteByPath(operationData.path) + .andThen(insert(History(operationData.path))) + .subscribeOn(Schedulers.io()) + .subscribe() + } + Operation.LIST -> + utilitiesDatabase + .listEntryDao() + .insert( + com.amaze.filemanager.database.models.utilities.List(operationData.path), + ) + .subscribeOn(Schedulers.io()) + .subscribe() + Operation.GRID -> + utilitiesDatabase + .gridEntryDao() + .insert(Grid(operationData.path)) + .subscribeOn(Schedulers.io()) + .subscribe() + Operation.BOOKMARKS -> + utilitiesDatabase + .bookmarkEntryDao() + .insert(Bookmark(operationData.name, operationData.path)) + .subscribeOn(Schedulers.io()) + .subscribe() + Operation.SMB -> + utilitiesDatabase.smbEntryDao().run { + deleteByNameAndPath(operationData.name, operationData.path) + .andThen(insert(SmbEntry(operationData.name, operationData.path))) + .subscribeOn(Schedulers.io()) + .subscribe() + } + Operation.SFTP -> + utilitiesDatabase + .sftpEntryDao().run { + deleteByNameAndPath(operationData.name, operationData.path) + .andThen( + insert( + SftpEntry( + operationData.path, + operationData.name, + operationData.hostKey, + operationData.sshKeyName, + operationData.sshKey, + ), + ), + ) + .subscribeOn(Schedulers.io()) + .subscribe() + } + + else -> throw IllegalStateException("Unidentified operation!") + } + } + + /** + * Main delete method. + */ + fun removeFromDatabase(operationData: OperationData) { + when (operationData.type) { + Operation.HIDDEN -> + utilitiesDatabase + .hiddenEntryDao() + .deleteByPath(operationData.path) + .subscribeOn(Schedulers.io()) + .subscribe() + Operation.HISTORY -> + utilitiesDatabase + .historyEntryDao() + .deleteByPath(operationData.path) + .subscribeOn(Schedulers.io()) + .subscribe() + Operation.LIST -> + utilitiesDatabase + .listEntryDao() + .deleteByPath(operationData.path) + .subscribeOn(Schedulers.io()) + .subscribe() + Operation.GRID -> + utilitiesDatabase + .gridEntryDao() + .deleteByPath(operationData.path) + .subscribeOn(Schedulers.io()) + .subscribe() + Operation.BOOKMARKS -> removeBookmarksPath(operationData.name, operationData.path) + Operation.SMB -> removeSmbPath(operationData.name, operationData.path) + Operation.SFTP -> removeSftpPath(operationData.name, operationData.path) + else -> throw IllegalStateException("Unidentified operation!") + } + } + + /** + * Create common bookmarks to database. + */ + fun addCommonBookmarks() { + val sd = Environment.getExternalStorageDirectory() + val dirs = + arrayOf( + File(sd, Environment.DIRECTORY_DCIM).absolutePath, + File(sd, Environment.DIRECTORY_DOWNLOADS).absolutePath, + File(sd, Environment.DIRECTORY_MOVIES).absolutePath, + File(sd, Environment.DIRECTORY_MUSIC).absolutePath, + File(sd, Environment.DIRECTORY_PICTURES).absolutePath, + ) + for (dir in dirs) { + saveToDatabase(OperationData(Operation.BOOKMARKS, File(dir).name, dir)) + } + } + + /** + * Update SSH connection entry. + */ + @Suppress("ComplexMethod", "LongParameterList") + fun updateSsh( + connectionName: String, + oldConnectionName: String, + path: String, + hostKey: String?, + sshKeyName: String?, + sshKey: String?, + ) { + utilitiesDatabase + .sftpEntryDao() + .findByName(oldConnectionName) + .subscribeOn(Schedulers.io()) + .subscribe { entry: SftpEntry -> + entry.name = connectionName + entry.path = path + entry.hostKey = hostKey + if (sshKeyName != null && sshKey != null) { + entry.sshKeyName = sshKeyName + entry.sshKey = sshKey + } + utilitiesDatabase + .sftpEntryDao() + .update(entry) + .subscribeOn(Schedulers.io()) + .subscribe() + } + } + + /** + * Get browse history in [LinkedList]. + */ + val historyLinkedList: LinkedList + get() { + val paths = LinkedList() + for ( + history in utilitiesDatabase.historyEntryDao().list().subscribeOn(Schedulers.io()) + .blockingGet() + ) { + paths.add(history.path) + } + return paths + } + + /** + * Return list of Hidden values + */ + val hiddenFilesConcurrentRadixTree: ConcurrentRadixTree + get() { + val paths = ConcurrentRadixTree(DefaultCharArrayNodeFactory()) + for ( + path in utilitiesDatabase.hiddenEntryDao().listPaths().subscribeOn(Schedulers.io()) + .blockingGet() + ) { + paths.put(path, VoidValue.SINGLETON) + } + return paths + } + + /** + * Return list of paths using list view. + */ + val listViewList: ArrayList + get() = + ArrayList( + utilitiesDatabase.listEntryDao().listPaths().subscribeOn(Schedulers.io()).blockingGet(), + ) + + /** + * Return list of paths using grid view. + */ + val gridViewList: ArrayList + get() = + ArrayList( + utilitiesDatabase.gridEntryDao().listPaths().subscribeOn(Schedulers.io()).blockingGet(), + ) + + /** + * Return list of bookmarks. + */ + val bookmarksList: ArrayList> + get() { + val row = ArrayList>() + for ( + bookmark in utilitiesDatabase.bookmarkEntryDao().list() + .subscribeOn(Schedulers.io()).blockingGet() + ) { + row.add(arrayOf(bookmark.name, bookmark.path)) + } + return row + } + + /** + * Return list of SMB connections in name/URI pairs. + */ + val smbList: ArrayList> + get() { + val retval = ArrayList>() + for ( + entry in utilitiesDatabase.smbEntryDao().list().subscribeOn(Schedulers.io()) + .blockingGet() + ) { + try { + retval.add(arrayOf(entry.name, entry.path)) + } catch (e: GeneralSecurityException) { + log.warn("failed to decrypt smb list path", e) + + // failing to decrypt the path, removing entry from database + Toast.makeText( + context, + context.getString(R.string.failed_smb_decrypt_path), + Toast.LENGTH_LONG, + ) + .show() + removeSmbPath(entry.name, "") + continue + } catch (e: IOException) { + log.warn("failed to decrypt smb list path", e) + Toast.makeText( + context, + context.getString(R.string.failed_smb_decrypt_path), + Toast.LENGTH_LONG, + ) + .show() + removeSmbPath(entry.name, "") + continue + } + } + return retval + } + + /** + * Return SSH connections in name/URI pairs. + */ + val sftpList: List> + get() { + val retval = ArrayList>() + for ( + entry in utilitiesDatabase.sftpEntryDao().list().subscribeOn(Schedulers.io()) + .blockingGet() + ) { + val path = entry.path + if (path == null) { + log.error("Error decrypting path: " + entry.path) + // failing to decrypt the path, removing entry from database + Toast.makeText( + context, + context.getString(R.string.failed_smb_decrypt_path), + Toast.LENGTH_LONG, + ).show() + } else { + retval.add(arrayOf(entry.name, path)) + } + } + return retval + } + + /** + * Returns SSH host key of specified URI. + */ + fun getRemoteHostKey(uri: String): String? = + runCatching { + utilitiesDatabase + .sftpEntryDao() + .getRemoteHostKey(uri) + .subscribeOn(Schedulers.io()) + .blockingGet() + }.onFailure { + if (BuildConfig.DEBUG) { + log.warn("Error getting public key for URI [$uri]", it) + } + }.getOrNull() + + /** + * Returns name of SSH private key of specified URI. + */ + fun getSshAuthPrivateKeyName(uri: String): String? = + runCatching { + utilitiesDatabase + .sftpEntryDao() + .getSshAuthPrivateKeyName(uri) + .subscribeOn(Schedulers.io()) + .blockingGet() + }.onFailure { + // catch error to handle Single#onError for blockingGet + log.error("Error getting SSH private key name", it) + }.getOrNull() + + /** + * Returns private key of specified SSH URI. + */ + fun getSshAuthPrivateKey(uri: String): String? = + runCatching { + utilitiesDatabase + .sftpEntryDao() + .getSshAuthPrivateKey(uri) + .subscribeOn(Schedulers.io()) + .blockingGet() + }.onFailure { + // catch error to handle Single#onError for blockingGet + if (BuildConfig.DEBUG) { + log.error("Error getting auth private key for URI [$uri]", it) + } + }.getOrNull() + + private fun removeBookmarksPath( + name: String, + path: String, + ) { + utilitiesDatabase + .bookmarkEntryDao() + .deleteByNameAndPath(name, path) + .subscribeOn(Schedulers.io()) + .subscribe() + } + + /** + * Remove SMB entry + * + * @param path the path we get from saved runtime variables is a decrypted, to remove entry, we + * must encrypt it's password fiend first first + */ + private fun removeSmbPath( + name: String, + path: String, + ) { + if ("" == path) { + utilitiesDatabase.smbEntryDao().deleteByName(name) + .subscribeOn(Schedulers.io()).subscribe() + } else { + utilitiesDatabase + .smbEntryDao() + .deleteByNameAndPath(name, path) + .subscribeOn(Schedulers.io()) + .subscribe() + } + } + + private fun removeSftpPath( + name: String, + path: String, + ) { + if ("" == path) { + utilitiesDatabase.sftpEntryDao().deleteByName(name) + .subscribeOn(Schedulers.io()).subscribe() + } else { + utilitiesDatabase + .sftpEntryDao() + .deleteByNameAndPath(name, path) + .subscribeOn(Schedulers.io()) + .subscribe() + } + } + + /** + * Update [Bookmark]. + */ + fun renameBookmark( + oldName: String, + oldPath: String, + newName: String, + newPath: String, + ) { + val bookmark: Bookmark = + kotlin.runCatching { + utilitiesDatabase + .bookmarkEntryDao() + .findByNameAndPath(oldName, oldPath) + .subscribeOn(Schedulers.io()) + .blockingGet() + }.onFailure { + // catch error to handle Single#onError for blockingGet + log.error(it.message!!) + return + }.getOrThrow() + + bookmark.name = newName + bookmark.path = newPath + utilitiesDatabase.bookmarkEntryDao().update(bookmark).subscribeOn(Schedulers.io()) + .subscribe() + } + + /** + * Update [SmbEntry]. + */ + fun renameSMB( + oldName: String, + oldPath: String, + newName: String, + newPath: String, + ) { + utilitiesDatabase + .smbEntryDao() + .findByNameAndPath(oldName, oldPath) + .subscribeOn(Schedulers.io()) + .subscribe { smbEntry: SmbEntry -> + smbEntry.name = newName + smbEntry.path = newPath + utilitiesDatabase + .smbEntryDao() + .update(smbEntry) + .subscribeOn(Schedulers.io()) + .subscribe() + } + } + + /** + * Clear specified table. Only supports [History] for now. + */ + fun clearTable(table: Operation) { + when (table) { + Operation.HISTORY -> + utilitiesDatabase.historyEntryDao().clear() + .subscribeOn(Schedulers.io()).subscribe() + else -> {} + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/database/daos/BookmarkEntryDao.java b/app/src/main/java/com/amaze/filemanager/database/daos/BookmarkEntryDao.java new file mode 100644 index 0000000..7b0ca71 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/daos/BookmarkEntryDao.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database.daos; + +import static com.amaze.filemanager.database.UtilitiesDatabase.COLUMN_NAME; +import static com.amaze.filemanager.database.UtilitiesDatabase.COLUMN_PATH; +import static com.amaze.filemanager.database.UtilitiesDatabase.TABLE_BOOKMARKS; + +import java.util.List; + +import com.amaze.filemanager.database.models.utilities.Bookmark; + +import androidx.room.Dao; +import androidx.room.Insert; +import androidx.room.OnConflictStrategy; +import androidx.room.Query; +import androidx.room.Update; + +import io.reactivex.Completable; +import io.reactivex.Single; + +/** + * {@link Dao} interface definition for {@link Bookmark}. Concrete class is generated by Room during + * build. + * + * @see Dao + * @see Bookmark + * @see com.amaze.filemanager.database.UtilitiesDatabase + */ +@Dao +public interface BookmarkEntryDao { + + @Insert(onConflict = OnConflictStrategy.IGNORE) + Completable insert(Bookmark instance); + + @Update + Completable update(Bookmark instance); + + @Query("SELECT * FROM " + TABLE_BOOKMARKS) + Single> list(); + + @Query( + "SELECT * FROM " + + TABLE_BOOKMARKS + + " WHERE " + + COLUMN_NAME + + " = :name AND " + + COLUMN_PATH + + " = :path") + Single findByNameAndPath(String name, String path); + + @Query( + "DELETE FROM " + + TABLE_BOOKMARKS + + " WHERE " + + COLUMN_NAME + + " = :name AND " + + COLUMN_PATH + + " = :path") + Completable deleteByNameAndPath(String name, String path); +} diff --git a/app/src/main/java/com/amaze/filemanager/database/daos/CloudEntryDao.java b/app/src/main/java/com/amaze/filemanager/database/daos/CloudEntryDao.java new file mode 100644 index 0000000..62a83b2 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/daos/CloudEntryDao.java @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database.daos; + +import static com.amaze.filemanager.database.ExplorerDatabase.COLUMN_CLOUD_SERVICE; +import static com.amaze.filemanager.database.ExplorerDatabase.TABLE_CLOUD_PERSIST; + +import java.util.List; + +import com.amaze.filemanager.database.models.explorer.CloudEntry; + +import androidx.room.Dao; +import androidx.room.Delete; +import androidx.room.Insert; +import androidx.room.Query; +import androidx.room.Transaction; +import androidx.room.Update; + +import io.reactivex.Completable; +import io.reactivex.Single; + +/** + * {@link Dao} interface definition for {@link CloudEntry}. Concrete class is generated by Room + * during build. + * + * @see Dao + * @see CloudEntry + * @see com.amaze.filemanager.database.ExplorerDatabase + */ +@Dao +public interface CloudEntryDao { + + @Insert + Completable insert(CloudEntry entry); + + @Query( + "SELECT * FROM " + TABLE_CLOUD_PERSIST + " WHERE " + COLUMN_CLOUD_SERVICE + " = :serviceType") + Single findByServiceType(int serviceType); + + @Query("SELECT * FROM " + TABLE_CLOUD_PERSIST) + Single> list(); + + @Update + Completable update(CloudEntry entry); + + @Delete + Completable delete(CloudEntry entry); + + @Transaction + @Query("DELETE FROM " + TABLE_CLOUD_PERSIST) + Completable clear(); +} diff --git a/app/src/main/java/com/amaze/filemanager/database/daos/EncryptedEntryDao.java b/app/src/main/java/com/amaze/filemanager/database/daos/EncryptedEntryDao.java new file mode 100644 index 0000000..e21d4e3 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/daos/EncryptedEntryDao.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database.daos; + +import static com.amaze.filemanager.database.ExplorerDatabase.COLUMN_PATH; +import static com.amaze.filemanager.database.ExplorerDatabase.TABLE_ENCRYPTED; + +import java.util.List; + +import com.amaze.filemanager.database.models.explorer.EncryptedEntry; + +import androidx.room.Dao; +import androidx.room.Insert; +import androidx.room.Query; +import androidx.room.Transaction; +import androidx.room.Update; + +import io.reactivex.Completable; +import io.reactivex.Single; + +/** + * {@link Dao} interface definition for {@link EncryptedEntry}. Concrete class is generated by Room + * during build. + * + * @see Dao + * @see EncryptedEntry + * @see com.amaze.filemanager.database.ExplorerDatabase + */ +@Dao +public interface EncryptedEntryDao { + + @Insert + Completable insert(EncryptedEntry entry); + + @Query("SELECT * FROM " + TABLE_ENCRYPTED + " WHERE " + COLUMN_PATH + " = :path") + Single select(String path); + + @Update + Completable update(EncryptedEntry entry); + + @Transaction + @Query("DELETE FROM " + TABLE_ENCRYPTED + " WHERE " + COLUMN_PATH + " = :path") + Completable delete(String path); + + @Query("SELECT * FROM " + TABLE_ENCRYPTED) + Single> list(); +} diff --git a/app/src/main/java/com/amaze/filemanager/database/daos/GridEntryDao.java b/app/src/main/java/com/amaze/filemanager/database/daos/GridEntryDao.java new file mode 100644 index 0000000..7ce356d --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/daos/GridEntryDao.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database.daos; + +import static com.amaze.filemanager.database.UtilitiesDatabase.COLUMN_PATH; +import static com.amaze.filemanager.database.UtilitiesDatabase.TABLE_GRID; + +import java.util.List; + +import com.amaze.filemanager.database.models.utilities.Grid; + +import androidx.room.Dao; +import androidx.room.Insert; +import androidx.room.Query; +import androidx.room.Update; + +import io.reactivex.Completable; +import io.reactivex.Single; + +/** + * {@link Dao} interface definition for {@link Grid}. Concrete class is generated by Room during + * build. + * + * @see Dao + * @see Grid + * @see com.amaze.filemanager.database.UtilitiesDatabase + */ +@Dao +public interface GridEntryDao { + + @Insert + Completable insert(Grid instance); + + @Update + Completable update(Grid instance); + + @Query("SELECT " + COLUMN_PATH + " FROM " + TABLE_GRID) + Single> listPaths(); + + @Query("DELETE FROM " + TABLE_GRID + " WHERE " + COLUMN_PATH + " = :path") + Completable deleteByPath(String path); +} diff --git a/app/src/main/java/com/amaze/filemanager/database/daos/HiddenEntryDao.java b/app/src/main/java/com/amaze/filemanager/database/daos/HiddenEntryDao.java new file mode 100644 index 0000000..d652155 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/daos/HiddenEntryDao.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database.daos; + +import static com.amaze.filemanager.database.UtilitiesDatabase.COLUMN_PATH; +import static com.amaze.filemanager.database.UtilitiesDatabase.TABLE_HIDDEN; + +import java.util.List; + +import com.amaze.filemanager.database.models.utilities.Hidden; + +import androidx.room.Dao; +import androidx.room.Insert; +import androidx.room.Query; +import androidx.room.Update; + +import io.reactivex.Completable; +import io.reactivex.Single; + +/** + * {@link Dao} interface definition for {@link Hidden}. Concrete class is generated by Room during + * build. + * + * @see Dao + * @see Hidden + * @see com.amaze.filemanager.database.UtilitiesDatabase + */ +@Dao +public interface HiddenEntryDao { + + @Insert + Completable insert(Hidden instance); + + @Update + Completable update(Hidden instance); + + @Query("SELECT " + COLUMN_PATH + " FROM " + TABLE_HIDDEN) + Single> listPaths(); + + @Query("DELETE FROM " + TABLE_HIDDEN + " WHERE " + COLUMN_PATH + " = :path") + Completable deleteByPath(String path); +} diff --git a/app/src/main/java/com/amaze/filemanager/database/daos/HistoryEntryDao.java b/app/src/main/java/com/amaze/filemanager/database/daos/HistoryEntryDao.java new file mode 100644 index 0000000..165e3ad --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/daos/HistoryEntryDao.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database.daos; + +import static com.amaze.filemanager.database.UtilitiesDatabase.COLUMN_PATH; +import static com.amaze.filemanager.database.UtilitiesDatabase.TABLE_HISTORY; + +import java.util.List; + +import com.amaze.filemanager.database.models.utilities.History; + +import androidx.room.Dao; +import androidx.room.Insert; +import androidx.room.OnConflictStrategy; +import androidx.room.Query; +import androidx.room.Update; + +import io.reactivex.Completable; +import io.reactivex.Single; + +/** + * {@link Dao} interface definition for {@link History}. Concrete class is generated by Room during + * build. + * + * @see Dao + * @see History + * @see com.amaze.filemanager.database.UtilitiesDatabase + */ +@Dao +public interface HistoryEntryDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + Completable insert(History instance); + + @Update + Completable update(History instance); + + @Query("SELECT * FROM " + TABLE_HISTORY) + Single> list(); + + @Query("DELETE FROM " + TABLE_HISTORY + " WHERE " + COLUMN_PATH + " = :path") + Completable deleteByPath(String path); + + @Query("DELETE FROM " + TABLE_HISTORY) + Completable clear(); +} diff --git a/app/src/main/java/com/amaze/filemanager/database/daos/ListEntryDao.java b/app/src/main/java/com/amaze/filemanager/database/daos/ListEntryDao.java new file mode 100644 index 0000000..f026f9d --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/daos/ListEntryDao.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database.daos; + +import static com.amaze.filemanager.database.UtilitiesDatabase.COLUMN_PATH; +import static com.amaze.filemanager.database.UtilitiesDatabase.TABLE_LIST; + +import com.amaze.filemanager.database.models.utilities.List; + +import androidx.room.Dao; +import androidx.room.Insert; +import androidx.room.OnConflictStrategy; +import androidx.room.Query; +import androidx.room.Update; + +import io.reactivex.Completable; +import io.reactivex.Single; + +/** + * {@link Dao} interface definition for {@link List}. Concrete class is generated by Room during + * build. + * + * @see Dao + * @see List + * @see com.amaze.filemanager.database.UtilitiesDatabase + */ +@Dao +public interface ListEntryDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + Completable insert(List instance); + + @Update + Completable update(List instance); + + @Query("SELECT " + COLUMN_PATH + " FROM " + TABLE_LIST) + Single> listPaths(); + + @Query("DELETE FROM " + TABLE_LIST + " WHERE " + COLUMN_PATH + " = :path") + Completable deleteByPath(String path); +} diff --git a/app/src/main/java/com/amaze/filemanager/database/daos/SftpEntryDao.java b/app/src/main/java/com/amaze/filemanager/database/daos/SftpEntryDao.java new file mode 100644 index 0000000..1e82e0c --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/daos/SftpEntryDao.java @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database.daos; + +import static com.amaze.filemanager.database.UtilitiesDatabase.COLUMN_HOST_PUBKEY; +import static com.amaze.filemanager.database.UtilitiesDatabase.COLUMN_NAME; +import static com.amaze.filemanager.database.UtilitiesDatabase.COLUMN_PATH; +import static com.amaze.filemanager.database.UtilitiesDatabase.COLUMN_PRIVATE_KEY; +import static com.amaze.filemanager.database.UtilitiesDatabase.COLUMN_PRIVATE_KEY_NAME; +import static com.amaze.filemanager.database.UtilitiesDatabase.TABLE_SFTP; + +import java.util.List; + +import com.amaze.filemanager.database.models.utilities.SftpEntry; + +import androidx.room.Dao; +import androidx.room.Insert; +import androidx.room.OnConflictStrategy; +import androidx.room.Query; +import androidx.room.Update; + +import io.reactivex.Completable; +import io.reactivex.Single; + +/** + * {@link Dao} interface definition for {@link SftpEntry}. Concrete class is generated by Room + * during build. + * + * @see Dao + * @see SftpEntry + * @see com.amaze.filemanager.database.UtilitiesDatabase + */ +@Dao +public interface SftpEntryDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + Completable insert(SftpEntry instance); + + @Update + Completable update(SftpEntry instance); + + @Query("SELECT * FROM " + TABLE_SFTP) + Single> list(); + + @Query( + "SELECT * FROM " + + TABLE_SFTP + + " WHERE " + + COLUMN_NAME + + " = :name AND " + + COLUMN_PATH + + " = :path") + Single findByNameAndPath(String name, String path); + + @Query("SELECT * FROM " + TABLE_SFTP + " WHERE " + COLUMN_NAME + " = :name") + Single findByName(String name); + + @Query( + "SELECT " + COLUMN_HOST_PUBKEY + " FROM " + TABLE_SFTP + " WHERE " + COLUMN_PATH + " = :uri") + Single getRemoteHostKey(String uri); + + @Query( + "SELECT " + + COLUMN_PRIVATE_KEY_NAME + + " FROM " + + TABLE_SFTP + + " WHERE " + + COLUMN_PATH + + " = :uri") + Single getSshAuthPrivateKeyName(String uri); + + @Query( + "SELECT " + COLUMN_PRIVATE_KEY + " FROM " + TABLE_SFTP + " WHERE " + COLUMN_PATH + " = :uri") + Single getSshAuthPrivateKey(String uri); + + @Query("DELETE FROM " + TABLE_SFTP + " WHERE " + COLUMN_NAME + " = :name") + Completable deleteByName(String name); + + @Query( + "DELETE FROM " + + TABLE_SFTP + + " WHERE " + + COLUMN_NAME + + " = :name AND " + + COLUMN_PATH + + " = :path") + Completable deleteByNameAndPath(String name, String path); +} diff --git a/app/src/main/java/com/amaze/filemanager/database/daos/SmbEntryDao.java b/app/src/main/java/com/amaze/filemanager/database/daos/SmbEntryDao.java new file mode 100644 index 0000000..b00c8df --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/daos/SmbEntryDao.java @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database.daos; + +import static com.amaze.filemanager.database.UtilitiesDatabase.COLUMN_NAME; +import static com.amaze.filemanager.database.UtilitiesDatabase.COLUMN_PATH; +import static com.amaze.filemanager.database.UtilitiesDatabase.TABLE_SMB; + +import java.util.List; + +import com.amaze.filemanager.database.models.utilities.SmbEntry; + +import androidx.room.Dao; +import androidx.room.Insert; +import androidx.room.OnConflictStrategy; +import androidx.room.Query; +import androidx.room.Update; + +import io.reactivex.Completable; +import io.reactivex.Single; + +/** + * {@link Dao} interface definition for {@link SmbEntry}. Concrete class is generated by Room during + * build. + * + * @see Dao + * @see SmbEntry + * @see com.amaze.filemanager.database.UtilitiesDatabase + */ +@Dao +public interface SmbEntryDao { + + @Insert(onConflict = OnConflictStrategy.IGNORE) + Completable insert(SmbEntry instance); + + @Update + Completable update(SmbEntry instance); + + @Query("SELECT * FROM " + TABLE_SMB) + Single> list(); + + @Query( + "SELECT * FROM " + + TABLE_SMB + + " WHERE " + + COLUMN_NAME + + " = :name AND " + + COLUMN_PATH + + " = :path") + Single findByNameAndPath(String name, String path); + + @Query("DELETE FROM " + TABLE_SMB + " WHERE " + COLUMN_NAME + " = :name") + Completable deleteByName(String name); + + @Query( + "DELETE FROM " + + TABLE_SMB + + " WHERE " + + COLUMN_NAME + + " = :name AND " + + COLUMN_PATH + + " = :path") + Completable deleteByNameAndPath(String name, String path); +} diff --git a/app/src/main/java/com/amaze/filemanager/database/daos/SortDao.java b/app/src/main/java/com/amaze/filemanager/database/daos/SortDao.java new file mode 100644 index 0000000..ff09a5d --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/daos/SortDao.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database.daos; + +import static com.amaze.filemanager.database.ExplorerDatabase.COLUMN_PATH; +import static com.amaze.filemanager.database.ExplorerDatabase.TABLE_SORT; + +import com.amaze.filemanager.database.models.explorer.Sort; + +import androidx.room.Dao; +import androidx.room.Insert; +import androidx.room.Query; +import androidx.room.Transaction; +import androidx.room.Update; + +import io.reactivex.Completable; +import io.reactivex.Single; + +/** + * {@link Dao} interface definition for {@link Sort}. Concrete class is generated by Room during + * build. + * + * @see Dao + * @see Sort + * @see com.amaze.filemanager.database.ExplorerDatabase + */ +@Dao +public interface SortDao { + + @Insert + Completable insert(Sort entity); + + @Query("SELECT * FROM " + TABLE_SORT + " WHERE " + COLUMN_PATH + " = :path") + Single find(String path); + + @Transaction + @Query("DELETE FROM " + TABLE_SORT + " WHERE " + COLUMN_PATH + " = :path") + Completable clear(String path); + + @Update + Completable update(Sort entity); +} diff --git a/app/src/main/java/com/amaze/filemanager/database/daos/TabDao.java b/app/src/main/java/com/amaze/filemanager/database/daos/TabDao.java new file mode 100644 index 0000000..164d1de --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/daos/TabDao.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database.daos; + +import static com.amaze.filemanager.database.ExplorerDatabase.COLUMN_TAB_NO; +import static com.amaze.filemanager.database.ExplorerDatabase.TABLE_TAB; + +import java.util.List; + +import com.amaze.filemanager.database.models.explorer.Tab; + +import androidx.room.Dao; +import androidx.room.Insert; +import androidx.room.OnConflictStrategy; +import androidx.room.Query; +import androidx.room.Transaction; +import androidx.room.Update; + +import io.reactivex.Completable; +import io.reactivex.Single; + +/** + * {@link Dao} interface definition for {@link Tab}. Concrete class is generated by Room during + * build. + * + * @see Dao + * @see Tab + * @see com.amaze.filemanager.database.ExplorerDatabase + */ +@Dao +public interface TabDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + Completable insertTab(Tab tab); + + @Transaction + @Query("DELETE FROM " + TABLE_TAB) + Completable clear(); + + @Query("SELECT * FROM " + TABLE_TAB + " WHERE " + COLUMN_TAB_NO + " = :tabNo") + Single find(int tabNo); + + @Update(onConflict = OnConflictStrategy.REPLACE) + void update(Tab tab); + + @Query("SELECT * FROM " + TABLE_TAB) + Single> list(); +} diff --git a/app/src/main/java/com/amaze/filemanager/database/models/OperationData.java b/app/src/main/java/com/amaze/filemanager/database/models/OperationData.java new file mode 100644 index 0000000..472e7b0 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/models/OperationData.java @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database.models; + +import static com.amaze.filemanager.database.UtilsHandler.Operation.BOOKMARKS; +import static com.amaze.filemanager.database.UtilsHandler.Operation.GRID; +import static com.amaze.filemanager.database.UtilsHandler.Operation.HIDDEN; +import static com.amaze.filemanager.database.UtilsHandler.Operation.HISTORY; +import static com.amaze.filemanager.database.UtilsHandler.Operation.LIST; +import static com.amaze.filemanager.database.UtilsHandler.Operation.SFTP; +import static com.amaze.filemanager.database.UtilsHandler.Operation.SMB; + +import com.amaze.filemanager.database.UtilsHandler; +import com.amaze.filemanager.database.UtilsHandler.Operation; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class OperationData { + public final Operation type; + public final String path; + public final String name; + public final String hostKey; + public final String sshKeyName; + public final String sshKey; + + /** + * Constructor for types {@link Operation#HIDDEN}, {@link Operation#HISTORY}, {@link + * Operation#LIST} or {@link Operation#GRID} + */ + public OperationData(Operation type, String path) { + if (type != HIDDEN && type != HISTORY && type != LIST && type != GRID) { + throw new IllegalArgumentException("Wrong constructor for object type"); + } + + this.type = type; + this.path = path; + + name = null; + hostKey = null; + sshKeyName = null; + sshKey = null; + } + + /** Constructor for types {@link Operation#BOOKMARKS} or {@link Operation#SMB} */ + public OperationData(Operation type, String name, String path) { + if (type != BOOKMARKS && type != SMB) + throw new IllegalArgumentException("Wrong constructor for object type"); + + this.type = type; + this.path = path; + this.name = name; + + hostKey = null; + sshKeyName = null; + sshKey = null; + } + + /** + * Constructor for {@link Operation#SFTP} {@param hostKey}, {@param sshKeyName} and {@param + * sshKey} may be null for when {@link OperationData} is used for {@link + * UtilsHandler#removeFromDatabase(OperationData)} + */ + public OperationData( + @NonNull Operation type, + @NonNull String path, + @NonNull String name, + @Nullable String hostKey, + @Nullable String sshKeyName, + @Nullable String sshKey) { + if (type != SFTP) throw new IllegalArgumentException("Wrong constructor for object type"); + + this.type = type; + this.path = path; + this.name = name; + this.hostKey = hostKey; + this.sshKeyName = sshKeyName; + this.sshKey = sshKey; + } + + @NonNull + @Override + public String toString() { + StringBuilder sb = + new StringBuilder("OperationData type=[") + .append(type) + .append("],path=[") + .append(path) + .append("]"); + + if (!TextUtils.isEmpty(hostKey)) sb.append(",hostKey=[").append(hostKey).append(']'); + + if (!TextUtils.isEmpty(sshKeyName)) + sb.append(",sshKeyName=[").append(sshKeyName).append("],sshKey=[redacted]"); + + return sb.toString(); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/database/models/StringWrapper.java b/app/src/main/java/com/amaze/filemanager/database/models/StringWrapper.java new file mode 100644 index 0000000..6968552 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/models/StringWrapper.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database.models; + +/** Simple StringWrapper. */ +public class StringWrapper { + + public final String value; + + public StringWrapper(String value) { + this.value = value; + } + + @Override + public String toString() { + return value; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/database/models/explorer/CloudEntry.java b/app/src/main/java/com/amaze/filemanager/database/models/explorer/CloudEntry.java new file mode 100644 index 0000000..b161593 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/models/explorer/CloudEntry.java @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database.models.explorer; + +import com.amaze.filemanager.database.ExplorerDatabase; +import com.amaze.filemanager.database.models.StringWrapper; +import com.amaze.filemanager.database.typeconverters.EncryptedStringTypeConverter; +import com.amaze.filemanager.database.typeconverters.OpenModeTypeConverter; +import com.amaze.filemanager.fileoperations.filesystem.OpenMode; + +import androidx.room.ColumnInfo; +import androidx.room.Entity; +import androidx.room.PrimaryKey; +import androidx.room.TypeConverters; + +/** Created by vishal on 18/4/17. */ +@Entity(tableName = ExplorerDatabase.TABLE_CLOUD_PERSIST) +public class CloudEntry { + + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = ExplorerDatabase.COLUMN_CLOUD_ID) + private int _id; + + @ColumnInfo(name = ExplorerDatabase.COLUMN_CLOUD_SERVICE) + @TypeConverters(OpenModeTypeConverter.class) + private OpenMode serviceType; + + @ColumnInfo(name = ExplorerDatabase.COLUMN_CLOUD_PERSIST) + @TypeConverters(EncryptedStringTypeConverter.class) + private StringWrapper persistData; + + public CloudEntry() {} + + public CloudEntry(OpenMode serviceType, String persistData) { + this.serviceType = serviceType; + this.persistData = new StringWrapper(persistData); + } + + public void setId(int _id) { + this._id = _id; + } + + public int getId() { + return this._id; + } + + public void setPersistData(StringWrapper persistData) { + this.persistData = persistData; + } + + public StringWrapper getPersistData() { + return this.persistData; + } + + /** Set the service type Support values from {@link OpenMode} */ + public void setServiceType(OpenMode openMode) { + this.serviceType = openMode; + } + + /** Returns ordinal value of service from {@link OpenMode} */ + public OpenMode getServiceType() { + return this.serviceType; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/database/models/explorer/EncryptedEntry.java b/app/src/main/java/com/amaze/filemanager/database/models/explorer/EncryptedEntry.java new file mode 100644 index 0000000..443f2c9 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/models/explorer/EncryptedEntry.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database.models.explorer; + +import com.amaze.filemanager.database.ExplorerDatabase; +import com.amaze.filemanager.database.models.StringWrapper; +import com.amaze.filemanager.database.typeconverters.EncryptedStringTypeConverter; + +import androidx.room.ColumnInfo; +import androidx.room.Entity; +import androidx.room.PrimaryKey; +import androidx.room.TypeConverters; + +/** Created by vishal on 8/4/17. */ +@Entity(tableName = ExplorerDatabase.TABLE_ENCRYPTED) +public class EncryptedEntry { + + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = ExplorerDatabase.COLUMN_ENCRYPTED_ID) + private int _id; + + @ColumnInfo(name = ExplorerDatabase.COLUMN_ENCRYPTED_PATH) + private String path; + + @ColumnInfo(name = ExplorerDatabase.COLUMN_ENCRYPTED_PASSWORD) + @TypeConverters(EncryptedStringTypeConverter.class) + private StringWrapper password; + + public EncryptedEntry() {} + + public EncryptedEntry(String path, String unencryptedPassword) { + this.path = path; + this.password = new StringWrapper(unencryptedPassword); + } + + public void setId(int _id) { + this._id = _id; + } + + public int getId() { + return this._id; + } + + public void setPath(String path) { + this.path = path; + } + + public String getPath() { + return this.path; + } + + public void setPassword(StringWrapper password) { + this.password = password; + } + + public StringWrapper getPassword() { + return this.password; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/database/models/explorer/Sort.java b/app/src/main/java/com/amaze/filemanager/database/models/explorer/Sort.java new file mode 100644 index 0000000..e05f6e2 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/models/explorer/Sort.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database.models.explorer; + +import com.amaze.filemanager.database.ExplorerDatabase; + +import androidx.annotation.NonNull; +import androidx.room.ColumnInfo; +import androidx.room.Entity; +import androidx.room.PrimaryKey; + +/** Created by Ning on 5/28/2018. */ +@Entity(tableName = ExplorerDatabase.TABLE_SORT) +public class Sort { + + @PrimaryKey + @NonNull + @ColumnInfo(name = ExplorerDatabase.COLUMN_SORT_PATH) + public final String path; + + @ColumnInfo(name = ExplorerDatabase.COLUMN_SORT_TYPE) + public final int type; + + public Sort(@NonNull String path, int type) { + this.path = path; + this.type = type; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/database/models/explorer/Tab.java b/app/src/main/java/com/amaze/filemanager/database/models/explorer/Tab.java new file mode 100644 index 0000000..b824a0c --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/models/explorer/Tab.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database.models.explorer; + +import com.amaze.filemanager.database.ExplorerDatabase; +import com.amaze.filemanager.filesystem.files.FileUtils; + +import android.content.SharedPreferences; + +import androidx.room.ColumnInfo; +import androidx.room.Entity; +import androidx.room.PrimaryKey; + +/** Created by Vishal on 9/17/2014 */ +@Entity(tableName = ExplorerDatabase.TABLE_TAB) +public class Tab { + + @PrimaryKey + @ColumnInfo(name = ExplorerDatabase.COLUMN_TAB_NO) + public final int tabNumber; + + @ColumnInfo(name = ExplorerDatabase.COLUMN_PATH) + public final String path; + + @ColumnInfo(name = ExplorerDatabase.COLUMN_HOME) + public final String home; + + public Tab(int tabNumber, String path, String home) { + this.tabNumber = tabNumber; + this.path = path; + this.home = home; + } + + public String getOriginalPath(boolean savePaths, SharedPreferences sharedPreferences) { + if (savePaths && FileUtils.isPathAccessible(path, sharedPreferences)) { + return path; + } else { + return home; + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/database/models/utilities/Bookmark.java b/app/src/main/java/com/amaze/filemanager/database/models/utilities/Bookmark.java new file mode 100644 index 0000000..c2ab2f7 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/models/utilities/Bookmark.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database.models.utilities; + +import static com.amaze.filemanager.database.UtilitiesDatabase.COLUMN_NAME; +import static com.amaze.filemanager.database.UtilitiesDatabase.COLUMN_PATH; +import static com.amaze.filemanager.database.UtilitiesDatabase.TABLE_BOOKMARKS; + +import com.amaze.filemanager.database.UtilitiesDatabase; + +import androidx.room.Entity; +import androidx.room.Index; + +/** + * {@link Entity} representation of bookmark table in utilities.db. + * + * @see UtilitiesDatabase + */ +@Entity( + tableName = TABLE_BOOKMARKS, + indices = { + @Index( + name = TABLE_BOOKMARKS + "_idx", + value = {COLUMN_NAME, COLUMN_PATH}, + unique = true) + }) +public class Bookmark extends OperationDataWithName { + public Bookmark(String name, String path) { + super(name, path); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/database/models/utilities/Grid.java b/app/src/main/java/com/amaze/filemanager/database/models/utilities/Grid.java new file mode 100644 index 0000000..dddfb2f --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/models/utilities/Grid.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database.models.utilities; + +import com.amaze.filemanager.database.UtilitiesDatabase; + +import androidx.room.Entity; + +/** + * {@link Entity} representation of grid table in utilities.db. + * + * @see UtilitiesDatabase + */ +@Entity(tableName = UtilitiesDatabase.TABLE_GRID) +public class Grid extends OperationData { + + public Grid(String path) { + super(path); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/database/models/utilities/Hidden.java b/app/src/main/java/com/amaze/filemanager/database/models/utilities/Hidden.java new file mode 100644 index 0000000..4ae6ebb --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/models/utilities/Hidden.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database.models.utilities; + +import com.amaze.filemanager.database.UtilitiesDatabase; + +import androidx.room.Entity; + +/** + * {@link Entity} representation of hidden table in utilities.db. + * + * @see UtilitiesDatabase + */ +@Entity(tableName = UtilitiesDatabase.TABLE_HIDDEN) +public class Hidden extends OperationData { + + public Hidden(String path) { + super(path); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/database/models/utilities/History.java b/app/src/main/java/com/amaze/filemanager/database/models/utilities/History.java new file mode 100644 index 0000000..14fb5bb --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/models/utilities/History.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database.models.utilities; + +import com.amaze.filemanager.database.UtilitiesDatabase; + +import androidx.room.Entity; + +/** + * {@link Entity} representation of history table in utilities.db. + * + * @see UtilitiesDatabase + */ +@Entity(tableName = UtilitiesDatabase.TABLE_HISTORY) +public class History extends OperationData { + + public History(String path) { + super(path); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/database/models/utilities/List.java b/app/src/main/java/com/amaze/filemanager/database/models/utilities/List.java new file mode 100644 index 0000000..0215527 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/models/utilities/List.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database.models.utilities; + +import com.amaze.filemanager.database.UtilitiesDatabase; + +import androidx.room.Entity; + +/** + * {@link Entity} representation of list table in utilities.db. + * + * @see UtilitiesDatabase + */ +@Entity(tableName = UtilitiesDatabase.TABLE_LIST) +public class List extends OperationData { + + public List(String path) { + super(path); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/database/models/utilities/OperationData.java b/app/src/main/java/com/amaze/filemanager/database/models/utilities/OperationData.java new file mode 100644 index 0000000..dee0051 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/models/utilities/OperationData.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database.models.utilities; + +import com.amaze.filemanager.database.UtilitiesDatabase; + +import androidx.annotation.NonNull; +import androidx.room.ColumnInfo; +import androidx.room.Entity; +import androidx.room.PrimaryKey; + +/** + * Base class {@link Entity} representation of tables in utilities.db. + * + *

This class is the base classwith id, path columns common to all + * tables. + * + * @see UtilitiesDatabase + */ +public abstract class OperationData { + + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = UtilitiesDatabase.COLUMN_ID) + public int _id; + + @ColumnInfo(name = UtilitiesDatabase.COLUMN_PATH) + public String path; + + public OperationData(@NonNull String path) { + this.path = path; + } + + @NonNull + @Override + public String toString() { + return new StringBuilder("OperationData type=[") + .append(getClass().getSimpleName()) + .append("],path=[") + .append(path) + .append("]") + .toString(); + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + + OperationData that = (OperationData) o; + return path.equals(that.path); + } + + @Override + public int hashCode() { + int result = getClass().getSimpleName().hashCode(); + result = 31 * result + path.hashCode(); + return result; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/database/models/utilities/OperationDataWithName.java b/app/src/main/java/com/amaze/filemanager/database/models/utilities/OperationDataWithName.java new file mode 100644 index 0000000..a39390c --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/models/utilities/OperationDataWithName.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database.models.utilities; + +import com.amaze.filemanager.database.UtilitiesDatabase; + +import androidx.annotation.NonNull; +import androidx.room.ColumnInfo; +import androidx.room.Entity; + +/** + * Base class {@link Entity} representation of tables in utilities.db. + * + *

This class is the base class extending {@link OperationData} adding the name + * column. + * + * @see OperationData + * @see UtilitiesDatabase + */ +public abstract class OperationDataWithName extends OperationData { + + @ColumnInfo(name = UtilitiesDatabase.COLUMN_NAME) + public String name; + + public OperationDataWithName(@NonNull String name, @NonNull String path) { + super(path); + this.name = name; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + + OperationDataWithName that = (OperationDataWithName) o; + return name.equals(that.name); + } + + @Override + public int hashCode() { + int result = super.hashCode(); + result = 31 * result + name.hashCode(); + return result; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/database/models/utilities/SftpEntry.java b/app/src/main/java/com/amaze/filemanager/database/models/utilities/SftpEntry.java new file mode 100644 index 0000000..d6d2e33 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/models/utilities/SftpEntry.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database.models.utilities; + +import com.amaze.filemanager.database.UtilitiesDatabase; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.room.ColumnInfo; +import androidx.room.Entity; + +/** + * {@link Entity} representation of sftp table in utilities.db. + * + * @see UtilitiesDatabase + */ +@Entity(tableName = UtilitiesDatabase.TABLE_SFTP) +public class SftpEntry extends OperationDataWithName { + + @ColumnInfo(name = UtilitiesDatabase.COLUMN_HOST_PUBKEY) + public String hostKey; + + @ColumnInfo(name = UtilitiesDatabase.COLUMN_PRIVATE_KEY_NAME) + public String sshKeyName; + + @ColumnInfo(name = UtilitiesDatabase.COLUMN_PRIVATE_KEY) + public String sshKey; + + public SftpEntry(String path, String name, String hostKey, String sshKeyName, String sshKey) { + super(name, path); + this.hostKey = hostKey; + this.sshKeyName = sshKeyName; + this.sshKey = sshKey; + } + + @NonNull + @Override + public String toString() { + StringBuilder sb = new StringBuilder(super.toString()); + + if (!TextUtils.isEmpty(hostKey)) sb.append(",hostKey=[").append(hostKey).append(']'); + + if (!TextUtils.isEmpty(sshKeyName)) + sb.append(",sshKeyName=[").append(sshKeyName).append("],sshKey=[redacted]"); + + return sb.toString(); + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + + SftpEntry sftpEntry = (SftpEntry) o; + + if (!hostKey.equals(sftpEntry.hostKey)) return false; + return (sshKey != null && sshKey.equals(sftpEntry.sshKey)) + || sshKey == null && sftpEntry.sshKey == null; + } + + @Override + public int hashCode() { + int result = super.hashCode(); + result = 31 * result + hostKey.hashCode(); + if (sshKey != null) { + result = 31 * result + sshKey.hashCode(); + } + return result; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/database/models/utilities/SmbEntry.java b/app/src/main/java/com/amaze/filemanager/database/models/utilities/SmbEntry.java new file mode 100644 index 0000000..3910444 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/models/utilities/SmbEntry.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database.models.utilities; + +import com.amaze.filemanager.database.UtilitiesDatabase; + +import androidx.room.Entity; + +/** + * {@link Entity} representation of smb table in utilities.db. + * + * @see UtilitiesDatabase + */ +@Entity(tableName = UtilitiesDatabase.TABLE_SMB) +public class SmbEntry extends OperationDataWithName { + + public SmbEntry(String name, String path) { + super(name, path); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/database/typeconverters/EncryptedStringTypeConverter.kt b/app/src/main/java/com/amaze/filemanager/database/typeconverters/EncryptedStringTypeConverter.kt new file mode 100644 index 0000000..9d9a197 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/typeconverters/EncryptedStringTypeConverter.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database.typeconverters + +import android.util.Log +import androidx.room.TypeConverter +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.database.models.StringWrapper +import com.amaze.filemanager.utils.PasswordUtil +import com.amaze.filemanager.utils.PasswordUtil.decryptPassword +import com.amaze.filemanager.utils.PasswordUtil.encryptPassword + +/** + * [TypeConverter] for password strings encrypted by [PasswordUtil]. + * + * @see StringWrapper + * + * @see PasswordUtil.encryptPassword + * @see PasswordUtil.decryptPassword + */ +object EncryptedStringTypeConverter { + @JvmStatic + private val TAG = EncryptedStringTypeConverter::class.java.simpleName + + /** + * Converts value in database to string. + */ + @JvmStatic + @TypeConverter + fun toPassword(encryptedStringEntryInDb: String): StringWrapper { + return runCatching { + StringWrapper( + decryptPassword(AppConfig.getInstance(), encryptedStringEntryInDb), + ) + }.onFailure { + Log.e(TAG, "Error decrypting password", it) + }.getOrElse { + StringWrapper(encryptedStringEntryInDb) + } + } + + /** + * Encrypt given password in plaintext for storage in database. + */ + @JvmStatic + @TypeConverter + fun fromPassword(unencryptedPasswordString: StringWrapper): String? { + return runCatching { + encryptPassword( + AppConfig.getInstance(), + unencryptedPasswordString.value, + ) + }.onFailure { + Log.e(TAG, "Error encrypting password", it) + }.getOrElse { + unencryptedPasswordString.value + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/database/typeconverters/OpenModeTypeConverter.kt b/app/src/main/java/com/amaze/filemanager/database/typeconverters/OpenModeTypeConverter.kt new file mode 100644 index 0000000..c1ca6e5 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/typeconverters/OpenModeTypeConverter.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.database.typeconverters + +import androidx.room.TypeConverter +import com.amaze.filemanager.fileoperations.filesystem.OpenMode + +/** [TypeConverter] for [OpenMode] objects to database columns. */ +object OpenModeTypeConverter { + /** + * Convert given [OpenMode] to integer constant for database storage. + */ + @JvmStatic + @TypeConverter + fun fromOpenMode(from: OpenMode): Int { + return from.ordinal + } + + /** + * Convert value in database to [OpenMode]. + */ + @JvmStatic + @TypeConverter + fun fromDatabaseValue(from: Int): OpenMode { + return OpenMode.getOpenMode(from) + } +} diff --git a/app/src/main/java/com/amaze/filemanager/exceptions/DocumentFileNotFoundException.kt b/app/src/main/java/com/amaze/filemanager/exceptions/DocumentFileNotFoundException.kt new file mode 100644 index 0000000..ac0b5fc --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/exceptions/DocumentFileNotFoundException.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.exceptions + +import android.net.Uri + +class DocumentFileNotFoundException(rootUri: Uri, path: String) : + RuntimeException("Root uri: %s and path %s".format(rootUri.path, path)) diff --git a/app/src/main/java/com/amaze/filemanager/exceptions/NotAllowedException.kt b/app/src/main/java/com/amaze/filemanager/exceptions/NotAllowedException.kt new file mode 100644 index 0000000..f2f84c0 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/exceptions/NotAllowedException.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.exceptions + +class NotAllowedException : Exception() diff --git a/app/src/main/java/com/amaze/filemanager/exceptions/OperationWouldOverwriteException.kt b/app/src/main/java/com/amaze/filemanager/exceptions/OperationWouldOverwriteException.kt new file mode 100644 index 0000000..59efc04 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/exceptions/OperationWouldOverwriteException.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.exceptions + +class OperationWouldOverwriteException : Exception() diff --git a/app/src/main/java/com/amaze/filemanager/exceptions/ShellCommandInvalidException.kt b/app/src/main/java/com/amaze/filemanager/exceptions/ShellCommandInvalidException.kt new file mode 100644 index 0000000..6f62655 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/exceptions/ShellCommandInvalidException.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.exceptions + +class ShellCommandInvalidException(override val message: String?) : Exception() diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/CustomFileObserver.java b/app/src/main/java/com/amaze/filemanager/filesystem/CustomFileObserver.java new file mode 100644 index 0000000..4792e62 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/CustomFileObserver.java @@ -0,0 +1,234 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import android.os.Build; +import android.os.FileObserver; +import android.os.Handler; + +import androidx.annotation.RequiresApi; + +/** + * Class which monitors any change in local filesystem and updates the adapter Makes use of inotify + * in Linux + */ +public class CustomFileObserver extends FileObserver { + + /** Values for what of Handler Message */ + public static final int GOBACK = -1, NEW_ITEM = 0, DELETED_ITEM = 1; + + /** + * When the bserver stops observing this event is recieved Check: + * http://rswiki.csie.org/lxr/http/source/include/linux/inotify.h?a=m68k#L45 + */ + private static final int IN_IGNORED = 0x00008000; + + private static final int DEFER_CONSTANT_SECONDS = 5; + private static final int DEFER_CONSTANT = DEFER_CONSTANT_SECONDS * 1000; + private static final int MASK = CREATE | MOVED_TO | DELETE | MOVED_FROM | DELETE_SELF | MOVE_SELF; + + private long lastMessagedTime = 0L; + private boolean messagingScheduled = false; + private boolean wasStopped = false; + + private Handler handler; + private String path; + private final List pathsAdded = Collections.synchronizedList(new ArrayList<>()); + private final List pathsRemoved = Collections.synchronizedList(new ArrayList<>()); + + public CustomFileObserver(String path, Handler handler) { + super(path, MASK); + this.path = path; + this.handler = handler; + } + + public boolean wasStopped() { + return wasStopped; + } + + public String getPath() { + return path; + } + + @Override + public void startWatching() { + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.M) { + startPollingSystem(); + } else { + super.startWatching(); + } + } + + @Override + public void stopWatching() { + wasStopped = true; + + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.M) { + stopPollingSystem(); + } else { + super.stopWatching(); + } + } + + @Override + public void onEvent(int event, String path) { + if (event == IN_IGNORED) { + wasStopped = true; + return; + } + + long deltaTime = Calendar.getInstance().getTimeInMillis() - lastMessagedTime; + + switch (event) { + case CREATE: + case MOVED_TO: + pathsAdded.add(path); + break; + case DELETE: + case MOVED_FROM: + pathsRemoved.add(path); + break; + case DELETE_SELF: + case MOVE_SELF: + handler.obtainMessage(GOBACK).sendToTarget(); + return; + } + + if (deltaTime <= DEFER_CONSTANT) { + // defer the observer until unless it reports a change after at least 5 secs of last one + // keep adding files added, if there were any, to the buffer + + new Timer() + .schedule( + new TimerTask() { + @Override + public void run() { + + if (messagingScheduled) return; + sendMessages(); + } + }, + DEFER_CONSTANT - deltaTime); + + messagingScheduled = true; + } else { + if (messagingScheduled) return; + sendMessages(); + } + } + + private synchronized void sendMessages() { + lastMessagedTime = Calendar.getInstance().getTimeInMillis(); + + synchronized (pathsAdded) { + for (String pathAdded : pathsAdded) { + handler.obtainMessage(NEW_ITEM, pathAdded).sendToTarget(); + } + } + pathsAdded.clear(); + + synchronized (pathsRemoved) { + for (String pathRemoved : pathsRemoved) { + handler.obtainMessage(DELETED_ITEM, pathRemoved).sendToTarget(); + } + } + pathsRemoved.clear(); + messagingScheduled = false; + } + + private ScheduledExecutorService executor = null; + + /** + * In Marshmallow FileObserver is broken, this hack will let you know of changes to a directory + * every DEFER_CONSTANT_SECONDS seconds, calling onEvent as expected EXCEPT when moving, in such + * cases the event will be creation (if moved into) or deletion (if moved out of) or DELETE_SELF + * instead of MOVE_SELF. + */ + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + private void startPollingSystem() { + executor = Executors.newScheduledThreadPool(1); + executor.scheduleWithFixedDelay( + new FileTimerTask(path, this), + DEFER_CONSTANT_SECONDS, + DEFER_CONSTANT_SECONDS, + TimeUnit.SECONDS); // This doesn't work with milliseconds (don't know why) + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + private void stopPollingSystem() { + executor.shutdown(); + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + private static class FileTimerTask implements Runnable { + private FileObserver fileObserver; + private String[] files = null; + private File file; + + private FileTimerTask(String path, FileObserver fileObserver) { + file = new File(path); + if (!file.isDirectory()) + throw new IllegalArgumentException("Illegal path, you can only watch directories!"); + files = file.list(); + this.fileObserver = fileObserver; + } + + @Override + public void run() { + if (!file.exists()) { + fileObserver.onEvent(DELETE_SELF, null); + return; + } + if (!file.canRead() || !file.isHidden()) { + fileObserver.onEvent(IN_IGNORED, null); + return; + } + + String[] newFiles = file.list(); + for (String s : compare(newFiles, files)) { + fileObserver.onEvent(CREATE, s); + } + for (String s : compare(files, newFiles)) { + fileObserver.onEvent(DELETE, s); + } + } + + private HashSet compare(String[] s1, String[] s2) { + HashSet set1 = new HashSet<>(Arrays.asList(s1)); + HashSet set2 = new HashSet<>(Arrays.asList(s2)); + set1.removeAll(set2); + return set1; + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/DeleteOperation.kt b/app/src/main/java/com/amaze/filemanager/filesystem/DeleteOperation.kt new file mode 100644 index 0000000..c295a42 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/DeleteOperation.kt @@ -0,0 +1,124 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem + +import android.content.ContentValues +import android.content.Context +import android.os.Build +import android.provider.MediaStore +import android.util.Log +import java.io.File + +object DeleteOperation { + private val LOG = "DeleteFileOperation" + + /** + * Delete a folder. + * + * @param file The folder name. + * @return true if successful. + */ + @JvmStatic + private fun rmdir( + file: File, + context: Context, + ): Boolean { + if (!file.exists()) return true + val files = file.listFiles() + if (files != null && files.size > 0) { + for (child in files) { + rmdir(child, context) + } + } + + // Try the normal way + if (file.delete()) { + return true + } + + // Try with Storage Access Framework. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + val document = ExternalSdCardOperation.getDocumentFile(file, true, context) + if (document != null && document.delete()) { + return true + } + } + + // Try the Kitkat workaround. + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT) { + val resolver = context.contentResolver + val values = ContentValues() + values.put(MediaStore.MediaColumns.DATA, file.absolutePath) + resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) + + // Delete the created entry, such that content provider will delete the file. + resolver.delete( + MediaStore.Files.getContentUri("external"), + MediaStore.MediaColumns.DATA + "=?", + arrayOf(file.absolutePath), + ) + } + return !file.exists() + } + + /** + * Delete a file. May be even on external SD card. + * + * @param file the file to be deleted. + * @return True if successfully deleted. + */ + @JvmStatic + fun deleteFile( + file: File, + context: Context, + ): Boolean { + // First try the normal deletion. + val fileDelete = rmdir(file, context) + if (file.delete() || fileDelete) return true + + // Try with Storage Access Framework. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && + ExternalSdCardOperation.isOnExtSdCard(file, context) + ) { + val document = ExternalSdCardOperation.getDocumentFile(file, false, context) + document ?: return true + return document.delete() + } + + // Try the Kitkat workaround. + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT) { + val resolver = context.contentResolver + return try { + val uri = MediaStoreHack.getUriFromFile(file.absolutePath, context) + if (uri == null) { + false + } else { + resolver.delete(uri, null, null) + !file.exists() + } + } catch (e: SecurityException) { + Log.e(LOG, "Security exception when checking for file " + file.absolutePath, e) + false + } + } + return !file.exists() + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/EditableFileAbstraction.java b/app/src/main/java/com/amaze/filemanager/filesystem/EditableFileAbstraction.java new file mode 100644 index 0000000..ef4b95b --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/EditableFileAbstraction.java @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem; + +import static com.amaze.filemanager.filesystem.EditableFileAbstraction.Scheme.CONTENT; +import static com.amaze.filemanager.filesystem.EditableFileAbstraction.Scheme.FILE; + +import com.amaze.filemanager.utils.Utils; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.OpenableColumns; + +import androidx.annotation.NonNull; + +/** + * This is a special representation of a file that is to be used so that uris can be loaded as + * editable files. + */ +public class EditableFileAbstraction { + + public enum Scheme { + CONTENT, + FILE + } + + public final Uri uri; + public final String name; + public final Scheme scheme; + public final HybridFileParcelable hybridFileParcelable; + + public EditableFileAbstraction(@NonNull Context context, @NonNull Uri uri) { + switch (uri.getScheme()) { + case ContentResolver.SCHEME_CONTENT: + this.uri = uri; + this.scheme = CONTENT; + + String tempName = null; + Cursor c = + context + .getContentResolver() + .query(uri, new String[] {OpenableColumns.DISPLAY_NAME}, null, null, null); + + if (c != null) { + c.moveToFirst(); + try { + /* + The result and whether [Cursor.getString()] throws an exception when the column + value is null or the column type is not a string type is implementation-defined. + */ + tempName = c.getString(c.getColumnIndex(OpenableColumns.DISPLAY_NAME)); + } catch (Exception e) { + tempName = null; + } + c.close(); + } + + if (tempName == null) { + // At least we have something to show the user... + tempName = uri.getLastPathSegment(); + } + + this.name = tempName; + + this.hybridFileParcelable = null; + break; + case ContentResolver.SCHEME_FILE: + this.scheme = FILE; + + String path = uri.getPath(); + if (path == null) + throw new NullPointerException("Uri '" + uri.toString() + "' is not hierarchical!"); + path = Utils.sanitizeInput(path); + this.hybridFileParcelable = new HybridFileParcelable(path); + + String tempN = hybridFileParcelable.getName(context); + if (tempN == null) tempN = uri.getLastPathSegment(); + this.name = tempN; + + this.uri = null; + break; + default: + throw new IllegalArgumentException( + "The scheme '" + uri.getScheme() + "' cannot be processed!"); + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ExternalSdCardOperation.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ExternalSdCardOperation.kt new file mode 100644 index 0000000..4872dfc --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/ExternalSdCardOperation.kt @@ -0,0 +1,202 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem + +import android.annotation.TargetApi +import android.content.Context +import android.net.Uri +import android.os.Build +import androidx.documentfile.provider.DocumentFile +import androidx.preference.PreferenceManager +import com.amaze.filemanager.database.UtilsHandler +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.File +import java.io.IOException + +object ExternalSdCardOperation { + private val log: Logger = LoggerFactory.getLogger(UtilsHandler::class.java) + + /** + * Get a DocumentFile corresponding to the given file (for writing on ExtSdCard on Android 5). If + * the file is not existing, it is created. + * + * @param file The file. + * @param isDirectory flag indicating if the file should be a directory. + * @return The DocumentFile + */ + @JvmStatic + fun getDocumentFile( + file: File, + isDirectory: Boolean, + context: Context, + ): DocumentFile? { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) return DocumentFile.fromFile(file) + val baseFolder = getExtSdCardFolder(file, context) + var originalDirectory = false + if (baseFolder == null) { + return null + } + var relativePath: String? = null + try { + val fullPath = file.canonicalPath + if (baseFolder != fullPath) { + relativePath = fullPath.substring(baseFolder.length + 1) + } else { + originalDirectory = true + } + } catch (e: IOException) { + return null + } + + val preferenceUri = + PreferenceManager.getDefaultSharedPreferences(context) + .getString(PreferencesConstants.PREFERENCE_URI, null) + var treeUri: Uri? = null + if (preferenceUri != null) { + treeUri = Uri.parse(preferenceUri) + } + if (treeUri == null) { + return null + } + + // start with root of SD card and then parse through document tree. + var document = DocumentFile.fromTreeUri(context, treeUri) + if (originalDirectory || relativePath == null) { + return document + } + + val parts = relativePath.split("/").toTypedArray() + for (i in parts.indices) { + if (document == null) { + return null + } + + var nextDocument = document.findFile(parts[i]) + if (nextDocument == null) { + nextDocument = + if (i < parts.size - 1 || isDirectory) { + document.createDirectory(parts[i]) + } else { + document.createFile("image", parts[i]) + } + } + document = nextDocument + } + + return document + } + + /** + * Get a list of external SD card paths. (Kitkat or higher.) + * + * @return A list of external SD card paths. + */ + @JvmStatic + @TargetApi(Build.VERSION_CODES.KITKAT) + private fun getExtSdCardPaths(context: Context): Array { + val paths: MutableList = ArrayList() + for (file in context.getExternalFilesDirs("external")) { + if (file != null && file != context.getExternalFilesDir("external")) { + val index = file.absolutePath.lastIndexOf("/Android/data") + if (index < 0) { + log.warn("Unexpected external file dir: " + file.absolutePath) + } else { + var path = file.absolutePath.substring(0, index) + try { + path = File(path).canonicalPath + } catch (e: IOException) { + // Keep non-canonical path. + } + paths.add(path) + } + } + } + if (paths.isEmpty()) paths.add("/storage/sdcard1") + return paths.toTypedArray() + } + + @JvmStatic + @TargetApi(Build.VERSION_CODES.KITKAT) + fun getExtSdCardPathsForActivity(context: Context): Array { + val paths: MutableList = ArrayList() + for (file in context.getExternalFilesDirs("external")) { + if (file != null) { + val index = file.absolutePath.lastIndexOf("/Android/data") + if (index < 0) { + log.warn("Unexpected external file dir: " + file.absolutePath) + } else { + var path = file.absolutePath.substring(0, index) + try { + path = File(path).canonicalPath + } catch (e: IOException) { + // Keep non-canonical path. + } + paths.add(path) + } + } + } + if (paths.isEmpty()) paths.add("/storage/sdcard1") + return paths.toTypedArray() + } + + /** + * Determine the main folder of the external SD card containing the given file. + * + * @param file the file. + * @return The main folder of the external SD card containing this file, if the file is on an SD + * card. Otherwise, null is returned. + */ + @JvmStatic + @TargetApi(Build.VERSION_CODES.KITKAT) + public fun getExtSdCardFolder( + file: File, + context: Context, + ): String? { + val extSdPaths = getExtSdCardPaths(context) + try { + for (i in extSdPaths.indices) { + if (file.canonicalPath.startsWith(extSdPaths[i])) { + return extSdPaths[i] + } + } + } catch (e: IOException) { + return null + } + return null + } + + /** + * Determine if a file is on external sd card. (Kitkat or higher.) + * + * @param file The file. + * @return true if on external sd card. + */ + @JvmStatic + @TargetApi(Build.VERSION_CODES.KITKAT) + fun isOnExtSdCard( + file: File, + c: Context, + ): Boolean { + return getExtSdCardFolder(file, c) != null + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/FileProperties.kt b/app/src/main/java/com/amaze/filemanager/filesystem/FileProperties.kt new file mode 100644 index 0000000..2fbab94 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/FileProperties.kt @@ -0,0 +1,301 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem + +import android.app.usage.StorageStatsManager +import android.content.ContentResolver.SCHEME_CONTENT +import android.content.Context +import android.net.Uri +import android.os.Build +import android.os.Build.VERSION_CODES.O +import android.os.Environment +import android.os.storage.StorageManager +import android.provider.DocumentsContract +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.database.CloudHandler +import com.amaze.filemanager.filesystem.DeleteOperation.deleteFile +import com.amaze.filemanager.filesystem.ExternalSdCardOperation.isOnExtSdCard +import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool +import com.amaze.filemanager.filesystem.smb.CifsContexts +import com.amaze.filemanager.utils.OTGUtil +import com.amaze.filemanager.utils.containsPath +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.File +import java.io.FileNotFoundException +import java.io.FileOutputStream +import java.io.IOException +import java.util.regex.Pattern + +// TODO check if these can be done with just File methods +// TODO make all of these methods File extensions +object FileProperties { + private val log: Logger = LoggerFactory.getLogger(FileProperties::class.java) + + private const val STORAGE_PRIMARY = "primary" + private const val COM_ANDROID_EXTERNALSTORAGE_DOCUMENTS = + "com.android.externalstorage.documents" + + @JvmField + val ANDROID_DATA_DIRS = + arrayOf( + "Android/data", + "Android/obb", + ) + + @JvmField + val ANDROID_DEVICE_DATA_DIRS = + ANDROID_DATA_DIRS.map { + File(Environment.getExternalStorageDirectory(), it).absolutePath + } + + /** + * Check if a file is readable. + * + * @param file The file + * @return true if the file is reabable. + */ + @JvmStatic + fun isReadable(file: File?): Boolean { + if (file == null) return false + if (!file.exists()) return false + return try { + file.canRead() + } catch (e: SecurityException) { + return false + } + } + + /** + * Check if a file is writable. Detects write issues on external SD card. + * + * @param file The file + * @return true if the file is writable. + */ + @JvmStatic + fun isWritable(file: File?): Boolean { + if (file == null) return false + val isExisting = file.exists() + try { + val output = FileOutputStream(file, true) + try { + output.close() + } catch (e: IOException) { + log.warn("failed to check if file is writable", e) + // do nothing. + } + } catch (e: FileNotFoundException) { + log.warn("failed to check if file is writable as file not available", e) + return false + } + val result = file.canWrite() + + // Ensure that file is not created during this process. + if (!isExisting) { + file.delete() + } + return result + } + + /** + * Check for a directory if it is possible to create files within this directory, either via + * normal writing or via Storage Access Framework. + * + * @param folder The directory + * @return true if it is possible to write in this directory. + */ + @JvmStatic + fun isWritableNormalOrSaf( + folder: File?, + c: Context, + ): Boolean { + if (folder == null) { + return false + } + + // Verify that this is a directory. + if (!folder.exists() || !folder.isDirectory) { + return false + } + + // Find a non-existing file in this directory. + var i = 0 + var file: File + do { + val fileName = "AugendiagnoseDummyFile" + ++i + file = File(folder, fileName) + } while (file.exists()) + + // First check regular writability + if (isWritable(file)) { + return true + } + + // Next check SAF writability. + val document = ExternalSdCardOperation.getDocumentFile(file, false, c) + document ?: return false + + // This should have created the file - otherwise something is wrong with access URL. + val result = document.canWrite() && file.exists() + + // Ensure that the dummy file is not remaining. + deleteFile(file, c) + return result + } + + // Utility methods for Kitkat + + /** + * Checks whether the target path exists or is writable + * + * @param f the target path + * @return 1 if exists or writable, 0 if not writable + */ + @JvmStatic + fun checkFolder( + f: String?, + context: Context, + ): Int { + if (f == null) return 0 + if (f.startsWith(CifsContexts.SMB_URI_PREFIX) || + f.startsWith(NetCopyClientConnectionPool.SSH_URI_PREFIX) || + f.startsWith(NetCopyClientConnectionPool.FTP_URI_PREFIX) || + f.startsWith(NetCopyClientConnectionPool.FTPS_URI_PREFIX) || + f.startsWith(OTGUtil.PREFIX_OTG) || + f.startsWith(CloudHandler.CLOUD_PREFIX_BOX) || + f.startsWith(CloudHandler.CLOUD_PREFIX_GOOGLE_DRIVE) || + f.startsWith(CloudHandler.CLOUD_PREFIX_DROPBOX) || + f.startsWith(CloudHandler.CLOUD_PREFIX_ONE_DRIVE) || + f.startsWith("content://") + ) { + return 1 + } + val folder = File(f) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && + isOnExtSdCard(folder, context) + ) { + if (!folder.exists() || !folder.isDirectory) { + return 0 + } + + // On Android 5, trigger storage access framework. + if (isWritableNormalOrSaf(folder, context)) { + return 1 + } + } else { + return if (Build.VERSION.SDK_INT == 19 && + isOnExtSdCard(folder, context) + ) { + // Assume that Kitkat workaround works + 1 + } else if (folder.canWrite()) { + 1 + } else { + 0 + } + } + return 0 + } + + /** + * Validate given text is a valid filename. + * + * @param text + * @return true if given text is a valid filename + */ + @JvmStatic + fun isValidFilename(text: String): Boolean { + val filenameRegex = + Pattern.compile("[\\\\\\/:\\*\\?\"<>\\|\\x01-\\x1F\\x7F]", Pattern.CASE_INSENSITIVE) + + // It's not easy to use regex to detect single/double dot while leaving valid values + // (filename.zip) behind... + // So we simply use equality to check them + return !filenameRegex.matcher(text).find() && "." != text && ".." != text + } + + @JvmStatic + fun unmapPathForApi30OrAbove(uriPath: String): String? { + return if (uriPath.startsWith(SCHEME_CONTENT)) { + val uri = Uri.parse(uriPath) + return uri.path?.let { p -> + File( + Environment.getExternalStorageDirectory(), + p.substringAfter("tree/primary:"), + ).absolutePath + } + } else { + uriPath + } + } + + /** + * Remap file path + * @param path file path + * @param openDocumentTree open document tree default false + * @return remapped file path + */ + @JvmStatic + fun remapPathForApi30OrAbove( + path: String, + openDocumentTree: Boolean = false, + ): String { + return if (ANDROID_DEVICE_DATA_DIRS.containsPath(path)) { + path + } else if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q && + ANDROID_DEVICE_DATA_DIRS.any { + path.startsWith(it) && path != it + } + ) { + val suffix = + path.substringAfter(Environment.getExternalStorageDirectory().absolutePath) + val documentId = "$STORAGE_PRIMARY:${suffix.substring(1)}" + SafRootHolder.volumeLabel = STORAGE_PRIMARY + if (openDocumentTree) { + DocumentsContract.buildDocumentUri( + COM_ANDROID_EXTERNALSTORAGE_DOCUMENTS, + documentId, + ).toString() + } else { + DocumentsContract.buildTreeDocumentUri( + COM_ANDROID_EXTERNALSTORAGE_DOCUMENTS, + documentId, + ).toString() + } + } else { + path + } + } + + @JvmStatic + fun getDeviceStorageRemainingSpace(volume: String = STORAGE_PRIMARY): Long { + return if (STORAGE_PRIMARY.equals(volume)) { + if (Build.VERSION.SDK_INT < O) { + Environment.getExternalStorageDirectory().freeSpace + } else { + AppConfig.getInstance().getSystemService(StorageStatsManager::class.java) + .getFreeBytes(StorageManager.UUID_DEFAULT) + } + } else { + 0L + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/FileUtil.java b/app/src/main/java/com/amaze/filemanager/filesystem/FileUtil.java new file mode 100644 index 0000000..0c55808 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/FileUtil.java @@ -0,0 +1,312 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.application.AppConfig; +import com.amaze.filemanager.exceptions.NotAllowedException; +import com.amaze.filemanager.exceptions.OperationWouldOverwriteException; +import com.amaze.filemanager.fileoperations.filesystem.OpenMode; +import com.amaze.filemanager.filesystem.cloud.CloudUtil; +import com.amaze.filemanager.filesystem.files.GenericCopyUtil; +import com.amaze.filemanager.ui.activities.MainActivity; +import com.amaze.filemanager.utils.DataUtils; +import com.amaze.filemanager.utils.OTGUtil; +import com.amaze.filemanager.utils.smb.SmbUtil; +import com.cloudrail.si.interfaces.CloudStorage; + +import android.content.ContentResolver; +import android.content.Context; +import android.media.MediaScannerConnection; +import android.net.Uri; +import android.os.Build; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.documentfile.provider.DocumentFile; + +import io.reactivex.Maybe; +import io.reactivex.MaybeObserver; +import io.reactivex.MaybeOnSubscribe; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; +import jcifs.smb.SmbFile; +import kotlin.NotImplementedError; + +/** Utility class for helping parsing file systems. */ +public abstract class FileUtil { + + private static final Logger LOG = LoggerFactory.getLogger(FileUtil.class); + + /** + * Determine the camera folder. There seems to be no Android API to work for real devices, so this + * is a best guess. + * + * @return the default camera folder. + */ + // TODO the function? + + @Nullable + public static OutputStream getOutputStream(final File target, Context context) + throws FileNotFoundException { + OutputStream outStream = null; + // First try the normal way + if (FileProperties.isWritable(target)) { + // standard way + outStream = new FileOutputStream(target); + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + // Storage Access Framework + DocumentFile targetDocument = + ExternalSdCardOperation.getDocumentFile(target, false, context); + if (targetDocument == null) return null; + outStream = context.getContentResolver().openOutputStream(targetDocument.getUri()); + } else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT) { + // Workaround for Kitkat ext SD card + return MediaStoreHack.getOutputStream(context, target.getPath()); + } + } + return outStream; + } + + /** Writes uri stream from external application to the specified path */ + public static final void writeUriToStorage( + @NonNull final MainActivity mainActivity, + @NonNull final List uris, + @NonNull final ContentResolver contentResolver, + @NonNull final String currentPath) { + + MaybeOnSubscribe> writeUri = + (MaybeOnSubscribe>) + emitter -> { + List retval = new ArrayList<>(); + + for (Uri uri : uris) { + + BufferedInputStream bufferedInputStream = null; + try { + bufferedInputStream = + new BufferedInputStream(contentResolver.openInputStream(uri)); + } catch (FileNotFoundException e) { + emitter.onError(e); + return; + } + + BufferedOutputStream bufferedOutputStream = null; + + try { + DocumentFile documentFile = DocumentFile.fromSingleUri(mainActivity, uri); + String filename = documentFile.getName(); + if (filename == null) { + filename = uri.getLastPathSegment(); + + // For cleaning up slashes. Back in #1217 there is a case of + // Uri.getLastPathSegment() end up with a full file path + if (filename.contains("/")) + filename = filename.substring(filename.lastIndexOf('/') + 1); + } + + String finalFilePath = currentPath + "/" + filename; + DataUtils dataUtils = DataUtils.getInstance(); + + HybridFile hFile = new HybridFile(OpenMode.UNKNOWN, currentPath); + hFile.generateMode(mainActivity); + + switch (hFile.getMode()) { + case FILE: + case ROOT: + File targetFile = new File(finalFilePath); + if (!FileProperties.isWritableNormalOrSaf( + targetFile.getParentFile(), mainActivity.getApplicationContext())) { + emitter.onError(new NotAllowedException()); + return; + } + + DocumentFile targetDocumentFile = + ExternalSdCardOperation.getDocumentFile( + targetFile, false, mainActivity.getApplicationContext()); + + // Fallback, in case getDocumentFile() didn't properly return a + // DocumentFile + // instance + if (targetDocumentFile == null) { + targetDocumentFile = DocumentFile.fromFile(targetFile); + } + + // Lazy check... and in fact, different apps may pass in URI in different + // formats, so we could only check filename matches + // FIXME?: Prompt overwrite instead of simply blocking + if (targetDocumentFile.exists() && targetDocumentFile.length() > 0) { + emitter.onError(new OperationWouldOverwriteException()); + return; + } + + bufferedOutputStream = + new BufferedOutputStream( + contentResolver.openOutputStream(targetDocumentFile.getUri())); + retval.add(targetFile.getPath()); + break; + case SMB: + SmbFile targetSmbFile = SmbUtil.create(finalFilePath); + if (targetSmbFile.exists()) { + emitter.onError(new OperationWouldOverwriteException()); + return; + } else { + OutputStream outputStream = targetSmbFile.getOutputStream(); + bufferedOutputStream = new BufferedOutputStream(outputStream); + retval.add(HybridFile.parseAndFormatUriForDisplay(targetSmbFile.getPath())); + } + break; + case SFTP: + // FIXME: implement support + AppConfig.toast(mainActivity, mainActivity.getString(R.string.not_allowed)); + emitter.onError(new NotImplementedError()); + return; + case DROPBOX: + case BOX: + case ONEDRIVE: + case GDRIVE: + OpenMode mode = hFile.getMode(); + + CloudStorage cloudStorage = dataUtils.getAccount(mode); + String path = CloudUtil.stripPath(mode, finalFilePath); + cloudStorage.upload(path, bufferedInputStream, documentFile.length(), true); + retval.add(path); + break; + case OTG: + DocumentFile documentTargetFile = + OTGUtil.getDocumentFile(finalFilePath, mainActivity, true); + + if (documentTargetFile.exists()) { + emitter.onError(new OperationWouldOverwriteException()); + return; + } + + bufferedOutputStream = + new BufferedOutputStream( + contentResolver.openOutputStream(documentTargetFile.getUri()), + GenericCopyUtil.DEFAULT_BUFFER_SIZE); + + retval.add(documentTargetFile.getUri().getPath()); + break; + default: + return; + } + + int count = 0; + byte[] buffer = new byte[GenericCopyUtil.DEFAULT_BUFFER_SIZE]; + + while (count != -1) { + count = bufferedInputStream.read(buffer); + if (count != -1) { + + bufferedOutputStream.write(buffer, 0, count); + } + } + bufferedOutputStream.flush(); + + } catch (IOException e) { + emitter.onError(e); + return; + } finally { + try { + if (bufferedInputStream != null) { + bufferedInputStream.close(); + } + if (bufferedOutputStream != null) { + bufferedOutputStream.close(); + } + } catch (IOException e) { + emitter.onError(e); + } + } + } + + if (retval.size() > 0) { + emitter.onSuccess(retval); + } else { + emitter.onError(new Exception()); + } + }; + + Maybe.create(writeUri) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + new MaybeObserver>() { + @Override + public void onSubscribe(@NonNull Disposable d) {} + + @Override + public void onSuccess(@NonNull List paths) { + MediaScannerConnection.scanFile( + mainActivity.getApplicationContext(), + paths.toArray(new String[0]), + new String[paths.size()], + null); + if (paths.size() == 1) { + Toast.makeText( + mainActivity, + mainActivity.getString(R.string.saved_single_file, paths.get(0)), + Toast.LENGTH_LONG) + .show(); + } else { + Toast.makeText( + mainActivity, + mainActivity.getString(R.string.saved_multi_files, paths.size()), + Toast.LENGTH_LONG) + .show(); + } + } + + @Override + public void onError(@NonNull Throwable e) { + if (e instanceof OperationWouldOverwriteException) { + AppConfig.toast(mainActivity, mainActivity.getString(R.string.cannot_overwrite)); + return; + } + if (e instanceof NotAllowedException) { + AppConfig.toast( + mainActivity, mainActivity.getResources().getString(R.string.not_allowed)); + } + LOG.warn("Failed to write uri to storage", e); + } + + @Override + public void onComplete() {} + }); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/FilenameHelper.kt b/app/src/main/java/com/amaze/filemanager/filesystem/FilenameHelper.kt new file mode 100644 index 0000000..bab18a8 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/FilenameHelper.kt @@ -0,0 +1,260 @@ +/* + * Copyright (C) 2014-2023 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem + +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.filesystem.ftp.NetCopyConnectionInfo.Companion.SLASH +import kotlin.math.absoluteValue + +/** + * Convenient extension to return path element of a path string = the part before the last slash. + */ +fun String.pathDirname(): String = + if (contains(SLASH)) { + substringBeforeLast(SLASH) + } else { + "" + } + +/** + * Convenient extension to return the name element of a path = the part after the last slash. + */ +fun String.pathBasename(): String = + if (contains(SLASH)) { + substringAfterLast(SLASH) + } else { + this + } + +/** + * Convenient extension to return the basename element of a filename = the part after the last + * slash and before the extension (.). + */ +fun String.pathFileBasename(): String = + if (contains('.')) { + pathBasename().substringBeforeLast('.') + } else { + pathBasename() + } + +/** + * Convenient extension to return the extension element of a filename = the part after the last + * slash and after the extension (.). Returns empty string if no extension dot exist. + */ +fun String.pathFileExtension(): String = + if (contains('.')) { + pathBasename().substringAfterLast('.') + } else { + "" + } + +enum class FilenameFormatFlag { + DARWIN, + DEFAULT, + WINDOWS, + LINUX, +} + +object FilenameHelper { + // Don't split complex regexs into multiple lines. + + private const val REGEX_RAW_NUMBERS = "| [0-9]+" + private const val REGEX_SOURCE = " \\((?:(another|[0-9]+(th|st|nd|rd)) )?copy\\)|copy( [0-9]+)?|\\.\\(incomplete\\)| \\([0-9]+\\)|[- ]+" + + private val ordinals = arrayOf("th", "st", "nd", "rd") + + /** + * Strip the file path to one without increments or numbers. + * + * Default will not strip the raw numbers; specify removeRawNumbers = true to do so. + */ + @JvmStatic + fun strip( + input: String, + removeRawNumbers: Boolean = false, + ): String { + val filepath = stripIncrementInternal(input, removeRawNumbers) + val extension = filepath.pathFileExtension() + val dirname = stripIncrementInternal(filepath.pathDirname(), removeRawNumbers) + val stem = stem(filepath, removeRawNumbers) + return StringBuilder().run { + if (dirname.isNotBlank()) { + append(dirname).append(SLASH) + } + append(stem) + if (extension.isNotBlank()) { + append('.').append(extension) + } + toString() + } + } + + /** + * Returns the ordinals of the given number. So that + * + * - toOrdinal(1) returns "1st" + * - toOrdinal(2) returns "2nd" + * - toOrdinal(10) returns "10th" + * - toOrdinal(11) returns "11th" + * - toOrdinal(12) returns "12th" + * - toOrdinal(21) returns "21st" + * - toOrdinal(22) returns "22nd" + * - toOrdinal(23) returns "23rd" + * + * etc. + */ + @JvmStatic + fun toOrdinal(n: Int): String = "$n${ordinal(n.absoluteValue)}" + + /** + * Increment the filename of a given [HybridFile]. + * + * Uses [HybridFile.exists] to check file existence and if it exists, returns a HybridFile + * with new filename which does not exist. + */ + @JvmStatic + fun increment( + file: HybridFile, + platform: FilenameFormatFlag = FilenameFormatFlag.DEFAULT, + strip: Boolean = true, + removeRawNumbers: Boolean = false, + startArg: Int = 1, + ): HybridFile { + var filename = file.getName(AppConfig.getInstance()) + var dirname = file.path.pathDirname() + var basename = filename.pathFileBasename() + val extension = filename.pathFileExtension() + + var start: Int = startArg + + if (strip) { + filename = stripIncrementInternal(filename, removeRawNumbers) + dirname = stripIncrementInternal(dirname, removeRawNumbers) + basename = strip(basename, removeRawNumbers) + } + + var retval = + HybridFile( + file.mode, + dirname, + filename, + file.isDirectory(AppConfig.getInstance()), + ) + + while (retval.exists(AppConfig.getInstance())) { + filename = + if (extension.isNotBlank()) { + format(platform, basename, start++) + ".$extension" + } else { + format(platform, basename, start++) + } + retval = + HybridFile( + file.mode, + dirname, + filename, + file.isDirectory(AppConfig.getInstance()), + ) + } + + return retval + } + + private fun stripIncrementInternal( + input: String, + removeRawNumbers: Boolean = false, + ): String { + val source = + StringBuilder().run { + append(REGEX_SOURCE) + if (removeRawNumbers) { + append(REGEX_RAW_NUMBERS) + } + toString() + } + return Regex("($source)+$", RegexOption.IGNORE_CASE).replace(input, "") + } + + private fun stem( + filepath: String, + removeRawNumbers: Boolean = false, + ): String { + val extension = filepath.pathFileExtension() + return stripIncrementInternal( + filepath.pathBasename().substringBefore(".$extension"), + removeRawNumbers, + ) + } + + private fun ordinal(n: Int): String { + var retval = ordinals.getOrNull(((n % 100) - 20) % 10) + if (retval == null) { + retval = ordinals.getOrNull(n % 100) + } + if (retval == null) { + retval = ordinals[0] + } + return retval + } + + // TODO: i18n + private fun format( + flag: FilenameFormatFlag, + stem: String, + n: Int, + ): String { + return when (flag) { + FilenameFormatFlag.DARWIN -> { + if (n == 1) { + "$stem copy" + } else if (n > 1) { + "$stem copy $n" + } else { + stem + } + } + FilenameFormatFlag.LINUX -> { + when (n) { + 0 -> { + stem + } + 1 -> { + "$stem (copy)" + } + 2 -> { + "$stem (another copy)" + } + else -> { + "$stem (${toOrdinal(n)} copy)" + } + } + } + // Windows and default formatting are the same. + else -> { + if (n >= 1) { + "$stem ($n)" + } else { + stem + } + } + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/HybridFile.java b/app/src/main/java/com/amaze/filemanager/filesystem/HybridFile.java new file mode 100644 index 0000000..37b01b0 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/HybridFile.java @@ -0,0 +1,1955 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem; + +import static com.amaze.filemanager.filesystem.FileProperties.ANDROID_DATA_DIRS; +import static com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.FTPS_URI_PREFIX; +import static com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.FTP_URI_PREFIX; +import static com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.SSH_URI_PREFIX; +import static com.amaze.filemanager.filesystem.ftp.NetCopyConnectionInfo.MULTI_SLASH; +import static com.amaze.filemanager.filesystem.smb.CifsContexts.SMB_URI_PREFIX; +import static com.amaze.filemanager.filesystem.ssh.SFTPClientExtKt.READ_AHEAD_MAX_UNCONFIRMED_READS; +import static com.amaze.filemanager.filesystem.ssh.SshClientUtils.sftpGetSize; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.net.MalformedURLException; +import java.net.URLDecoder; +import java.nio.file.Files; +import java.nio.file.attribute.FileTime; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.commons.net.ftp.FTP; +import org.apache.commons.net.ftp.FTPClient; +import org.apache.commons.net.ftp.FTPFile; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.afollestad.materialdialogs.MaterialDialog; +import com.amaze.filemanager.R; +import com.amaze.filemanager.adapters.data.LayoutElementParcelable; +import com.amaze.filemanager.application.AppConfig; +import com.amaze.filemanager.database.CloudHandler; +import com.amaze.filemanager.fileoperations.exceptions.CloudPluginException; +import com.amaze.filemanager.fileoperations.exceptions.ShellNotRunningException; +import com.amaze.filemanager.fileoperations.filesystem.OpenMode; +import com.amaze.filemanager.fileoperations.filesystem.root.NativeOperations; +import com.amaze.filemanager.filesystem.cloud.CloudUtil; +import com.amaze.filemanager.filesystem.files.FileUtils; +import com.amaze.filemanager.filesystem.files.GenericCopyUtil; +import com.amaze.filemanager.filesystem.files.MediaConnectionUtils; +import com.amaze.filemanager.filesystem.ftp.ExtensionsKt; +import com.amaze.filemanager.filesystem.ftp.FTPClientImpl; +import com.amaze.filemanager.filesystem.ftp.FtpClientTemplate; +import com.amaze.filemanager.filesystem.ftp.NetCopyClientUtils; +import com.amaze.filemanager.filesystem.ftp.NetCopyConnectionInfo; +import com.amaze.filemanager.filesystem.root.DeleteFileCommand; +import com.amaze.filemanager.filesystem.root.ListFilesCommand; +import com.amaze.filemanager.filesystem.ssh.SFTPClientExtKt; +import com.amaze.filemanager.filesystem.ssh.SFtpClientTemplate; +import com.amaze.filemanager.filesystem.ssh.SshClientSessionTemplate; +import com.amaze.filemanager.filesystem.ssh.SshClientUtils; +import com.amaze.filemanager.filesystem.ssh.Statvfs; +import com.amaze.filemanager.ui.activities.MainActivity; +import com.amaze.filemanager.ui.dialogs.GeneralDialogCreation; +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants; +import com.amaze.filemanager.utils.DataUtils; +import com.amaze.filemanager.utils.OTGUtil; +import com.amaze.filemanager.utils.OnFileFound; +import com.amaze.filemanager.utils.Utils; +import com.amaze.filemanager.utils.smb.SmbUtil; +import com.amaze.trashbin.TrashBin; +import com.amaze.trashbin.TrashBinFile; +import com.cloudrail.si.interfaces.CloudStorage; +import com.cloudrail.si.types.SpaceAllocation; + +import android.content.ContentResolver; +import android.content.Context; +import android.net.Uri; +import android.os.Build; +import android.text.TextUtils; +import android.text.format.Formatter; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.arch.core.util.Function; +import androidx.documentfile.provider.DocumentFile; +import androidx.preference.PreferenceManager; + +import io.reactivex.Single; +import io.reactivex.SingleObserver; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; +import jcifs.smb.SmbException; +import jcifs.smb.SmbFile; +import kotlin.collections.ArraysKt; +import kotlin.io.ByteStreamsKt; +import kotlin.text.Charsets; +import net.schmizz.sshj.SSHClient; +import net.schmizz.sshj.common.Buffer; +import net.schmizz.sshj.common.IOUtils; +import net.schmizz.sshj.connection.channel.direct.Session; +import net.schmizz.sshj.sftp.FileMode; +import net.schmizz.sshj.sftp.RemoteFile; +import net.schmizz.sshj.sftp.RemoteResourceInfo; +import net.schmizz.sshj.sftp.SFTPClient; +import net.schmizz.sshj.sftp.SFTPException; + +/** Hybrid file for handeling all types of files */ +public class HybridFile { + + private static final Logger LOG = LoggerFactory.getLogger(HybridFile.class); + + public static final String DOCUMENT_FILE_PREFIX = + "content://com.android.externalstorage.documents"; + + protected String path; + protected OpenMode mode; + protected String name; + + private final DataUtils dataUtils = DataUtils.getInstance(); + + public HybridFile(OpenMode mode, String path) { + this.path = path; + this.mode = mode; + sanitizePathAsNecessary(); + } + + public HybridFile(OpenMode mode, String path, String name, boolean isDirectory) { + this(mode, path); + this.name = name; + if (path.startsWith(SMB_URI_PREFIX) || isSmb() || isDocumentFile() || isOtgFile()) { + Uri.Builder pathBuilder = Uri.parse(this.path).buildUpon().appendEncodedPath(name); + if ((path.startsWith(SMB_URI_PREFIX) || isSmb()) && isDirectory) { + pathBuilder.appendEncodedPath("/"); + } + this.path = pathBuilder.build().toString(); + } else if (path.startsWith(SSH_URI_PREFIX) || isSftp()) { + this.path += "/" + name; + } else if (isRoot() && path.equals("/")) { + // root of filesystem, don't concat another '/' + this.path += name; + } else if (isTrashBin()) { + this.path = path; + } else { + this.path += "/" + name; + } + sanitizePathAsNecessary(); + } + + public void generateMode(Context context) { + if (path.startsWith(SMB_URI_PREFIX)) { + mode = OpenMode.SMB; + } else if (path.startsWith(SSH_URI_PREFIX)) { + mode = OpenMode.SFTP; + } else if (path.startsWith(OTGUtil.PREFIX_OTG)) { + mode = OpenMode.OTG; + } else if (path.startsWith(FTP_URI_PREFIX) || path.startsWith(FTPS_URI_PREFIX)) { + mode = OpenMode.FTP; + } else if (path.startsWith(DOCUMENT_FILE_PREFIX)) { + mode = OpenMode.DOCUMENT_FILE; + } else if (isCustomPath()) { + mode = OpenMode.CUSTOM; + } else if (path.startsWith(CloudHandler.CLOUD_PREFIX_BOX)) { + mode = OpenMode.BOX; + } else if (path.startsWith(CloudHandler.CLOUD_PREFIX_ONE_DRIVE)) { + mode = OpenMode.ONEDRIVE; + } else if (path.startsWith(CloudHandler.CLOUD_PREFIX_GOOGLE_DRIVE)) { + mode = OpenMode.GDRIVE; + } else if (path.startsWith(CloudHandler.CLOUD_PREFIX_DROPBOX)) { + mode = OpenMode.DROPBOX; + } else if (path.equals("7") || isTrashBin()) { + mode = OpenMode.TRASH_BIN; + } else if (context == null) { + mode = OpenMode.FILE; + } else { + boolean rootmode = + PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(PreferencesConstants.PREFERENCE_ROOTMODE, false); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { + mode = OpenMode.FILE; + if (rootmode && !getFile().canRead()) { + mode = OpenMode.ROOT; + } + } else { + if (ExternalSdCardOperation.isOnExtSdCard(getFile(), context)) { + mode = OpenMode.FILE; + } else if (rootmode && !getFile().canRead()) { + mode = OpenMode.ROOT; + } + + // In some cases, non-numeric path is passed into HybridFile while mode is still + // CUSTOM here. We are forcing OpenMode.FILE in such case too. See #2225 + if (OpenMode.UNKNOWN.equals(mode) || OpenMode.CUSTOM.equals(mode)) { + mode = OpenMode.FILE; + } + } + } + } + + public void setMode(OpenMode mode) { + this.mode = mode; + } + + public OpenMode getMode() { + return mode; + } + + public void setPath(String path) { + this.path = path; + } + + public boolean isLocal() { + return mode == OpenMode.FILE; + } + + public boolean isRoot() { + return mode == OpenMode.ROOT; + } + + public boolean isTrashBin() { + return mode == OpenMode.TRASH_BIN; + } + + public boolean isSmb() { + return mode == OpenMode.SMB; + } + + public boolean isSftp() { + return mode == OpenMode.SFTP; + } + + public boolean isOtgFile() { + return mode == OpenMode.OTG; + } + + public boolean isFtp() { + return mode == OpenMode.FTP; + } + + public boolean isDocumentFile() { + return mode == OpenMode.DOCUMENT_FILE; + } + + public boolean isBoxFile() { + return mode == OpenMode.BOX; + } + + public boolean isDropBoxFile() { + return mode == OpenMode.DROPBOX; + } + + public boolean isOneDriveFile() { + return mode == OpenMode.ONEDRIVE; + } + + public boolean isGoogleDriveFile() { + return mode == OpenMode.GDRIVE; + } + + public boolean isAndroidDataDir() { + return mode == OpenMode.ANDROID_DATA; + } + + public boolean isCloudDriveFile() { + return isBoxFile() || isDropBoxFile() || isOneDriveFile() || isGoogleDriveFile(); + } + + @Nullable + public File getFile() { + return new File(path); + } + + @Nullable + public DocumentFile getDocumentFile(boolean createRecursive) { + return OTGUtil.getDocumentFile( + path, + SafRootHolder.getUriRoot(), + AppConfig.getInstance(), + OpenMode.DOCUMENT_FILE, + createRecursive); + } + + HybridFileParcelable generateBaseFileFromParent() { + ArrayList arrayList = + RootHelper.getFilesList(getFile().getParent(), true, true); + for (HybridFileParcelable baseFile : arrayList) { + if (baseFile.getPath().equals(path)) return baseFile; + } + return null; + } + + public long lastModified() { + switch (mode) { + case SFTP: + final Long returnValue = + SshClientUtils.execute( + new SFtpClientTemplate(path, true) { + @Override + public Long execute(@NonNull SFTPClient client) throws IOException { + return client.mtime(NetCopyClientUtils.extractRemotePathFrom(path)); + } + }); + + if (returnValue == null) { + LOG.error("Error obtaining last modification time over SFTP"); + } + + return returnValue == null ? 0L : returnValue; + case SMB: + SmbFile smbFile = getSmbFile(); + if (smbFile != null) { + try { + return smbFile.lastModified(); + } catch (SmbException e) { + LOG.error("Error getting last modified time for SMB [" + path + "]", e); + return 0; + } + } + break; + case FTP: + FTPFile ftpFile = getFtpFile(); + return ftpFile != null ? ftpFile.getTimestamp().getTimeInMillis() : 0L; + case NFS: + break; + case FILE: + case TRASH_BIN: + return getFile().lastModified(); + case DOCUMENT_FILE: + return getDocumentFile(false).lastModified(); + case ROOT: + HybridFileParcelable baseFile = generateBaseFileFromParent(); + if (baseFile != null) return baseFile.getDate(); + } + return new File("/").lastModified(); + } + + /** Helper method to find length */ + public long length(Context context) { + long s = 0L; + switch (mode) { + case SFTP: + if (this instanceof HybridFileParcelable) { + return ((HybridFileParcelable) this).getSize(); + } else { + return sftpGetSize.invoke(getPath()); + } + case SMB: + s = + Single.fromCallable( + () -> { + SmbFile smbFile = getSmbFile(); + if (smbFile != null) { + try { + return smbFile.length(); + } catch (SmbException e) { + LOG.warn("failed to get length for smb file", e); + return 0L; + } + } else { + return 0L; + } + }) + .subscribeOn(Schedulers.io()) + .blockingGet(); + return s; + case FTP: + FTPFile ftpFile = getFtpFile(); + s = ftpFile != null ? ftpFile.getSize() : 0L; + return s; + case NFS: + case FILE: + case TRASH_BIN: + s = getFile().length(); + return s; + case ROOT: + HybridFileParcelable baseFile = generateBaseFileFromParent(); + if (baseFile != null) return baseFile.getSize(); + break; + case DOCUMENT_FILE: + s = getDocumentFile(false).length(); + break; + case OTG: + s = OTGUtil.getDocumentFile(path, context, false).length(); + break; + case DROPBOX: + case BOX: + case ONEDRIVE: + case GDRIVE: + s = + Single.fromCallable( + () -> + dataUtils + .getAccount(mode) + .getMetadata(CloudUtil.stripPath(mode, path)) + .getSize()) + .subscribeOn(Schedulers.io()) + .blockingGet(); + return s; + default: + break; + } + return s; + } + + /** + * Path accessor. Avoid direct access to path (for non-local files) since path may have been URL + * encoded. + * + * @return URL decoded path (for non-local files); the actual path for local files + */ + public String getPath() { + + if (isLocal() || isTrashBin() || isRoot() || isDocumentFile() || isAndroidDataDir()) + return path; + + try { + return URLDecoder.decode(path, "UTF-8"); + } catch (UnsupportedEncodingException | IllegalArgumentException e) { + LOG.warn("failed to decode path {}", path, e); + return path; + } + } + + public String getSimpleName() { + String name = null; + switch (mode) { + case SMB: + SmbFile smbFile = getSmbFile(); + if (smbFile != null) return smbFile.getName(); + break; + default: + StringBuilder builder = new StringBuilder(path); + name = builder.substring(builder.lastIndexOf("/") + 1, builder.length()); + } + return name; + } + + public String getName(Context context) { + switch (mode) { + case SMB: + SmbFile smbFile = getSmbFile(); + if (smbFile != null) { + return smbFile.getName(); + } + return null; + case FILE: + case ROOT: + return getFile().getName(); + case OTG: + if (!Utils.isNullOrEmpty(name)) { + return name; + } + return OTGUtil.getDocumentFile(path, context, false).getName(); + case DOCUMENT_FILE: + if (!Utils.isNullOrEmpty(name)) { + return name; + } + return OTGUtil.getDocumentFile( + path, SafRootHolder.getUriRoot(), context, OpenMode.DOCUMENT_FILE, false) + .getName(); + case TRASH_BIN: + return name; + default: + if (path.isEmpty()) { + return ""; + } + + String _path = null; + try { + _path = URLDecoder.decode(path, "UTF-8"); + } catch (UnsupportedEncodingException | IllegalArgumentException e) { + LOG.warn("failed to decode path {}", path, e); + } + if (path.endsWith("/")) { + _path = path.substring(0, path.length() - 1); + } + + int lastSeparator = _path.lastIndexOf('/'); + + return _path.substring(lastSeparator + 1); + } + } + + public SmbFile getSmbFile(int timeout) { + try { + SmbFile smbFile = SmbUtil.create(path); + smbFile.setConnectTimeout(timeout); + return smbFile; + } catch (MalformedURLException e) { + LOG.warn("failed to get smb file with timeout", e); + return null; + } + } + + public SmbFile getSmbFile() { + try { + return SmbUtil.create(path); + } catch (MalformedURLException e) { + LOG.warn("failed to get smb file", e); + return null; + } + } + + @Nullable + public FTPFile getFtpFile() { + return NetCopyClientUtils.INSTANCE.execute( + new FtpClientTemplate(path, false) { + public FTPFile executeWithFtpClient(@NonNull FTPClient ftpClient) throws IOException { + String path = + NetCopyClientUtils.extractRemotePathFrom(getParent(AppConfig.getInstance())); + ftpClient.changeWorkingDirectory(path); + for (FTPFile ftpFile : ftpClient.listFiles()) { + if (ftpFile.getName().equals(getName(AppConfig.getInstance()))) return ftpFile; + } + return null; + } + }); + } + + public boolean isCustomPath() { + return path.equals("0") + || path.equals("1") + || path.equals("2") + || path.equals("3") + || path.equals("4") + || path.equals("5") + || path.equals("6"); + } + + /** Helper method to get parent path */ + @Nullable + public String getParent(Context context) { + switch (mode) { + case SMB: + SmbFile smbFile = getSmbFile(); + if (smbFile != null) { + return smbFile.getParent(); + } + return ""; + case FILE: + case ROOT: + return getFile().getParent(); + case TRASH_BIN: + return "7"; + case SFTP: + case DOCUMENT_FILE: + String thisPath = path; + if (thisPath.contains("%")) { + try { + thisPath = URLDecoder.decode(getPath(), Charsets.UTF_8.name()); + } catch (UnsupportedEncodingException ignored) { + } + } + List pathSegments = Uri.parse(thisPath).getPathSegments(); + + if (thisPath.isEmpty() || pathSegments.isEmpty()) return null; + + String currentName = pathSegments.get(pathSegments.size() - 1); + int currentNameStartIndex = thisPath.lastIndexOf(currentName); + if (currentNameStartIndex < 0) { + return null; + } + String parent = thisPath.substring(0, currentNameStartIndex); + if (ArraysKt.any(ANDROID_DATA_DIRS, dir -> parent.endsWith(dir + "/"))) { + return FileProperties.unmapPathForApi30OrAbove(parent); + } else { + return parent; + } + default: + if (getPath().length() <= getName(context).length()) { + return null; + } + + int start = 0; + int end = getPath().length() - getName(context).length() - 1; + + return getPath().substring(start, end); + } + } + + /** + * Whether this object refers to a directory or file, handles all types of files + * + * @deprecated use {@link #isDirectory(Context)} to handle content resolvers + */ + public boolean isDirectory() { + boolean isDirectory; + switch (mode) { + case SFTP: + case FTP: + case SMB: + return isDirectory(AppConfig.getInstance()); + case ROOT: + isDirectory = NativeOperations.isDirectory(path); + break; + case DOCUMENT_FILE: + return getDocumentFile(false).isDirectory(); + case OTG: + // TODO: support for this method in OTG on-the-fly + // you need to manually call {@link RootHelper#getDocumentFile() method + isDirectory = false; + break; + case FILE: + case TRASH_BIN: + default: + isDirectory = getFile().isDirectory(); + break; + } + return isDirectory; + } + + public boolean isDirectory(Context context) { + switch (mode) { + case SFTP: + final Boolean returnValue = + SshClientUtils.execute( + new SFtpClientTemplate(path, true) { + @Override + public Boolean execute(@NonNull SFTPClient client) { + try { + return client + .stat(NetCopyClientUtils.extractRemotePathFrom(path)) + .getType() + .equals(FileMode.Type.DIRECTORY); + } catch (IOException notFound) { + LOG.error("Fail to execute isDirectory for SFTP path :" + path, notFound); + return false; + } + } + }); + + if (returnValue == null) { + LOG.error("Error obtaining if path is directory over SFTP"); + return false; + } + + return returnValue; + case SMB: + try { + return Single.fromCallable(() -> getSmbFile().isDirectory()) + .subscribeOn(Schedulers.io()) + .blockingGet(); + } catch (Exception e) { + LOG.warn("failed to get isDirectory with context for smb file", e); + return false; + } + case FTP: + FTPFile ftpFile = getFtpFile(); + return ftpFile != null && ftpFile.isDirectory(); + case ROOT: + return NativeOperations.isDirectory(path); + case DOCUMENT_FILE: + DocumentFile documentFile = getDocumentFile(false); + return documentFile != null && documentFile.isDirectory(); + case OTG: + DocumentFile otgFile = OTGUtil.getDocumentFile(path, context, false); + return otgFile != null && otgFile.isDirectory(); + case DROPBOX: + case BOX: + case GDRIVE: + case ONEDRIVE: + return Single.fromCallable( + () -> + dataUtils + .getAccount(mode) + .getMetadata(CloudUtil.stripPath(mode, path)) + .getFolder()) + .subscribeOn(Schedulers.io()) + .blockingGet(); + case TRASH_BIN: + default: // also handles the case `FILE` + File file = getFile(); + return file != null && file.isDirectory(); + } + } + + /** + * @deprecated use {@link #folderSize(Context)} + */ + public long folderSize() { + long size = 0L; + + switch (mode) { + case SFTP: + case FTP: + return folderSize(AppConfig.getInstance()); + case SMB: + SmbFile smbFile = getSmbFile(); + size = smbFile != null ? FileUtils.folderSize(getSmbFile()) : 0; + break; + case FILE: + case TRASH_BIN: + size = FileUtils.folderSize(getFile(), null); + break; + case ROOT: + HybridFileParcelable baseFile = generateBaseFileFromParent(); + if (baseFile != null) size = baseFile.getSize(); + break; + default: + return 0L; + } + return size; + } + + /** Helper method to get length of folder in an otg */ + public long folderSize(Context context) { + + long size = 0L; + + switch (mode) { + case SFTP: + Long retval = -1L; + String result = SshClientUtils.execute(getRemoteShellCommandLineResult("du -bs \"%s\"")); + if (!TextUtils.isEmpty(result) && result.indexOf('\t') > 0) { + try { + retval = Long.valueOf(result.substring(0, result.lastIndexOf('\t'))); + } catch (NumberFormatException ifParseFailed) { + LOG.warn("Unable to parse result (Seen {\"\"}), resort to old method", result); + retval = -1L; + } + } + if (retval == -1L) { + Long returnValue = sftpGetSize.invoke(getPath()); + if (returnValue == null) { + LOG.error("Error obtaining size of folder over SFTP"); + } + return returnValue == null ? 0L : returnValue; + } + return retval; + case SMB: + SmbFile smbFile = getSmbFile(); + size = (smbFile != null) ? FileUtils.folderSize(smbFile) : 0L; + break; + case FILE: + case TRASH_BIN: + size = FileUtils.folderSize(getFile(), null); + break; + case ROOT: + HybridFileParcelable baseFile = generateBaseFileFromParent(); + if (baseFile != null) size = baseFile.getSize(); + break; + case OTG: + size = FileUtils.otgFolderSize(path, context); + break; + case DOCUMENT_FILE: + final AtomicLong totalBytes = new AtomicLong(0); + OTGUtil.getDocumentFiles( + SafRootHolder.getUriRoot(), + path, + context, + OpenMode.DOCUMENT_FILE, + file -> totalBytes.addAndGet(FileUtils.getBaseFileSize(file, context))); + break; + case DROPBOX: + case BOX: + case GDRIVE: + case ONEDRIVE: + size = + FileUtils.folderSizeCloud( + mode, dataUtils.getAccount(mode).getMetadata(CloudUtil.stripPath(mode, path))); + break; + case FTP: + default: + return 0l; + } + return size; + } + + /** Gets usable i.e. free space of a device */ + public long getUsableSpace() { + long size = 0L; + switch (mode) { + case SMB: + size = + Single.fromCallable( + (Callable) + () -> { + try { + SmbFile smbFile = getSmbFile(); + return smbFile != null ? smbFile.getDiskFreeSpace() : 0L; + } catch (SmbException e) { + LOG.warn("failed to get usage space for smb file", e); + return 0L; + } + }) + .subscribeOn(Schedulers.io()) + .blockingGet(); + break; + case FILE: + case ROOT: + case TRASH_BIN: + size = getFile().getUsableSpace(); + break; + case DROPBOX: + case BOX: + case GDRIVE: + case ONEDRIVE: + SpaceAllocation spaceAllocation = dataUtils.getAccount(mode).getAllocation(); + size = spaceAllocation.getTotal() - spaceAllocation.getUsed(); + break; + case SFTP: + final Long returnValue = + SshClientUtils.execute( + new SFtpClientTemplate(path, true) { + @Override + public Long execute(@NonNull SFTPClient client) throws IOException { + try { + Statvfs.Response response = + new Statvfs.Response( + path, + client + .getSFTPEngine() + .request( + Statvfs.request( + client, NetCopyClientUtils.extractRemotePathFrom(path))) + .retrieve()); + return response.diskFreeSpace(); + } catch (SFTPException e) { + LOG.error("Error querying server", e); + return 0L; + } catch (Buffer.BufferException e) { + LOG.error("Error parsing reply", e); + return 0L; + } + } + }); + + if (returnValue == null) { + LOG.error("Error obtaining usable space over SFTP"); + } + + size = returnValue == null ? 0L : returnValue; + break; + case DOCUMENT_FILE: + size = + FileProperties.getDeviceStorageRemainingSpace(SafRootHolder.INSTANCE.getVolumeLabel()); + break; + case FTP: + /* + * Quirk, or dirty trick. + * + * I think 99.9% FTP servers in this world will not report their disk's remaining space, + * simply because they are not Serv-U (using AVBL command) or IIS (extended LIST command on + * it own). But it doesn't make sense to simply block write to FTP servers either, hence + * this value Integer.MAX_VALUE = 2048MB, which should be suitable for 99% of the cases. + * + * File sizes bigger than this, either Android device (unless TV boxes) would have + * difficulty to handle, either client and server side. In that case I shall recommend you + * to send it in splits, or just move to better transmission mechanism, like WiFi Direct + * as provided by Amaze File Utilities ;) + * + * - TranceLove + */ + size = Integer.MAX_VALUE; + case OTG: + // TODO: Get free space from OTG when {@link DocumentFile} API adds support + break; + } + return size; + } + + /** Gets total size of the disk */ + public long getTotal(Context context) { + long size = 0l; + switch (mode) { + case SMB: + // TODO: Find total storage space of SMB when JCIFS adds support + try { + SmbFile smbFile = getSmbFile(); + size = smbFile != null ? smbFile.getDiskFreeSpace() : 0L; + } catch (SmbException e) { + LOG.warn("failed to get total space for smb file", e); + } + break; + case FILE: + case ROOT: + case TRASH_BIN: + size = getFile().getTotalSpace(); + break; + case DROPBOX: + case BOX: + case ONEDRIVE: + case GDRIVE: + SpaceAllocation spaceAllocation = dataUtils.getAccount(mode).getAllocation(); + size = spaceAllocation.getTotal(); + break; + case SFTP: + final Long returnValue = + SshClientUtils.execute( + new SFtpClientTemplate(path, true) { + @Override + public Long execute(@NonNull SFTPClient client) throws IOException { + try { + Statvfs.Response response = + new Statvfs.Response( + path, + client + .getSFTPEngine() + .request( + Statvfs.request( + client, NetCopyClientUtils.extractRemotePathFrom(path))) + .retrieve()); + return response.diskSize(); + } catch (SFTPException e) { + LOG.error("Error querying server", e); + return 0L; + } catch (Buffer.BufferException e) { + LOG.error("Error parsing reply", e); + return 0L; + } + } + }); + + if (returnValue == null) { + LOG.error("Error obtaining total space over SFTP"); + } + + size = returnValue == null ? 0L : returnValue; + break; + case OTG: + // TODO: Find total storage space of OTG when {@link DocumentFile} API adds support + DocumentFile documentFile = OTGUtil.getDocumentFile(path, context, false); + size = documentFile.length(); + break; + case DOCUMENT_FILE: + size = getDocumentFile(false).length(); + break; + case FTP: + size = 0L; + } + return size; + } + + /** Helper method to list children of this file */ + public void forEachChildrenFile(Context context, boolean isRoot, OnFileFound onFileFound) { + switch (mode) { + case SFTP: + SshClientUtils.execute( + new SFtpClientTemplate(getPath(), true) { + @Override + public Boolean execute(@NonNull SFTPClient client) { + try { + for (RemoteResourceInfo info : + client.ls(NetCopyClientUtils.extractRemotePathFrom(getPath()))) { + boolean isDirectory = false; + try { + isDirectory = SshClientUtils.isDirectory(client, info); + } catch (IOException ifBrokenSymlink) { + LOG.warn("IOException checking isDirectory(): " + info.getPath()); + continue; + } + HybridFileParcelable f = new HybridFileParcelable(getPath(), isDirectory, info); + onFileFound.onFileFound(f); + } + } catch (IOException e) { + LOG.warn("IOException", e); + AppConfig.toast( + context, + context.getString( + R.string.cannot_read_directory, + parseAndFormatUriForDisplay(getPath()), + e.getMessage())); + } + return true; + } + }); + break; + case SMB: + try { + SmbFile smbFile = getSmbFile(); + if (smbFile != null) { + for (SmbFile smbFile1 : smbFile.listFiles()) { + HybridFileParcelable baseFile; + try { + SmbFile sf = new SmbFile(smbFile1.getURL(), smbFile.getContext()); + baseFile = new HybridFileParcelable(sf); + } catch (MalformedURLException shouldNeverHappen) { + LOG.warn("failed to get children file for smb", shouldNeverHappen); + baseFile = new HybridFileParcelable(smbFile1); + } + onFileFound.onFileFound(baseFile); + } + } + } catch (SmbException e) { + LOG.warn("failed to get children file for smb file", e); + } + break; + case FTP: + String thisPath = NetCopyClientUtils.extractRemotePathFrom(getPath()); + FTPFile[] ftpFiles = + NetCopyClientUtils.INSTANCE.execute( + new FtpClientTemplate(getPath(), false) { + public FTPFile[] executeWithFtpClient(@NonNull FTPClient ftpClient) + throws IOException { + ftpClient.changeWorkingDirectory(thisPath); + return ftpClient.listFiles(); + } + }); + for (FTPFile ftpFile : ftpFiles) { + onFileFound.onFileFound(new HybridFileParcelable(getPath(), ftpFile)); + } + break; + case OTG: + OTGUtil.getDocumentFiles(path, context, onFileFound); + break; + case DOCUMENT_FILE: + OTGUtil.getDocumentFiles( + SafRootHolder.getUriRoot(), path, context, OpenMode.DOCUMENT_FILE, onFileFound); + break; + case DROPBOX: + case BOX: + case GDRIVE: + case ONEDRIVE: + try { + CloudUtil.getCloudFiles(path, dataUtils.getAccount(mode), mode, onFileFound); + } catch (CloudPluginException e) { + LOG.warn("failed to get children file for cloud file", e); + } + break; + case TRASH_BIN: + default: + ListFilesCommand.INSTANCE.listFiles( + path, + isRoot, + true, + openMode -> null, + hybridFileParcelable -> { + onFileFound.onFileFound(hybridFileParcelable); + return null; + }); + } + } + + /** + * Helper method to list children of this file + * + * @deprecated use forEachChildrenFile() + */ + public ArrayList listFiles(Context context, boolean isRoot) { + ArrayList arrayList = new ArrayList<>(); + forEachChildrenFile(context, isRoot, arrayList::add); + return arrayList; + } + + public String getReadablePath(String path) { + if (isSftp() || isSmb() || isFtp()) return parseAndFormatUriForDisplay(path); + else return path; + } + + public static String parseAndFormatUriForDisplay(@NonNull String uriString) { + if (uriString.startsWith(SSH_URI_PREFIX) + || uriString.startsWith(FTP_URI_PREFIX) + || uriString.startsWith(FTPS_URI_PREFIX)) { + NetCopyConnectionInfo connInfo = new NetCopyConnectionInfo(uriString); + return connInfo.toString(); + } else { + Uri uri = Uri.parse(uriString); + return formatUriForDisplayInternal(uri.getScheme(), uri.getHost(), uri.getPath()); + } + } + + private static String formatUriForDisplayInternal( + @NonNull String scheme, @NonNull String host, @NonNull String path) { + return String.format("%s://%s%s", scheme, host, path); + } + + /** + * Handles getting input stream for various {@link OpenMode} + * + * @param context + * @return + */ + @Nullable + public InputStream getInputStream(Context context) { + InputStream inputStream; + + switch (mode) { + case SFTP: + inputStream = + SshClientUtils.execute( + new SFtpClientTemplate(getPath(), false) { + @Override + public InputStream execute(@NonNull final SFTPClient client) throws IOException { + final RemoteFile rf = + SFTPClientExtKt.openWithReadAheadSupport( + client, NetCopyClientUtils.extractRemotePathFrom(getPath())); + return rf.new ReadAheadRemoteFileInputStream(READ_AHEAD_MAX_UNCONFIRMED_READS) { + @Override + public void close() throws IOException { + try { + LOG.debug("Closing input stream for {}", getPath()); + super.close(); + } catch (Throwable e) { + e.printStackTrace(); + } finally { + LOG.debug("Closing client for {}", getPath()); + rf.close(); + client.close(); + } + } + }; + } + }); + break; + case SMB: + try { + inputStream = getSmbFile().getInputStream(); + } catch (IOException e) { + inputStream = null; + LOG.warn("failed to get input stream for smb file", e); + } + break; + case FTP: + inputStream = + NetCopyClientUtils.INSTANCE.execute( + new FtpClientTemplate(getPath(), false) { + public InputStream executeWithFtpClient(@NonNull FTPClient ftpClient) + throws IOException { + String parent = getParent(AppConfig.getInstance()); + /* + * Use temp file to hold the FTP file. + * + * Due to the single thread nature of FTPClient, it is not possible to open + * both input and output streams on the same FTP server on the same time. + * Hence have to use placeholder temp file to hold contents for freeing out + * the thread for output stream. - TranceLove + */ + File tmpFile = File.createTempFile("ftp-transfer_", ".tmp"); + tmpFile.deleteOnExit(); + ftpClient.changeWorkingDirectory( + NetCopyClientUtils.extractRemotePathFrom(parent)); + ftpClient.setFileType(FTP.BINARY_FILE_TYPE); + InputStream fin = + ftpClient.retrieveFileStream(getName(AppConfig.getInstance())); + FileOutputStream fout = new FileOutputStream(tmpFile); + ByteStreamsKt.copyTo(fin, fout, GenericCopyUtil.DEFAULT_BUFFER_SIZE); + fin.close(); + fout.close(); + ftpClient.completePendingCommand(); + return FTPClientImpl.wrap(tmpFile); + } + }); + break; + case DOCUMENT_FILE: + ContentResolver contentResolver = context.getContentResolver(); + DocumentFile documentSourceFile = getDocumentFile(false); + try { + inputStream = contentResolver.openInputStream(documentSourceFile.getUri()); + } catch (FileNotFoundException e) { + LOG.warn("failed to get input stream for document file", e); + inputStream = null; + } + break; + case OTG: + contentResolver = context.getContentResolver(); + documentSourceFile = OTGUtil.getDocumentFile(path, context, false); + try { + inputStream = contentResolver.openInputStream(documentSourceFile.getUri()); + } catch (FileNotFoundException e) { + LOG.warn("failed to get input stream for otg file", e); + inputStream = null; + } + break; + case DROPBOX: + case BOX: + case GDRIVE: + case ONEDRIVE: + CloudStorage cloudStorageOneDrive = dataUtils.getAccount(mode); + LOG.debug(CloudUtil.stripPath(mode, path)); + inputStream = cloudStorageOneDrive.download(CloudUtil.stripPath(mode, path)); + break; + case TRASH_BIN: + default: + try { + inputStream = new FileInputStream(path); + } catch (FileNotFoundException e) { + inputStream = null; + LOG.warn("failed to get input stream", e); + } + break; + } + return inputStream; + } + + @Nullable + public OutputStream getOutputStream(Context context) { + OutputStream outputStream; + switch (mode) { + case SFTP: + return SshClientUtils.execute( + new SFtpClientTemplate(getPath(), false) { + @Nullable + @Override + public OutputStream execute(@NonNull SFTPClient client) throws IOException { + final RemoteFile rf = + client.open( + NetCopyClientUtils.extractRemotePathFrom(getPath()), + EnumSet.of( + net.schmizz.sshj.sftp.OpenMode.WRITE, + net.schmizz.sshj.sftp.OpenMode.CREAT)); + return rf.new RemoteFileOutputStream() { + @Override + public void close() throws IOException { + try { + super.close(); + } finally { + try { + rf.close(); + client.close(); + } catch (Exception e) { + LOG.warn("Error closing stream", e); + } + } + } + }; + } + }); + case FTP: + outputStream = + NetCopyClientUtils.INSTANCE.execute( + new FtpClientTemplate(path, false) { + public OutputStream executeWithFtpClient(@NonNull FTPClient ftpClient) + throws IOException { + ftpClient.setFileType(FTP.BINARY_FILE_TYPE); + String remotePath = NetCopyClientUtils.extractRemotePathFrom(path); + OutputStream outputStream = ftpClient.storeFileStream(remotePath); + if (outputStream != null) { + return FTPClientImpl.wrap(outputStream, ftpClient); + } else { + return null; + } + } + }); + return outputStream; + case SMB: + try { + outputStream = getSmbFile().getOutputStream(); + } catch (IOException e) { + outputStream = null; + LOG.warn("failed to get output stream for smb file", e); + } + break; + case DOCUMENT_FILE: + ContentResolver contentResolver = context.getContentResolver(); + DocumentFile documentSourceFile = getDocumentFile(true); + try { + outputStream = contentResolver.openOutputStream(documentSourceFile.getUri()); + } catch (FileNotFoundException e) { + LOG.warn("failed to get output stream for document file", e); + outputStream = null; + } + break; + case OTG: + contentResolver = context.getContentResolver(); + documentSourceFile = OTGUtil.getDocumentFile(path, context, true); + try { + outputStream = contentResolver.openOutputStream(documentSourceFile.getUri()); + } catch (FileNotFoundException e) { + LOG.warn("failed to get output stream for otg file", e); + outputStream = null; + } + break; + case TRASH_BIN: + default: + try { + outputStream = FileUtil.getOutputStream(getFile(), context); + } catch (Exception e) { + outputStream = null; + LOG.warn("failed to get output stream", e); + } + } + return outputStream; + } + + public boolean exists() { + boolean exists = false; + if (isSftp()) { + final Boolean executionReturn = + NetCopyClientUtils.INSTANCE.execute( + new SFtpClientTemplate(path, true) { + @Override + public Boolean execute(SFTPClient client) throws IOException { + try { + return client.stat(NetCopyClientUtils.extractRemotePathFrom(path)) != null; + } catch (SFTPException notFound) { + return false; + } + } + }); + if (executionReturn == null) { + LOG.error("Error obtaining existance of file over SFTP"); + } + //noinspection SimplifiableConditionalExpression + exists = executionReturn == null ? false : executionReturn; + } else if (isSmb()) { + try { + SmbFile smbFile = getSmbFile(2000); + exists = smbFile != null && smbFile.exists(); + } catch (SmbException e) { + LOG.warn("failed to find existence for smb file", e); + exists = false; + } + } else if (isFtp()) { + if (getPath().equals("/")) exists = true; + else { + exists = getFtpFile() != null; + } + } else if (isDropBoxFile()) { + CloudStorage cloudStorageDropbox = dataUtils.getAccount(OpenMode.DROPBOX); + exists = cloudStorageDropbox.exists(CloudUtil.stripPath(OpenMode.DROPBOX, path)); + } else if (isBoxFile()) { + CloudStorage cloudStorageBox = dataUtils.getAccount(OpenMode.BOX); + exists = cloudStorageBox.exists(CloudUtil.stripPath(OpenMode.BOX, path)); + } else if (isGoogleDriveFile()) { + CloudStorage cloudStorageGoogleDrive = dataUtils.getAccount(OpenMode.GDRIVE); + exists = cloudStorageGoogleDrive.exists(CloudUtil.stripPath(OpenMode.GDRIVE, path)); + } else if (isOneDriveFile()) { + CloudStorage cloudStorageOneDrive = dataUtils.getAccount(OpenMode.ONEDRIVE); + exists = cloudStorageOneDrive.exists(CloudUtil.stripPath(OpenMode.ONEDRIVE, path)); + } else if (isLocal()) { + exists = getFile().exists(); + } else if (isRoot()) { + return RootHelper.fileExists(path); + } else if (isTrashBin()) { + if (getFile() != null) return getFile().exists(); + else return false; + } + + return exists; + } + + /** Helper method to check file existence in otg */ + public boolean exists(Context context) { + boolean exists = false; + try { + if (isOtgFile()) { + exists = OTGUtil.getDocumentFile(path, context, false) != null; + } else if (isDocumentFile()) { + exists = + OTGUtil.getDocumentFile( + path, SafRootHolder.getUriRoot(), context, OpenMode.DOCUMENT_FILE, false) + != null; + } else return (exists()); + } catch (Exception e) { + LOG.info("Failed to find file", e); + } + return exists; + } + + /** + * Whether file is a simple file (i.e. not a directory/smb/otg/other) + * + * @return true if file; other wise false + */ + public boolean isSimpleFile() { + return !isSmb() + && !isOtgFile() + && !isDocumentFile() + && !isCustomPath() + && !android.util.Patterns.EMAIL_ADDRESS.matcher(path).matches() + && (getFile() != null && !getFile().isDirectory()) + && !isOneDriveFile() + && !isGoogleDriveFile() + && !isDropBoxFile() + && !isBoxFile() + && !isSftp() + && !isFtp() + && !isTrashBin(); + } + + public boolean setLastModified(final long date) { + if (isSmb()) { + try { + SmbFile smbFile = getSmbFile(); + if (smbFile != null) { + smbFile.setLastModified(date); + return true; + } else { + return false; + } + } catch (SmbException e) { + LOG.warn("failed to get last modified for smb file", e); + return false; + } + } else if (isFtp()) { + return Boolean.TRUE.equals( + NetCopyClientUtils.INSTANCE.execute( + new FtpClientTemplate(path, false) { + public Boolean executeWithFtpClient(@NonNull FTPClient ftpClient) + throws IOException { + return ftpClient.setModificationTime( + NetCopyClientUtils.extractRemotePathFrom(path), + NetCopyClientUtils.getTimestampForTouch(date)); + } + })); + } else if (isSftp()) { + return Boolean.TRUE.equals( + SshClientUtils.execute( + new SshClientSessionTemplate(getPath()) { + @Override + public Boolean execute(@NonNull Session session) throws IOException { + Session.Command cmd = + session.exec( + String.format( + Locale.US, + "touch -m -t %s \"%s\"", + NetCopyClientUtils.getTimestampForTouch(date), + getPath())); + // Quirk: need to wait the command to finish + IOUtils.readFully(cmd.getInputStream()); + cmd.close(); + return 0 == cmd.getExitStatus(); + } + })); + } else if (isTrashBin()) { + // do nothing + return true; + } else { + if (getFile().setLastModified(date)) return true; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + try { + Files.setLastModifiedTime(getFile().toPath(), FileTime.fromMillis(date)); + return true; + } catch (IOException e) { + LOG.error("Files#setLastModifiedTime", e); + return false; + } + } + return false; + } + } + + public void mkdir(Context context) { + if (isSftp()) { + SshClientUtils.execute( + new SFtpClientTemplate(path, true) { + @Override + public Boolean execute(@NonNull SFTPClient client) { + try { + client.mkdir(NetCopyClientUtils.extractRemotePathFrom(path)); + } catch (IOException e) { + LOG.error("Error making directory over SFTP", e); + } + return true; + } + }); + } else if (isFtp()) { + NetCopyClientUtils.INSTANCE.execute( + new FtpClientTemplate(getPath(), false) { + public Boolean executeWithFtpClient(@NonNull FTPClient ftpClient) throws IOException { + ExtensionsKt.makeDirectoryTree( + ftpClient, NetCopyClientUtils.extractRemotePathFrom(getPath())); + return true; + } + }); + } else if (isSmb()) { + try { + getSmbFile().mkdirs(); + } catch (SmbException e) { + LOG.warn("failed to make dir for smb file", e); + } + } else if (isOtgFile()) { + if (!exists(context)) { + DocumentFile parentDirectory = OTGUtil.getDocumentFile(getParent(context), context, true); + if (parentDirectory.isDirectory()) { + parentDirectory.createDirectory(getName(context)); + } + } + } else if (isDocumentFile()) { + if (!exists(context)) { + DocumentFile parentDirectory = + OTGUtil.getDocumentFile( + getParent(context), + SafRootHolder.getUriRoot(), + context, + OpenMode.DOCUMENT_FILE, + true); + if (parentDirectory.isDirectory()) { + parentDirectory.createDirectory(getName(context)); + } + } + } else if (isCloudDriveFile()) { + CloudStorage cloudStorageDropbox = dataUtils.getAccount(mode); + try { + cloudStorageDropbox.createFolder(CloudUtil.stripPath(mode, path)); + } catch (Exception e) { + LOG.warn("failed to create folder for cloud file", e); + } + } else if (isTrashBin()) { // do nothing + } else MakeDirectoryOperation.mkdirs(context, this); + } + + public boolean delete(Context context, boolean rootmode) + throws ShellNotRunningException, SmbException { + if (isSftp()) { + Boolean retval = + SshClientUtils.execute( + new SFtpClientTemplate(path, true) { + @Override + public Boolean execute(@NonNull SFTPClient client) throws IOException { + String _path = NetCopyClientUtils.extractRemotePathFrom(path); + if (isDirectory(AppConfig.getInstance())) client.rmdir(_path); + else client.rm(_path); + return client.statExistence(_path) == null; + } + }); + return retval != null && retval; + } else if (isFtp()) { + Boolean retval = + NetCopyClientUtils.INSTANCE.execute( + new FtpClientTemplate(path, false) { + @Override + public Boolean executeWithFtpClient(@NonNull FTPClient ftpClient) + throws IOException { + return ftpClient.deleteFile(NetCopyClientUtils.extractRemotePathFrom(path)); + } + }); + return retval != null && retval; + } else if (isSmb()) { + try { + getSmbFile().delete(); + } catch (SmbException e) { + LOG.error("Error delete SMB file", e); + throw e; + } + } else if (isTrashBin()) { + try { + deletePermanentlyFromBin(context); + } catch (Exception e) { + LOG.error("failed to delete trash bin file", e); + throw e; + } + } else { + if (isRoot() && rootmode) { + setMode(OpenMode.ROOT); + DeleteFileCommand.INSTANCE.deleteFile(getPath()); + } else { + DeleteOperation.deleteFile(getFile(), context); + } + } + return !exists(); + } + + public void restoreFromBin(Context context) { + List trashBinFiles = Collections.singletonList(this.toTrashBinFile(context)); + TrashBin trashBin = AppConfig.getInstance().getTrashBinInstance(); + if (trashBin != null) { + trashBin.moveToBin( + trashBinFiles, + true, + (originalFilePath, trashBinDestination) -> { + File source = new File(originalFilePath); + File dest = new File(trashBinDestination); + if (!source.renameTo(dest)) { + return false; + } + MediaConnectionUtils.scanFile(context, new HybridFile[] {this}); + return true; + }); + } + } + + public boolean moveToBin(Context context) { + List trashBinFiles = Collections.singletonList(this.toTrashBinFile(context)); + TrashBin trashBin = AppConfig.getInstance().getTrashBinInstance(); + if (trashBin != null) { + trashBin.moveToBin( + trashBinFiles, + true, + (originalFilePath, trashBinDestination) -> { + File source = new File(originalFilePath); + File dest = new File(trashBinDestination); + return source.renameTo(dest); + }); + } + return true; + } + + public boolean deletePermanentlyFromBin(Context context) { + List trashBinFiles = + Collections.singletonList(this.toTrashBinRestoreFile(context)); + TrashBin trashBin = AppConfig.getInstance().getTrashBinInstance(); + AtomicBoolean isDelete = new AtomicBoolean(false); + if (trashBin != null) { + trashBin.deletePermanently( + trashBinFiles, + s -> { + LOG.info("deleting from bin at path " + s); + isDelete.set(DeleteOperation.deleteFile(getFile(), context)); + return isDelete.get(); + }, + true); + } + return isDelete.get(); + } + + /** + * Returns the name of file excluding it's extension If no extension is found then whole file name + * is returned + */ + public String getNameString(Context context) { + String fileName = getName(context); + + int extensionStartIndex = fileName.lastIndexOf("."); + return fileName.substring( + 0, extensionStartIndex == -1 ? fileName.length() : extensionStartIndex); + } + + /** + * Generates a {@link LayoutElementParcelable} adapted compatible element. Currently supports only + * local filesystem + */ + public LayoutElementParcelable generateLayoutElement(@NonNull Context c, boolean showThumbs) { + switch (mode) { + case FILE: + case ROOT: + case TRASH_BIN: + File file = getFile(); + LayoutElementParcelable layoutElement; + if (isDirectory(c)) { + + layoutElement = + new LayoutElementParcelable( + c, + path, + RootHelper.parseFilePermission(file), + "", + folderSize() + "", + 0, + true, + file.lastModified() + "", + file.isDirectory(), + showThumbs, + mode); + } else { + layoutElement = + new LayoutElementParcelable( + c, + file.getPath(), + RootHelper.parseFilePermission(file), + file.getPath(), + file.length() + "", + file.length(), + false, + file.lastModified() + "", + false, + showThumbs, + mode); + } + return layoutElement; + default: + return null; + } + } + + /** + * Open this hybrid file + * + * @param activity + * @param doShowDialog should show confirmation dialog (in case of deeplink) + */ + public void openFile(MainActivity activity, boolean doShowDialog) { + if (doShowDialog) { + AtomicReference md5 = new AtomicReference<>(activity.getString(R.string.calculating)); + AtomicReference sha256 = + new AtomicReference<>(activity.getString(R.string.calculating)); + AtomicReference pathToDisplay = new AtomicReference<>(); + pathToDisplay.set(path); + if (isSftp() || isSmb() || isFtp()) { + LOG.debug("convert authorised path to simple path for display"); + pathToDisplay.set(parseAndFormatUriForDisplay(path)); + } + + AtomicReference dialogContent = + new AtomicReference<>( + String.format( + activity.getResources().getString(R.string.open_file_confirmation), + getName(activity), + pathToDisplay.get(), + Formatter.formatShortFileSize(activity, length(activity)), + md5.get(), + sha256.get())); + MaterialDialog dialog = + GeneralDialogCreation.showOpenFileDeeplinkDialog( + this, activity, dialogContent.get(), () -> openFileInternal(activity)); + dialog.show(); + getMd5Checksum( + activity, + s -> { + md5.set(s); + dialogContent.set( + String.format( + activity.getResources().getString(R.string.open_file_confirmation), + getName(activity), + pathToDisplay.get(), + Formatter.formatShortFileSize(activity, length(activity)), + md5.get(), + sha256.get())); + dialog.setContent(dialogContent.get()); + return null; + }); + getSha256Checksum( + activity, + s -> { + sha256.set(s); + dialogContent.set( + String.format( + activity.getResources().getString(R.string.open_file_confirmation), + getName(activity), + pathToDisplay.get(), + Formatter.formatShortFileSize(activity, length(activity)), + md5.get(), + sha256.get())); + dialog.setContent(dialogContent.get()); + return null; + }); + } else { + openFileInternal(activity); + } + } + + public void getMd5Checksum(Context context, Function callback) { + Single.fromCallable( + () -> { + try { + switch (mode) { + case SFTP: + String md5Command = "md5sum -b \"%s\" | cut -c -32"; + return SshClientUtils.execute(getRemoteShellCommandLineResult(md5Command)); + default: + byte[] b = createChecksum(context); + String result = ""; + + for (byte aB : b) { + result += Integer.toString((aB & 0xff) + 0x100, 16).substring(1); + } + return result; + } + } catch (Exception e) { + LOG.warn("failed to get md5 checksum for sftp file", e); + return context.getString(R.string.error); + } + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + new SingleObserver() { + @Override + public void onSubscribe(Disposable d) {} + + @Override + public void onSuccess(String t) { + callback.apply(t); + } + + @Override + public void onError(Throwable e) { + LOG.warn("failed to get md5 for sftp file", e); + callback.apply(context.getString(R.string.error)); + } + }); + } + + public void getSha256Checksum(Context context, Function callback) { + Single.fromCallable( + () -> { + try { + switch (mode) { + case SFTP: + String shaCommand = "sha256sum -b \"%s\" | cut -c -64"; + return SshClientUtils.execute(getRemoteShellCommandLineResult(shaCommand)); + default: + MessageDigest messageDigest = MessageDigest.getInstance("SHA-256"); + byte[] input = new byte[GenericCopyUtil.DEFAULT_BUFFER_SIZE]; + int length; + InputStream inputStream = getInputStream(context); + while ((length = inputStream.read(input)) != -1) { + if (length > 0) messageDigest.update(input, 0, length); + } + + byte[] hash = messageDigest.digest(); + + StringBuilder hexString = new StringBuilder(); + + for (byte aHash : hash) { + // convert hash to base 16 + String hex = Integer.toHexString(0xff & aHash); + if (hex.length() == 1) hexString.append('0'); + hexString.append(hex); + } + inputStream.close(); + return hexString.toString(); + } + } catch (IOException | NoSuchAlgorithmException ne) { + LOG.warn("failed to get sha checksum for sftp file", ne); + return context.getString(R.string.error); + } + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + new SingleObserver() { + @Override + public void onSubscribe(Disposable d) {} + + @Override + public void onSuccess(String t) { + callback.apply(t); + } + + @Override + public void onError(Throwable e) { + LOG.warn("failed to get sha256 for file", e); + callback.apply(context.getString(R.string.error)); + } + }); + } + + /** + * Returns trash bin file with path that points to deleted path + * + * @param context + * @return + */ + public TrashBinFile toTrashBinFile(Context context) { + return new TrashBinFile(getName(context), isDirectory(context), path, length(context), null); + } + + /** + * Returns trash bin file with path that points to where the file should be restored + * + * @param context + * @return + */ + public TrashBinFile toTrashBinRestoreFile(Context context) { + TrashBin trashBin = AppConfig.getInstance().getTrashBinInstance(); + for (TrashBinFile trashBinFile : trashBin.listFilesInBin()) { + if (trashBinFile.getDeletedPath(trashBin.getConfig()).equals(path)) { + // finding path to restore tof + return new TrashBinFile( + getName(context), isDirectory(context), trashBinFile.getPath(), length(context), null); + } + } + return null; + } + + private SshClientSessionTemplate getRemoteShellCommandLineResult(String command) { + return new SshClientSessionTemplate(path) { + @Override + public String execute(Session session) throws IOException { + String extractedPath = NetCopyClientUtils.extractRemotePathFrom(getPath()); + String fullCommand = String.format(command, extractedPath); + Session.Command cmd = session.exec(fullCommand); + String result = new String(IOUtils.readFully(cmd.getInputStream()).toByteArray()); + cmd.close(); + if (cmd.getExitStatus() == 0) { + return result; + } else { + return null; + } + } + }; + } + + private byte[] createChecksum(Context context) throws Exception { + InputStream fis = getInputStream(context); + + byte[] buffer = new byte[8192]; + MessageDigest complete = MessageDigest.getInstance("MD5"); + int numRead; + + do { + numRead = fis.read(buffer); + if (numRead > 0) { + complete.update(buffer, 0, numRead); + } + } while (numRead != -1); + + fis.close(); + return complete.digest(); + } + + private void openFileInternal(MainActivity activity) { + switch (mode) { + case SMB: + FileUtils.launchSMB(this, activity); + break; + case SFTP: + case FTP: + Toast.makeText( + activity, + activity.getResources().getString(R.string.please_wait), + Toast.LENGTH_LONG) + .show(); + SshClientUtils.launchFtp(this, activity); + break; + case OTG: + FileUtils.openFile( + OTGUtil.getDocumentFile(path, activity, false), activity, activity.getPrefs()); + break; + case DOCUMENT_FILE: + FileUtils.openFile( + OTGUtil.getDocumentFile( + path, SafRootHolder.getUriRoot(), activity, OpenMode.DOCUMENT_FILE, false), + activity, + activity.getPrefs()); + break; + case DROPBOX: + case BOX: + case GDRIVE: + case ONEDRIVE: + Toast.makeText( + activity, + activity.getResources().getString(R.string.please_wait), + Toast.LENGTH_LONG) + .show(); + CloudUtil.launchCloud(this, mode, activity); + break; + default: + FileUtils.openFile(new File(path), activity, activity.getPrefs()); + break; + } + } + + private void sanitizePathAsNecessary() { + this.path = this.path.replaceAll(MULTI_SLASH, "/"); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/HybridFileParcelable.java b/app/src/main/java/com/amaze/filemanager/filesystem/HybridFileParcelable.java new file mode 100644 index 0000000..46381a0 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/HybridFileParcelable.java @@ -0,0 +1,271 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem; + +import static com.amaze.filemanager.fileoperations.filesystem.OpenMode.DOCUMENT_FILE; + +import org.apache.commons.net.ftp.FTPFile; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.amaze.filemanager.fileoperations.filesystem.OpenMode; +import com.amaze.filemanager.filesystem.files.sort.ComparableParcelable; +import com.amaze.filemanager.filesystem.ftp.ExtensionsKt; +import com.amaze.filemanager.utils.Utils; + +import android.content.ContentResolver; +import android.content.Context; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import jcifs.smb.SmbException; +import jcifs.smb.SmbFile; +import net.schmizz.sshj.sftp.RemoteResourceInfo; +import net.schmizz.sshj.xfer.FilePermission; + +public class HybridFileParcelable extends HybridFile implements Parcelable, ComparableParcelable { + private final Logger LOG = LoggerFactory.getLogger(HybridFileParcelable.class); + + private long date, size; + private boolean isDirectory; + private String permission; + private String name; + private String link = ""; + private Uri fullUri = null; + + public HybridFileParcelable(String path) { + super(OpenMode.FILE, path); + } + + public HybridFileParcelable( + String path, String permission, long date, long size, boolean isDirectory) { + super(OpenMode.FILE, path); + this.date = date; + this.size = size; + this.isDirectory = isDirectory; + this.permission = permission; + } + + /** Constructor for jcifs {@link SmbFile}. */ + public HybridFileParcelable(SmbFile smbFile) throws SmbException { + super(OpenMode.SMB, smbFile.getPath()); + setName(smbFile.getName()); + setDirectory(smbFile.isDirectory()); + setDate(smbFile.lastModified()); + setSize(smbFile.isDirectory() ? 0 : smbFile.length()); + } + + public HybridFileParcelable(String path, FTPFile ftpFile) { + super( + OpenMode.FTP, + path + (ftpFile.getName().startsWith("/") ? ftpFile.getName() : "/" + ftpFile.getName())); + setName(ftpFile.getName()); + setDirectory(ftpFile.getType() == FTPFile.DIRECTORY_TYPE); + setDate(ftpFile.getTimestamp().getTimeInMillis()); + setSize(ftpFile.getSize()); + setPermission( + Integer.toString(FilePermission.toMask(ExtensionsKt.toFilePermissions(ftpFile)), 8)); + } + + /** Constructor for sshj {@link RemoteResourceInfo}. */ + public HybridFileParcelable(String path, boolean isDirectory, RemoteResourceInfo sshFile) { + super(OpenMode.SFTP, String.format("%s/%s", path, sshFile.getName())); + setName(sshFile.getName()); + setDirectory(isDirectory); + setDate(sshFile.getAttributes().getMtime() * 1000); + setSize(isDirectory ? 0 : sshFile.getAttributes().getSize()); + setPermission( + Integer.toString(FilePermission.toMask(sshFile.getAttributes().getPermissions()), 8)); + } + + @Override + public long lastModified() { + return date; + } + + public String getName() { + if (!Utils.isNullOrEmpty(name)) return name; + else return super.getSimpleName(); + } + + @Override + public String getName(Context context) { + if (!Utils.isNullOrEmpty(name)) return name; + else return super.getName(context); + } + + public void setName(String name) { + this.name = name; + } + + public String getLink() { + return link; + } + + public void setLink(String link) { + this.link = link; + } + + public long getDate() { + return date; + } + + public void setDate(long date) { + this.date = date; + } + + public long getSize() { + return size; + } + + public void setSize(long size) { + this.size = size; + } + + public boolean isDirectory() { + return isDirectory; + } + + @Override + public boolean isDirectory(Context context) { + if (isSmb() || isSftp()) return isDirectory; + else return super.isDirectory(context); + } + + public boolean isHidden() { + return name.startsWith("."); + } + + public void setDirectory(boolean directory) { + isDirectory = directory; + } + + public String getPermission() { + return permission; + } + + public void setPermission(String permission) { + this.permission = permission; + } + + @Nullable + public Uri getFullUri() { + return DOCUMENT_FILE.equals(mode) ? fullUri : null; + } + + public void setFullUri(Uri fullUri) { + if (!ContentResolver.SCHEME_CONTENT.equals(fullUri.getScheme())) { + // TODO: throw IllegalArgumentException is not a good idea here? + // FIXME: OpenMode is mutable (which is a bad idea) hence check for OpenMode.DOCUMENT_FILE + // will not make sense either. + LOG.debug("Provided URI is not content URI, skipping. Given URI: " + fullUri.toString()); + } else { + this.fullUri = fullUri; + } + } + + protected HybridFileParcelable(Parcel in) { + super(OpenMode.getOpenMode(in.readInt()), in.readString()); + permission = in.readString(); + name = in.readString(); + date = in.readLong(); + size = in.readLong(); + isDirectory = in.readByte() != 0; + } + + public static final Creator CREATOR = + new Creator() { + @Override + public HybridFileParcelable createFromParcel(Parcel in) { + return new HybridFileParcelable(in); + } + + @Override + public HybridFileParcelable[] newArray(int size) { + return new HybridFileParcelable[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(getMode().ordinal()); + dest.writeString(getPath()); + dest.writeString(permission); + dest.writeString(name); + dest.writeLong(date); + dest.writeLong(size); + dest.writeByte((byte) (isDirectory ? 1 : 0)); + } + + @NonNull + @Override + public String toString() { + return "HybridFileParcelable, path=[" + + getPath() + + "]" + + ", name=[" + + name + + "]" + + ", size=[" + + size + + "]" + + ", date=[" + + date + + "]" + + ", permission=[" + + permission + + "]"; + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof HybridFileParcelable)) { + return false; + } + + return getPath().equals(((HybridFileParcelable) obj).getPath()); + } + + @Override + public int hashCode() { + int result = getPath().hashCode(); + result = 37 * result + name.hashCode(); + result = 37 * result + (isDirectory ? 1 : 0); + result = 37 * result + (int) (size ^ size >>> 32); + result = 37 * result + (int) (date ^ date >>> 32); + return result; + } + + @NonNull + @Override + public String getParcelableName() { + return getName(); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/MakeDirectoryOperation.kt b/app/src/main/java/com/amaze/filemanager/filesystem/MakeDirectoryOperation.kt new file mode 100644 index 0000000..0106c0d --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/MakeDirectoryOperation.kt @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem + +import android.content.Context +import android.os.Build +import com.amaze.filemanager.fileoperations.filesystem.OpenMode +import com.amaze.filemanager.utils.OTGUtil +import jcifs.smb.SmbException +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.File +import java.io.IOException + +// This object is here to not polute the global namespace +// All functions must be static +object MakeDirectoryOperation { + private val log: Logger = LoggerFactory.getLogger(MakeDirectoryOperation::class.java) + + /** + * Create a folder. The folder may even be on external SD card for Kitkat. + * + * @param file The folder to be created. + * @return True if creation was successful. + */ + @JvmStatic + @Deprecated("use {@link #mkdirs(Context, HybridFile)}") + fun mkdir( + file: File?, + context: Context, + ): Boolean { + if (file == null) return false + if (file.exists()) { + // nothing to create. + return file.isDirectory + } + + // Try the normal way + if (file.mkdirs()) { + return true + } + + // Try with Storage Access Framework. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && + ExternalSdCardOperation.isOnExtSdCard(file, context) + ) { + val document = ExternalSdCardOperation.getDocumentFile(file, true, context) + document ?: return false + // getDocumentFile implicitly creates the directory. + return document.exists() + } + + // Try the Kitkat workaround. + return if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT) { + try { + MediaStoreHack.mkdir(context, file) + } catch (e: IOException) { + false + } + } else { + false + } + } + + /** + * Creates the directories on given [file] path, including nonexistent parent directories. + * So use proper [HybridFile] constructor as per your need. + * + * @return true if successfully created directory, otherwise returns false. + */ + @JvmStatic + fun mkdirs( + context: Context, + file: HybridFile, + ): Boolean { + var isSuccessful = true + when (file.mode) { + OpenMode.SMB -> + try { + val smbFile = file.smbFile + smbFile.mkdirs() + } catch (e: SmbException) { + log.warn("failed to make directory in smb", e) + isSuccessful = false + } + OpenMode.OTG -> { + val documentFile = OTGUtil.getDocumentFile(file.getPath(), context, true) + isSuccessful = documentFile != null + } + OpenMode.FILE -> isSuccessful = mkdir(File(file.getPath()), context) + // With ANDROID_DATA will not accept create directory + OpenMode.ANDROID_DATA -> isSuccessful = false + else -> isSuccessful = true + } + return isSuccessful + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/MakeFileOperation.kt b/app/src/main/java/com/amaze/filemanager/filesystem/MakeFileOperation.kt new file mode 100644 index 0000000..1e49ef1 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/MakeFileOperation.kt @@ -0,0 +1,153 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem + +import android.content.Context +import android.os.Build +import com.amaze.filemanager.ui.icons.MimeTypes +import com.amaze.filemanager.utils.AppConstants +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.OutputStreamWriter + +// This object is here to not polute the global namespace +// All functions must be static +object MakeFileOperation { + private val log: Logger = LoggerFactory.getLogger(MakeFileOperation::class.java) + + /** + * Get a temp file. + * + * @param file The base file for which to create a temp file. + * @return The temp file. + */ + @JvmStatic + fun getTempFile( + file: File, + context: Context, + ): File { + val extDir = context.getExternalFilesDir(null) + return File(extDir, file.name) + } + + /** + * Make normal file + * @param file File + * @param context Context + * @return true for success and false for failed + */ + @JvmStatic + fun mkfile( + file: File?, + context: Context, + ): Boolean { + if (file == null) return false + if (file.exists()) { + // nothing to create. + return !file.isDirectory + } + + // Try the normal way + try { + if (file.createNewFile()) { + return true + } + } catch (e: IOException) { + log.warn("failed to make file", e) + } + + // Try with Storage Access Framework. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && + ExternalSdCardOperation.isOnExtSdCard(file, context) + ) { + val document = ExternalSdCardOperation.getDocumentFile(file.parentFile, true, context) + // getDocumentFile implicitly creates the directory. + return try { + ( + document?.createFile( + MimeTypes.getMimeType(file.path, file.isDirectory), + file.name, + ) + != null + ) + } catch (e: UnsupportedOperationException) { + log.warn("Failed to create file on sd card using document file", e) + false + } + } + return if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT) { + MediaStoreHack.mkfile(context, file) + } else { + false + } + } + + /** + * Make text file + * @param data file data + * @param path path + * @param fileName file name + * @return true for success and false for failed + */ + @JvmStatic + fun mktextfile( + data: String?, + path: String?, + fileName: String, + ): Boolean { + val f = + File( + path, + "$fileName${AppConstants.NEW_FILE_DELIMITER}${AppConstants.NEW_FILE_EXTENSION_TXT}", + ) + var out: FileOutputStream? = null + var outputWriter: OutputStreamWriter? = null + return try { + if (f.createNewFile()) { + out = FileOutputStream(f, false) + outputWriter = OutputStreamWriter(out) + outputWriter.write(data) + true + } else { + false + } + } catch (io: IOException) { + log.warn("Error writing file contents", io) + false + } finally { + try { + if (outputWriter != null) { + outputWriter.flush() + outputWriter.close() + } + if (out != null) { + out.flush() + out.close() + } + } catch (e: IOException) { + log.warn("Error closing file output stream", e) + } + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/MediaStoreHack.java b/app/src/main/java/com/amaze/filemanager/filesystem/MediaStoreHack.java new file mode 100644 index 0000000..5336fd6 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/MediaStoreHack.java @@ -0,0 +1,301 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem; + +/** Created by Arpit on 29-06-2015. */ +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Locale; + +import com.amaze.filemanager.R; + +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.provider.BaseColumns; +import android.provider.MediaStore; +import android.util.Log; + +import androidx.annotation.Nullable; + +/** + * Wrapper for manipulating files via the Android Media Content Provider. As of Android 4.4 KitKat, + * applications can no longer write to the "secondary storage" of a device. Write operations using + * the java.io.File API will thus fail. This class restores access to those write operations by way + * of the Media Content Provider. Note that this class relies on the internal operational + * characteristics of the media content provider API, and as such is not guaranteed to be + * future-proof. Then again, we did all think the java.io.File API was going to be future-proof for + * media card access, so all bets are off. If you're forced to use this class, it's because + * Google/AOSP made a very poor API decision in Android 4.4 KitKat. Read more at + * https://plus.google.com/+TodLiebeck/posts/gjnmuaDM8sn Your application must declare the + * permission "android.permission.WRITE_EXTERNAL_STORAGE". Adapted from: + * http://forum.xda-developers.com/showpost.php?p=52151865&postcount=20 + * + * @author Jared Rummler + */ +public class MediaStoreHack { + private static final String TAG = "MediaStoreHack"; + + private static final String ALBUM_ART_URI = "content://media/external/audio/albumart"; + + private static final String[] ALBUM_PROJECTION = { + BaseColumns._ID, MediaStore.Audio.AlbumColumns.ALBUM_ID, "media_type" + }; + + /** + * Deletes the file. Returns true if the file has been successfully deleted or otherwise does not + * exist. This operation is not recursive. + */ + public static boolean delete(final Context context, final File file) { + final String where = MediaStore.MediaColumns.DATA + "=?"; + final String[] selectionArgs = new String[] {file.getAbsolutePath()}; + final ContentResolver contentResolver = context.getContentResolver(); + final Uri filesUri = MediaStore.Files.getContentUri("external"); + // Delete the entry from the media database. This will actually delete media files. + contentResolver.delete(filesUri, where, selectionArgs); + // If the file is not a media file, create a new entry. + if (file.exists()) { + final ContentValues values = new ContentValues(); + values.put(MediaStore.MediaColumns.DATA, file.getAbsolutePath()); + contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values); + // Delete the created entry, such that content provider will delete the file. + contentResolver.delete(filesUri, where, selectionArgs); + } + return !file.exists(); + } + + private static File getExternalFilesDir(final Context context) { + return context.getExternalFilesDir(null); + } + + public static InputStream getInputStream( + final Context context, final File file, final long size) { + try { + final String where = MediaStore.MediaColumns.DATA + "=?"; + final String[] selectionArgs = new String[] {file.getAbsolutePath()}; + final ContentResolver contentResolver = context.getContentResolver(); + final Uri filesUri = MediaStore.Files.getContentUri("external"); + contentResolver.delete(filesUri, where, selectionArgs); + final ContentValues values = new ContentValues(); + values.put(MediaStore.MediaColumns.DATA, file.getAbsolutePath()); + values.put(MediaStore.MediaColumns.SIZE, size); + final Uri uri = contentResolver.insert(filesUri, values); + return contentResolver.openInputStream(uri); + } catch (final Throwable t) { + return null; + } + } + + public static OutputStream getOutputStream(Context context, String str) { + OutputStream outputStream = null; + Uri fileUri = getUriFromFile(str, context); + if (fileUri != null) { + try { + outputStream = context.getContentResolver().openOutputStream(fileUri); + } catch (Throwable th) { + } + } + return outputStream; + } + + /** + * Fallback to get uri from a path. Used only as a workaround for Kitkat ext SD card + * + * @param path file path + * @param context context + * @return uri of file or null if resolver.query fails + */ + public static @Nullable Uri getUriFromFile(final String path, Context context) { + ContentResolver resolver = context.getContentResolver(); + + Cursor filecursor = + resolver.query( + MediaStore.Files.getContentUri("external"), + new String[] {BaseColumns._ID}, + MediaStore.MediaColumns.DATA + " = ?", + new String[] {path}, + MediaStore.MediaColumns.DATE_ADDED + " desc"); + + if (filecursor == null) { + Log.e(TAG, "Error when deleting file " + path); + return null; + } + + filecursor.moveToFirst(); + + if (filecursor.isAfterLast()) { + filecursor.close(); + ContentValues values = new ContentValues(); + values.put(MediaStore.MediaColumns.DATA, path); + return resolver.insert(MediaStore.Files.getContentUri("external"), values); + } else { + int imageId = filecursor.getInt(filecursor.getColumnIndex(BaseColumns._ID)); + Uri uri = + MediaStore.Files.getContentUri("external") + .buildUpon() + .appendEncodedPath(Integer.toString(imageId)) + .build(); + filecursor.close(); + return uri; + } + } + + /** Returns an OutputStream to write to the file. The file will be truncated immediately. */ + private static int getTemporaryAlbumId(final Context context) { + final File temporaryTrack; + try { + temporaryTrack = installTemporaryTrack(context); + } catch (final IOException ex) { + Log.w(MediaStoreHack.TAG, "Error installing temporary track.", ex); + return 0; + } + final Uri filesUri = MediaStore.Files.getContentUri("external"); + final String[] selectionArgs = {temporaryTrack.getAbsolutePath()}; + final ContentResolver contentResolver = context.getContentResolver(); + Cursor cursor = + contentResolver.query( + filesUri, ALBUM_PROJECTION, MediaStore.MediaColumns.DATA + "=?", selectionArgs, null); + if (cursor == null || !cursor.moveToFirst()) { + if (cursor != null) { + cursor.close(); + cursor = null; + } + final ContentValues values = new ContentValues(); + values.put(MediaStore.MediaColumns.DATA, temporaryTrack.getAbsolutePath()); + values.put(MediaStore.MediaColumns.TITLE, "{MediaWrite Workaround}"); + values.put(MediaStore.MediaColumns.SIZE, temporaryTrack.length()); + values.put(MediaStore.MediaColumns.MIME_TYPE, "audio/mpeg"); + values.put(MediaStore.Audio.AudioColumns.IS_MUSIC, true); + contentResolver.insert(filesUri, values); + } + cursor = + contentResolver.query( + filesUri, ALBUM_PROJECTION, MediaStore.MediaColumns.DATA + "=?", selectionArgs, null); + if (cursor == null) { + return 0; + } + if (!cursor.moveToFirst()) { + cursor.close(); + return 0; + } + final int id = cursor.getInt(0); + final int albumId = cursor.getInt(1); + final int mediaType = cursor.getInt(2); + cursor.close(); + final ContentValues values = new ContentValues(); + boolean updateRequired = false; + if (albumId == 0) { + values.put(MediaStore.Audio.AlbumColumns.ALBUM_ID, 13371337); + updateRequired = true; + } + if (mediaType != 2) { + values.put("media_type", 2); + updateRequired = true; + } + if (updateRequired) { + contentResolver.update(filesUri, values, BaseColumns._ID + "=" + id, null); + } + cursor = + contentResolver.query( + filesUri, ALBUM_PROJECTION, MediaStore.MediaColumns.DATA + "=?", selectionArgs, null); + if (cursor == null) { + return 0; + } + try { + if (!cursor.moveToFirst()) { + return 0; + } + return cursor.getInt(1); + } finally { + cursor.close(); + } + } + + private static File installTemporaryTrack(final Context context) throws IOException { + final File externalFilesDir = getExternalFilesDir(context); + if (externalFilesDir == null) { + return null; + } + final File temporaryTrack = new File(externalFilesDir, "temptrack.mp3"); + if (!temporaryTrack.exists()) { + InputStream in = null; + OutputStream out = null; + try { + in = context.getResources().openRawResource(R.raw.temptrack); + out = new FileOutputStream(temporaryTrack); + final byte[] buffer = new byte[4096]; + int bytesRead; + while ((bytesRead = in.read(buffer)) != -1) { + out.write(buffer, 0, bytesRead); + } + } finally { + out.close(); + in.close(); + } + } + return temporaryTrack; + } + + public static boolean mkdir(final Context context, final File file) throws IOException { + if (file.exists()) { + return file.isDirectory(); + } + final File tmpFile = new File(file, ".MediaWriteTemp"); + final int albumId = getTemporaryAlbumId(context); + if (albumId == 0) { + throw new IOException("Failed to create temporary album id."); + } + final Uri albumUri = Uri.parse(String.format(Locale.US, ALBUM_ART_URI + "/%d", albumId)); + final ContentValues values = new ContentValues(); + values.put(MediaStore.MediaColumns.DATA, tmpFile.getAbsolutePath()); + final ContentResolver contentResolver = context.getContentResolver(); + if (contentResolver.update(albumUri, values, null, null) == 0) { + values.put(MediaStore.Audio.AlbumColumns.ALBUM_ID, albumId); + contentResolver.insert(Uri.parse(ALBUM_ART_URI), values); + } + try { + final ParcelFileDescriptor fd = contentResolver.openFileDescriptor(albumUri, "r"); + fd.close(); + } finally { + delete(context, tmpFile); + } + return file.exists(); + } + + public static boolean mkfile(final Context context, final File file) { + final OutputStream outputStream = getOutputStream(context, file.getPath()); + if (outputStream == null) { + return false; + } + try { + outputStream.close(); + return true; + } catch (final IOException e) { + } + return false; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/Operations.java b/app/src/main/java/com/amaze/filemanager/filesystem/Operations.java new file mode 100644 index 0000000..02d46eb --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/Operations.java @@ -0,0 +1,837 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem; + +import static com.amaze.filemanager.ui.activities.MainActivity.TAG_INTENT_FILTER_FAILED_OPS; +import static com.amaze.filemanager.ui.activities.MainActivity.TAG_INTENT_FILTER_GENERAL; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.ArrayList; +import java.util.concurrent.Executor; + +import org.apache.commons.net.ftp.FTPClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.fileoperations.exceptions.ShellNotRunningException; +import com.amaze.filemanager.fileoperations.filesystem.OpenMode; +import com.amaze.filemanager.filesystem.cloud.CloudUtil; +import com.amaze.filemanager.filesystem.files.FileUtils; +import com.amaze.filemanager.filesystem.files.MediaConnectionUtils; +import com.amaze.filemanager.filesystem.ftp.FtpClientTemplate; +import com.amaze.filemanager.filesystem.ftp.NetCopyClientUtils; +import com.amaze.filemanager.filesystem.root.MakeDirectoryCommand; +import com.amaze.filemanager.filesystem.root.MakeFileCommand; +import com.amaze.filemanager.filesystem.root.RenameFileCommand; +import com.amaze.filemanager.filesystem.ssh.SFtpClientTemplate; +import com.amaze.filemanager.filesystem.ssh.SshClientUtils; +import com.amaze.filemanager.utils.DataUtils; +import com.amaze.filemanager.utils.OTGUtil; +import com.cloudrail.si.interfaces.CloudStorage; + +import android.content.Context; +import android.content.Intent; +import android.os.AsyncTask; +import android.os.Build; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.arch.core.util.Function; +import androidx.documentfile.provider.DocumentFile; + +import jcifs.smb.SmbException; +import jcifs.smb.SmbFile; +import net.schmizz.sshj.sftp.SFTPClient; + +public class Operations { + + private static final Executor executor = AsyncTask.THREAD_POOL_EXECUTOR; + + private static final Logger LOG = LoggerFactory.getLogger(Operations.class); + + // reserved characters by OS, shall not be allowed in file names + private static final String FOREWARD_SLASH = "/"; + private static final String BACKWARD_SLASH = "\\"; + private static final String COLON = ":"; + private static final String ASTERISK = "*"; + private static final String QUESTION_MARK = "?"; + private static final String QUOTE = "\""; + private static final String GREATER_THAN = ">"; + private static final String LESS_THAN = "<"; + + private static final String FAT = "FAT"; + + public interface ErrorCallBack { + + /** Callback fired when file being created in process already exists */ + void exists(HybridFile file); + + /** + * Callback fired when creating new file/directory and required storage access framework + * permission to access SD Card is not available + */ + void launchSAF(HybridFile file); + + /** + * Callback fired when renaming file and required storage access framework permission to access + * SD Card is not available + */ + void launchSAF(HybridFile file, HybridFile file1); + + /** + * Callback fired when we're done processing the operation + * + * @param b defines whether operation was successful + */ + void done(HybridFile hFile, boolean b); + + /** Callback fired when an invalid file name is found. */ + void invalidName(HybridFile file); + } + + public static void mkdir( + final HybridFile parentFile, + @NonNull final HybridFile file, + final Context context, + final boolean rootMode, + @NonNull final ErrorCallBack errorCallBack) { + + new AsyncTask() { + + private DataUtils dataUtils = DataUtils.getInstance(); + + private Function safCreateDirectory = + input -> { + if (input != null && input.isDirectory()) { + boolean result = false; + try { + result = input.createDirectory(file.getName(context)) != null; + } catch (Exception e) { + LOG.warn("Failed to make directory", e); + } + errorCallBack.done(file, result); + } else errorCallBack.done(file, false); + return null; + }; + + @Override + protected Void doInBackground(Void... params) { + // checking whether filename is valid or a recursive call possible + if (!Operations.isFileNameValid(file.getName(context))) { + errorCallBack.invalidName(file); + return null; + } + + if (file.exists()) { + errorCallBack.exists(file); + return null; + } + + // Android data directory, prohibit create directory + if (file.isAndroidDataDir()) { + errorCallBack.done(file, false); + return null; + } + + if (file.isSftp() || file.isFtp()) { + file.mkdir(context); + /* + FIXME: throw Exceptions from HybridFile.mkdir() so errorCallback can throw Exceptions + here + */ + errorCallBack.done(file, true); + return null; + } + if (file.isSmb()) { + try { + file.getSmbFile(2000).mkdirs(); + } catch (SmbException e) { + LOG.warn("failed to make smb directories", e); + errorCallBack.done(file, false); + return null; + } + errorCallBack.done(file, file.exists()); + return null; + } + if (file.isOtgFile()) { + if (checkOtgNewFileExists(file, context)) { + errorCallBack.exists(file); + return null; + } + safCreateDirectory.apply(OTGUtil.getDocumentFile(parentFile.getPath(), context, false)); + return null; + } + if (file.isDocumentFile()) { + if (checkDocumentFileNewFileExists(file, context)) { + errorCallBack.exists(file); + return null; + } + safCreateDirectory.apply( + OTGUtil.getDocumentFile( + parentFile.getPath(), + SafRootHolder.getUriRoot(), + context, + OpenMode.DOCUMENT_FILE, + false)); + return null; + } else if (file.isDropBoxFile()) { + CloudStorage cloudStorageDropbox = dataUtils.getAccount(OpenMode.DROPBOX); + try { + cloudStorageDropbox.createFolder(CloudUtil.stripPath(OpenMode.DROPBOX, file.getPath())); + errorCallBack.done(file, true); + } catch (Exception e) { + LOG.warn("failed to make directory in cloud connection", e); + errorCallBack.done(file, false); + } + } else if (file.isBoxFile()) { + CloudStorage cloudStorageBox = dataUtils.getAccount(OpenMode.BOX); + try { + cloudStorageBox.createFolder(CloudUtil.stripPath(OpenMode.BOX, file.getPath())); + errorCallBack.done(file, true); + } catch (Exception e) { + LOG.warn("failed to make directory in cloud connection", e); + errorCallBack.done(file, false); + } + } else if (file.isOneDriveFile()) { + CloudStorage cloudStorageOneDrive = dataUtils.getAccount(OpenMode.ONEDRIVE); + try { + cloudStorageOneDrive.createFolder( + CloudUtil.stripPath(OpenMode.ONEDRIVE, file.getPath())); + errorCallBack.done(file, true); + } catch (Exception e) { + LOG.warn("failed to make directory in cloud connection", e); + errorCallBack.done(file, false); + } + } else if (file.isGoogleDriveFile()) { + CloudStorage cloudStorageGdrive = dataUtils.getAccount(OpenMode.GDRIVE); + try { + cloudStorageGdrive.createFolder(CloudUtil.stripPath(OpenMode.GDRIVE, file.getPath())); + errorCallBack.done(file, true); + } catch (Exception e) { + LOG.warn("failed to make directory in cloud connection", e); + errorCallBack.done(file, false); + } + } else { + if (file.isLocal() || file.isRoot()) { + int mode = checkFolder(new File(file.getParent(context)), context); + if (mode == 2) { + errorCallBack.launchSAF(file); + return null; + } + if (mode == 1 || mode == 0) MakeDirectoryOperation.mkdir(file.getFile(), context); + if (!file.exists() && rootMode) { + file.setMode(OpenMode.ROOT); + if (file.exists()) errorCallBack.exists(file); + try { + MakeDirectoryCommand.INSTANCE.makeDirectory( + file.getParent(context), file.getName(context)); + } catch (ShellNotRunningException e) { + LOG.warn("failed to make directory in local filesystem", e); + } + errorCallBack.done(file, file.exists()); + return null; + } + errorCallBack.done(file, file.exists()); + return null; + } + + errorCallBack.done(file, file.exists()); + } + return null; + } + }.executeOnExecutor(executor); + } + + public static void mkfile( + final HybridFile parentFile, + @NonNull final HybridFile file, + final Context context, + final boolean rootMode, + @NonNull final ErrorCallBack errorCallBack) { + + new AsyncTask() { + + private DataUtils dataUtils = DataUtils.getInstance(); + + private Function safCreateFile = + input -> { + if (input != null && input.isDirectory()) { + boolean result = false; + try { + result = + input.createFile( + file.getName(context).substring(file.getName(context).lastIndexOf(".")), + file.getName(context)) + != null; + } catch (Exception e) { + LOG.warn(getClass().getSimpleName(), "Failed to make file", e); + } + errorCallBack.done(file, result); + } else errorCallBack.done(file, false); + return null; + }; + + @Override + protected Void doInBackground(Void... params) { + // check whether filename is valid or not + if (!Operations.isFileNameValid(file.getName(context))) { + errorCallBack.invalidName(file); + return null; + } + + if (file.exists()) { + errorCallBack.exists(file); + return null; + } + + // Android data directory, prohibit create file + if (file.isAndroidDataDir()) { + errorCallBack.done(file, false); + return null; + } + + if (file.isSftp() || file.isFtp()) { + OutputStream out = file.getOutputStream(context); + if (out == null) { + errorCallBack.done(file, false); + return null; + } + try { + out.close(); + errorCallBack.done(file, true); + return null; + } catch (IOException e) { + errorCallBack.done(file, false); + return null; + } + } + if (file.isSmb()) { + try { + file.getSmbFile(2000).createNewFile(); + } catch (SmbException e) { + LOG.warn("failed to make file in smb connection", e); + errorCallBack.done(file, false); + return null; + } + errorCallBack.done(file, file.exists()); + return null; + } else if (file.isDropBoxFile()) { + CloudStorage cloudStorageDropbox = dataUtils.getAccount(OpenMode.DROPBOX); + try { + byte[] tempBytes = new byte[0]; + ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(tempBytes); + cloudStorageDropbox.upload( + CloudUtil.stripPath(OpenMode.DROPBOX, file.getPath()), + byteArrayInputStream, + 0l, + true); + errorCallBack.done(file, true); + } catch (Exception e) { + LOG.warn("failed to make file in cloud connection", e); + errorCallBack.done(file, false); + } + } else if (file.isBoxFile()) { + CloudStorage cloudStorageBox = dataUtils.getAccount(OpenMode.BOX); + try { + byte[] tempBytes = new byte[0]; + ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(tempBytes); + cloudStorageBox.upload( + CloudUtil.stripPath(OpenMode.BOX, file.getPath()), byteArrayInputStream, 0l, true); + errorCallBack.done(file, true); + } catch (Exception e) { + LOG.warn("failed to make file in cloud connection", e); + errorCallBack.done(file, false); + } + } else if (file.isOneDriveFile()) { + CloudStorage cloudStorageOneDrive = dataUtils.getAccount(OpenMode.ONEDRIVE); + try { + byte[] tempBytes = new byte[0]; + ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(tempBytes); + cloudStorageOneDrive.upload( + CloudUtil.stripPath(OpenMode.ONEDRIVE, file.getPath()), + byteArrayInputStream, + 0l, + true); + errorCallBack.done(file, true); + } catch (Exception e) { + LOG.warn("failed to make file in cloud connection", e); + errorCallBack.done(file, false); + } + } else if (file.isGoogleDriveFile()) { + CloudStorage cloudStorageGdrive = dataUtils.getAccount(OpenMode.GDRIVE); + try { + byte[] tempBytes = new byte[0]; + ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(tempBytes); + cloudStorageGdrive.upload( + CloudUtil.stripPath(OpenMode.GDRIVE, file.getPath()), + byteArrayInputStream, + 0l, + true); + errorCallBack.done(file, true); + } catch (Exception e) { + LOG.warn("failed to make file in cloud connection", e); + errorCallBack.done(file, false); + } + } else if (file.isOtgFile()) { + if (checkOtgNewFileExists(file, context)) { + errorCallBack.exists(file); + return null; + } + safCreateFile.apply(OTGUtil.getDocumentFile(parentFile.getPath(), context, false)); + return null; + } else if (file.isDocumentFile()) { + if (checkDocumentFileNewFileExists(file, context)) { + errorCallBack.exists(file); + return null; + } + safCreateFile.apply( + OTGUtil.getDocumentFile( + parentFile.getPath(), + SafRootHolder.getUriRoot(), + context, + OpenMode.DOCUMENT_FILE, + false)); + return null; + } else { + if (file.isLocal() || file.isRoot()) { + int mode = checkFolder(new File(file.getParent(context)), context); + if (mode == 2) { + errorCallBack.launchSAF(file); + return null; + } + if (mode == 1 || mode == 0) MakeFileOperation.mkfile(file.getFile(), context); + if (!file.exists() && rootMode) { + file.setMode(OpenMode.ROOT); + if (file.exists()) errorCallBack.exists(file); + try { + MakeFileCommand.INSTANCE.makeFile(file.getPath()); + } catch (ShellNotRunningException e) { + LOG.warn("failed to make file in local filesystem", e); + } + errorCallBack.done(file, file.exists()); + return null; + } + errorCallBack.done(file, file.exists()); + return null; + } + errorCallBack.done(file, file.exists()); + } + return null; + } + }.executeOnExecutor(executor); + } + + public static void rename( + @NonNull final HybridFile oldFile, + @NonNull final HybridFile newFile, + final boolean rootMode, + @NonNull final Context context, + @NonNull final ErrorCallBack errorCallBack) { + + new AsyncTask() { + + private final DataUtils dataUtils = DataUtils.getInstance(); + + /** + * Determines whether double rename is required based on original and new file name regardless + * of the case-sensitivity of the filesystem + */ + private final boolean isCaseSensitiveRename = + oldFile.getSimpleName().equalsIgnoreCase(newFile.getSimpleName()) + && !oldFile.getSimpleName().equals(newFile.getSimpleName()); + + /** + * random string that is appended to file to prevent name collision, max file name is 255 + * bytes + */ + private static final String TEMP_FILE_EXT = "u0CtHRqWUnvxIaeBQ@nY2umVm9MDyR1P"; + + private boolean localRename(@NonNull HybridFile oldFile, @NonNull HybridFile newFile) { + File file = new File(oldFile.getPath()); + File file1 = new File(newFile.getPath()); + boolean result = false; + + switch (oldFile.getMode()) { + case FILE: + int mode = checkFolder(file.getParentFile(), context); + if (mode == 1 || mode == 0) { + try { + RenameOperation.renameFolder(file, file1, context); + } catch (ShellNotRunningException e) { + LOG.warn("failed to rename file in local filesystem", e); + } + result = !file.exists() && file1.exists(); + if (!result && rootMode) { + try { + RenameFileCommand.INSTANCE.renameFile(file.getPath(), file1.getPath()); + } catch (ShellNotRunningException e) { + LOG.warn("failed to rename file in local filesystem", e); + } + oldFile.setMode(OpenMode.ROOT); + newFile.setMode(OpenMode.ROOT); + result = !file.exists() && file1.exists(); + } + } + break; + case ROOT: + try { + result = RenameFileCommand.INSTANCE.renameFile(file.getPath(), file1.getPath()); + } catch (ShellNotRunningException e) { + LOG.warn("failed to rename file in root", e); + } + newFile.setMode(OpenMode.ROOT); + break; + } + return result; + } + + private boolean localDoubleRename(@NonNull HybridFile oldFile, @NonNull HybridFile newFile) { + HybridFile tempFile = new HybridFile(oldFile.mode, oldFile.getPath().concat(TEMP_FILE_EXT)); + if (localRename(oldFile, tempFile)) { + if (localRename(tempFile, newFile)) { + return true; + } else { + // attempts to rollback + // changes the temporary file name back to original file name + LOG.warn("reverting temporary file rename"); + return localRename(tempFile, oldFile); + } + } + return false; + } + + private Function safRenameFile = + input -> { + boolean result = false; + try { + result = input.renameTo(newFile.getName(context)); + } catch (Exception e) { + LOG.warn(getClass().getSimpleName(), "Failed to rename", e); + } + errorCallBack.done(newFile, result); + return null; + }; + + @Override + protected Void doInBackground(Void... params) { + // check whether file names for new file are valid or recursion occurs. + // If rename is on OTG, we are skipping + if (!Operations.isFileNameValid(newFile.getName(context))) { + errorCallBack.invalidName(newFile); + return null; + } + + if (newFile.exists() && !isCaseSensitiveRename) { + errorCallBack.exists(newFile); + return null; + } + + if (oldFile.isSmb()) { + try { + SmbFile smbFile = oldFile.getSmbFile(); + // FIXME: smbFile1 should be created from SmbUtil too so it can be mocked + SmbFile smbFile1 = new SmbFile(new URL(newFile.getPath()), smbFile.getContext()); + if (newFile.exists()) { + errorCallBack.exists(newFile); + return null; + } + smbFile.renameTo(smbFile1); + if (!smbFile.exists() && smbFile1.exists()) errorCallBack.done(newFile, true); + } catch (SmbException | MalformedURLException e) { + String errmsg = + context.getString( + R.string.cannot_rename_file, + HybridFile.parseAndFormatUriForDisplay(oldFile.getPath()), + e.getMessage()); + try { + ArrayList failedOps = new ArrayList<>(); + failedOps.add(new HybridFileParcelable(oldFile.getSmbFile())); + context.sendBroadcast( + new Intent(TAG_INTENT_FILTER_GENERAL) + .putParcelableArrayListExtra(TAG_INTENT_FILTER_FAILED_OPS, failedOps)); + } catch (SmbException exceptionThrownDuringBuildParcelable) { + LOG.error( + "Error creating HybridFileParcelable", exceptionThrownDuringBuildParcelable); + } + LOG.error(errmsg, e); + } + return null; + } else if (oldFile.isSftp()) { + SshClientUtils.execute( + new SFtpClientTemplate(oldFile.getPath(), true) { + @Override + public Void execute(@NonNull SFTPClient client) { + try { + client.rename( + NetCopyClientUtils.extractRemotePathFrom(oldFile.getPath()), + NetCopyClientUtils.extractRemotePathFrom(newFile.getPath())); + errorCallBack.done(newFile, true); + } catch (IOException e) { + String errmsg = + context.getString( + R.string.cannot_rename_file, + HybridFile.parseAndFormatUriForDisplay(oldFile.getPath()), + e.getMessage()); + LOG.error(errmsg); + ArrayList failedOps = new ArrayList<>(); + // Nobody care the size or actual permission here. Put a simple "r" and zero + // here + failedOps.add( + new HybridFileParcelable( + oldFile.getPath(), + "r", + oldFile.lastModified(), + 0, + oldFile.isDirectory(context))); + context.sendBroadcast( + new Intent(TAG_INTENT_FILTER_GENERAL) + .putParcelableArrayListExtra(TAG_INTENT_FILTER_FAILED_OPS, failedOps)); + errorCallBack.done(newFile, false); + } + return null; + } + }); + } else if (oldFile.isFtp()) { + NetCopyClientUtils.INSTANCE.execute( + new FtpClientTemplate(oldFile.getPath(), false) { + public Boolean executeWithFtpClient(@NonNull FTPClient ftpClient) + throws IOException { + boolean result = + ftpClient.rename( + NetCopyClientUtils.extractRemotePathFrom(oldFile.getPath()), + NetCopyClientUtils.extractRemotePathFrom(newFile.getPath())); + errorCallBack.done(newFile, result); + return result; + } + }); + } else if (oldFile.isDropBoxFile()) { + CloudStorage cloudStorageDropbox = dataUtils.getAccount(OpenMode.DROPBOX); + try { + cloudStorageDropbox.move( + CloudUtil.stripPath(OpenMode.DROPBOX, oldFile.getPath()), + CloudUtil.stripPath(OpenMode.DROPBOX, newFile.getPath())); + errorCallBack.done(newFile, true); + } catch (Exception e) { + LOG.warn("failed to rename file in cloud connection", e); + errorCallBack.done(newFile, false); + } + } else if (oldFile.isBoxFile()) { + CloudStorage cloudStorageBox = dataUtils.getAccount(OpenMode.BOX); + try { + cloudStorageBox.move( + CloudUtil.stripPath(OpenMode.BOX, oldFile.getPath()), + CloudUtil.stripPath(OpenMode.BOX, newFile.getPath())); + errorCallBack.done(newFile, true); + } catch (Exception e) { + LOG.warn("failed to rename file in cloud connection", e); + errorCallBack.done(newFile, false); + } + } else if (oldFile.isOneDriveFile()) { + CloudStorage cloudStorageOneDrive = dataUtils.getAccount(OpenMode.ONEDRIVE); + try { + cloudStorageOneDrive.move( + CloudUtil.stripPath(OpenMode.ONEDRIVE, oldFile.getPath()), + CloudUtil.stripPath(OpenMode.ONEDRIVE, newFile.getPath())); + errorCallBack.done(newFile, true); + } catch (Exception e) { + LOG.warn("failed to rename file in cloud connection", e); + errorCallBack.done(newFile, false); + } + } else if (oldFile.isGoogleDriveFile()) { + CloudStorage cloudStorageGdrive = dataUtils.getAccount(OpenMode.GDRIVE); + try { + cloudStorageGdrive.move( + CloudUtil.stripPath(OpenMode.GDRIVE, oldFile.getPath()), + CloudUtil.stripPath(OpenMode.GDRIVE, newFile.getPath())); + errorCallBack.done(newFile, true); + } catch (Exception e) { + LOG.warn("failed to rename file in cloud connection", e); + errorCallBack.done(newFile, false); + } + } else if (oldFile.isOtgFile()) { + if (checkOtgNewFileExists(newFile, context)) { + errorCallBack.exists(newFile); + return null; + } + safRenameFile.apply(OTGUtil.getDocumentFile(oldFile.getPath(), context, false)); + return null; + } else if (oldFile.isDocumentFile()) { + if (checkDocumentFileNewFileExists(newFile, context)) { + errorCallBack.exists(newFile); + return null; + } + safRenameFile.apply( + OTGUtil.getDocumentFile( + oldFile.getPath(), + SafRootHolder.getUriRoot(), + context, + OpenMode.DOCUMENT_FILE, + false)); + return null; + } else { + File file = new File(oldFile.getPath()); + if (oldFile.getMode() == OpenMode.FILE) { + int mode = checkFolder(file.getParentFile(), context); + if (mode == 2) { + errorCallBack.launchSAF(oldFile, newFile); + } + } + + boolean result; + if (isCaseSensitiveRename) { + result = localDoubleRename(oldFile, newFile); + } else { + result = localRename(oldFile, newFile); + } + errorCallBack.done(newFile, result); + } + return null; + } + + @Override + protected void onPostExecute(Void aVoid) { + super.onPostExecute(aVoid); + if (newFile != null && oldFile != null) { + HybridFile[] hybridFiles = {newFile, oldFile}; + MediaConnectionUtils.scanFile(context, hybridFiles); + } + } + }.executeOnExecutor(executor); + } + + private static boolean checkOtgNewFileExists(HybridFile newFile, Context context) { + boolean doesFileExist = false; + try { + doesFileExist = OTGUtil.getDocumentFile(newFile.getPath(), context, false) != null; + } catch (Exception e) { + LOG.debug("Failed find existing file", e); + } + return doesFileExist; + } + + private static boolean checkDocumentFileNewFileExists(HybridFile newFile, Context context) { + boolean doesFileExist = false; + try { + doesFileExist = + OTGUtil.getDocumentFile( + newFile.getPath(), + SafRootHolder.getUriRoot(), + context, + OpenMode.DOCUMENT_FILE, + false) + != null; + } catch (Exception e) { + LOG.warn("Failed to find existing file", e); + } + return doesFileExist; + } + + private static int checkFolder(final File folder, Context context) { + boolean lol = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP; + if (lol) { + + boolean ext = ExternalSdCardOperation.isOnExtSdCard(folder, context); + if (ext) { + + if (!folder.exists() || !folder.isDirectory()) { + return 0; + } + + // On Android 5, trigger storage access framework. + if (!FileProperties.isWritableNormalOrSaf(folder, context)) { + return 2; + } + return 1; + } + } else if (Build.VERSION.SDK_INT == 19) { + // Assume that Kitkat workaround works + if (ExternalSdCardOperation.isOnExtSdCard(folder, context)) return 1; + } + + // file not on external sd card + if (FileProperties.isWritable(new File(folder, FileUtils.DUMMY_FILE))) { + return 1; + } else { + return 0; + } + } + + /** + * Well, we wouldn't want to copy when the target is inside the source otherwise it'll end into a + * loop + * + * @return true when copy loop is possible + */ + public static boolean isCopyLoopPossible(HybridFileParcelable sourceFile, HybridFile targetFile) { + return targetFile.getPath().contains(sourceFile.getPath()); + } + + /** + * Validates file name special reserved characters shall not be allowed in the file names on FAT + * filesystems + * + * @param fileName the filename, not the full path! + * @return boolean if the file name is valid or invalid + */ + public static boolean isFileNameValid(String fileName) { + + // Trim the trailing slash if there is one. + if (fileName.endsWith("/")) fileName = fileName.substring(0, fileName.lastIndexOf('/') - 1); + // Trim the leading slashes if there is any. + if (fileName.contains("/")) fileName = fileName.substring(fileName.lastIndexOf('/') + 1); + + return !TextUtils.isEmpty(fileName) + && !(fileName.contains(ASTERISK) + || fileName.contains(BACKWARD_SLASH) + || fileName.contains(COLON) + || fileName.contains(FOREWARD_SLASH) + || fileName.contains(GREATER_THAN) + || fileName.contains(LESS_THAN) + || fileName.contains(QUESTION_MARK) + || fileName.contains(QUOTE)); + } + + private static boolean isFileSystemFAT(String mountPoint) { + String[] args = + new String[] { + "/bin/bash", + "-c", + "df -DO_NOT_REPLACE | awk '{print $1,$2,$NF}' | grep \"^" + mountPoint + "\"" + }; + try { + Process proc = new ProcessBuilder(args).start(); + OutputStream outputStream = proc.getOutputStream(); + String buffer = null; + outputStream.write(buffer.getBytes()); + return buffer != null && buffer.contains(FAT); + } catch (IOException e) { + LOG.warn("failed to determin is filesystem FAT", e); + // process interrupted, returning true, as a word of cation + return true; + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/PasteHelper.java b/app/src/main/java/com/amaze/filemanager/filesystem/PasteHelper.java new file mode 100644 index 0000000..74adac5 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/PasteHelper.java @@ -0,0 +1,213 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem; + +import static androidx.core.text.HtmlCompat.FROM_HTML_MODE_COMPACT; + +import java.util.ArrayList; +import java.util.Arrays; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.asynchronous.asynctasks.movecopy.PreparePasteTask; +import com.amaze.filemanager.ui.activities.MainActivity; +import com.amaze.filemanager.ui.fragments.MainFragment; +import com.amaze.filemanager.utils.Utils; +import com.google.android.material.snackbar.BaseTransientBottomBar; +import com.google.android.material.snackbar.Snackbar; + +import android.os.Parcel; +import android.os.Parcelable; +import android.text.Spanned; + +import androidx.annotation.Nullable; +import androidx.core.text.HtmlCompat; + +import io.reactivex.Single; +import io.reactivex.SingleObserver; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; + +/** + * Special immutable class for handling cut/copy operations. + * + * @author Emmanuel on 5/9/2017, at 09:59. + */ +public final class PasteHelper implements Parcelable { + + private static final Logger LOG = LoggerFactory.getLogger(PasteHelper.class); + + public static final int OPERATION_COPY = 0, OPERATION_CUT = 1; + + private final int operation; + private final HybridFileParcelable[] paths; + private Snackbar snackbar; + @Nullable private MainActivity mainActivity; + + public PasteHelper(@Nullable MainActivity mainActivity, int op, HybridFileParcelable[] paths) { + if (paths == null || paths.length == 0) throw new IllegalArgumentException(); + operation = op; + this.paths = paths; + this.mainActivity = mainActivity; + showSnackbar(); + } + + private PasteHelper(Parcel in) { + operation = in.readInt(); + paths = in.createTypedArray(HybridFileParcelable.CREATOR); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(operation); + dest.writeTypedArray(paths, 0); + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + public PasteHelper createFromParcel(Parcel in) { + return new PasteHelper(in); + } + + public PasteHelper[] newArray(int size) { + return new PasteHelper[size]; + } + }; + + public int getOperation() { + return this.operation; + } + + public HybridFileParcelable[] getPaths() { + return paths; + } + + /** + * Invalidates the snackbar after fragment changes / reapply config changes. Keeping the contents + * to copy/move intact + * + * @param mainActivity main activity + * @param showSnackbar whether to show snackbar or hide + */ + public void invalidateSnackbar(MainActivity mainActivity, boolean showSnackbar) { + this.mainActivity = mainActivity; + if (showSnackbar) { + showSnackbar(); + } else { + dismissSnackbar(false); + } + } + + public Snackbar getSnackbar() { + return snackbar; + } + + /** + * Dismisses snackbar and fab + * + * @param shouldClearPasteData should the paste data be cleared + */ + private void dismissSnackbar(boolean shouldClearPasteData) { + if (snackbar != null) { + snackbar.dismiss(); + snackbar = null; + } + if (shouldClearPasteData) { + mainActivity.setPaste(null); + } + } + + private void showSnackbar() { + Single.fromCallable(() -> getSnackbarContent()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + new SingleObserver() { + @Override + public void onSubscribe(Disposable d) {} + + @Override + public void onSuccess(Spanned spanned) { + snackbar = + Utils.showCutCopySnackBar( + mainActivity, + spanned, + BaseTransientBottomBar.LENGTH_INDEFINITE, + R.string.paste, + () -> { + final MainFragment mainFragment = mainActivity.getCurrentMainFragment(); + if (mainFragment == null) return; + String path = mainFragment.getCurrentPath(); + ArrayList arrayList = + new ArrayList<>(Arrays.asList(paths)); + boolean move = operation == PasteHelper.OPERATION_CUT; + new PreparePasteTask(mainActivity) + .execute( + path, + move, + mainActivity.isRootExplorer(), + mainFragment.getMainFragmentViewModel().getOpenMode(), + arrayList); + dismissSnackbar(true); + }, + () -> dismissSnackbar(true)); + } + + @Override + public void onError(Throwable e) { + LOG.warn("Failed to show paste snackbar" + e); + } + }); + } + + private Spanned getSnackbarContent() { + String operationText = "%s"; + operationText = + String.format( + operationText, + operation == OPERATION_COPY + ? mainActivity.getString(R.string.copy) + : mainActivity.getString(R.string.move)); + operationText = operationText.concat(": "); + int foldersCount = 0; + int filesCount = 0; + for (HybridFileParcelable fileParcelable : paths) { + if (fileParcelable.isDirectory(mainActivity.getApplicationContext())) { + foldersCount++; + } else { + filesCount++; + } + } + operationText = + operationText.concat( + mainActivity.getString(R.string.folderfilecount, foldersCount, filesCount)); + + return HtmlCompat.fromHtml(operationText, FROM_HTML_MODE_COMPACT); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/RenameOperation.kt b/app/src/main/java/com/amaze/filemanager/filesystem/RenameOperation.kt new file mode 100644 index 0000000..27a4e2b --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/RenameOperation.kt @@ -0,0 +1,200 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem + +import android.content.Context +import android.os.Build +import android.util.Log +import com.amaze.filemanager.fileoperations.exceptions.ShellNotRunningException +import com.amaze.filemanager.filesystem.MakeDirectoryOperation.mkdir +import com.amaze.filemanager.filesystem.root.RenameFileCommand.renameFile +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.io.OutputStream +import java.nio.channels.FileChannel + +object RenameOperation { + private val LOG = "RenameOperation" + + /** + * Copy a file. The target file may even be on external SD card for Kitkat. + * + * @param source The source file + * @param target The target file + * @return true if the copying was successful. + */ + @JvmStatic + private fun copyFile( + source: File, + target: File, + context: Context, + ): Boolean { + var inStream: FileInputStream? = null + var outStream: OutputStream? = null + var inChannel: FileChannel? = null + var outChannel: FileChannel? = null + try { + inStream = FileInputStream(source) + // First try the normal way + if (FileProperties.isWritable(target)) { + // standard way + outStream = FileOutputStream(target) + inChannel = inStream.channel + outChannel = outStream.channel + inChannel.transferTo(0, inChannel.size(), outChannel) + } else { + outStream = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + // Storage Access Framework + val targetDocument = + ExternalSdCardOperation.getDocumentFile(target, false, context) + targetDocument ?: throw IOException("Couldn't get DocumentFile") + context.contentResolver.openOutputStream(targetDocument.uri) + } else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT) { + // Workaround for Kitkat ext SD card + val uri = MediaStoreHack.getUriFromFile(target.absolutePath, context) + uri ?: return false + context.contentResolver.openOutputStream(uri) + } else { + return false + } + if (outStream != null) { + // Both for SAF and for Kitkat, write to output stream. + val buffer = ByteArray(16384) // MAGIC_NUMBER + var bytesRead: Int + while (inStream.read(buffer).also { bytesRead = it } != -1) { + outStream.write(buffer, 0, bytesRead) + } + } + } + } catch (e: IOException) { + Log.e( + LOG, + "Error when copying file from ${source.absolutePath} to ${target.absolutePath}", + e, + ) + return false + } finally { + try { + inStream?.close() + } catch (e: IOException) { + // ignore exception + } + try { + outStream?.close() + } catch (e: IOException) { + // ignore exception + } + try { + inChannel?.close() + } catch (e: IOException) { + // ignore exception + } + try { + outChannel?.close() + } catch (e: IOException) { + // ignore exception + } + } + return true + } + + @JvmStatic + @Throws(ShellNotRunningException::class) + private fun rename( + f: File, + name: String, + root: Boolean, + ): Boolean { + val parentName = f.parent ?: return false + val parentFile = f.parentFile ?: return false + + val newPath = "$parentName/$name" + if (parentFile.canWrite()) { + return f.renameTo(File(newPath)) + } else if (root) { + renameFile(f.path, newPath) + return true + } + return false + } + + /** + * Rename a folder. In case of extSdCard in Kitkat, the old folder stays in place, but files are + * moved. + * + * @param source The source folder. + * @param target The target folder. + * @return true if the renaming was successful. + */ + @JvmStatic + @Throws(ShellNotRunningException::class) + fun renameFolder( + source: File, + target: File, + context: Context, + ): Boolean { + // First try the normal rename. + if (rename(source, target.name, false)) { + return true + } + if (target.exists()) { + return false + } + + // Try the Storage Access Framework if it is just a rename within the same parent folder. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && + source.parent == target.parent && + ExternalSdCardOperation.isOnExtSdCard(source, context) + ) { + val document = ExternalSdCardOperation.getDocumentFile(source, true, context) + document ?: return false + if (document.renameTo(target.name)) { + return true + } + } + + // Try the manual way, moving files individually. + if (!mkdir(target, context)) { + return false + } + val sourceFiles = source.listFiles() as Array? + sourceFiles ?: return true + for (sourceFile in sourceFiles) { + val fileName = sourceFile.name + val targetFile = File(target, fileName) + if (!copyFile(sourceFile, targetFile, context)) { + // stop on first error + return false + } + } + // Only after successfully copying all files, delete files on source folder. + for (sourceFile in sourceFiles) { + if (!DeleteOperation.deleteFile(sourceFile, context)) { + // stop on first error + return false + } + } + return true + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/RootHelper.java b/app/src/main/java/com/amaze/filemanager/filesystem/RootHelper.java new file mode 100644 index 0000000..bead57d --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/RootHelper.java @@ -0,0 +1,161 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +import com.amaze.filemanager.fileoperations.filesystem.OpenMode; +import com.amaze.filemanager.filesystem.root.ListFilesCommand; + +import androidx.documentfile.provider.DocumentFile; + +public class RootHelper { + + public static final int CHMOD_READ = 4; + public static final int CHMOD_WRITE = 2; + public static final int CHMOD_EXECUTE = 1; + + private static final String UNIX_INPUT_WHITELIST = "[^a-zA-Z0-9@/:}{\\-_=+.,'\"\\s]"; + + public static String getCommandLineString(String input) { + return input.replaceAll(UNIX_INPUT_WHITELIST, ""); + } + + public static HybridFileParcelable generateBaseFile(File x, boolean showHidden) { + long size = 0; + if (!x.isDirectory()) size = x.length(); + HybridFileParcelable baseFile = + new HybridFileParcelable( + x.getPath(), parseFilePermission(x), x.lastModified(), size, x.isDirectory()); + baseFile.setName(x.getName()); + baseFile.setMode(OpenMode.FILE); + if (showHidden) { + return (baseFile); + } else if (!x.isHidden()) { + return (baseFile); + } + return null; + } + + public static String parseFilePermission(File f) { + String per = ""; + if (f.canRead()) { + per = per + "r"; + } + if (f.canWrite()) { + per = per + "w"; + } + if (f.canExecute()) { + per = per + "x"; + } + return per; + } + + public static String parseDocumentFilePermission(DocumentFile file) { + String per = ""; + if (file.canRead()) { + per = per + "r"; + } + if (file.canWrite()) { + per = per + "w"; + } + if (file.canWrite()) { + per = per + "x"; + } + return per; + } + + /** + * Whether a file exist at a specified path. We try to reload a list and conform from that list of + * parent's children that the file we're looking for is there or not. + */ + public static boolean fileExists(String path) { + File f = new File(path); + String p = f.getParent(); + if (p != null && p.length() > 0) { + List filesList = new ArrayList<>(); + ListFilesCommand.INSTANCE.listFiles( + p, + true, + true, + openMode -> null, + hybridFileParcelable -> { + filesList.add(hybridFileParcelable); + return null; + }); + for (HybridFileParcelable strings : filesList) { + if (strings.getPath() != null && strings.getPath().equals(path)) { + return true; + } + } + } + return false; + } + + /** + * Get a list of files using shell, supposing the path is not a SMB/OTG/Custom (*.apk/images) + * TODO: Avoid parsing ls + * + * @param root whether root is available or not + * @param showHidden to show hidden files + */ + public static ArrayList getFilesList( + String path, boolean root, boolean showHidden) { + ArrayList files = new ArrayList<>(); + ListFilesCommand.INSTANCE.listFiles( + path, + root, + showHidden, + openMode -> null, + hybridFileParcelable -> { + files.add(hybridFileParcelable); + return null; + }); + return files; + } + + /** + * This converts from a set of booleans to OCTAL permissions notations. For use with {@link + * com.amaze.filemanager.filesystem.root.ChangeFilePermissionsCommand->CHMOD_COMMAND} (true, + * false, false, true, true, false, false, false, true) => 0461 + */ + public static int permissionsToOctalString( + boolean ur, + boolean uw, + boolean ux, + boolean gr, + boolean gw, + boolean gx, + boolean or, + boolean ow, + boolean ox) { + int u = getPermissionInOctal(ur, uw, ux) << 6; + int g = getPermissionInOctal(gr, gw, gx) << 3; + int o = getPermissionInOctal(or, ow, ox); + return u | g | o; + } + + private static int getPermissionInOctal(boolean read, boolean write, boolean execute) { + return (read ? CHMOD_READ : 0) | (write ? CHMOD_WRITE : 0) | (execute ? CHMOD_EXECUTE : 0); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/SafRootHolder.kt b/app/src/main/java/com/amaze/filemanager/filesystem/SafRootHolder.kt new file mode 100644 index 0000000..4c3bafa --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/SafRootHolder.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem + +import android.net.Uri + +object SafRootHolder { + var uriRoot: Uri? = null + @JvmStatic set + + @JvmStatic get + var volumeLabel: String? = null + @JvmStatic set + + @JvmStatic get +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/cloud/CloudUtil.java b/app/src/main/java/com/amaze/filemanager/filesystem/cloud/CloudUtil.java new file mode 100644 index 0000000..bc7bb48 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/cloud/CloudUtil.java @@ -0,0 +1,311 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.cloud; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.adapters.data.IconDataParcelable; +import com.amaze.filemanager.database.CloudHandler; +import com.amaze.filemanager.fileoperations.exceptions.CloudPluginException; +import com.amaze.filemanager.fileoperations.filesystem.OpenMode; +import com.amaze.filemanager.fileoperations.filesystem.cloud.CloudStreamer; +import com.amaze.filemanager.filesystem.HybridFile; +import com.amaze.filemanager.filesystem.HybridFileParcelable; +import com.amaze.filemanager.ui.activities.MainActivity; +import com.amaze.filemanager.ui.icons.MimeTypes; +import com.amaze.filemanager.utils.DataUtils; +import com.amaze.filemanager.utils.OTGUtil; +import com.amaze.filemanager.utils.OnFileFound; +import com.cloudrail.si.interfaces.CloudStorage; +import com.cloudrail.si.types.CloudMetaData; + +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.net.Uri; +import android.os.AsyncTask; +import android.widget.Toast; + +import androidx.annotation.Nullable; +import androidx.documentfile.provider.DocumentFile; + +/** + * Created by vishal on 19/4/17. + * + *

Class provides helper methods for cloud utilities + */ +public class CloudUtil { + + private static final Logger LOG = LoggerFactory.getLogger(CloudUtil.class); + + /** + * @deprecated use getCloudFiles() + */ + public static ArrayList listFiles( + String path, CloudStorage cloudStorage, OpenMode openMode) throws CloudPluginException { + final ArrayList baseFiles = new ArrayList<>(); + getCloudFiles(path, cloudStorage, openMode, baseFiles::add); + return baseFiles; + } + + public static void getCloudFiles( + String path, CloudStorage cloudStorage, OpenMode openMode, OnFileFound fileFoundCallback) + throws CloudPluginException { + String strippedPath = stripPath(openMode, path); + try { + for (CloudMetaData cloudMetaData : cloudStorage.getChildren(strippedPath)) { + HybridFileParcelable baseFile = + new HybridFileParcelable( + path + "/" + cloudMetaData.getName(), + "", + (cloudMetaData.getModifiedAt() == null) ? 0l : cloudMetaData.getModifiedAt(), + cloudMetaData.getSize(), + cloudMetaData.getFolder()); + baseFile.setName(cloudMetaData.getName()); + baseFile.setMode(openMode); + fileFoundCallback.onFileFound(baseFile); + } + } catch (Exception e) { + LOG.warn("failed to get cloud files", e); + throw new CloudPluginException(); + } + } + + /** Strips down the cloud path to remove any prefix */ + public static String stripPath(OpenMode openMode, String path) { + final String prefix; + + switch (openMode) { + case DROPBOX: + prefix = CloudHandler.CLOUD_PREFIX_DROPBOX; + break; + case BOX: + prefix = CloudHandler.CLOUD_PREFIX_BOX; + break; + case ONEDRIVE: + prefix = CloudHandler.CLOUD_PREFIX_ONE_DRIVE; + break; + case GDRIVE: + prefix = CloudHandler.CLOUD_PREFIX_GOOGLE_DRIVE; + break; + default: + return path; + } + + if (path.equals(prefix + "/")) { + // we're at root, just replace the prefix + return path.replace(prefix, ""); + } else { + // we're not at root, replace prefix + / + // handle when paths are in format gdrive:/Documents // TODO: normalize drive paths + String pathReplaced = path.replace(prefix + "/", ""); + if (pathReplaced.equals(path)) { + // we convert gdrive:/Documents to /Documents + return path.replace(prefix.substring(0, prefix.length() - 1), ""); + } + return pathReplaced; + } + } + + public static void launchCloud( + final HybridFile baseFile, final OpenMode serviceType, final Activity activity) { + final CloudStreamer streamer = CloudStreamer.getInstance(); + + new Thread( + () -> { + try { + streamer.setStreamSrc( + baseFile.getInputStream(activity), + baseFile.getName(activity), + baseFile.length(activity)); + activity.runOnUiThread( + () -> { + try { + File file = + new File( + Uri.parse(CloudUtil.stripPath(serviceType, baseFile.getPath())) + .getPath()); + Uri uri = + Uri.parse(CloudStreamer.URL + Uri.fromFile(file).getEncodedPath()); + Intent i = new Intent(Intent.ACTION_VIEW); + i.setDataAndType( + uri, + MimeTypes.getMimeType( + baseFile.getPath(), baseFile.isDirectory(activity))); + PackageManager packageManager = activity.getPackageManager(); + List resInfos = packageManager.queryIntentActivities(i, 0); + if (resInfos != null && resInfos.size() > 0) activity.startActivity(i); + else + Toast.makeText( + activity, + activity.getString(R.string.smb_launch_error), + Toast.LENGTH_SHORT) + .show(); + } catch (ActivityNotFoundException e) { + LOG.warn("failed to launch cloud file in activity", e); + } + }); + } catch (Exception e) { + LOG.warn("failed to launch cloud file", e); + } + }) + .start(); + } + + /** + * Asynctask checks if the item pressed on is a cloud account, and if the token that is saved for + * it is invalid or not, in which case, we'll clear off the saved token and authenticate the user + * again + * + * @param path the path of item in drawer + * @param mainActivity reference to main activity to fire callbacks to delete/add connection + */ + public static void checkToken(String path, final MainActivity mainActivity) { + + new AsyncTask() { + OpenMode serviceType; + + @Override + protected Boolean doInBackground(String... params) { + final DataUtils dataUtils = DataUtils.getInstance(); + boolean isTokenValid = true; + String path = params[0]; + final CloudStorage cloudStorage; + + if (path.startsWith(CloudHandler.CLOUD_PREFIX_DROPBOX)) { + // dropbox account + serviceType = OpenMode.DROPBOX; + cloudStorage = dataUtils.getAccount(OpenMode.DROPBOX); + } else if (path.startsWith(CloudHandler.CLOUD_PREFIX_ONE_DRIVE)) { + + serviceType = OpenMode.ONEDRIVE; + cloudStorage = dataUtils.getAccount(OpenMode.ONEDRIVE); + } else if (path.startsWith(CloudHandler.CLOUD_PREFIX_BOX)) { + + serviceType = OpenMode.BOX; + cloudStorage = dataUtils.getAccount(OpenMode.BOX); + } else if (path.startsWith(CloudHandler.CLOUD_PREFIX_GOOGLE_DRIVE)) { + serviceType = OpenMode.GDRIVE; + cloudStorage = dataUtils.getAccount(OpenMode.GDRIVE); + } else { + throw new IllegalStateException(); + } + + try { + cloudStorage.getUserLogin(); + } catch (RuntimeException e) { + LOG.warn("Failed to validate user token for cloud connection", e); + isTokenValid = false; + } + return isTokenValid; + } + + @Override + protected void onPostExecute(Boolean aBoolean) { + super.onPostExecute(aBoolean); + + if (!aBoolean) { + // delete account and create a new one + Toast.makeText( + mainActivity, + mainActivity.getResources().getString(R.string.cloud_token_lost), + Toast.LENGTH_LONG) + .show(); + mainActivity.deleteConnection(serviceType); + mainActivity.addConnection(serviceType); + } + } + }.execute(path); + } + + /** Get an input stream for thumbnail for a given {@link IconDataParcelable} */ + @Nullable + public static InputStream getThumbnailInputStreamForCloud(Context context, String path) { + InputStream inputStream; + HybridFile hybridFile = new HybridFile(OpenMode.UNKNOWN, path); + hybridFile.generateMode(context); + DataUtils dataUtils = DataUtils.getInstance(); + + switch (hybridFile.getMode()) { + case SFTP: + inputStream = hybridFile.getInputStream(context); + break; + case FTP: + // Until we find a way to properly handle threading issues with thread unsafe FTPClient, + // we refrain from loading any files via FTP as file thumbnail. - TranceLove + inputStream = null; + break; + case SMB: + try { + inputStream = hybridFile.getSmbFile().getInputStream(); + } catch (IOException e) { + inputStream = null; + LOG.warn("failed to get inputstream for smb file for thumbnail", e); + } + break; + case OTG: + ContentResolver contentResolver = context.getContentResolver(); + DocumentFile documentSourceFile = + OTGUtil.getDocumentFile(hybridFile.getPath(), context, false); + try { + inputStream = contentResolver.openInputStream(documentSourceFile.getUri()); + } catch (FileNotFoundException e) { + LOG.warn("failed to get inputstream for otg for thumbnail", e); + inputStream = null; + } + break; + case DROPBOX: + case BOX: + case GDRIVE: + case ONEDRIVE: + OpenMode mode = hybridFile.getMode(); + + CloudStorage cloudStorageDropbox = dataUtils.getAccount(mode); + String stripped = CloudUtil.stripPath(mode, hybridFile.getPath()); + inputStream = cloudStorageDropbox.getThumbnail(stripped); + break; + default: + try { + inputStream = new FileInputStream(hybridFile.getPath()); + } catch (FileNotFoundException e) { + inputStream = null; + LOG.warn("failed to get inputstream for cloud files for thumbnail", e); + } + break; + } + + return inputStream; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/CompressedHelper.java b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/CompressedHelper.java new file mode 100644 index 0000000..5d36ad0 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/CompressedHelper.java @@ -0,0 +1,289 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.compressed; + +import java.io.File; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.amaze.filemanager.BuildConfig; +import com.amaze.filemanager.fileoperations.utils.UpdatePosition; +import com.amaze.filemanager.filesystem.compressed.extractcontents.Extractor; +import com.amaze.filemanager.filesystem.compressed.extractcontents.helpers.Bzip2Extractor; +import com.amaze.filemanager.filesystem.compressed.extractcontents.helpers.GzipExtractor; +import com.amaze.filemanager.filesystem.compressed.extractcontents.helpers.LzmaExtractor; +import com.amaze.filemanager.filesystem.compressed.extractcontents.helpers.RarExtractor; +import com.amaze.filemanager.filesystem.compressed.extractcontents.helpers.SevenZipExtractor; +import com.amaze.filemanager.filesystem.compressed.extractcontents.helpers.TarBzip2Extractor; +import com.amaze.filemanager.filesystem.compressed.extractcontents.helpers.TarExtractor; +import com.amaze.filemanager.filesystem.compressed.extractcontents.helpers.TarGzExtractor; +import com.amaze.filemanager.filesystem.compressed.extractcontents.helpers.TarLzmaExtractor; +import com.amaze.filemanager.filesystem.compressed.extractcontents.helpers.TarXzExtractor; +import com.amaze.filemanager.filesystem.compressed.extractcontents.helpers.XzExtractor; +import com.amaze.filemanager.filesystem.compressed.extractcontents.helpers.ZipExtractor; +import com.amaze.filemanager.filesystem.compressed.showcontents.Decompressor; +import com.amaze.filemanager.filesystem.compressed.showcontents.helpers.RarDecompressor; +import com.amaze.filemanager.filesystem.compressed.showcontents.helpers.SevenZipDecompressor; +import com.amaze.filemanager.filesystem.compressed.showcontents.helpers.TarBzip2Decompressor; +import com.amaze.filemanager.filesystem.compressed.showcontents.helpers.TarDecompressor; +import com.amaze.filemanager.filesystem.compressed.showcontents.helpers.TarGzDecompressor; +import com.amaze.filemanager.filesystem.compressed.showcontents.helpers.TarLzmaDecompressor; +import com.amaze.filemanager.filesystem.compressed.showcontents.helpers.TarXzDecompressor; +import com.amaze.filemanager.filesystem.compressed.showcontents.helpers.UnknownCompressedFileDecompressor; +import com.amaze.filemanager.filesystem.compressed.showcontents.helpers.ZipDecompressor; +import com.amaze.filemanager.utils.Utils; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public abstract class CompressedHelper { + private static final Logger LOG = LoggerFactory.getLogger(CompressedHelper.class); + + /** + * Path separator used by all Decompressors and Extractors. e.g. rar internally uses '\' but is + * converted to "/" for the app. + */ + public static final char SEPARATOR_CHAR = '/'; + + public static final String SEPARATOR = String.valueOf(SEPARATOR_CHAR).intern(); + + public static final String fileExtensionZip = "zip", + fileExtensionJar = "jar", + fileExtensionApk = "apk", + fileExtensionApks = "apks"; + public static final String fileExtensionTar = "tar"; + public static final String fileExtensionGzipTarLong = "tar.gz", fileExtensionGzipTarShort = "tgz"; + public static final String fileExtensionBzip2TarLong = "tar.bz2", + fileExtensionBzip2TarShort = "tbz"; + public static final String fileExtensionRar = "rar"; + public static final String fileExtension7zip = "7z"; + public static final String fileExtensionTarLzma = "tar.lzma"; + public static final String fileExtensionTarXz = "tar.xz"; + public static final String fileExtensionXz = "xz"; + public static final String fileExtensionLzma = "lzma"; + public static final String fileExtensionGz = "gz"; + public static final String fileExtensionBzip2 = "bz2"; + + private static final String TAG = CompressedHelper.class.getSimpleName(); + + /** To add compatibility with other compressed file types edit this method */ + @Nullable + public static Extractor getExtractorInstance( + @NonNull Context context, + @NonNull File file, + @NonNull String outputPath, + @NonNull Extractor.OnUpdate listener, + @NonNull UpdatePosition updatePosition) { + Extractor extractor; + String type = getExtension(file.getPath()); + + if (isZip(type)) { + extractor = new ZipExtractor(context, file.getPath(), outputPath, listener, updatePosition); + } else if (BuildConfig.FLAVOR.equals("play") && isRar(type)) { + extractor = new RarExtractor(context, file.getPath(), outputPath, listener, updatePosition); + } else if (isTar(type)) { + extractor = new TarExtractor(context, file.getPath(), outputPath, listener, updatePosition); + } else if (isGzippedTar(type)) { + extractor = new TarGzExtractor(context, file.getPath(), outputPath, listener, updatePosition); + } else if (isBzippedTar(type)) { + extractor = + new TarBzip2Extractor(context, file.getPath(), outputPath, listener, updatePosition); + } else if (isXzippedTar(type)) { + extractor = new TarXzExtractor(context, file.getPath(), outputPath, listener, updatePosition); + } else if (isLzippedTar(type)) { + extractor = + new TarLzmaExtractor(context, file.getPath(), outputPath, listener, updatePosition); + } else if (is7zip(type)) { + extractor = + new SevenZipExtractor(context, file.getPath(), outputPath, listener, updatePosition); + } else if (isLzma(type)) { + extractor = new LzmaExtractor(context, file.getPath(), outputPath, listener, updatePosition); + } else if (isXz(type)) { + extractor = new XzExtractor(context, file.getPath(), outputPath, listener, updatePosition); + } else if (isGzip(type)) { + extractor = new GzipExtractor(context, file.getPath(), outputPath, listener, updatePosition); + } else if (isBzip2(type)) { + extractor = new Bzip2Extractor(context, file.getPath(), outputPath, listener, updatePosition); + } else { + if (BuildConfig.DEBUG) { + throw new IllegalArgumentException("The compressed file has no way of opening it: " + file); + } + LOG.error("The compressed file has no way of opening it: " + file); + extractor = null; + } + + return extractor; + } + + /** To add compatibility with other compressed file types edit this method */ + @Nullable + public static Decompressor getCompressorInstance(@NonNull Context context, @NonNull File file) { + Decompressor decompressor; + String type = getExtension(file.getPath()); + + if (isZip(type)) { + decompressor = new ZipDecompressor(context); + } else if (BuildConfig.FLAVOR.equals("play") && isRar(type)) { + decompressor = new RarDecompressor(context); + } else if (isTar(type)) { + decompressor = new TarDecompressor(context); + } else if (isGzippedTar(type)) { + decompressor = new TarGzDecompressor(context); + } else if (isBzippedTar(type)) { + decompressor = new TarBzip2Decompressor(context); + } else if (isXzippedTar(type)) { + decompressor = new TarXzDecompressor(context); + } else if (isLzippedTar(type)) { + decompressor = new TarLzmaDecompressor(context); + } else if (is7zip(type)) { + decompressor = new SevenZipDecompressor(context); + } else if (isXz(type) || isLzma(type) || isGzip(type) || isBzip2(type)) { + // These 4 types are only compressing one single file. + // Hence invoking this UnknownCompressedFileDecompressor which only returns the filename + // without the compression extension + decompressor = new UnknownCompressedFileDecompressor(context); + } else { + if (BuildConfig.DEBUG) { + throw new IllegalArgumentException("The compressed file has no way of opening it: " + file); + } + + LOG.error("The compressed file has no way of opening it: " + file); + decompressor = null; + } + + if (decompressor != null) { + decompressor.setFilePath(file.getPath()); + } + + return decompressor; + } + + public static boolean isFileExtractable(String path) { + String type = getExtension(path); + + return isZip(type) + || isTar(type) + || isRar(type) + || isGzippedTar(type) + || is7zip(type) + || isBzippedTar(type) + || isXzippedTar(type) + || isLzippedTar(type) + || isBzip2(type) + || isGzip(type) + || isLzma(type) + || isXz(type); + } + + /** + * Gets the name of the file without compression extention. For example: "s.tar.gz" to "s" "s.tar" + * to "s" + */ + public static String getFileName(String compressedName) { + compressedName = compressedName.toLowerCase(); + boolean hasFileName = compressedName.contains("."); + if (hasFileName + && (isZip(compressedName) + || isTar(compressedName) + || isRar(compressedName) + || is7zip(compressedName) + || isXz(compressedName) + || isLzma(compressedName) + || isGzip(compressedName) + || compressedName.endsWith(fileExtensionGzipTarShort) + || compressedName.endsWith(fileExtensionBzip2TarShort) + || isGzip(compressedName) + || isBzip2(compressedName) + || isLzma(compressedName) + || isXz(compressedName))) { + return compressedName.substring(0, compressedName.lastIndexOf(".")); + } else if (hasFileName && isGzippedTar(compressedName) + || isXzippedTar(compressedName) + || isLzippedTar(compressedName) + || isBzippedTar(compressedName)) { + return compressedName.substring(0, Utils.nthToLastCharIndex(2, compressedName, '.')); + } else { + return compressedName; + } + } + + public static final boolean isEntryPathValid(String entryPath) { + return !entryPath.startsWith("..\\") && !entryPath.startsWith("../") && !entryPath.equals(".."); + } + + private static boolean isZip(String type) { + return type.endsWith(fileExtensionZip) + || type.endsWith(fileExtensionJar) + || type.endsWith(fileExtensionApk) + || type.endsWith(fileExtensionApks); + } + + private static boolean isTar(String type) { + return type.endsWith(fileExtensionTar); + } + + private static boolean isGzippedTar(String type) { + return type.endsWith(fileExtensionGzipTarLong) || type.endsWith(fileExtensionGzipTarShort); + } + + private static boolean isBzippedTar(String type) { + return type.endsWith(fileExtensionBzip2TarLong) || type.endsWith(fileExtensionBzip2TarShort); + } + + private static boolean isRar(String type) { + return type.endsWith(fileExtensionRar); + } + + private static boolean is7zip(String type) { + return type.endsWith(fileExtension7zip); + } + + private static boolean isXzippedTar(String type) { + return type.endsWith(fileExtensionTarXz); + } + + private static boolean isLzippedTar(String type) { + return type.endsWith(fileExtensionTarLzma); + } + + private static boolean isXz(String type) { + return type.endsWith(fileExtensionXz) && !isXzippedTar(type); + } + + private static boolean isLzma(String type) { + return type.endsWith(fileExtensionLzma) && !isLzippedTar(type); + } + + private static boolean isGzip(String type) { + return type.endsWith(fileExtensionGz) && !isGzippedTar(type); + } + + private static boolean isBzip2(String type) { + return type.endsWith(fileExtensionBzip2) && !isBzippedTar(type); + } + + private static String getExtension(String path) { + return path.substring(path.indexOf('.') + 1).toLowerCase(); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/Extractor.java b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/Extractor.java new file mode 100644 index 0000000..3e1a509 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/Extractor.java @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.compressed.extractcontents; + +import static com.amaze.filemanager.filesystem.compressed.CompressedHelper.SEPARATOR; +import static com.amaze.filemanager.filesystem.compressed.CompressedHelper.SEPARATOR_CHAR; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; + +import com.amaze.filemanager.fileoperations.utils.UpdatePosition; + +import android.content.Context; + +import androidx.annotation.NonNull; + +public abstract class Extractor { + + protected Context context; + protected String filePath, outputPath; + protected OnUpdate listener; + protected List invalidArchiveEntries; + protected UpdatePosition updatePosition; + + public Extractor( + @NonNull Context context, + @NonNull String filePath, + @NonNull String outputPath, + @NonNull Extractor.OnUpdate listener, + @NonNull UpdatePosition updatePosition) { + this.context = context; + this.filePath = filePath; + this.outputPath = outputPath; + this.listener = listener; + this.invalidArchiveEntries = new ArrayList<>(); + this.updatePosition = updatePosition; + } + + public void extractFiles(String[] files) throws IOException { + HashSet filesToExtract = new HashSet<>(files.length); + Collections.addAll(filesToExtract, files); + + extractWithFilter( + (relativePath, isDir) -> { + if (filesToExtract.contains(relativePath)) { + if (!isDir) filesToExtract.remove(relativePath); + return true; + } else { // header to be extracted is at least the entry path (may be more, when it is a + // directory) + for (String path : filesToExtract) { + if (relativePath.startsWith(path) || relativePath.startsWith("/" + path)) { + return true; + } + } + return false; + } + }); + } + + public void extractEverything() throws IOException { + extractWithFilter((relativePath, isDir) -> true); + } + + public List getInvalidArchiveEntries() { + return invalidArchiveEntries; + } + + protected abstract void extractWithFilter(@NonNull Filter filter) throws IOException; + + protected interface Filter { + boolean shouldExtract(String relativePath, boolean isDirectory); + } + + public interface OnUpdate { + void onStart(long totalBytes, String firstEntryName); + + void onUpdate(String entryPath); + + void onFinish(); + + boolean isCancelled(); + } + + protected String fixEntryName(String entryName) { + if (entryName.indexOf('\\') >= 0) { + return fixEntryName(entryName.replaceAll("\\\\", SEPARATOR)); + } else if (entryName.indexOf(SEPARATOR_CHAR) == 0) { + // if entryName starts with "/" (e.g. "/test.txt"), strip the prefixing "/"s + return entryName.replaceAll("^/+", ""); + } else { + return entryName; + } + } + + public static class EmptyArchiveNotice extends IOException {} + + public static class BadArchiveNotice extends IOException { + public BadArchiveNotice(@NonNull Throwable reason) { + super(reason); + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/AbstractCommonsArchiveExtractor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/AbstractCommonsArchiveExtractor.kt new file mode 100644 index 0000000..d9e8e63 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/AbstractCommonsArchiveExtractor.kt @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.compressed.extractcontents.helpers + +import android.content.Context +import com.amaze.filemanager.R +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.fileoperations.utils.UpdatePosition +import com.amaze.filemanager.filesystem.FileUtil +import com.amaze.filemanager.filesystem.MakeDirectoryOperation +import com.amaze.filemanager.filesystem.compressed.extractcontents.Extractor +import com.amaze.filemanager.filesystem.files.GenericCopyUtil +import org.apache.commons.compress.archivers.ArchiveEntry +import org.apache.commons.compress.archivers.ArchiveInputStream +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.IOException +import java.io.InputStream + +abstract class AbstractCommonsArchiveExtractor( + context: Context, + filePath: String, + outputPath: String, + listener: OnUpdate, + updatePosition: UpdatePosition, +) : Extractor(context, filePath, outputPath, listener, updatePosition) { + /** + * Subclasses implement this method to create [ArchiveInputStream] instances with given archive + * as [InputStream]. + * + * @param inputStream archive as [InputStream] + */ + abstract fun createFrom(inputStream: InputStream): ArchiveInputStream + + @Throws(IOException::class) + @Suppress("EmptyWhileBlock") + override fun extractWithFilter(filter: Filter) { + var totalBytes: Long = 0 + val archiveEntries = ArrayList() + var inputStream = createFrom(FileInputStream(filePath)) + var archiveEntry: ArchiveEntry? + try { + while (inputStream.nextEntry.also { archiveEntry = it } != null) { + archiveEntry?.run { + if (filter.shouldExtract(name, isDirectory)) { + archiveEntries.add(this) + totalBytes += size + } + } + } + if (archiveEntries.size > 0) { + listener.onStart(totalBytes, archiveEntries[0].name) + inputStream.close() + inputStream = createFrom(FileInputStream(filePath)) + archiveEntries.forEach { entry -> + if (!listener.isCancelled) { + listener.onUpdate(entry.name) + // TAR is sequential, you need to walk all the way to the file you want + while (entry.hashCode() != inputStream.nextEntry.hashCode()) {} + extractEntry(context, inputStream, entry, outputPath) + } + } + inputStream.close() + listener.onFinish() + } else { + throw EmptyArchiveNotice() + } + } finally { + inputStream.close() + } + } + + @Throws(IOException::class) + protected fun extractEntry( + context: Context, + inputStream: ArchiveInputStream, + entry: ArchiveEntry, + outputDir: String, + ) { + if (entry.isDirectory) { + MakeDirectoryOperation.mkdir(File(outputDir, entry.name), context) + return + } + val outputFile = File(outputDir, entry.name) + if (false == outputFile.parentFile?.exists()) { + MakeDirectoryOperation.mkdir(outputFile.parentFile, context) + } + FileUtil.getOutputStream(outputFile, context)?.let { fileOutputStream -> + BufferedOutputStream(fileOutputStream).run { + var len: Int + val buf = ByteArray(GenericCopyUtil.DEFAULT_BUFFER_SIZE) + while (inputStream.read(buf).also { len = it } != -1) { + write(buf, 0, len) + updatePosition.updatePosition(len.toLong()) + } + close() + outputFile.setLastModified(entry.lastModifiedDate.time) + } + } ?: AppConfig.toast( + context, + context.getString( + R.string.error_archive_cannot_extract, + entry.name, + outputDir, + ), + ) + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/AbstractCommonsCompressedFileExtractor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/AbstractCommonsCompressedFileExtractor.kt new file mode 100644 index 0000000..1157b6e --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/AbstractCommonsCompressedFileExtractor.kt @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.compressed.extractcontents.helpers + +import android.content.Context +import com.amaze.filemanager.R +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.fileoperations.utils.UpdatePosition +import com.amaze.filemanager.filesystem.FileUtil +import com.amaze.filemanager.filesystem.MakeDirectoryOperation +import com.amaze.filemanager.filesystem.compressed.extractcontents.Extractor +import com.amaze.filemanager.filesystem.files.GenericCopyUtil +import org.apache.commons.compress.compressors.CompressorInputStream +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.InputStream +import java.lang.reflect.Constructor + +abstract class AbstractCommonsCompressedFileExtractor( + context: Context, + filePath: String, + outputPath: String, + listener: OnUpdate, + updatePosition: UpdatePosition, +) : Extractor(context, filePath, outputPath, listener, updatePosition) { + private val compressorInputStreamConstructor: Constructor + + init { + compressorInputStreamConstructor = + getCompressorInputStreamClass() + .getDeclaredConstructor(InputStream::class.java) + compressorInputStreamConstructor.isAccessible = true + } + + /** + * Subclasses implement this method to specify the [CompressorInputStream] class to be used. + * + * @return Class representing the implementation will be handling + */ + abstract fun getCompressorInputStreamClass(): Class + + override fun extractWithFilter(filter: Filter) { + val entryName = filePath.substringAfterLast('/').substringBeforeLast('.') + runCatching { + compressorInputStreamConstructor + .newInstance(FileInputStream(filePath)) + .use { inputStream -> + val outputFile = File(outputPath, entryName) + if (false == outputFile.parentFile?.exists()) { + MakeDirectoryOperation.mkdir(outputFile.parentFile, context) + } + FileUtil.getOutputStream(outputFile, context)?.let { fileOutputStream -> + BufferedOutputStream(fileOutputStream).use { + var len: Int + val buf = ByteArray(GenericCopyUtil.DEFAULT_BUFFER_SIZE) + while (inputStream.read(buf).also { len = it } != -1) { + it.write(buf, 0, len) + updatePosition.updatePosition(len.toLong()) + } + listener.onFinish() + } + outputFile.setLastModified(File(filePath).lastModified()) + } ?: AppConfig.toast( + context, + context.getString( + R.string.error_archive_cannot_extract, + entryName, + outputPath, + ), + ) + } + }.onFailure { + throw BadArchiveNotice(it) + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/AbstractCompressedTarArchiveExtractor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/AbstractCompressedTarArchiveExtractor.kt new file mode 100644 index 0000000..1999ed7 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/AbstractCompressedTarArchiveExtractor.kt @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.compressed.extractcontents.helpers + +import android.content.Context +import com.amaze.filemanager.fileoperations.utils.UpdatePosition +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream +import org.apache.commons.compress.compressors.CompressorInputStream +import java.io.InputStream +import java.lang.reflect.Constructor + +abstract class AbstractCompressedTarArchiveExtractor( + context: Context, + filePath: String, + outputPath: String, + listener: OnUpdate, + updatePosition: UpdatePosition, +) : + AbstractCommonsArchiveExtractor(context, filePath, outputPath, listener, updatePosition) { + private val compressorInputStreamConstructor: Constructor + + init { + compressorInputStreamConstructor = + getCompressorInputStreamClass() + .getDeclaredConstructor(InputStream::class.java) + compressorInputStreamConstructor.isAccessible = true + } + + /** + * Subclasses implement this method to specify the [CompressorInputStream] class to be used. It + * will be used to create the backing inputstream beneath [TarArchiveInputStream] in + * [createFrom]. + * + * @return Class representing the implementation will be handling + */ + abstract fun getCompressorInputStreamClass(): Class + + override fun createFrom(inputStream: InputStream): TarArchiveInputStream { + return runCatching { + TarArchiveInputStream(compressorInputStreamConstructor.newInstance(inputStream)) + }.getOrElse { + throw BadArchiveNotice(it) + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/Bzip2Extractor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/Bzip2Extractor.kt new file mode 100644 index 0000000..38a05d8 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/Bzip2Extractor.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.compressed.extractcontents.helpers + +import android.content.Context +import com.amaze.filemanager.fileoperations.utils.UpdatePosition +import org.apache.commons.compress.compressors.CompressorInputStream +import org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream + +class Bzip2Extractor( + context: Context, + filePath: String, + outputPath: String, + listener: OnUpdate, + updatePosition: UpdatePosition, +) : AbstractCommonsCompressedFileExtractor( + context, + filePath, + outputPath, + listener, + updatePosition, + ) { + override fun getCompressorInputStreamClass(): Class { + return BZip2CompressorInputStream::class.java + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/GzipExtractor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/GzipExtractor.kt new file mode 100644 index 0000000..b20a507 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/GzipExtractor.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.compressed.extractcontents.helpers + +import android.content.Context +import com.amaze.filemanager.fileoperations.utils.UpdatePosition +import org.apache.commons.compress.compressors.CompressorInputStream +import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream + +class GzipExtractor( + context: Context, + filePath: String, + outputPath: String, + listener: OnUpdate, + updatePosition: UpdatePosition, +) : AbstractCommonsCompressedFileExtractor( + context, + filePath, + outputPath, + listener, + updatePosition, + ) { + override fun getCompressorInputStreamClass(): Class { + return GzipCompressorInputStream::class.java + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/LzmaExtractor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/LzmaExtractor.kt new file mode 100644 index 0000000..fd5021d --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/LzmaExtractor.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.compressed.extractcontents.helpers + +import android.content.Context +import com.amaze.filemanager.fileoperations.utils.UpdatePosition +import org.apache.commons.compress.compressors.CompressorInputStream +import org.apache.commons.compress.compressors.lzma.LZMACompressorInputStream + +class LzmaExtractor( + context: Context, + filePath: String, + outputPath: String, + listener: OnUpdate, + updatePosition: UpdatePosition, +) : AbstractCommonsCompressedFileExtractor( + context, + filePath, + outputPath, + listener, + updatePosition, + ) { + override fun getCompressorInputStreamClass(): Class { + return LZMACompressorInputStream::class.java + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/SevenZipExtractor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/SevenZipExtractor.kt new file mode 100644 index 0000000..b39c9db --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/SevenZipExtractor.kt @@ -0,0 +1,159 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.compressed.extractcontents.helpers + +import android.content.Context +import com.amaze.filemanager.R +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.fileoperations.filesystem.compressed.ArchivePasswordCache +import com.amaze.filemanager.fileoperations.utils.UpdatePosition +import com.amaze.filemanager.filesystem.FileUtil +import com.amaze.filemanager.filesystem.MakeDirectoryOperation +import com.amaze.filemanager.filesystem.compressed.extractcontents.Extractor +import com.amaze.filemanager.filesystem.compressed.sevenz.SevenZArchiveEntry +import com.amaze.filemanager.filesystem.compressed.sevenz.SevenZFile +import com.amaze.filemanager.filesystem.files.GenericCopyUtil +import org.apache.commons.compress.PasswordRequiredException +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.tukaani.xz.CorruptedInputException +import java.io.BufferedOutputStream +import java.io.File +import java.io.IOException + +class SevenZipExtractor( + context: Context, + filePath: String, + outputPath: String, + listener: OnUpdate, + updatePosition: UpdatePosition, +) : + Extractor(context, filePath, outputPath, listener, updatePosition) { + companion object { + @JvmStatic + private val LOG: Logger = LoggerFactory.getLogger(SevenZipExtractor::class.java) + } + + @Throws(IOException::class) + override fun extractWithFilter(filter: Filter) { + var totalBytes: Long = 0 + val sevenzFile = + runCatching { + if (ArchivePasswordCache.getInstance().containsKey(filePath)) { + SevenZFile( + File(filePath), + ArchivePasswordCache.getInstance()[filePath]!!.toCharArray(), + ) + } else { + SevenZFile(File(filePath)) + } + }.getOrElse { + if (it is PasswordRequiredException || it is CorruptedInputException) { + throw it + } else { + throw BadArchiveNotice(it) + } + } + val arrayList = ArrayList() + + // iterating archive elements to find file names that are to be extracted + for (entry in sevenzFile.entries) { + if (filter.shouldExtract(entry.name, entry.isDirectory)) { + // Entry to be extracted is at least the entry path + // (may be more, when it is a directory) + arrayList.add(entry) + totalBytes += entry.size + } + } + listener.onStart(totalBytes, arrayList[0].name) + var entry: SevenZArchiveEntry? + while (sevenzFile.nextEntry.also { entry = it } != null) { + if (!arrayList.contains(entry)) { + continue + } + if (!listener.isCancelled) { + listener.onUpdate(entry!!.name) + extractEntry(context, sevenzFile, entry!!, outputPath) + } + } + sevenzFile.close() + listener.onFinish() + } + + @Throws(IOException::class) + private fun extractEntry( + context: Context, + sevenzFile: SevenZFile, + entry: SevenZArchiveEntry, + outputDir: String, + ) { + val name = entry.name + if (entry.isDirectory) { + MakeDirectoryOperation.mkdir(File(outputDir, name), context) + return + } + val outputFile = File(outputDir, name) + if (!outputFile.parentFile.exists()) { + MakeDirectoryOperation.mkdir(outputFile.parentFile, context) + } + FileUtil.getOutputStream(outputFile, context)?.let { fileOutputStream -> + BufferedOutputStream(fileOutputStream).runCatching { + val content = ByteArray(GenericCopyUtil.DEFAULT_BUFFER_SIZE) + var progress: Long = 0 + while (progress < entry.size) { + var length: Int + val bytesLeft = java.lang.Long.valueOf(entry.size - progress).toInt() + length = + sevenzFile.read( + content, + 0, + if (bytesLeft > GenericCopyUtil.DEFAULT_BUFFER_SIZE) { + GenericCopyUtil.DEFAULT_BUFFER_SIZE + } else { + bytesLeft + }, + ) + write(content, 0, length) + updatePosition.updatePosition(length.toLong()) + progress += length.toLong() + } + close() + val lastModifiedDate = + try { + entry.lastModifiedDate.time + } catch (e: UnsupportedOperationException) { + LOG.warn("Unable to get modified date for 7zip file") + System.currentTimeMillis() + } + outputFile.setLastModified(lastModifiedDate) + } + }?.onFailure { + throw it + } ?: AppConfig.toast( + context, + context.getString( + R.string.error_archive_cannot_extract, + entry.name, + outputDir, + ), + ) + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/TarBzip2Extractor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/TarBzip2Extractor.kt new file mode 100644 index 0000000..fd44569 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/TarBzip2Extractor.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.compressed.extractcontents.helpers + +import android.content.Context +import com.amaze.filemanager.fileoperations.utils.UpdatePosition +import org.apache.commons.compress.compressors.CompressorInputStream +import org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream + +class TarBzip2Extractor( + context: Context, + filePath: String, + outputPath: String, + listener: OnUpdate, + updatePosition: UpdatePosition, +) : AbstractCompressedTarArchiveExtractor( + context, + filePath, + outputPath, + listener, + updatePosition, + ) { + override fun getCompressorInputStreamClass(): Class = BZip2CompressorInputStream::class.java +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/TarExtractor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/TarExtractor.kt new file mode 100644 index 0000000..b803433 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/TarExtractor.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.compressed.extractcontents.helpers + +import android.content.Context +import com.amaze.filemanager.fileoperations.utils.UpdatePosition +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream +import java.io.InputStream + +class TarExtractor( + context: Context, + filePath: String, + outputPath: String, + listener: OnUpdate, + updatePosition: UpdatePosition, +) : AbstractCommonsArchiveExtractor( + context, + filePath, + outputPath, + listener, + updatePosition, + ) { + override fun createFrom(inputStream: InputStream): TarArchiveInputStream = + runCatching { + TarArchiveInputStream(inputStream) + }.getOrElse { + throw BadArchiveNotice(it) + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/TarGzExtractor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/TarGzExtractor.kt new file mode 100644 index 0000000..3cafd24 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/TarGzExtractor.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.compressed.extractcontents.helpers + +import android.content.Context +import com.amaze.filemanager.fileoperations.utils.UpdatePosition +import org.apache.commons.compress.compressors.CompressorInputStream +import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream + +class TarGzExtractor( + context: Context, + filePath: String, + outputPath: String, + listener: OnUpdate, + updatePosition: UpdatePosition, +) : AbstractCompressedTarArchiveExtractor( + context, + filePath, + outputPath, + listener, + updatePosition, + ) { + override fun getCompressorInputStreamClass(): Class = GzipCompressorInputStream::class.java +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/TarLzmaExtractor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/TarLzmaExtractor.kt new file mode 100644 index 0000000..553f0fc --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/TarLzmaExtractor.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.compressed.extractcontents.helpers + +import android.content.Context +import com.amaze.filemanager.fileoperations.utils.UpdatePosition +import org.apache.commons.compress.compressors.CompressorInputStream +import org.apache.commons.compress.compressors.lzma.LZMACompressorInputStream + +class TarLzmaExtractor( + context: Context, + filePath: String, + outputPath: String, + listener: OnUpdate, + updatePosition: UpdatePosition, +) : AbstractCompressedTarArchiveExtractor( + context, + filePath, + outputPath, + listener, + updatePosition, + ) { + override fun getCompressorInputStreamClass(): Class = LZMACompressorInputStream::class.java +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/TarXzExtractor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/TarXzExtractor.kt new file mode 100644 index 0000000..6b701c2 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/TarXzExtractor.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.compressed.extractcontents.helpers + +import android.content.Context +import com.amaze.filemanager.fileoperations.utils.UpdatePosition +import org.apache.commons.compress.compressors.CompressorInputStream +import org.apache.commons.compress.compressors.xz.XZCompressorInputStream + +class TarXzExtractor( + context: Context, + filePath: String, + outputPath: String, + listener: OnUpdate, + updatePosition: UpdatePosition, +) : AbstractCompressedTarArchiveExtractor( + context, + filePath, + outputPath, + listener, + updatePosition, + ) { + override fun getCompressorInputStreamClass(): Class = XZCompressorInputStream::class.java +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/XzExtractor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/XzExtractor.kt new file mode 100644 index 0000000..0e219c1 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/XzExtractor.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.compressed.extractcontents.helpers + +import android.content.Context +import com.amaze.filemanager.fileoperations.utils.UpdatePosition +import org.apache.commons.compress.compressors.CompressorInputStream +import org.apache.commons.compress.compressors.xz.XZCompressorInputStream + +class XzExtractor( + context: Context, + filePath: String, + outputPath: String, + listener: OnUpdate, + updatePosition: UpdatePosition, +) : AbstractCommonsCompressedFileExtractor( + context, + filePath, + outputPath, + listener, + updatePosition, + ) { + override fun getCompressorInputStreamClass(): Class { + return XZCompressorInputStream::class.java + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/ZipExtractor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/ZipExtractor.kt new file mode 100644 index 0000000..368bbbf --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/ZipExtractor.kt @@ -0,0 +1,153 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.compressed.extractcontents.helpers + +import android.content.Context +import android.os.Build +import com.amaze.filemanager.R +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.fileoperations.filesystem.compressed.ArchivePasswordCache +import com.amaze.filemanager.fileoperations.utils.UpdatePosition +import com.amaze.filemanager.filesystem.FileUtil +import com.amaze.filemanager.filesystem.MakeDirectoryOperation +import com.amaze.filemanager.filesystem.compressed.CompressedHelper +import com.amaze.filemanager.filesystem.compressed.extractcontents.Extractor +import com.amaze.filemanager.filesystem.files.GenericCopyUtil +import net.lingala.zip4j.ZipFile +import net.lingala.zip4j.exception.ZipException +import net.lingala.zip4j.model.FileHeader +import org.apache.commons.compress.PasswordRequiredException +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.File +import java.io.IOException + +class ZipExtractor( + context: Context, + filePath: String, + outputPath: String, + listener: OnUpdate, + updatePosition: UpdatePosition, +) : Extractor(context, filePath, outputPath, listener, updatePosition) { + private val isRobolectricTest = Build.HARDWARE == "robolectric" + + @Throws(IOException::class) + override fun extractWithFilter(filter: Filter) { + var totalBytes: Long = 0 + val entriesToExtract: MutableList = ArrayList() + try { + val zipfile = ZipFile(filePath) + if (ArchivePasswordCache.getInstance().containsKey(filePath)) { + zipfile.setPassword(ArchivePasswordCache.getInstance()[filePath]!!.toCharArray()) + } + + // iterating archive elements to find file names that are to be extracted + zipfile.fileHeaders.forEach { obj -> + val fileHeader = obj as FileHeader + if (CompressedHelper.isEntryPathValid(fileHeader.fileName)) { + if (filter.shouldExtract(fileHeader.fileName, fileHeader.isDirectory)) { + entriesToExtract.add(fileHeader) + totalBytes += fileHeader.uncompressedSize + } + } else { + invalidArchiveEntries.add(fileHeader.fileName) + } + } + if (entriesToExtract.size > 0) { + listener.onStart(totalBytes, entriesToExtract[0].fileName) + for (entry in entriesToExtract) { + if (!listener.isCancelled) { + listener.onUpdate(entry.fileName) + extractEntry(context, zipfile, entry, outputPath) + } + } + } else { + throw EmptyArchiveNotice() + } + listener.onFinish() + } catch (e: ZipException) { + if (true == e.message?.lowercase()?.contains("password")) { + // Hack. + // zip4j uses ZipException for all problems, so we need to distinguish password + // related problems and throw PasswordRequiredException here + throw PasswordRequiredException(e.message) + } else { + throw BadArchiveNotice(e) + } + } + } + + /** + * Method extracts [FileHeader] from [ZipFile] + * + * @param zipFile zip file from which entriesToExtract are to be extracted + * @param entry zip entry that is to be extracted + * @param outputDir output directory + */ + @Throws(IOException::class) + private fun extractEntry( + context: Context, + zipFile: ZipFile, + entry: FileHeader, + outputDir: String, + ) { + val outputFile = File(outputDir, fixEntryName(entry.fileName)) + if (!outputFile.canonicalPath.startsWith(outputDir) && + (isRobolectricTest && !outputFile.canonicalPath.startsWith("/private$outputDir")) + ) { + throw IOException("Incorrect ZipEntry path!") + } + if (entry.isDirectory) { + // zip entry is a directory, return after creating new directory + MakeDirectoryOperation.mkdir(outputFile, context) + return + } + if (!outputFile.parentFile.exists()) { + // creating directory if not already exists + MakeDirectoryOperation.mkdir(outputFile.parentFile, context) + } + BufferedInputStream(zipFile.getInputStream(entry)).use { inputStream -> + FileUtil.getOutputStream(outputFile, context)?.let { fileOutputStream -> + BufferedOutputStream(fileOutputStream).run { + var len: Int + val buf = ByteArray(GenericCopyUtil.DEFAULT_BUFFER_SIZE) + while (inputStream.read(buf).also { len = it } != -1) { + if (!listener.isCancelled) { + write(buf, 0, len) + updatePosition.updatePosition(len.toLong()) + } else { + break + } + } + close() + outputFile.setLastModified(entry.lastModifiedTimeEpoch) + } + } ?: AppConfig.toast( + context, + context.getString( + R.string.error_archive_cannot_extract, + entry.fileName, + outputDir, + ), + ) + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/showcontents/Decompressor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/showcontents/Decompressor.kt new file mode 100644 index 0000000..65f9fa0 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/showcontents/Decompressor.kt @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.compressed.showcontents + +import android.content.Context +import android.content.Intent +import com.amaze.filemanager.asynchronous.asynctasks.compress.CompressedHelperCallable +import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil +import com.amaze.filemanager.asynchronous.services.ExtractService + +/** @author Emmanuel on 20/11/2017, at 17:14. + */ +abstract class Decompressor(protected var context: Context) { + lateinit var filePath: String + + /** + * Separator must be "/" + * + * @param path end with "/" if it is a directory, does not if it's a file + */ + abstract fun changePath( + path: String, + addGoBackItem: Boolean, + ): CompressedHelperCallable + + /** Decompress a file somewhere */ + fun decompress(whereToDecompress: String) { + val intent = + Intent(context, ExtractService::class.java).also { + it.putExtra(ExtractService.KEY_PATH_ZIP, filePath) + it.putExtra(ExtractService.KEY_ENTRIES_ZIP, arrayOfNulls(0)) + it.putExtra(ExtractService.KEY_PATH_EXTRACT, whereToDecompress) + } + ServiceWatcherUtil.runService(context, intent) + } + + /** + * Decompress files or dirs inside the compressed file. + * + * @param subDirectories separator is "/", ended with "/" if it is a directory, does not if it's a + * file + */ + fun decompress( + whereToDecompress: String, + subDirectories: Array, + ) { + subDirectories.filterNotNull().map { + realRelativeDirectory(it) + }.run { + val intent = + Intent(context, ExtractService::class.java).also { + it.putExtra(ExtractService.KEY_PATH_ZIP, filePath) + it.putExtra(ExtractService.KEY_ENTRIES_ZIP, subDirectories) + it.putExtra(ExtractService.KEY_PATH_EXTRACT, whereToDecompress) + } + ServiceWatcherUtil.runService(context, intent) + } + } + + /** Get the real relative directory path (useful if you converted the separator or something) */ + protected open fun realRelativeDirectory(dir: String): String { + return dir + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/showcontents/helpers/SevenZipDecompressor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/showcontents/helpers/SevenZipDecompressor.kt new file mode 100644 index 0000000..7c8aedd --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/showcontents/helpers/SevenZipDecompressor.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.compressed.showcontents.helpers + +import android.content.Context +import com.amaze.filemanager.asynchronous.asynctasks.compress.SevenZipHelperCallable +import com.amaze.filemanager.filesystem.compressed.showcontents.Decompressor + +class SevenZipDecompressor(context: Context) : Decompressor(context) { + override fun changePath( + path: String, + addGoBackItem: Boolean, + ) = SevenZipHelperCallable(filePath, path, addGoBackItem) +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/showcontents/helpers/TarBzip2Decompressor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/showcontents/helpers/TarBzip2Decompressor.kt new file mode 100644 index 0000000..9a63002 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/showcontents/helpers/TarBzip2Decompressor.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.compressed.showcontents.helpers + +import android.content.Context +import com.amaze.filemanager.asynchronous.asynctasks.compress.TarBzip2HelperCallable +import com.amaze.filemanager.filesystem.compressed.showcontents.Decompressor + +class TarBzip2Decompressor(context: Context) : Decompressor(context) { + override fun changePath( + path: String, + addGoBackItem: Boolean, + ) = TarBzip2HelperCallable(context, filePath, path, addGoBackItem) +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/showcontents/helpers/TarDecompressor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/showcontents/helpers/TarDecompressor.kt new file mode 100644 index 0000000..6de4cf5 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/showcontents/helpers/TarDecompressor.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.compressed.showcontents.helpers + +import android.content.Context +import com.amaze.filemanager.asynchronous.asynctasks.compress.TarHelperCallable +import com.amaze.filemanager.filesystem.compressed.showcontents.Decompressor + +class TarDecompressor(context: Context) : Decompressor(context) { + override fun changePath( + path: String, + addGoBackItem: Boolean, + ) = TarHelperCallable(context, filePath, path, addGoBackItem) +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/showcontents/helpers/TarGzDecompressor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/showcontents/helpers/TarGzDecompressor.kt new file mode 100644 index 0000000..870f26a --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/showcontents/helpers/TarGzDecompressor.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.compressed.showcontents.helpers + +import android.content.Context +import com.amaze.filemanager.asynchronous.asynctasks.compress.TarGzHelperCallable +import com.amaze.filemanager.filesystem.compressed.showcontents.Decompressor + +class TarGzDecompressor(context: Context) : Decompressor(context) { + override fun changePath( + path: String, + addGoBackItem: Boolean, + ) = TarGzHelperCallable(context, filePath, path, addGoBackItem) +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/showcontents/helpers/TarLzmaDecompressor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/showcontents/helpers/TarLzmaDecompressor.kt new file mode 100644 index 0000000..4151224 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/showcontents/helpers/TarLzmaDecompressor.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.compressed.showcontents.helpers + +import android.content.Context +import com.amaze.filemanager.asynchronous.asynctasks.compress.TarLzmaHelperCallable +import com.amaze.filemanager.filesystem.compressed.showcontents.Decompressor + +class TarLzmaDecompressor(context: Context) : Decompressor(context) { + override fun changePath( + path: String, + addGoBackItem: Boolean, + ) = TarLzmaHelperCallable(context, filePath, path, addGoBackItem) +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/showcontents/helpers/TarXzDecompressor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/showcontents/helpers/TarXzDecompressor.kt new file mode 100644 index 0000000..ef89a5e --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/showcontents/helpers/TarXzDecompressor.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.compressed.showcontents.helpers + +import android.content.Context +import com.amaze.filemanager.asynchronous.asynctasks.compress.TarXzHelperCallable +import com.amaze.filemanager.filesystem.compressed.showcontents.Decompressor + +class TarXzDecompressor(context: Context) : Decompressor(context) { + override fun changePath( + path: String, + addGoBackItem: Boolean, + ) = TarXzHelperCallable(context, filePath, path, addGoBackItem) +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/showcontents/helpers/UnknownCompressedFileDecompressor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/showcontents/helpers/UnknownCompressedFileDecompressor.kt new file mode 100644 index 0000000..a72ae88 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/showcontents/helpers/UnknownCompressedFileDecompressor.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.compressed.showcontents.helpers + +import android.content.Context +import com.amaze.filemanager.asynchronous.asynctasks.compress.UnknownCompressedFileHelperCallable +import com.amaze.filemanager.filesystem.compressed.showcontents.Decompressor + +/** + * Used by files compressed with gzip, bz2, lzma and xz. + */ +class UnknownCompressedFileDecompressor(context: Context) : Decompressor(context) { + override fun changePath( + path: String, + addGoBackItem: Boolean, + ) = UnknownCompressedFileHelperCallable(filePath, addGoBackItem) +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/showcontents/helpers/ZipDecompressor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/showcontents/helpers/ZipDecompressor.kt new file mode 100644 index 0000000..f9288c2 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/showcontents/helpers/ZipDecompressor.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.compressed.showcontents.helpers + +import android.content.Context +import com.amaze.filemanager.asynchronous.asynctasks.compress.ZipHelperCallable +import com.amaze.filemanager.filesystem.compressed.showcontents.Decompressor + +class ZipDecompressor(context: Context) : Decompressor(context) { + override fun changePath( + path: String, + addGoBackItem: Boolean, + ) = ZipHelperCallable(context, filePath, path, addGoBackItem) +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/files/CryptUtil.java b/app/src/main/java/com/amaze/filemanager/filesystem/files/CryptUtil.java new file mode 100644 index 0000000..b6ea1a7 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/files/CryptUtil.java @@ -0,0 +1,397 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.files; + +import static android.os.Build.VERSION.SDK_INT; +import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2; +import static android.os.Build.VERSION_CODES.KITKAT; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.Key; +import java.security.spec.AlgorithmParameterSpec; +import java.util.ArrayList; + +import javax.crypto.Cipher; +import javax.crypto.CipherOutputStream; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.IvParameterSpec; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.amaze.filemanager.BuildConfig; +import com.amaze.filemanager.application.AppConfig; +import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil; +import com.amaze.filemanager.fileoperations.filesystem.OpenMode; +import com.amaze.filemanager.filesystem.HybridFile; +import com.amaze.filemanager.filesystem.HybridFileParcelable; +import com.amaze.filemanager.filesystem.MakeDirectoryOperation; +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants; +import com.amaze.filemanager.utils.AESCrypt; +import com.amaze.filemanager.utils.ProgressHandler; +import com.amaze.filemanager.utils.security.SecretKeygen; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import kotlin.io.ByteStreamsKt; +import kotlin.io.ConstantsKt; + +/** + * Created by vishal on 6/4/17. + * + *

Class provide helper methods to encrypt/decrypt various type of files, or passwords We take + * the password from user before encrypting file. First, the password is encrypted against the key + * created in keystore in android {@see #encryptPassword(String)}. We're using AES encryption with + * GCM as the processor algorithm. The encrypted password is mapped against the file path to be + * encrypted in database for later use. This is handled by the service invoking this instance. The + * service then calls the constructor which fires up the subsequent encryption/decryption process. + * + *

We differentiate between already encrypted files from new ones by encrypting the + * plaintext {@link PreferencesConstants#ENCRYPT_PASSWORD_MASTER} and {@link + * PreferencesConstants#ENCRYPT_PASSWORD_FINGERPRINT} against the path in database. At the time of + * decryption, we check for these values and either retrieve master password from preferences or + * fire up the fingerprint sensor authentication. + * + *

From new ones we mean the ones when were encrypted after user changed preference for + * master password/fingerprint sensor from settings. + * + *

We use buffered streams to process files, usage of NIO will probably mildly effect the + * performance. + * + *

Be sure to use constructors to encrypt/decrypt files only, and to call service through {@link + * ServiceWatcherUtil} and to initialize watchers beforehand + */ +public class CryptUtil { + + public static final String KEY_STORE_ANDROID = "AndroidKeyStore"; + public static final String KEY_ALIAS_AMAZE = "AmazeKey"; + public static final String ALGO_AES = "AES/GCM/NoPadding"; + // TODO: Generate a random IV every time, and keep track of it (in database against encrypted + // files) + private static final String IV = + BuildConfig.CRYPTO_IV; // 12 byte long IV supported by android for GCM + private static final int GCM_TAG_LENGTH = 128; + private final Logger LOG = LoggerFactory.getLogger(CryptUtil.class); + + public static final String CRYPT_EXTENSION = ".aze"; + public static final String AESCRYPT_EXTENSION = ".aes"; + + private final ProgressHandler progressHandler; + private final ArrayList failedOps; + + /** + * Constructor will start encryption process serially. Make sure to call with background thread. + * The result file of encryption will be in the same directory with a {@link #CRYPT_EXTENSION} + * extension + * + *

Make sure you're done with encrypting password for this file and map it with this file in + * database + * + *

Be sure to use constructors to encrypt/decrypt files only, and to call service through + * {@link ServiceWatcherUtil} and to initialize watchers beforehand + * + * @param sourceFile the file to encrypt + */ + public CryptUtil( + @NonNull Context context, + @NonNull HybridFileParcelable sourceFile, + @NonNull ProgressHandler progressHandler, + @NonNull ArrayList failedOps, + @NonNull String targetFilename, + boolean useAesCrypt, + @Nullable String password) + throws GeneralSecurityException, IOException { + + this.progressHandler = progressHandler; + this.failedOps = failedOps; + + // target encrypted file + HybridFile hFile = new HybridFile(sourceFile.getMode(), sourceFile.getParent(context)); + encrypt(context, sourceFile, hFile, targetFilename, useAesCrypt, password); + } + + /** + * Decrypt the file in specified path. Can be used to open the file (decrypt in cache) or simply + * decrypt the file in the same (or in a custom preference) directory Make sure to decrypt and + * check user provided passwords beforehand from database + * + *

Be sure to use constructors to encrypt/decrypt files only, and to call service through + * {@link ServiceWatcherUtil} and to initialize watchers beforehand + * + * @param baseFile the encrypted file + * @param targetPath the directory in which file is to be decrypted the source's parent in normal + * case + */ + public CryptUtil( + @NonNull Context context, + @NonNull HybridFileParcelable baseFile, + @NonNull String targetPath, + @NonNull ProgressHandler progressHandler, + @NonNull ArrayList failedOps, + @Nullable String password) + throws GeneralSecurityException, IOException { + + this.progressHandler = progressHandler; + this.failedOps = failedOps; + boolean useAesCrypt = baseFile.getName().endsWith(AESCRYPT_EXTENSION); + + HybridFile targetDirectory = new HybridFile(OpenMode.FILE, targetPath); + if (!targetPath.equals(context.getExternalCacheDir())) { + + // same file system as of base file + targetDirectory.setMode(baseFile.getMode()); + } + decrypt(context, baseFile, targetDirectory, useAesCrypt, password); + } + + /** + * Wrapper around handling decryption for directory tree + * + * @param sourceFile the source file to decrypt + * @param targetDirectory the target directory inside which we're going to decrypt + */ + private void decrypt( + @NonNull final Context context, + @NonNull HybridFileParcelable sourceFile, + @NonNull HybridFile targetDirectory, + boolean useAescrypt, + @Nullable String password) + throws GeneralSecurityException, IOException { + + if (progressHandler.getCancelled()) return; + if (sourceFile.isDirectory()) { + + final HybridFile hFile = + new HybridFile( + targetDirectory.getMode(), + targetDirectory.getPath(), + sourceFile + .getName(context) + .replace(CRYPT_EXTENSION, "") + .replace(AESCRYPT_EXTENSION, ""), + sourceFile.isDirectory()); + MakeDirectoryOperation.mkdirs(context, hFile); + + sourceFile.forEachChildrenFile( + context, + sourceFile.isRoot(), + file -> { + try { + decrypt(context, file, hFile, useAescrypt, password); + } catch (IOException | GeneralSecurityException e) { + throw new IllegalStateException(e); // throw unchecked exception, no throws needed + } + }); + } else { + + if (!sourceFile.getPath().endsWith(CRYPT_EXTENSION) + && !sourceFile.getPath().endsWith(AESCRYPT_EXTENSION)) { + failedOps.add(sourceFile); + return; + } + + BufferedInputStream inputStream = + new BufferedInputStream( + sourceFile.getInputStream(context), GenericCopyUtil.DEFAULT_BUFFER_SIZE); + + HybridFile targetFile = + new HybridFile( + targetDirectory.getMode(), + targetDirectory.getPath(), + sourceFile + .getName(context) + .replace(CRYPT_EXTENSION, "") + .replace(AESCRYPT_EXTENSION, ""), + sourceFile.isDirectory()); + + progressHandler.setFileName(sourceFile.getName(context)); + + BufferedOutputStream outputStream = + new BufferedOutputStream( + targetFile.getOutputStream(context), GenericCopyUtil.DEFAULT_BUFFER_SIZE); + + if (useAescrypt) { + new AESCrypt(password).decrypt(sourceFile.getSize(), inputStream, outputStream); + } else { + doEncrypt(inputStream, outputStream, Cipher.DECRYPT_MODE); + } + } + } + + /** + * Wrapper around handling encryption in directory tree + * + * @param sourceFile the source file to encrypt + * @param targetDirectory the target directory in which we're going to encrypt + */ + private void encrypt( + @NonNull final Context context, + @NonNull HybridFileParcelable sourceFile, + @NonNull HybridFile targetDirectory, + @NonNull String targetFilename, + boolean useAesCrypt, + @Nullable String password) + throws GeneralSecurityException, IOException { + + if (progressHandler.getCancelled()) return; + if (sourceFile.isDirectory()) { + + // succeed #CRYPT_EXTENSION at end of directory/file name + final HybridFile hFile = + new HybridFile( + targetDirectory.getMode(), + targetDirectory.getPath(), + targetFilename, + sourceFile.isDirectory()); + MakeDirectoryOperation.mkdirs(context, hFile); + + sourceFile.forEachChildrenFile( + context, + sourceFile.isRoot(), + file -> { + try { + encrypt( + context, + file, + hFile, + file.getName(context).concat(useAesCrypt ? AESCRYPT_EXTENSION : CRYPT_EXTENSION), + useAesCrypt, + password); + } catch (IOException | GeneralSecurityException e) { + throw new IllegalStateException(e); // throw unchecked exception, no throws needed + } + }); + } else { + + if (sourceFile.getName(context).endsWith(CRYPT_EXTENSION) + || sourceFile.getName(context).endsWith(AESCRYPT_EXTENSION)) { + failedOps.add(sourceFile); + return; + } + + BufferedInputStream inputStream = + new BufferedInputStream( + sourceFile.getInputStream(context), GenericCopyUtil.DEFAULT_BUFFER_SIZE); + + // succeed #CRYPT_EXTENSION at end of directory/file name + HybridFile targetFile = + new HybridFile( + targetDirectory.getMode(), + targetDirectory.getPath(), + targetFilename, + sourceFile.isDirectory()); + + progressHandler.setFileName(sourceFile.getName(context)); + + BufferedOutputStream outputStream = + new BufferedOutputStream( + targetFile.getOutputStream(context), GenericCopyUtil.DEFAULT_BUFFER_SIZE); + + if (useAesCrypt) { + new AESCrypt(password) + .encrypt( + AESCrypt.AESCRYPT_SPEC_VERSION, + sourceFile.getInputStream(AppConfig.getInstance()), + targetFile.getOutputStream(AppConfig.getInstance()), + progressHandler); + } else { + doEncrypt(inputStream, outputStream, Cipher.ENCRYPT_MODE); + } + } + } + + /** + * Core encryption/decryption routine. + * + * @param inputStream stream associated with the file to be encrypted + * @param outputStream stream associated with new output encrypted file + * @param operationMode either Cipher.ENCRYPT_MODE or Cipher.DECRYPT_MODE + * + */ + private void doEncrypt( + BufferedInputStream inputStream, BufferedOutputStream outputStream, int operationMode) + throws GeneralSecurityException, IOException { + + Cipher cipher = Cipher.getInstance(ALGO_AES); + AlgorithmParameterSpec parameterSpec; + if (SDK_INT >= KITKAT) { + parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, IV.getBytes()); + } else { + parameterSpec = new IvParameterSpec(IV.getBytes()); + } + + Key secretKey = SecretKeygen.INSTANCE.getSecretKey(); + if (secretKey == null) { + // Discard crypto setup objects and just pipe input to output + parameterSpec = null; + cipher = null; + ByteStreamsKt.copyTo(inputStream, outputStream, ConstantsKt.DEFAULT_BUFFER_SIZE); + inputStream.close(); + outputStream.close(); + } else { + cipher.init(operationMode, SecretKeygen.INSTANCE.getSecretKey(), parameterSpec); + + byte[] buffer = new byte[GenericCopyUtil.DEFAULT_BUFFER_SIZE]; + int count; + + CipherOutputStream cipherOutputStream = new CipherOutputStream(outputStream, cipher); + + try { + while ((count = inputStream.read(buffer)) != -1) { + if (!progressHandler.getCancelled()) { + cipherOutputStream.write(buffer, 0, count); + ServiceWatcherUtil.position += count; + } else break; + } + } catch (Exception x) { + LOG.error("I/O error writing output", x); + } finally { + cipherOutputStream.flush(); + cipherOutputStream.close(); + inputStream.close(); + outputStream.close(); + } + } + } + + /** + * Method initializes a Cipher to be used by {@link + * android.hardware.fingerprint.FingerprintManager} + */ + public static Cipher initCipher() throws GeneralSecurityException { + Cipher cipher = null; + if (SDK_INT >= KITKAT) { + cipher = Cipher.getInstance(ALGO_AES); + GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, IV.getBytes()); + cipher.init(Cipher.ENCRYPT_MODE, SecretKeygen.INSTANCE.getSecretKey(), gcmParameterSpec); + } else if (SDK_INT >= JELLY_BEAN_MR2) { + cipher = Cipher.getInstance(ALGO_AES); + cipher.init(Cipher.ENCRYPT_MODE, SecretKeygen.INSTANCE.getSecretKey()); + } + return cipher; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/files/EncryptDecryptUtils.kt b/app/src/main/java/com/amaze/filemanager/filesystem/files/EncryptDecryptUtils.kt new file mode 100644 index 0000000..a389000 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/files/EncryptDecryptUtils.kt @@ -0,0 +1,315 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.amaze.filemanager.filesystem.files + +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.os.Build +import android.util.Base64 +import android.widget.Toast +import androidx.appcompat.widget.AppCompatEditText +import androidx.preference.PreferenceManager +import com.afollestad.materialdialogs.DialogAction +import com.afollestad.materialdialogs.MaterialDialog +import com.amaze.filemanager.R +import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil +import com.amaze.filemanager.asynchronous.services.DecryptService +import com.amaze.filemanager.asynchronous.services.EncryptService +import com.amaze.filemanager.database.CryptHandler +import com.amaze.filemanager.database.CryptHandler.addEntry +import com.amaze.filemanager.database.models.explorer.EncryptedEntry +import com.amaze.filemanager.fileoperations.filesystem.OpenMode +import com.amaze.filemanager.filesystem.HybridFileParcelable +import com.amaze.filemanager.ui.activities.MainActivity +import com.amaze.filemanager.ui.dialogs.DecryptFingerprintDialog.show +import com.amaze.filemanager.ui.dialogs.GeneralDialogCreation +import com.amaze.filemanager.ui.fragments.MainFragment +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants +import com.amaze.filemanager.ui.provider.UtilitiesProvider +import com.amaze.filemanager.utils.PasswordUtil.decryptPassword +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.IOException +import java.security.GeneralSecurityException + +/** + * Provides useful interfaces and methods for encryption/decryption + * + * @author Emmanuel on 25/5/2017, at 16:55. + */ +object EncryptDecryptUtils { + const val DECRYPT_BROADCAST: String = "decrypt_broadcast" + + private val LOG: Logger = LoggerFactory.getLogger(EncryptDecryptUtils::class.java) + + /** + * Queries database to map path and password. Starts the encryption process after database query + * + * @param path the path of file to encrypt + * @param password the password in plaintext + * @throws GeneralSecurityException Errors on encrypting file/folder + * @throws IOException I/O errors on encrypting file/folder + */ + @JvmStatic + @Throws(GeneralSecurityException::class, IOException::class) + fun startEncryption( + c: Context?, + path: String, + password: String?, + intent: Intent, + ) { + val destPath = + path.substring( + 0, + path.lastIndexOf('/') + 1, + ) + intent.getStringExtra(EncryptService.TAG_ENCRYPT_TARGET) + + // EncryptService.TAG_ENCRYPT_TARGET already has the .aze extension, no need to append again + if (!intent.getBooleanExtra(EncryptService.TAG_AESCRYPT, false)) { + val encryptedEntry = EncryptedEntry(destPath, password) + addEntry(encryptedEntry) + } + // start the encryption process + ServiceWatcherUtil.runService(c, intent) + } + + /** + * Routine to decrypt file. Include branches for AESCrypt, password and fingerprint methods. + */ + @JvmStatic + fun decryptFile( + c: Context, + mainActivity: MainActivity, + main: MainFragment, + openMode: OpenMode, + sourceFile: HybridFileParcelable, + decryptPath: String?, + utilsProvider: UtilitiesProvider, + broadcastResult: Boolean, + ) { + val decryptIntent = Intent(main.context, DecryptService::class.java) + decryptIntent.putExtra(EncryptService.TAG_OPEN_MODE, openMode.ordinal) + decryptIntent.putExtra(EncryptService.TAG_SOURCE, sourceFile) + decryptIntent.putExtra(EncryptService.TAG_DECRYPT_PATH, decryptPath) + val preferences = PreferenceManager.getDefaultSharedPreferences(main.requireContext()) + + if (sourceFile.path.endsWith(CryptUtil.AESCRYPT_EXTENSION)) { + displayDecryptDialogForAescrypt(c, mainActivity, utilsProvider, decryptIntent, main) + } else { + val encryptedEntry: EncryptedEntry? + + try { + encryptedEntry = findEncryptedEntry(sourceFile.path) + } catch (e: GeneralSecurityException) { + LOG.warn("failed to find encrypted entry while decrypting", e) + // we couldn't find any entry in database or lost the key to decipher + toastDecryptionFailure(main) + return + } catch (e: IOException) { + LOG.warn("failed to find encrypted entry while decrypting", e) + toastDecryptionFailure(main) + return + } + + val decryptButtonCallbackInterface: DecryptButtonCallbackInterface = createCallback(main) + + if (encryptedEntry == null && !sourceFile.path.endsWith(CryptUtil.AESCRYPT_EXTENSION)) { + // couldn't find the matching path in database, we lost the password + toastDecryptionFailure(main) + return + } + + when (encryptedEntry!!.password.value) { + PreferencesConstants.ENCRYPT_PASSWORD_FINGERPRINT -> + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + show( + c, + mainActivity, + decryptIntent, + decryptButtonCallbackInterface, + ) + } else { + throw IllegalStateException("API < M!") + } + } catch (e: GeneralSecurityException) { + LOG.warn("failed to form fingerprint dialog", e) + toastDecryptionFailure(main) + } catch (e: IOException) { + LOG.warn("failed to form fingerprint dialog", e) + toastDecryptionFailure(main) + } catch (e: IllegalStateException) { + LOG.warn("failed to form fingerprint dialog", e) + toastDecryptionFailure(main) + } + + PreferencesConstants.ENCRYPT_PASSWORD_MASTER -> + try { + displayDecryptDialogWithMasterPassword( + c, + mainActivity, + decryptIntent, + utilsProvider, + preferences, + decryptButtonCallbackInterface, + ) + } catch (e: GeneralSecurityException) { + LOG.warn("failed to show decrypt dialog, e") + toastDecryptionFailure(main) + } catch (e: IOException) { + LOG.warn("failed to show decrypt dialog, e") + toastDecryptionFailure(main) + } + + else -> + GeneralDialogCreation.showDecryptDialog( + c, + mainActivity, + decryptIntent, + utilsProvider.appTheme, + encryptedEntry.password.value, + decryptButtonCallbackInterface, + ) + } + } + } + + private fun displayDecryptDialogWithMasterPassword( + c: Context, + mainActivity: MainActivity, + decryptIntent: Intent, + utilsProvider: UtilitiesProvider, + preferences: SharedPreferences, + decryptButtonCallbackInterface: DecryptButtonCallbackInterface, + ) { + GeneralDialogCreation.showDecryptDialog( + c, + mainActivity, + decryptIntent, + utilsProvider.appTheme, + decryptPassword( + c, + preferences.getString( + PreferencesConstants.PREFERENCE_CRYPT_MASTER_PASSWORD, + PreferencesConstants.PREFERENCE_CRYPT_MASTER_PASSWORD_DEFAULT, + )!!, + Base64.DEFAULT, + ), + decryptButtonCallbackInterface, + ) + } + + private fun displayDecryptDialogForAescrypt( + c: Context?, + mainActivity: MainActivity?, + utilsProvider: UtilitiesProvider, + decryptIntent: Intent, + main: MainFragment, + ) { + GeneralDialogCreation.showPasswordDialog( + c!!, + mainActivity!!, + utilsProvider.appTheme, + R.string.crypt_decrypt, + R.string.authenticate_password, + { dialog: MaterialDialog, which: DialogAction? -> + val editText = + dialog.view.findViewById(R.id.singleedittext_input) + decryptIntent.putExtra(EncryptService.TAG_PASSWORD, editText.text.toString()) + ServiceWatcherUtil.runService(main.context, decryptIntent) + dialog.dismiss() + }, + null, + ) + } + + private fun createCallback(main: MainFragment): DecryptButtonCallbackInterface { + return object : DecryptButtonCallbackInterface { + override fun confirm(intent: Intent) { + ServiceWatcherUtil.runService(main.context, intent) + } + + override fun failed() { + Toast.makeText( + main.context, + main.requireMainActivity().getString(R.string.crypt_decryption_fail_password), + Toast.LENGTH_LONG, + ).show() + } + } + } + + private fun toastDecryptionFailure(main: MainFragment) { + Toast.makeText( + main.context, + main.requireMainActivity().getString(R.string.crypt_decryption_fail), + Toast.LENGTH_LONG, + ).show() + } + + /** + * Queries database to find entry for the specific path + * + * @param path the path to match with + * @return the entry + */ + @JvmStatic + @Throws(GeneralSecurityException::class, IOException::class) + private fun findEncryptedEntry(path: String): EncryptedEntry? { + val handler = CryptHandler + + var matchedEntry: EncryptedEntry? = null + // find closest path which matches with database entry + for (encryptedEntry in handler.allEntries) { + if (path.contains(encryptedEntry.path)) { + if (matchedEntry == null || + matchedEntry.path.length < encryptedEntry.path.length + ) { + matchedEntry = encryptedEntry + } + } + } + return matchedEntry + } + + interface EncryptButtonCallbackInterface { + /** + * Callback fired when user has entered a password for encryption Not called when we've a master + * password set or enable fingerprint authentication + * + * @param password the password entered by user + */ + @Throws(GeneralSecurityException::class, IOException::class) + fun onButtonPressed( + intent: Intent, + password: String, + ) { + } + } + + interface DecryptButtonCallbackInterface { + /** Callback fired when we've confirmed the password matches the database */ + fun confirm(intent: Intent) {} + + /** Callback fired when password doesn't match the value entered by user */ + fun failed() {} + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/files/FileListSorter.kt b/app/src/main/java/com/amaze/filemanager/filesystem/files/FileListSorter.kt new file mode 100644 index 0000000..0398be0 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/files/FileListSorter.kt @@ -0,0 +1,147 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.files + +import com.amaze.filemanager.adapters.data.LayoutElementParcelable +import com.amaze.filemanager.filesystem.files.sort.ComparableParcelable +import com.amaze.filemanager.filesystem.files.sort.DirSortBy +import com.amaze.filemanager.filesystem.files.sort.SortBy +import com.amaze.filemanager.filesystem.files.sort.SortType +import java.lang.Long +import java.util.Locale +import kotlin.Boolean +import kotlin.Comparator +import kotlin.Int +import kotlin.String + +/** + * [Comparator] implementation to sort [LayoutElementParcelable]s. + */ +class FileListSorter( + dirArg: DirSortBy, + sortType: SortType, +) : Comparator { + private var dirsOnTop = dirArg + private val asc: Int = sortType.sortOrder.sortFactor + private val sort: SortBy = sortType.sortBy + + private fun isDirectory(path: ComparableParcelable): Boolean { + return path.isDirectory() + } + + /** Compares the names of [file1] and [file2] */ + private fun compareName( + file1: ComparableParcelable, + file2: ComparableParcelable, + ): Int { + return file1.getParcelableName().compareTo(file2.getParcelableName(), ignoreCase = true) + } + + /** + * Compares two elements and return negative, zero and positive integer if first argument is less + * than, equal to or greater than second + */ + override fun compare( + file1: ComparableParcelable, + file2: ComparableParcelable, + ): Int { + /*File f1; + + if(!file1.hasSymlink()) { + + f1=new File(file1.getDesc()); + } else { + f1=new File(file1.getSymlink()); + } + + File f2; + + if(!file2.hasSymlink()) { + + f2=new File(file2.getDesc()); + } else { + f2=new File(file1.getSymlink()); + }*/ + if (dirsOnTop == DirSortBy.DIR_ON_TOP) { + if (isDirectory(file1) && !isDirectory(file2)) { + return -1 + } else if (isDirectory(file2) && !isDirectory(file1)) { + return 1 + } + } else if (dirsOnTop == DirSortBy.FILE_ON_TOP) { + if (isDirectory(file1) && !isDirectory(file2)) { + return 1 + } else if (isDirectory(file2) && !isDirectory(file1)) { + return -1 + } + } + + when (sort) { + SortBy.NAME -> { + // sort by name + return asc * compareName(file1, file2) + } + SortBy.LAST_MODIFIED -> { + // sort by last modified + return asc * Long.valueOf(file1.getDate()).compareTo(file2.getDate()) + } + SortBy.SIZE -> { + // sort by size + return if (!isDirectory(file1) && !isDirectory(file2)) { + asc * Long.valueOf(file1.getSize()).compareTo(file2.getSize()) + } else { + compareName(file1, file2) + } + } + SortBy.TYPE -> { + // sort by type + return if (!isDirectory(file1) && !isDirectory(file2)) { + val ext_a = getExtension(file1.getParcelableName()) + val ext_b = getExtension(file2.getParcelableName()) + val res = asc * ext_a.compareTo(ext_b) + if (res == 0) { + asc * compareName(file1, file2) + } else { + res + } + } else { + compareName(file1, file2) + } + } + SortBy.RELEVANCE -> { + // This case should not be called because it is not defined + return 0 + } + } + } + + companion object { + /** + * Convenience method to get the file extension in given path. + * + * TODO: merge with same definition somewhere else (if any) + */ + @JvmStatic + fun getExtension(a: String): String { + return a.substringAfterLast('.').lowercase(Locale.getDefault()) + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/files/FileUtils.java b/app/src/main/java/com/amaze/filemanager/filesystem/files/FileUtils.java new file mode 100644 index 0000000..1363b6a --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/files/FileUtils.java @@ -0,0 +1,1052 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.files; + +import static com.amaze.filemanager.filesystem.EditableFileAbstraction.Scheme.CONTENT; + +import java.io.File; +import java.text.ParsePosition; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicLong; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.afollestad.materialdialogs.MaterialDialog; +import com.amaze.filemanager.R; +import com.amaze.filemanager.adapters.data.LayoutElementParcelable; +import com.amaze.filemanager.application.AppConfig; +import com.amaze.filemanager.fileoperations.filesystem.OpenMode; +import com.amaze.filemanager.fileoperations.filesystem.smbstreamer.Streamer; +import com.amaze.filemanager.filesystem.HybridFile; +import com.amaze.filemanager.filesystem.HybridFileParcelable; +import com.amaze.filemanager.filesystem.Operations; +import com.amaze.filemanager.filesystem.RootHelper; +import com.amaze.filemanager.filesystem.cloud.CloudUtil; +import com.amaze.filemanager.filesystem.compressed.CompressedHelper; +import com.amaze.filemanager.ui.activities.DatabaseViewerActivity; +import com.amaze.filemanager.ui.activities.MainActivity; +import com.amaze.filemanager.ui.activities.superclasses.PermissionsActivity; +import com.amaze.filemanager.ui.activities.superclasses.PreferenceActivity; +import com.amaze.filemanager.ui.dialogs.GeneralDialogCreation; +import com.amaze.filemanager.ui.dialogs.OpenFileDialogFragment; +import com.amaze.filemanager.ui.dialogs.share.ShareTask; +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants; +import com.amaze.filemanager.ui.icons.MimeTypes; +import com.amaze.filemanager.ui.theme.AppTheme; +import com.amaze.filemanager.utils.DataUtils; +import com.amaze.filemanager.utils.OTGUtil; +import com.amaze.filemanager.utils.OnProgressUpdate; +import com.amaze.filemanager.utils.PackageInstallValidation; +import com.cloudrail.si.interfaces.CloudStorage; +import com.cloudrail.si.types.CloudMetaData; +import com.googlecode.concurrenttrees.radix.ConcurrentRadixTree; +import com.googlecode.concurrenttrees.radix.node.concrete.voidvalue.VoidValue; + +import android.Manifest; +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Build; +import android.view.View; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.FileProvider; +import androidx.core.util.Pair; +import androidx.documentfile.provider.DocumentFile; + +import jcifs.smb.SmbFile; +import kotlin.collections.ArraysKt; +import net.schmizz.sshj.sftp.RemoteResourceInfo; +import net.schmizz.sshj.sftp.SFTPClient; +import net.schmizz.sshj.sftp.SFTPException; + +/** Functions that deal with files */ +public class FileUtils { + + private static final Logger LOG = LoggerFactory.getLogger(FileUtils.class); + + private static final String[] COMPRESSED_FILE_EXTENSIONS = + new String[] { + "zip", "rar", "cab", "bz2", "ace", "bz", "gz", "7z", "jar", "apk", "xz", "lzma", "Z" + }; + + public static final String FILE_PROVIDER_PREFIX = "storage_root"; + public static final String NOMEDIA_FILE = ".nomedia"; + public static final String DUMMY_FILE = ".DummyFile"; + + public static long folderSize(File directory, OnProgressUpdate updateState) { + long length = 0; + try { + for (File file : directory.listFiles()) { + if (file.isFile()) length += file.length(); + else + length += + folderSize(file, null); // null because updateState would be called for children dirs + + if (updateState != null) updateState.onUpdate(length); + } + } catch (Exception e) { + LOG.warn("failed to get folder size", e); + } + return length; + } + + public static long folderSize(HybridFile directory, OnProgressUpdate updateState) { + if (directory.isSimpleFile()) return folderSize(new File(directory.getPath()), updateState); + else return directory.folderSize(AppConfig.getInstance()); + } + + public static long folderSize(SmbFile directory) { + long length = 0; + try { + for (SmbFile file : directory.listFiles()) { + + if (file.isFile()) length += file.length(); + else length += folderSize(file); + } + } catch (Exception e) { + LOG.warn("failed to get folder size", e); + } + return length; + } + + /** + * Use recursive ls to get folder size. + * + *

It is slow, it is stupid, and may be inaccurate (because of permission problems). Only for + * fallback use when du is not available. + * + * @see HybridFile#folderSize(Context) + * @return Folder size in bytes + */ + public static Long folderSizeSftp(SFTPClient client, String remotePath) { + Long retval = 0L; + try { + for (RemoteResourceInfo info : client.ls(remotePath)) { + if (info.isDirectory()) retval += folderSizeSftp(client, info.getPath()); + else retval += info.getAttributes().getSize(); + } + } catch (SFTPException e) { + // Usually happens when permission denied listing files in directory + LOG.error("folderSizeSftp", "Problem accessing " + remotePath, e); + } finally { + return retval; + } + } + + public static long folderSizeCloud(OpenMode openMode, CloudMetaData sourceFileMeta) { + + DataUtils dataUtils = DataUtils.getInstance(); + long length = 0; + CloudStorage cloudStorage = dataUtils.getAccount(openMode); + for (CloudMetaData metaData : + cloudStorage.getChildren(CloudUtil.stripPath(openMode, sourceFileMeta.getPath()))) { + + if (metaData.getFolder()) { + length += folderSizeCloud(openMode, metaData); + } else { + length += metaData.getSize(); + } + } + + return length; + } + + /** Helper method to get size of an otg folder */ + public static long otgFolderSize(String path, final Context context) { + final AtomicLong totalBytes = new AtomicLong(0); + OTGUtil.getDocumentFiles( + path, context, file -> totalBytes.addAndGet(getBaseFileSize(file, context))); + return totalBytes.longValue(); + } + + /** Helper method to calculate source files size */ + public static long getTotalBytes(ArrayList files, Context context) { + long totalBytes = 0L; + for (HybridFileParcelable file : files) { + totalBytes += getBaseFileSize(file, context); + } + return totalBytes; + } + + public static long getBaseFileSize(HybridFileParcelable baseFile, Context context) { + if (baseFile.isDirectory(context)) { + return baseFile.folderSize(context); + } else { + return baseFile.length(context); + } + } + + public static void crossfade(View buttons, final View pathbar) { + // Set the content view to 0% opacity but visible, so that it is visible + // (but fully transparent) during the animation. + buttons.setAlpha(0f); + buttons.setVisibility(View.VISIBLE); + + // Animate the content view to 100% opacity, and clear any animation + // listener set on the view. + buttons.animate().alpha(1f).setDuration(100).setListener(null); + pathbar + .animate() + .alpha(0f) + .setDuration(100) + .setListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + pathbar.setVisibility(View.GONE); + } + }); + // Animate the loading view to 0% opacity. After the animation ends, + // set its visibility to GONE as an optimization step (it won't + // participate in layout passes, etc.) + } + + public static void crossfadeInverse(final View buttons, final View pathbar) { + // Set the content view to 0% opacity but visible, so that it is visible + // (but fully transparent) during the animation. + + pathbar.setAlpha(0f); + pathbar.setVisibility(View.VISIBLE); + + // Animate the content view to 100% opacity, and clear any animation + // listener set on the view. + pathbar.animate().alpha(1f).setDuration(500).setListener(null); + buttons + .animate() + .alpha(0f) + .setDuration(500) + .setListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + buttons.setVisibility(View.GONE); + } + }); + // Animate the loading view to 0% opacity. After the animation ends, + // set its visibility to GONE as an optimization step (it won't + // participate in layout passes, etc.) + } + + public static void shareCloudFile(String path, final OpenMode openMode, final Context context) { + new AsyncTask() { + + @Override + protected String doInBackground(String... params) { + String shareFilePath = params[0]; + CloudStorage cloudStorage = DataUtils.getInstance().getAccount(openMode); + return cloudStorage.createShareLink(CloudUtil.stripPath(openMode, shareFilePath)); + } + + @Override + protected void onPostExecute(String s) { + super.onPostExecute(s); + + FileUtils.copyToClipboard(context, s); + Toast.makeText(context, context.getString(R.string.cloud_share_copied), Toast.LENGTH_LONG) + .show(); + } + }.execute(path); + } + + public static void shareCloudFiles( + ArrayList files, final OpenMode openMode, final Context context) { + String[] paths = new String[files.size()]; + for (int i = 0; i < files.size(); i++) { + paths[i] = files.get(i).desc; + } + new AsyncTask() { + @Override + protected String doInBackground(String... params) { + CloudStorage cloudStorage = DataUtils.getInstance().getAccount(openMode); + StringBuilder links = new StringBuilder(); + links.append(cloudStorage.createShareLink(CloudUtil.stripPath(openMode, params[0]))); + for (int i = 1; i < params.length; i++) { + links.append('\n'); + links.append(cloudStorage.createShareLink(CloudUtil.stripPath(openMode, params[i]))); + } + return links.toString(); + } + + @Override + protected void onPostExecute(String s) { + super.onPostExecute(s); + + FileUtils.copyToClipboard(context, s); + Toast.makeText(context, context.getString(R.string.cloud_share_copied), Toast.LENGTH_LONG) + .show(); + } + }.execute(paths); + } + + public static void shareFiles( + ArrayList files, Activity activity, AppTheme appTheme, int fab_skin) { + + ArrayList uris = new ArrayList<>(); + boolean isGenericFileType = false; + + String mime = + files.size() > 1 + ? MimeTypes.getMimeType(files.get(0).getPath(), files.get(0).isDirectory()) + : null; + + for (File f : files) { + uris.add(FileProvider.getUriForFile(activity, activity.getPackageName(), f)); + if (!isGenericFileType + && (mime == null || !mime.equals(MimeTypes.getMimeType(f.getPath(), f.isDirectory())))) { + isGenericFileType = true; + } + } + + if (isGenericFileType || mime == null) mime = MimeTypes.ALL_MIME_TYPES; + + try { + new ShareTask(activity, uris, appTheme, fab_skin).execute(mime); + } catch (Exception e) { + LOG.warn("failed to get share files", e); + } + } + + public static float readableFileSizeFloat(long size) { + if (size <= 0) return 0; + return (float) (size / (1024 * 1024)); + } + + /** + * Install .apk file. + * + * @param permissionsActivity needed to ask for {@link + * Manifest.permission#REQUEST_INSTALL_PACKAGES} permission + */ + public static void installApk( + final @NonNull File f, final @NonNull PermissionsActivity permissionsActivity) { + + try { + PackageInstallValidation.validatePackageInstallability(f); + } catch (PackageInstallValidation.PackageCannotBeInstalledException e) { + Toast.makeText( + permissionsActivity, + R.string.error_google_play_cannot_update_myself, + Toast.LENGTH_LONG) + .show(); + return; + } catch (IllegalStateException e) { + Toast.makeText( + permissionsActivity, + permissionsActivity.getString( + R.string.error_cannot_get_package_info, f.getAbsolutePath()), + Toast.LENGTH_LONG) + .show(); + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + && !permissionsActivity.getPackageManager().canRequestPackageInstalls()) { + permissionsActivity.requestInstallApkPermission( + () -> installApk(f, permissionsActivity), true); + } + + Intent intent = new Intent(Intent.ACTION_VIEW); + String type = "application/vnd.android.package-archive"; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + Uri downloadedApk = + FileProvider.getUriForFile( + permissionsActivity.getApplicationContext(), permissionsActivity.getPackageName(), f); + intent.setDataAndType(downloadedApk, type); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + } else { + intent.setDataAndType(Uri.fromFile(f), type); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + } + + try { + permissionsActivity.startActivity(intent); + } catch (Exception e) { + LOG.warn("failed to install apk", e); + Toast.makeText(permissionsActivity, R.string.failed_install_apk, Toast.LENGTH_SHORT).show(); + } + } + + private static void openUnknownInternal( + Uri contentUri, String type, MainActivity c, boolean forcechooser, boolean useNewStack) { + Intent chooserIntent = new Intent(); + chooserIntent.setAction(Intent.ACTION_VIEW); + chooserIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + + if (type != null && type.trim().length() != 0 && !type.equals(MimeTypes.ALL_MIME_TYPES)) { + chooserIntent.setDataAndType(contentUri, type); + Intent activityIntent; + if (forcechooser) { + if (useNewStack) applyNewDocFlag(chooserIntent); + activityIntent = Intent.createChooser(chooserIntent, c.getString(R.string.open_with)); + } else { + activityIntent = chooserIntent; + if (useNewStack) applyNewDocFlag(chooserIntent); + } + + try { + c.startActivity(activityIntent); + } catch (ActivityNotFoundException e) { + LOG.error(e.getMessage(), e); + Toast.makeText(c, R.string.no_app_found, Toast.LENGTH_SHORT).show(); + openWith(contentUri, c, useNewStack); + } + } else { + openWith(contentUri, c, useNewStack); + } + } + + private static void applyNewDocFlag(Intent i) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + i.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT); + } else { + i.setFlags( + Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_CLEAR_TASK + | Intent.FLAG_ACTIVITY_TASK_ON_HOME); + } + } + + /** Method supports showing a UI to ask user to open a file without any extension/mime */ + public static void openWith( + final File f, final PreferenceActivity activity, final boolean useNewStack) { + openWith( + FileProvider.getUriForFile(activity, activity.getPackageName(), f), activity, useNewStack); + } + + public static void openWith( + final DocumentFile f, final PreferenceActivity activity, final boolean useNewStack) { + openWith(f.getUri(), activity, useNewStack); + } + + public static void openWith( + final Uri uri, final PreferenceActivity activity, final boolean useNewStack) { + MaterialDialog.Builder a = new MaterialDialog.Builder(activity); + a.title(activity.getString(R.string.open_as)); + String[] items = + new String[] { + activity.getString(R.string.text), + activity.getString(R.string.image), + activity.getString(R.string.video), + activity.getString(R.string.audio), + activity.getString(R.string.database), + activity.getString(R.string.other) + }; + + a.items(items) + .itemsCallback( + (materialDialog, view, i, charSequence) -> { + String mimeType = null; + Intent intent = null; + + switch (i) { + case 0: + mimeType = "text/*"; + break; + case 1: + mimeType = "image/*"; + break; + case 2: + mimeType = "video/*"; + break; + case 3: + mimeType = "audio/*"; + break; + case 4: + intent = new Intent(activity, DatabaseViewerActivity.class); + intent.setAction(Intent.ACTION_VIEW); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + intent.addFlags(Intent.FLAG_ACTIVITY_RETAIN_IN_RECENTS); + } + // DatabaseViewerActivity only accepts java.io.File paths, need to strip the URI + // to file's absolute path + intent.putExtra( + "path", + uri.getPath() + .substring( + uri.getPath().indexOf(FILE_PROVIDER_PREFIX) - 1, + FILE_PROVIDER_PREFIX.length() + 1)); + break; + case 5: + mimeType = MimeTypes.getMimeType(uri.getPath(), false); + if (mimeType == null) mimeType = MimeTypes.ALL_MIME_TYPES; + break; + } + try { + if (intent != null) { + activity.startActivity(intent); + } else { + OpenFileDialogFragment.Companion.openFileOrShow( + uri, mimeType, useNewStack, activity, true); + } + } catch (Exception e) { + Toast.makeText(activity, R.string.no_app_found, Toast.LENGTH_SHORT).show(); + openWith(uri, activity, useNewStack); + } + }); + + a.build().show(); + } + + /** Method determines if there is something to go back to */ + public static boolean canGoBack(Context context, HybridFile currentFile) { + switch (currentFile.getMode()) { + // we're on main thread and can't list the cloud files + case DROPBOX: + case BOX: + case GDRIVE: + case ONEDRIVE: + case OTG: + case SFTP: + return true; + default: + return true; // TODO: 29/9/2017 there might be nothing to go back to (check parent) + } + } + + public static long[] getSpaces( + HybridFile hFile, Context context, final OnProgressUpdate updateState) { + long totalSpace = hFile.getTotal(context); + long freeSpace = hFile.getUsableSpace(); + long fileSize = 0l; + + if (hFile.isDirectory(context)) { + fileSize = hFile.folderSize(context); + } else { + fileSize = hFile.length(context); + } + return new long[] {totalSpace, freeSpace, fileSize}; + } + + public static boolean copyToClipboard(Context context, String text) { + try { + android.content.ClipboardManager clipboard = + (android.content.ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + android.content.ClipData clip = + android.content.ClipData.newPlainText( + context.getString(R.string.clipboard_path_copy), text); + clipboard.setPrimaryClip(clip); + return true; + } catch (Exception e) { + return false; + } + } + + public static String[] getFolderNamesInPath(String path) { + if (!path.endsWith("/")) path += "/"; + @Nullable Pair splitUri = splitUri(path); + if (splitUri != null) { + path = splitUri.second; + } + return ("root" + path).split("/"); + } + + /** + * Parse a given path to a string array of the "steps" to target. + * + *

For local paths, output will be like + * ["/", "/storage", "/storage/emulated", "/storage/emulated/0", "/storage/emulated/0/Download", "/storage/emulated/0/Download/file.zip"] + * For URI paths, output will be like + * ["smb://user;workgroup:passw0rd@12.3.4", "smb://user;workgroup:passw0rd@12.3.4/user", "smb://user;workgroup:passw0rd@12.3.4/user/Documents", "smb://user;workgroup:passw0rd@12.3.4/user/Documents/flare.doc"] + * + * + * @param path + * @return string array of incremental path segments + */ + public static String[] getPathsInPath(String path) { + if (path.endsWith("/")) { + path = path.substring(0, path.length() - 1); + } + path = path.trim(); + + ArrayList paths = new ArrayList<>(); + @Nullable String urlPrefix = null; + @Nullable Pair splitUri = splitUri(path); + if (splitUri != null) { + urlPrefix = splitUri.first; + path = splitUri.second; + } + + if (!path.startsWith("/")) { + path = "/" + path; + } + + while (path.length() > 0) { + if (urlPrefix != null) { + paths.add(urlPrefix + path); + } else { + paths.add(path); + } + if (path.contains("/")) { + path = path.substring(0, path.lastIndexOf('/')); + } else { + break; + } + } + + if (urlPrefix != null) { + paths.add(urlPrefix); + } else { + paths.add("/"); + } + Collections.reverse(paths); + + return paths.toArray(new String[0]); + } + + /** + * Splits a given path to URI prefix (if exists) and path. + * + * @param path + * @return {@link Pair} tuple if given path is URI (scheme is not null). Tuple contains: + *

    + *
  • First: URI section of the given path, if given path is an URI + *
  • Second: Path section of the given path. Never null + *
+ */ + public static @Nullable Pair splitUri(@NonNull final String path) { + Uri uri = Uri.parse(path); + if (uri.getScheme() != null) { + String urlPrefix = uri.getScheme() + "://" + uri.getEncodedAuthority(); + String retPath = path.substring(urlPrefix.length()); + return new Pair<>(urlPrefix, retPath); + } else { + return null; + } + } + + public static boolean canListFiles(File f) { + return f.canRead() && f.isDirectory(); + } + + public static void openFile( + @NonNull final File f, + @NonNull final MainActivity mainActivity, + @NonNull final SharedPreferences sharedPrefs) { + boolean useNewStack = + sharedPrefs.getBoolean(PreferencesConstants.PREFERENCE_TEXTEDITOR_NEWSTACK, false); + boolean defaultHandler = isSelfDefault(f, mainActivity); + + if (f.getName().toLowerCase().endsWith(".apk")) { + GeneralDialogCreation.showPackageDialog(f, mainActivity); + } else if (defaultHandler && CompressedHelper.isFileExtractable(f.getPath())) { + GeneralDialogCreation.showArchiveDialog(f, mainActivity); + } else if (defaultHandler && f.getName().toLowerCase().endsWith(".db")) { + Intent intent = new Intent(mainActivity, DatabaseViewerActivity.class); + intent.setType(MimeTypes.getMimeType(f.getPath(), false)); + intent.putExtra("path", f.getPath()); + mainActivity.startActivity(intent); + } else { + try { + openFileDialogFragmentFor(f, mainActivity, useNewStack); + } catch (Exception e) { + Toast.makeText( + mainActivity, mainActivity.getString(R.string.no_app_found), Toast.LENGTH_LONG) + .show(); + openWith(f, mainActivity, useNewStack); + } + } + } + + private static void openFileDialogFragmentFor( + @NonNull File file, @NonNull MainActivity mainActivity, @NonNull Boolean useNewStack) { + openFileDialogFragmentFor( + file, mainActivity, MimeTypes.getMimeType(file.getAbsolutePath(), false), useNewStack); + } + + private static void openFileDialogFragmentFor( + @NonNull File file, + @NonNull MainActivity mainActivity, + @NonNull String mimeType, + @NonNull Boolean useNewStack) { + OpenFileDialogFragment.Companion.openFileOrShow( + FileProvider.getUriForFile(mainActivity, mainActivity.getPackageName(), file), + mimeType, + useNewStack, + mainActivity, + false); + } + + private static void openFileDialogFragmentFor( + @NonNull DocumentFile file, + @NonNull MainActivity mainActivity, + @NonNull Boolean useNewStack) { + openFileDialogFragmentFor( + file.getUri(), + mainActivity, + MimeTypes.getMimeType(file.getUri().toString(), false), + useNewStack); + } + + private static void openFileDialogFragmentFor( + @NonNull Uri uri, + @NonNull MainActivity mainActivity, + @NonNull String mimeType, + @NonNull Boolean useNewStack) { + OpenFileDialogFragment.Companion.openFileOrShow( + uri, mimeType, useNewStack, mainActivity, false); + } + + private static boolean isSelfDefault(File f, Context c) { + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setDataAndType(Uri.fromFile(f), MimeTypes.getMimeType(f.getPath(), f.isDirectory())); + ResolveInfo info = + c.getPackageManager().resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY); + if (info != null && info.activityInfo != null) { + return info.activityInfo.packageName.equals(c.getPackageName()); + } else { + return true; + } + } + + /** Support file opening for {@link DocumentFile} (eg. OTG) */ + public static void openFile( + final DocumentFile f, final MainActivity m, SharedPreferences sharedPrefs) { + boolean useNewStack = + sharedPrefs.getBoolean(PreferencesConstants.PREFERENCE_TEXTEDITOR_NEWSTACK, false); + try { + openFileDialogFragmentFor(f, m, useNewStack); + } catch (Exception e) { + Toast.makeText(m, m.getString(R.string.no_app_found), Toast.LENGTH_LONG).show(); + openWith(f, m, useNewStack); + } + } + + public static void launchSMB(final HybridFile baseFile, final Activity activity) { + final Streamer s = Streamer.getInstance(); + new Thread() { + public void run() { + try { + /* + List subtitleFiles = new ArrayList(); + + // finding subtitles + for (Layoutelements layoutelement : LIST_ELEMENTS) { + SmbFile smbFile = new SmbFile(layoutelement.getDesc()); + if (smbFile.getName().contains(smbFile.getName())) subtitleFiles.add(smbFile); + } + */ + + s.setStreamSrc(baseFile.getSmbFile(), baseFile.length(activity)); + activity.runOnUiThread( + () -> { + try { + Uri uri = + Uri.parse( + Streamer.URL + + Uri.fromFile(new File(Uri.parse(baseFile.getPath()).getPath())) + .getEncodedPath()); + Intent i = new Intent(Intent.ACTION_VIEW); + i.setDataAndType( + uri, + MimeTypes.getMimeType(baseFile.getPath(), baseFile.isDirectory(activity))); + PackageManager packageManager = activity.getPackageManager(); + List resInfos = packageManager.queryIntentActivities(i, 0); + if (resInfos != null && resInfos.size() > 0) activity.startActivity(i); + else + Toast.makeText( + activity, + activity.getResources().getString(R.string.smb_launch_error), + Toast.LENGTH_SHORT) + .show(); + } catch (ActivityNotFoundException e) { + LOG.warn("Failed to launch smb file due to no activity", e); + } + }); + + } catch (Exception e) { + LOG.warn("failed to launch smb file", e); + } + } + }.start(); + } + + public static ArrayList toHybridFileConcurrentRadixTree( + ConcurrentRadixTree a) { + ArrayList b = new ArrayList<>(); + for (CharSequence o : a.getKeysStartingWith("")) { + HybridFile hFile = new HybridFile(OpenMode.UNKNOWN, o.toString()); + hFile.generateMode(null); + b.add(hFile); + } + return b; + } + + public static ArrayList toHybridFileArrayList(LinkedList a) { + ArrayList b = new ArrayList<>(); + for (String s : a) { + HybridFile hFile = new HybridFile(OpenMode.UNKNOWN, s); + hFile.generateMode(null); + b.add(hFile); + } + return b; + } + + /** + * We're parsing a line returned from a stdout of shell. + * + * @param line must be the line returned from 'ls' or 'stat' command + */ + public static HybridFileParcelable parseName(String line, boolean isStat) { + boolean linked = false; + StringBuilder name = new StringBuilder(); + StringBuilder link = new StringBuilder(); + String size = "-1"; + String date = ""; + String[] array = line.split(" +"); + if (array.length < 6) return null; + for (String anArray : array) { + if (anArray.contains("->") && array[0].startsWith("l")) { + linked = true; + break; + } + } + int p = getColonPosition(array); + if (p != -1 && (p + 1) != array.length) { + date = array[p - 1] + " | " + array[p]; + size = array[p - 2]; + } else if (isStat) { + date = array[5]; + size = array[4]; + p = 5; + } + if (!linked) { + for (int i = p + 1; i < array.length; i++) { + name.append(" ").append(array[i]); + } + name = new StringBuilder(name.toString().trim()); + } else { + int q = getLinkPosition(array); + for (int i = p + 1; i < q; i++) { + name.append(" ").append(array[i]); + } + // Newer *boxes may introduce full path during stat. Trim down to the very last / + if (name.lastIndexOf("/") > 0) { + name.delete(0, name.lastIndexOf("/") + 1); + } + name = new StringBuilder(name.toString().trim()); + for (int i = q + 1; i < array.length; i++) { + link.append(" ").append(array[i]); + } + link = new StringBuilder(link.toString().trim()); + } + long Size; + if (size == null || size.trim().length() == 0) { + Size = -1; + } else { + try { + Size = Long.parseLong(size); + } catch (NumberFormatException ifItIsNotANumber) { + Size = -1; + } + } + if (date.trim().length() > 0 && !isStat) { + ParsePosition pos = new ParsePosition(0); + SimpleDateFormat simpledateformat = new SimpleDateFormat("yyyy-MM-dd | HH:mm", Locale.US); + Date stringDate = simpledateformat.parse(date, pos); + if (stringDate == null) { + LOG.warn("parseName: unable to parse datetime string [" + date + "]"); + } + HybridFileParcelable baseFile = + new HybridFileParcelable( + name.toString(), array[0], stringDate != null ? stringDate.getTime() : 0, Size, true); + baseFile.setLink(link.toString()); + return baseFile; + } else if (isStat) { + HybridFileParcelable baseFile = + new HybridFileParcelable( + name.toString(), array[0], Long.parseLong(date) * 1000, Size, true); + baseFile.setLink(link.toString()); + return baseFile; + } else { + HybridFileParcelable baseFile = + new HybridFileParcelable( + name.toString(), array[0], new File("/").lastModified(), Size, true); + baseFile.setLink(link.toString()); + return baseFile; + } + } + + private static int getLinkPosition(String[] array) { + for (int i = 0; i < array.length; i++) { + if (array[i].contains("->")) return i; + } + return 0; + } + + private static int getColonPosition(String[] array) { + for (int i = 0; i < array.length; i++) { + if (array[i].contains(":")) return i; + } + return -1; + } + + public static ArrayList parse(String permLine) { + ArrayList arrayList = new ArrayList<>(3); + Boolean[] read = + new Boolean[] { + permLine.charAt(1) == 'r', permLine.charAt(4) == 'r', permLine.charAt(7) == 'r' + }; + + Boolean[] write = + new Boolean[] { + permLine.charAt(2) == 'w', permLine.charAt(5) == 'w', permLine.charAt(8) == 'w' + }; + + Boolean[] execute = + new Boolean[] { + permLine.charAt(3) == 'x', permLine.charAt(6) == 'x', permLine.charAt(9) == 'x' + }; + + arrayList.add(read); + arrayList.add(write); + arrayList.add(execute); + return arrayList; + } + + public static boolean isStorage(String path) { + for (String s : DataUtils.getInstance().getStorages()) if (s.equals(path)) return true; + return false; + } + + public static boolean isPathAccessible(String dir, SharedPreferences pref) { + File f = new File(dir); + boolean showIfHidden = pref.getBoolean(PreferencesConstants.PREFERENCE_SHOW_HIDDENFILES, false), + isDirSelfOrParent = dir.endsWith("/.") || dir.endsWith("/.."), + showIfRoot = pref.getBoolean(PreferencesConstants.PREFERENCE_ROOTMODE, false); + + return f.exists() + && f.isDirectory() + && (!f.isHidden() || (showIfHidden && !isDirSelfOrParent)) + && (!isRoot(dir) || showIfRoot); + + // TODO: 2/5/2017 use another system that doesn't create new object + } + + public static boolean isRoot( + String dir) { // TODO: 5/5/2017 hardcoding root might lead to problems down the line + return !dir.contains(OTGUtil.PREFIX_OTG) + && !dir.startsWith(OTGUtil.PREFIX_MEDIA_REMOVABLE) + && !dir.startsWith("/storage"); + } + + /** Convenience method to return if a path points to a compressed file. */ + public static boolean isCompressedFile(String path) { + @Nullable String extension = MimeTypes.getExtension(path); + return ArraysKt.indexOf(COMPRESSED_FILE_EXTENSIONS, extension) > -1; + } + + /** Converts ArrayList of HybridFileParcelable to ArrayList of File */ + public static ArrayList hybridListToFileArrayList(ArrayList a) { + ArrayList b = new ArrayList<>(); + for (int i = 0; i < a.size(); i++) { + b.add(new File(a.get(i).getPath())); + } + return b; + } + + /** Checks whether path for bookmark exists If path is not found, empty directory is created */ + public static void checkForPath(Context context, String path, boolean isRootExplorer) { + // TODO: Add support for SMB and OTG in this function + if (!new File(path).exists()) { + Toast.makeText(context, context.getString(R.string.bookmark_lost), Toast.LENGTH_SHORT).show(); + Operations.mkdir( + new HybridFile(OpenMode.FILE, path), + RootHelper.generateBaseFile(new File(path), true), + context, + isRootExplorer, + new Operations.ErrorCallBack() { + // TODO empty + @Override + public void exists(HybridFile file) {} + + @Override + public void launchSAF(HybridFile file) {} + + @Override + public void launchSAF(HybridFile file, HybridFile file1) {} + + @Override + public void done(HybridFile hFile, boolean b) {} + + @Override + public void invalidName(HybridFile file) {} + }); + } + } + + public static File fromContentUri(@NonNull Uri uri) { + if (!CONTENT.name().equalsIgnoreCase(uri.getScheme())) { + LOG.warn("URI must start with content://. URI was [" + uri + "]"); + } + File pathFile = new File(uri.getPath().substring(FILE_PROVIDER_PREFIX.length() + 1)); + if (!pathFile.exists()) { + LOG.warn("Failed to navigate to the initial path: {}", pathFile.getPath()); + pathFile = new File(uri.getPath()); + LOG.warn("Attempting to navigate to the fallback path: {}", pathFile.getPath()); + } + return pathFile; + } + + /** + * Uninstalls a given package + * + * @param pkg packge + * @param context context + * @return success + */ + public static boolean uninstallPackage(String pkg, Context context) { + try { + Intent intent = new Intent(Intent.ACTION_DELETE); + intent.setData(Uri.parse("package:" + pkg)); + context.startActivity(intent); + } catch (Exception e) { + Toast.makeText(context, "" + e, Toast.LENGTH_SHORT).show(); + LOG.warn("failed to uninstall apk", e); + return false; + } + return true; + } + + /** Determines the specified path is beyond storage level, i.e should require root access. */ + @SuppressWarnings("PMD.DoNotHardCodeSDCard") + public static boolean isRunningAboveStorage(@NonNull String path) { + return !path.startsWith("/storage") && !path.startsWith("/sdcard"); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/files/GenericCopyUtil.java b/app/src/main/java/com/amaze/filemanager/filesystem/files/GenericCopyUtil.java new file mode 100644 index 0000000..75dc8ab --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/files/GenericCopyUtil.java @@ -0,0 +1,413 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.files; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.RandomAccessFile; +import java.nio.ByteBuffer; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; +import java.nio.channels.ReadableByteChannel; +import java.nio.channels.WritableByteChannel; +import java.util.Objects; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.amaze.filemanager.fileoperations.filesystem.OpenMode; +import com.amaze.filemanager.fileoperations.utils.OnLowMemory; +import com.amaze.filemanager.fileoperations.utils.UpdatePosition; +import com.amaze.filemanager.filesystem.ExternalSdCardOperation; +import com.amaze.filemanager.filesystem.FileProperties; +import com.amaze.filemanager.filesystem.HybridFile; +import com.amaze.filemanager.filesystem.HybridFileParcelable; +import com.amaze.filemanager.filesystem.MediaStoreHack; +import com.amaze.filemanager.filesystem.SafRootHolder; +import com.amaze.filemanager.filesystem.cloud.CloudUtil; +import com.amaze.filemanager.utils.DataUtils; +import com.amaze.filemanager.utils.OTGUtil; +import com.amaze.filemanager.utils.ProgressHandler; +import com.cloudrail.si.interfaces.CloudStorage; + +import android.content.ContentResolver; +import android.content.Context; +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import androidx.documentfile.provider.DocumentFile; + +/** Base class to handle file copy. */ +public class GenericCopyUtil { + private final Logger LOG = LoggerFactory.getLogger(GenericCopyUtil.class); + + private HybridFileParcelable mSourceFile; + private HybridFile mTargetFile; + private final Context mContext; // context needed to find the DocumentFile in otg/sd card + private final DataUtils dataUtils = DataUtils.getInstance(); + private final ProgressHandler progressHandler; + + public static final int DEFAULT_BUFFER_SIZE = 8192; + + /* + Defines the block size per transfer over NIO channels. + + Cannot modify DEFAULT_BUFFER_SIZE since it's used by other classes, will have undesired + effect on other functions + */ + private static final int DEFAULT_TRANSFER_QUANTUM = 1024 * 1024; + + public GenericCopyUtil(Context context, ProgressHandler progressHandler) { + this.mContext = context; + this.progressHandler = progressHandler; + } + + /** + * Starts copy of file Supports : {@link File}, {@link jcifs.smb.SmbFile}, {@link DocumentFile}, + * {@link CloudStorage} + * + * @param lowOnMemory defines whether system is running low on memory, in which case we'll switch + * to using streams instead of channel which maps the who buffer in memory. TODO: Use buffers + * even on low memory but don't map the whole file to memory but parts of it, and transfer + * each part instead. + */ + private void startCopy( + boolean lowOnMemory, @NonNull OnLowMemory onLowMemory, @NonNull UpdatePosition updatePosition) + throws IOException { + + ReadableByteChannel inChannel = null; + WritableByteChannel outChannel = null; + BufferedInputStream bufferedInputStream = null; + BufferedOutputStream bufferedOutputStream = null; + + try { + // initializing the input channels based on file types + if (mSourceFile.isOtgFile() || mSourceFile.isDocumentFile()) { + // source is in otg + ContentResolver contentResolver = mContext.getContentResolver(); + DocumentFile documentSourceFile = + mSourceFile.isDocumentFile() + ? OTGUtil.getDocumentFile( + mSourceFile.getPath(), + SafRootHolder.getUriRoot(), + mContext, + mSourceFile.isOtgFile() ? OpenMode.OTG : OpenMode.DOCUMENT_FILE, + false) + : OTGUtil.getDocumentFile(mSourceFile.getPath(), mContext, false); + + bufferedInputStream = + new BufferedInputStream( + contentResolver.openInputStream(documentSourceFile.getUri()), DEFAULT_BUFFER_SIZE); + } else if (mSourceFile.isSmb() || mSourceFile.isSftp() || mSourceFile.isFtp()) { + bufferedInputStream = + new BufferedInputStream(mSourceFile.getInputStream(mContext), DEFAULT_TRANSFER_QUANTUM); + } else if (mSourceFile.isDropBoxFile() + || mSourceFile.isBoxFile() + || mSourceFile.isGoogleDriveFile() + || mSourceFile.isOneDriveFile()) { + OpenMode openMode = mSourceFile.getMode(); + + CloudStorage cloudStorage = dataUtils.getAccount(openMode); + bufferedInputStream = + new BufferedInputStream( + cloudStorage.download(CloudUtil.stripPath(openMode, mSourceFile.getPath()))); + } else { + + // source file is neither smb nor otg; getting a channel from direct file instead of stream + File file = new File(mSourceFile.getPath()); + if (FileProperties.isReadable(file)) { + + if (mTargetFile.isOneDriveFile() + || mTargetFile.isDropBoxFile() + || mTargetFile.isGoogleDriveFile() + || mTargetFile.isBoxFile() + || lowOnMemory) { + // our target is cloud, we need a stream not channel + bufferedInputStream = new BufferedInputStream(new FileInputStream(file)); + } else { + + inChannel = new RandomAccessFile(file, "r").getChannel(); + } + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + ContentResolver contentResolver = mContext.getContentResolver(); + DocumentFile documentSourceFile = + ExternalSdCardOperation.getDocumentFile(file, mSourceFile.isDirectory(), mContext); + + bufferedInputStream = + new BufferedInputStream( + contentResolver.openInputStream(documentSourceFile.getUri()), + DEFAULT_BUFFER_SIZE); + } else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT) { + InputStream inputStream1 = + MediaStoreHack.getInputStream(mContext, file, mSourceFile.getSize()); + bufferedInputStream = new BufferedInputStream(inputStream1); + } + } + } + + // initializing the output channels based on file types + if (mTargetFile.isOtgFile() || mTargetFile.isDocumentFile()) { + // target in OTG, obtain streams from DocumentFile Uri's + ContentResolver contentResolver = mContext.getContentResolver(); + DocumentFile documentTargetFile = + mTargetFile.isDocumentFile() + ? OTGUtil.getDocumentFile( + mTargetFile.getPath(), + SafRootHolder.getUriRoot(), + mContext, + mTargetFile.isOtgFile() ? OpenMode.OTG : OpenMode.DOCUMENT_FILE, + true) + : OTGUtil.getDocumentFile(mTargetFile.getPath(), mContext, true); + + bufferedOutputStream = + new BufferedOutputStream( + contentResolver.openOutputStream(documentTargetFile.getUri()), DEFAULT_BUFFER_SIZE); + } else if (mTargetFile.isFtp() || mTargetFile.isSftp() || mTargetFile.isSmb()) { + bufferedOutputStream = + new BufferedOutputStream( + mTargetFile.getOutputStream(mContext), DEFAULT_TRANSFER_QUANTUM); + } else if (mTargetFile.isDropBoxFile() + || mTargetFile.isBoxFile() + || mTargetFile.isGoogleDriveFile() + || mTargetFile.isOneDriveFile()) { + cloudCopy(mTargetFile.getMode(), bufferedInputStream); + return; + } else { + // copying normal file, target not in OTG + File file = new File(mTargetFile.getPath()); + if (FileProperties.isWritable(file)) { + + if (lowOnMemory) { + bufferedOutputStream = new BufferedOutputStream(new FileOutputStream(file)); + } else { + + outChannel = new RandomAccessFile(file, "rw").getChannel(); + } + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + ContentResolver contentResolver = mContext.getContentResolver(); + DocumentFile documentTargetFile = + ExternalSdCardOperation.getDocumentFile( + file, mTargetFile.isDirectory(mContext), mContext); + + bufferedOutputStream = + new BufferedOutputStream( + contentResolver.openOutputStream(documentTargetFile.getUri()), + DEFAULT_BUFFER_SIZE); + } else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT) { + // Workaround for Kitkat ext SD card + bufferedOutputStream = + new BufferedOutputStream(MediaStoreHack.getOutputStream(mContext, file.getPath())); + } + } + } + + if (bufferedInputStream != null) { + inChannel = Channels.newChannel(bufferedInputStream); + } + + if (bufferedOutputStream != null) { + outChannel = Channels.newChannel(bufferedOutputStream); + } + + Objects.requireNonNull(inChannel); + Objects.requireNonNull(outChannel); + + doCopy(inChannel, outChannel, updatePosition); + } catch (IOException e) { + LOG.error("I/O Error copy {} to {}: {}", mSourceFile, mTargetFile, e); + throw new IOException(e); + } catch (OutOfMemoryError e) { + LOG.warn("low memory while copying {} to {}: {}", mSourceFile, mTargetFile, e); + + onLowMemory.onLowMemory(); + + startCopy(true, onLowMemory, updatePosition); + } finally { + + try { + if (inChannel != null && inChannel.isOpen()) inChannel.close(); + if (outChannel != null && outChannel.isOpen()) outChannel.close(); + /* + * It does seems closing the inChannel/outChannel is already sufficient closing the below + * bufferedInputStream and bufferedOutputStream instances. These 2 lines prevented FTP + * copy from working, especially on Android 9 - TranceLove + */ + // if (bufferedInputStream != null) bufferedInputStream.close(); + // if (bufferedOutputStream != null) bufferedOutputStream.close(); + } catch (IOException e) { + LOG.warn("failed to close stream after copying", e); + // failure in closing stream + } + + // If target file is copied onto the device and copy was successful, trigger media store + // rescan + if (mTargetFile != null) { + MediaConnectionUtils.scanFile(mContext, new HybridFile[] {mTargetFile}); + } + } + } + + private void cloudCopy( + @NonNull OpenMode openMode, @NonNull BufferedInputStream bufferedInputStream) + throws IOException { + DataUtils dataUtils = DataUtils.getInstance(); + // API doesn't support output stream, we'll upload the file directly + CloudStorage cloudStorage = dataUtils.getAccount(openMode); + + if (mSourceFile.getMode() == openMode) { + // we're in the same provider, use api method + cloudStorage.copy( + CloudUtil.stripPath(openMode, mSourceFile.getPath()), + CloudUtil.stripPath(openMode, mTargetFile.getPath())); + } else { + cloudStorage.upload( + CloudUtil.stripPath(openMode, mTargetFile.getPath()), + bufferedInputStream, + mSourceFile.getSize(), + true); + bufferedInputStream.close(); + } + } + + /** + * Method exposes this class to initiate copy + * + * @param sourceFile the source file, which is to be copied + * @param targetFile the target file + */ + public void copy( + HybridFileParcelable sourceFile, + HybridFile targetFile, + @NonNull OnLowMemory onLowMemory, + @NonNull UpdatePosition updatePosition) + throws IOException { + this.mSourceFile = sourceFile; + this.mTargetFile = targetFile; + + startCopy(false, onLowMemory, updatePosition); + } + + /** + * Calls {@link #doCopy(ReadableByteChannel, WritableByteChannel, UpdatePosition)}. + * + * @see Channels#newChannel(InputStream) + * @param bufferedInputStream source + * @param outChannel target + * @throws IOException + */ + @VisibleForTesting + void copyFile( + @NonNull BufferedInputStream bufferedInputStream, + @NonNull FileChannel outChannel, + @NonNull UpdatePosition updatePosition) + throws IOException { + doCopy(Channels.newChannel(bufferedInputStream), outChannel, updatePosition); + } + + /** + * Calls {@link #doCopy(ReadableByteChannel, WritableByteChannel, UpdatePosition)}. + * + * @param inChannel source + * @param outChannel target + * @throws IOException + */ + @VisibleForTesting + void copyFile( + @NonNull FileChannel inChannel, + @NonNull FileChannel outChannel, + @NonNull UpdatePosition updatePosition) + throws IOException { + // MappedByteBuffer inByteBuffer = inChannel.map(FileChannel.MapMode.READ_ONLY, 0, + // inChannel.size()); + // MappedByteBuffer outByteBuffer = outChannel.map(FileChannel.MapMode.READ_WRITE, 0, + // inChannel.size()); + doCopy(inChannel, outChannel, updatePosition); + } + + /** + * Calls {@link #doCopy(ReadableByteChannel, WritableByteChannel, UpdatePosition)}. + * + * @see Channels#newChannel(InputStream) + * @see Channels#newChannel(OutputStream) + * @param bufferedInputStream source + * @param bufferedOutputStream target + * @throws IOException + */ + @VisibleForTesting + void copyFile( + @NonNull BufferedInputStream bufferedInputStream, + @NonNull BufferedOutputStream bufferedOutputStream, + @NonNull UpdatePosition updatePosition) + throws IOException { + doCopy( + Channels.newChannel(bufferedInputStream), + Channels.newChannel(bufferedOutputStream), + updatePosition); + } + + /** + * Calls {@link #doCopy(ReadableByteChannel, WritableByteChannel, UpdatePosition)}. + * + * @see Channels#newChannel(OutputStream) + * @param inChannel source + * @param bufferedOutputStream target + * @throws IOException + */ + @VisibleForTesting + void copyFile( + @NonNull FileChannel inChannel, + @NonNull BufferedOutputStream bufferedOutputStream, + @NonNull UpdatePosition updatePosition) + throws IOException { + doCopy(inChannel, Channels.newChannel(bufferedOutputStream), updatePosition); + } + + @VisibleForTesting + void doCopy( + @NonNull ReadableByteChannel from, + @NonNull WritableByteChannel to, + @NonNull UpdatePosition updatePosition) + throws IOException { + ByteBuffer buffer = ByteBuffer.allocateDirect(DEFAULT_TRANSFER_QUANTUM); + long count; + while ((from.read(buffer) != -1 || buffer.position() > 0) && !progressHandler.getCancelled()) { + buffer.flip(); + count = to.write(buffer); + updatePosition.updatePosition(count); + buffer.compact(); + } + + buffer.flip(); + while (buffer.hasRemaining()) to.write(buffer); + + from.close(); + to.close(); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/files/MediaConnectionUtils.kt b/app/src/main/java/com/amaze/filemanager/filesystem/files/MediaConnectionUtils.kt new file mode 100644 index 0000000..e513705 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/files/MediaConnectionUtils.kt @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.files + +import android.content.Context +import android.media.MediaScannerConnection +import android.net.Uri +import com.amaze.filemanager.filesystem.HybridFile +import org.slf4j.LoggerFactory + +object MediaConnectionUtils { + private val LOG = LoggerFactory.getLogger(MediaConnectionUtils::class.java) + + /** + * Invokes MediaScannerConnection#scanFile for the given files + * + * @param context the context + * @param hybridFiles files to be scanned + */ + @JvmStatic + fun scanFile( + context: Context, + hybridFiles: Array, + ) { + val paths = arrayOfNulls(hybridFiles.size) + + for (i in hybridFiles.indices) paths[i] = hybridFiles[i].path + + MediaScannerConnection.scanFile( + context, + paths, + null, + ) { path: String, _: Uri? -> + LOG.info("MediaConnectionUtils#scanFile finished scanning path$path") + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/files/sort/ComparableParcelable.kt b/app/src/main/java/com/amaze/filemanager/filesystem/files/sort/ComparableParcelable.kt new file mode 100644 index 0000000..c0034cc --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/files/sort/ComparableParcelable.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2014-2023 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.files.sort + +/** Used by [FileListSorter] to get the needed information from a `Parcelable` */ +interface ComparableParcelable { + /** Returns if the parcelable represents a directory */ + fun isDirectory(): Boolean + + /** Returns the name of the item represented by the parcelable */ + fun getParcelableName(): String + + /** Returns the date of the item represented by the parcelable as a Long */ + fun getDate(): Long + + /** Returns the size of the item represented by the parcelable */ + fun getSize(): Long +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/files/sort/DirSortBy.kt b/app/src/main/java/com/amaze/filemanager/filesystem/files/sort/DirSortBy.kt new file mode 100644 index 0000000..00ffd5b --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/files/sort/DirSortBy.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2014-2023 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.files.sort + +/** Represents the way in which directories and files should be sorted */ +enum class DirSortBy { + DIR_ON_TOP, + FILE_ON_TOP, + NONE_ON_TOP, + ; + + companion object { + /** Returns the corresponding [DirSortBy] to [index] */ + @JvmStatic + fun getDirSortBy(index: Int): DirSortBy { + return when (index) { + 0 -> DIR_ON_TOP + 1 -> FILE_ON_TOP + 2 -> NONE_ON_TOP + else -> NONE_ON_TOP + } + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/files/sort/SortBy.kt b/app/src/main/java/com/amaze/filemanager/filesystem/files/sort/SortBy.kt new file mode 100644 index 0000000..c72836d --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/files/sort/SortBy.kt @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2014-2023 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.files.sort + +import android.content.Context +import com.amaze.filemanager.R + +/** + * Represents the sort by types. + * [index] is the index of the sort in the xml string array resource + * [sortDirectory] indicates if the sort can be used to sort an directory. + */ +enum class SortBy(val index: Int, val sortDirectory: Boolean) { + NAME(0, true), + LAST_MODIFIED(1, true), + SIZE(2, true), + TYPE(3, true), + RELEVANCE(4, false), + ; + + /** Returns the corresponding string resource of the enum */ + fun toResourceString(context: Context): String { + return when (this) { + NAME -> context.resources.getString(R.string.sort_name) + LAST_MODIFIED -> context.resources.getString(R.string.lastModified) + SIZE -> context.resources.getString(R.string.sort_size) + TYPE -> context.resources.getString(R.string.type) + RELEVANCE -> context.resources.getString(R.string.sort_relevance) + } + } + + companion object { + const val NAME_INDEX = 0 + const val LAST_MODIFIED_INDEX = 1 + const val SIZE_INDEX = 2 + const val TYPE_INDEX = 3 + const val RELEVANCE_INDEX = 4 + + /** Returns the SortBy corresponding to [index] which can be used to sort directories */ + @JvmStatic + fun getDirectorySortBy(index: Int): SortBy { + return when (index) { + NAME_INDEX -> NAME + LAST_MODIFIED_INDEX -> LAST_MODIFIED + SIZE_INDEX -> SIZE + TYPE_INDEX -> TYPE + else -> NAME + } + } + + /** Returns the SortBy corresponding to [index] */ + @JvmStatic + fun getSortBy(index: Int): SortBy { + return when (index) { + NAME_INDEX -> NAME + LAST_MODIFIED_INDEX -> LAST_MODIFIED + SIZE_INDEX -> SIZE + TYPE_INDEX -> TYPE + RELEVANCE_INDEX -> RELEVANCE + else -> NAME + } + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/files/sort/SortOrder.kt b/app/src/main/java/com/amaze/filemanager/filesystem/files/sort/SortOrder.kt new file mode 100644 index 0000000..17faafd --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/files/sort/SortOrder.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2014-2023 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.files.sort + +/** + * Represents the direction the sort should be ordered + * + * [sortFactor] is the factor that should be multiplied to the result of `compareTo()` to achieve the correct sort direction + */ +enum class SortOrder(val sortFactor: Int) { + ASC(1), + DESC(-1), +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/files/sort/SortType.kt b/app/src/main/java/com/amaze/filemanager/filesystem/files/sort/SortType.kt new file mode 100644 index 0000000..9ac6862 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/files/sort/SortType.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2014-2023 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.files.sort + +/** Describes how to sort with [sortBy] and which direction to use for the sort with [sortOrder] */ +data class SortType(val sortBy: SortBy, val sortOrder: SortOrder) { + /** + * Returns the Int corresponding to the combination of [sortBy] and [sortOrder] + */ + fun toDirectorySortInt(): Int { + val sortIndex = if (sortBy.sortDirectory) sortBy.index else 0 + return when (sortOrder) { + SortOrder.ASC -> sortIndex + SortOrder.DESC -> sortIndex + 4 + } + } + + companion object { + /** + * Returns the [SortType] with the [SortBy] and [SortOrder] corresponding to [index] + */ + @JvmStatic + fun getDirectorySortType(index: Int): SortType { + val sortOrder = if (index <= 3) SortOrder.ASC else SortOrder.DESC + val normalizedIndex = if (index <= 3) index else index - 4 + val sortBy = SortBy.getDirectorySortBy(normalizedIndex) + return SortType(sortBy, sortOrder) + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftp/Extensions.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ftp/Extensions.kt new file mode 100644 index 0000000..f2752db --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/ftp/Extensions.kt @@ -0,0 +1,124 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.ftp + +import net.schmizz.sshj.xfer.FilePermission +import org.apache.commons.net.ftp.FTPClient +import org.apache.commons.net.ftp.FTPFile +import org.apache.commons.net.ftp.FTPFile.EXECUTE_PERMISSION +import org.apache.commons.net.ftp.FTPFile.GROUP_ACCESS +import org.apache.commons.net.ftp.FTPFile.READ_PERMISSION +import org.apache.commons.net.ftp.FTPFile.USER_ACCESS +import org.apache.commons.net.ftp.FTPFile.WORLD_ACCESS +import org.apache.commons.net.ftp.FTPFile.WRITE_PERMISSION +import org.apache.commons.net.ftp.FTPReply +import java.io.IOException + +/** + * @see https://stackoverflow.com/a/7606723 + */ +@Throws(IOException::class) +fun FTPClient.makeDirectoryTree(dirTree: String) { + if (dirTree == "/") { + return + } + + changeWorkingDirectory("/") + var dirExists = true + // Tokenize the string and attempt to change into each directory level. + // If you cannot, then start creating. + val directories = dirTree.split('/') + for (dir in directories) { + if (dir.isNotEmpty()) { + if (dirExists) { + dirExists = changeWorkingDirectory(dir) + } + if (!dirExists) { + if (!makeDirectory(dir)) { + throw IOException( + "Unable to create remote directory '$dir'. Error='$replyString'", + ) + } + if (!changeWorkingDirectory(dir)) { + throw IOException( + "Unable to change into newly created remote directory '$dir'. " + + "Error='$replyString'", + ) + } + } + } + } +} + +/** + * Try to ask server for space available, if it supports use of AVBL command. + * + * @param path Path to check for space available. + */ +fun FTPClient.getSpaceAvailable(path: String = "/"): Long { + if (hasFeature("AVBL")) { + val result = sendCommand("AVBL", path) + if (result == FTPReply.FILE_STATUS) { + // skip the return code (e.g. 213) and the space + return getReplyString().substring(4).toLong() + } else { + return -1L + } + } else { + return -1L + } +} + +/** + * Translate FTPFile's permission to Set of [FilePermission]. + */ +fun FTPFile.toFilePermissions(): Set { + val retval = HashSet() + // Got a better and smarter idea? + if (hasPermission(USER_ACCESS, READ_PERMISSION)) { + retval.add(FilePermission.USR_R) + } + if (hasPermission(USER_ACCESS, WRITE_PERMISSION)) { + retval.add(FilePermission.USR_W) + } + if (hasPermission(USER_ACCESS, EXECUTE_PERMISSION)) { + retval.add(FilePermission.USR_X) + } + if (hasPermission(GROUP_ACCESS, READ_PERMISSION)) { + retval.add(FilePermission.GRP_R) + } + if (hasPermission(GROUP_ACCESS, WRITE_PERMISSION)) { + retval.add(FilePermission.GRP_W) + } + if (hasPermission(GROUP_ACCESS, EXECUTE_PERMISSION)) { + retval.add(FilePermission.GRP_X) + } + if (hasPermission(WORLD_ACCESS, READ_PERMISSION)) { + retval.add(FilePermission.OTH_R) + } + if (hasPermission(WORLD_ACCESS, WRITE_PERMISSION)) { + retval.add(FilePermission.OTH_W) + } + if (hasPermission(WORLD_ACCESS, EXECUTE_PERMISSION)) { + retval.add(FilePermission.OTH_X) + } + return retval +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftp/FTPClientImpl.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ftp/FTPClientImpl.kt new file mode 100644 index 0000000..ece7f0d --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/ftp/FTPClientImpl.kt @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.ftp + +import org.apache.commons.net.ftp.FTPClient +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.File +import java.io.FileInputStream +import java.io.InputStream +import java.io.OutputStream +import kotlin.random.Random + +class FTPClientImpl(private val ftpClient: FTPClient) : NetCopyClient { + companion object { + @JvmStatic + private val logger: Logger = LoggerFactory.getLogger(FTPClientImpl::class.java) + + const val ANONYMOUS = "anonymous" + + const val ARG_TLS = "tls" + + const val TLS_EXPLICIT = "explicit" + + private const val ALPHABET = "abcdefghijklmnopqrstuvwxyz1234567890" + + @JvmStatic + private fun randomString(strlen: Int) = + (1..strlen) + .map { Random.nextInt(0, ALPHABET.length) } + .map(ALPHABET::get) + .joinToString("") + + /** + * Generate random email address for anonymous FTP login. + */ + @JvmStatic + fun generateRandomEmailAddressForLogin( + usernameLen: Int = 8, + domainPrefixLen: Int = 5, + domainSuffixLen: Int = 3, + ): String { + val username = randomString(usernameLen) + val domainPrefix = randomString(domainPrefixLen) + val domainSuffix = randomString(domainSuffixLen) + + return "$username@$domainPrefix.$domainSuffix" + } + + /** + * Wraps an an temporary [File] returned by [FTPClient.retrieveFileStream]. + * Most important part is to do [File.delete] when the reading is done. + */ + @JvmStatic + fun wrap(inputFile: File) = + object : InputStream() { + private val inputStream = FileInputStream(inputFile) + + override fun read() = inputStream.read() + + override fun read(b: ByteArray?): Int = inputStream.read(b) + + override fun read( + b: ByteArray?, + off: Int, + len: Int, + ): Int = inputStream.read(b, off, len) + + override fun reset() = inputStream.reset() + + override fun available(): Int = inputStream.available() + + override fun close() { + inputStream.close() + inputFile.delete() + } + + override fun markSupported(): Boolean = inputStream.markSupported() + + override fun mark(readlimit: Int) = inputStream.mark(readlimit) + + override fun skip(n: Long): Long = inputStream.skip(n) + } + + /** + * Wraps an [OutputStream] returned by [FTPClient.storeFileStream]. + * Most important part is to do [FTPClient.completePendingCommand] on [OutputStream.close]. + */ + @JvmStatic + fun wrap( + outputStream: OutputStream, + ftpClient: FTPClient, + ) = object : OutputStream() { + override fun write(b: Int) = outputStream.write(b) + + override fun write(b: ByteArray?) = outputStream.write(b) + + override fun write( + b: ByteArray?, + off: Int, + len: Int, + ) = outputStream.write(b, off, len) + + override fun flush() = outputStream.flush() + + override fun close() { + outputStream.close() + ftpClient.completePendingCommand() + } + } + } + + override fun getClientImpl() = ftpClient + + override fun isConnectionValid(): Boolean = ftpClient.isAvailable + + override fun isRequireThreadSafety(): Boolean = true + + override fun expire() { + ftpClient.disconnect() + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftp/FtpClientTemplate.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ftp/FtpClientTemplate.kt new file mode 100644 index 0000000..09cd441 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/ftp/FtpClientTemplate.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.ftp + +import org.apache.commons.net.ftp.FTPClient +import java.io.IOException + +/** + * Template class for executing actions with [NetCopyClient] while leave the complexities of + * handling connection setup/teardown to [NetCopyClientUtils]. + */ +abstract class FtpClientTemplate(url: String, closeClientOnFinish: Boolean = true) : + NetCopyClientTemplate(url, closeClientOnFinish) { + @Throws(IOException::class) + final override fun execute(client: NetCopyClient): T? { + val ftpClient: FTPClient = client.getClientImpl() + return executeWithFtpClient(ftpClient) + } + + /** + * Implement logic here. + * + * @param client [FTPClient] instance, with connection opened and authenticated + * @param Requested return type + * @return Result of the execution of the type requested + **/ + @Throws(IOException::class) + abstract fun executeWithFtpClient(ftpClient: FTPClient): T? +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftp/NetCopyClient.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ftp/NetCopyClient.kt new file mode 100644 index 0000000..840ba72 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/ftp/NetCopyClient.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.ftp + +/** + * Base interface for defining client class that interacts with a remote server. + */ +interface NetCopyClient { + /** + * Returns the physical client implementation. + */ + fun getClientImpl(): T + + /** + * Answers if the connection of the underlying client is still valid. + */ + fun isConnectionValid(): Boolean + + /** + * Answers if the client returned by [getClientImpl] requires thread safety. + * + * [NetCopyClientUtils.execute] will see this flag and enforce locking as necessary. + */ + fun isRequireThreadSafety(): Boolean = false + + /** + * Implement logic to expire the underlying connection if it went stale, timeout, etc. + */ + fun expire(): Unit +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftp/NetCopyClientConnectionPool.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ftp/NetCopyClientConnectionPool.kt new file mode 100644 index 0000000..91d43d5 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/ftp/NetCopyClientConnectionPool.kt @@ -0,0 +1,475 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.ftp + +import android.annotation.SuppressLint +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.asynchronous.asynctasks.ftp.auth.FtpAuthenticationTask +import com.amaze.filemanager.asynchronous.asynctasks.ssh.PemToKeyPairObservable +import com.amaze.filemanager.asynchronous.asynctasks.ssh.SshAuthenticationTask +import com.amaze.filemanager.filesystem.ftp.FTPClientImpl.Companion.ARG_TLS +import com.amaze.filemanager.filesystem.ftp.FTPClientImpl.Companion.TLS_EXPLICIT +import com.amaze.filemanager.filesystem.ftp.NetCopyClientUtils.extractBaseUriFrom +import com.amaze.filemanager.filesystem.ftp.NetCopyConnectionInfo.Companion.QUESTION_MARK +import io.reactivex.Flowable +import io.reactivex.Maybe +import io.reactivex.Observable.create +import io.reactivex.Single +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import net.schmizz.sshj.Config +import net.schmizz.sshj.SSHClient +import org.apache.commons.net.ftp.FTPClient +import org.apache.commons.net.ftp.FTPSClient +import org.json.JSONObject +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.security.KeyPair +import java.util.concurrent.Callable +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.atomic.AtomicReference + +object NetCopyClientConnectionPool { + const val FTP_DEFAULT_PORT = 21 + const val FTPS_DEFAULT_PORT = 990 + const val SSH_DEFAULT_PORT = 22 + const val FTP_URI_PREFIX = "ftp://" + const val FTPS_URI_PREFIX = "ftps://" + const val SSH_URI_PREFIX = "ssh://" + const val CONNECT_TIMEOUT = 30000 + + private var connections: MutableMap> = ConcurrentHashMap() + + @JvmStatic + private val LOG: Logger = LoggerFactory.getLogger(NetCopyClientConnectionPool::class.java) + + @JvmField + var sshClientFactory: SSHClientFactory = DefaultSSHClientFactory() + + @JvmField + var ftpClientFactory: FTPClientFactory = DefaultFTPClientFactory() + + /** + * Obtain a [NetCopyClient] connection from the underlying connection pool. + * + * Beneath it will return the connection if it exists; otherwise it will create a new one and + * put it into the connection pool. + * + * @param url SSH connection URL, in the form of ` + * ssh://:@:` or ` + * ssh://@:` + * @return [NetCopyClient] connection, already opened and authenticated + * @throws IOException IOExceptions that occur during connection setup + */ + fun getConnection(url: String): NetCopyClient? { + var client = connections[url] + if (client == null) { + client = createNetCopyClient.invoke(url) + if (client != null) { + connections[extractBaseUriFrom(url)] = client + } + } else { + if (!validate(client)) { + LOG.debug("Connection no longer usable. Reconnecting...") + expire(client) + connections.remove(url) + client = createNetCopyClient.invoke(url) + if (client != null) { + connections[extractBaseUriFrom(url)] = client + } + } + } + return if (client != null) { + client as NetCopyClient? + } else { + null + } + } + + /** + * Obtain a [NetCopyClient] connection from the underlying connection pool. + * + * + * Beneath it will return the connection if it exists; otherwise it will create a new one and + * put it into the connection pool. + * + * + * Different from [.getConnection] above, this accepts broken down parameters as + * convenience method during setting up SCP/SFTP connection. + * + * @param protocol server protocol, required + * @param host host name/IP, required + * @param port remote server port, required + * @param hostFingerprint expected host fingerprint, required + * @param username username, required + * @param password password, required if using password to authenticate + * @param keyPair [KeyPair], required if using key-based authentication + * @return [NetCopyClient] connection + */ + @Suppress("LongParameterList") + fun getConnection( + protocol: String, + host: String, + port: Int, + hostFingerprint: String? = null, + username: String, + password: String? = null, + keyPair: KeyPair? = null, + explicitTls: Boolean = false, + ): NetCopyClient<*>? { + val url = + NetCopyClientUtils.deriveUriFrom( + protocol, + host, + port, + "", + username, + password, + explicitTls, + ) + var client = connections[url] + if (client == null) { + client = + createNetCopyClientInternal( + protocol, + host, + port, + hostFingerprint, + username, + password, + keyPair, + explicitTls, + ) + if (client != null) connections[url] = client + } else { + if (!validate(client)) { + LOG.debug("Connection no longer usable. Reconnecting...") + expire(client) + connections.remove(url) + client = createNetCopyClient(url) + if (client != null) connections[url] = client + } + } + return client + } + + private val createNetCopyClient: (String) -> NetCopyClient<*>? = { url -> + if (url.startsWith(SSH_URI_PREFIX)) { + createSshClient(url) + } else { + createFtpClient(url) + } + } + + private val createNetCopyClientInternal: ( + String, + String, + Int, + String?, + String, + String?, + KeyPair?, + Boolean, + ) -> NetCopyClient<*>? = { protocol, host, port, hostFingerprint, username, password, keyPair, explicitTls -> + if (protocol == SSH_URI_PREFIX) { + createSshClient(host, port, hostFingerprint!!, username, password, keyPair) + } else { + createFtpClient( + protocol, + host, + port, + hostFingerprint?.let { JSONObject(it) }, + username, + password, + explicitTls, + ) + } + } + + /** + * Remove specified connection from connection pool. Disconnects from server before removing. + * + * For updating SSH/FTP connection settings. + * + * This method will silently end without feedback if the specified connection URI does not + * exist in the connection pool. + * + * @param url SSH connection URI + */ + @SuppressLint("CheckResult") + fun removeConnection( + url: String, + callback: () -> Unit, + ) { + Maybe.fromCallable(AsyncRemoveConnection(url)) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { callback.invoke() } + } + + /** + * Kill any connection that is still in place. Used by MainActivity. + * + * @see MainActivity.onDestroy + * @see MainActivity.exit + */ + fun shutdown() { + AppConfig.getInstance().runInBackground { + if (connections.isNotEmpty()) { + connections.values.forEach { + it.expire() + } + connections.clear() + } + } + } + + private fun validate(client: NetCopyClient<*>): Boolean { + return Single.fromCallable { + client.isConnectionValid() + }.subscribeOn(NetCopyClientUtils.getScheduler(client)).blockingGet() + } + + private fun expire(client: NetCopyClient<*>) = + Flowable.fromCallable { + client.expire() + }.subscribeOn(NetCopyClientUtils.getScheduler(client)) + + // Logic for creating SSH connection. Depends on password existence in given Uri password or + // key-based authentication + @Suppress("TooGenericExceptionThrown") + private fun createSshClient(url: String): NetCopyClient? { + val connInfo = NetCopyConnectionInfo(url) + val utilsHandler = AppConfig.getInstance().utilsHandler + val pem = utilsHandler.getSshAuthPrivateKey(url) + val keyPair = AtomicReference(null) + if (true == pem?.isNotEmpty()) { + val observable = PemToKeyPairObservable(pem) + keyPair.set( + create(observable) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .retryWhen { exceptions -> + exceptions.flatMap { exception -> + create { subscriber -> + observable.displayPassphraseDialog(exception, { + subscriber.onNext(Unit) + }, { + subscriber.onError(exception) + }) + } + } + } + .blockingFirst(), + ) + } + val hostKey = utilsHandler.getRemoteHostKey(url) ?: return null + return createSshClientInternal( + connInfo.host, + connInfo.port, + hostKey, + connInfo.username, + connInfo.password, + keyPair.get(), + ) + } + + @Suppress("LongParameterList") + private fun createSshClient( + host: String, + port: Int, + hostKey: String, + username: String, + password: String?, + keyPair: KeyPair?, + ): NetCopyClient? { + return createSshClientInternal( + host, + port, + hostKey, + username, + password, + keyPair, + ) + } + + @Suppress("LongParameterList") + private fun createSshClientInternal( + host: String, + port: Int, + hostKey: String, + username: String, + password: String?, + keyPair: KeyPair?, + ): NetCopyClient? { + val task = + SshAuthenticationTask( + hostname = host, + port = port, + hostKey = hostKey, + username = username, + password = password, + privateKey = keyPair, + ) + val latch = CountDownLatch(1) + var retval: SSHClient? = null + Maybe.fromCallable(task.getTask()) + .subscribeOn(Schedulers.io()) + .subscribe({ + retval = it + latch.countDown() + }, { + latch.countDown() + task.onError(it) + }) + latch.await() + return retval?.let { + SSHClientImpl(it) + } + } + + private fun createFtpClient(url: String): NetCopyClient? { + NetCopyConnectionInfo(url).run { + val certInfo = + if (FTPS_URI_PREFIX == prefix) { + AppConfig.getInstance().utilsHandler.getRemoteHostKey(url) + } else { + null + } + return createFtpClient( + prefix, + host, + port, + certInfo?.let { JSONObject(it) }, + username, + password, + true == arguments?.containsKey(ARG_TLS) && + TLS_EXPLICIT == arguments?.get(ARG_TLS), + ) + } + } + + @Suppress("LongParameterList") + private fun createFtpClient( + protocol: String, + host: String, + port: Int, + certInfo: JSONObject?, + username: String, + password: String?, + explicitTls: Boolean = false, + ): NetCopyClient? { + val task = + FtpAuthenticationTask( + protocol, + host, + port, + certInfo, + username, + password, + explicitTls, + ) + val latch = CountDownLatch(1) + var result: FTPClient? = null + Single.fromCallable(task.getTask()) + .subscribeOn(Schedulers.io()) + .subscribe({ + result = it + latch.countDown() + }, { + latch.countDown() + task.onError(it) + }) + latch.await() + return result?.let { ftpClient -> + FTPClientImpl(ftpClient) + } + } + + class AsyncRemoveConnection internal constructor( + private val url: String, + ) : Callable { + override fun call() { + extractBaseUriFrom(url).run { + if (connections.containsKey(this)) { + connections[this]?.expire() + connections.remove(this) + } + } + } + } + + /** + * Interface defining a factory class for creating [SSHClient] instances. + * + * In normal usage you won't need this; will be useful however when writing tests concerning + * SSHClient, that mocked instances can be returned so tests can be run without a real SSH + * server. + */ + interface SSHClientFactory { + /** + * Implement this to return [SSHClient] instances. + */ + fun create(config: Config): SSHClient + } + + /** + * Interface defining a factory class for creating [FTPClient] instances. + * + * In normal usage you won't need this; will be useful however when writing tests concerning + * FTPClient, that mocked instances can be returned so tests can be run without a real FTP + * server. + */ + interface FTPClientFactory { + /** + * Implement this to return [FTPClient] instances. + */ + fun create(uri: String): FTPClient + } + + /** Default [SSHClientFactory] implementation. */ + internal class DefaultSSHClientFactory : SSHClientFactory { + override fun create(config: Config): SSHClient { + return SSHClient(config) + } + } + + internal class DefaultFTPClientFactory : FTPClientFactory { + override fun create(uri: String): FTPClient { + return ( + if (uri.startsWith(FTPS_URI_PREFIX)) { + FTPSClient( + "TLS", + !uri.contains(QUESTION_MARK) || + !uri.substringAfter(QUESTION_MARK).contains("$ARG_TLS=$TLS_EXPLICIT"), + ) + } else { + FTPClient() + } + ).also { + it.addProtocolCommandListener(Slf4jPrintCommandListener()) + it.connectTimeout = CONNECT_TIMEOUT + it.controlEncoding = Charsets.UTF_8.name() + } + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftp/NetCopyClientTemplate.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ftp/NetCopyClientTemplate.kt new file mode 100644 index 0000000..82b80cc --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/ftp/NetCopyClientTemplate.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.ftp + +import java.io.IOException + +abstract class NetCopyClientTemplate +/** + * Constructor, with closeClientOnFinish set to true (that the connection must close after ` + * execute`. + * + * @param url SSH connection URL, in the form of ` + * ssh://:@:` or ` + * ssh://@:` + */ + @JvmOverloads + constructor( + @JvmField val url: String, + @JvmField val closeClientOnFinish: Boolean = true, + ) { + /** + * Implement logic here. + * + * @param client [NetCopyClient] instance, with connection opened and authenticated + * @param Requested return type + * @return Result of the execution of the type requested + **/ + @Throws(IOException::class) + abstract fun execute(client: NetCopyClient): T? + } diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftp/NetCopyClientUtils.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ftp/NetCopyClientUtils.kt new file mode 100644 index 0000000..557efb9 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/ftp/NetCopyClientUtils.kt @@ -0,0 +1,324 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.ftp + +import androidx.annotation.VisibleForTesting +import androidx.annotation.WorkerThread +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.fileoperations.filesystem.DOESNT_EXIST +import com.amaze.filemanager.fileoperations.filesystem.FolderState +import com.amaze.filemanager.fileoperations.filesystem.WRITABLE_ON_REMOTE +import com.amaze.filemanager.filesystem.ftp.FTPClientImpl.Companion.ARG_TLS +import com.amaze.filemanager.filesystem.ftp.FTPClientImpl.Companion.TLS_EXPLICIT +import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.FTPS_DEFAULT_PORT +import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.FTPS_URI_PREFIX +import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.FTP_DEFAULT_PORT +import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.FTP_URI_PREFIX +import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.SSH_DEFAULT_PORT +import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.SSH_URI_PREFIX +import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.getConnection +import com.amaze.filemanager.filesystem.ftp.NetCopyConnectionInfo.Companion.AND +import com.amaze.filemanager.filesystem.ftp.NetCopyConnectionInfo.Companion.AT +import com.amaze.filemanager.filesystem.ftp.NetCopyConnectionInfo.Companion.COLON +import com.amaze.filemanager.filesystem.ftp.NetCopyConnectionInfo.Companion.QUESTION_MARK +import com.amaze.filemanager.filesystem.ftp.NetCopyConnectionInfo.Companion.SLASH +import com.amaze.filemanager.filesystem.smb.CifsContexts.SMB_URI_PREFIX +import com.amaze.filemanager.filesystem.ssh.SFtpClientTemplate +import com.amaze.filemanager.utils.smb.SmbUtil +import com.amaze.filemanager.utils.urlEncoded +import io.reactivex.Maybe +import io.reactivex.Scheduler +import io.reactivex.schedulers.Schedulers +import net.schmizz.sshj.sftp.SFTPClient +import org.apache.commons.net.ftp.FTPClient +import org.apache.commons.net.ftp.FTPReply +import org.slf4j.LoggerFactory +import java.io.IOException +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale + +object NetCopyClientUtils { + @JvmStatic + private val LOG = LoggerFactory.getLogger(NetCopyClientUtils::class.java) + + /** + * Lambda to determine the [Scheduler] to use. + * Default is [Schedulers.io] while [Schedulers.single] is used when thread safety is required. + */ + @JvmStatic + var getScheduler: (NetCopyClient<*>) -> Scheduler = { + if (it.isRequireThreadSafety()) { + Schedulers.single() + } else { + Schedulers.io() + } + } + // Allow test cases to override the Scheduler to use, or deadlocks will occur + // because tests are run in parallel + @VisibleForTesting set + + /** + * Execute the given NetCopyClientTemplate. + * + * This template pattern is borrowed from Spring Framework, to simplify code on operations + * using NetCopyClientTemplate. + * + * FIXME: Over-simplification implementation causing unnecessarily closing SSHClient. + * + * @param template [NetCopyClientTemplate] to execute + * @param Type of return value + * @return Template execution results + */ + @WorkerThread + fun execute(template: NetCopyClientTemplate): T? { + var client = getConnection(extractBaseUriFrom(template.url)) + if (client == null) { + client = getConnection(template.url) + } + var retval: T? = null + if (client != null) { + retval = + runCatching { + Maybe.fromCallable { + template.execute(client) + }.subscribeOn(getScheduler.invoke(client)).blockingGet() + }.onFailure { + LOG.error("Error executing template method", it) + }.also { + if (template.closeClientOnFinish) { + tryDisconnect(client) + } + }.getOrNull() + } + return retval + } + + /** + * Convenience method to call [SmbUtil.getSmbEncryptedPath] if the given + * SSH URL contains the password (assuming the password is encrypted). + * + * @param fullUri SSH URL + * @return SSH URL with the password (if exists) encrypted + */ + fun encryptFtpPathAsNecessary(fullUri: String): String { + val uriWithoutProtocol: String = fullUri.substringAfter("://") + return if (uriWithoutProtocol.substringBefore(AT).indexOf(COLON) > 0) { + SmbUtil.getSmbEncryptedPath( + AppConfig.getInstance(), + fullUri, + ) + } else { + fullUri + } + } + + /** + * Convenience method to call [SmbUtil.getSmbDecryptedPath] if the given + * SSH URL contains the password (assuming the password is encrypted). + * + * @param fullUri SSH URL + * @return SSH URL with the password (if exists) decrypted + */ + fun decryptFtpPathAsNecessary(fullUri: String): String { + return runCatching { + val uriWithoutProtocol: String = fullUri.substringAfter("://") + if (uriWithoutProtocol.lastIndexOf(COLON) > 0) { + SmbUtil.getSmbDecryptedPath( + AppConfig.getInstance(), + fullUri, + ) + } else { + fullUri + } + }.getOrElse { e -> + LOG.error("Error decrypting path", e) + fullUri + } + } + + /** + * Convenience method to extract the Base URL from the given SSH URL. + * + * + * For example, given `ssh://user:password@127.0.0.1:22/home/user/foo/bar`, this + * method returns `ssh://user:password@127.0.0.1:22`. + * + * @param fullUri Full SSH URL + * @return The remote path part of the full SSH URL + */ + fun extractBaseUriFrom(fullUri: String): String { + return NetCopyConnectionInfo(fullUri).let { + buildString { + append(it.prefix) + append(it.username.ifEmpty { "" }) + if (true == it.password?.isNotEmpty()) { + append(COLON).append(it.password) + } + if (it.username.isNotEmpty()) { + append(AT) + } + append(it.host) + if (it.port > 0) { + append(COLON).append(it.port) + } + if (!it.arguments.isNullOrEmpty()) { + append(QUESTION_MARK) + .append(it.arguments?.entries?.joinToString(AND.toString())) + } + } + } + } + + /** + * Convenience method to extract the remote path from the given SSH URL. + * + * + * For example, given `ssh://user:password@127.0.0.1:22/home/user/foo/bar`, this + * method returns `/home/user/foo/bar`. + * + * @param fullUri Full SSH URL + * @return The remote path part of the full SSH URL + */ + @JvmStatic + fun extractRemotePathFrom(fullUri: String): String { + return NetCopyConnectionInfo(fullUri).let { connInfo -> + if (true == connInfo.defaultPath?.isNotEmpty()) { + buildString { + append(connInfo.defaultPath) + if (true == connInfo.filename?.isNotEmpty()) { + append(SLASH).append(connInfo.filename) + } + } + } else { + SLASH.toString() + } + } + } + + /** + * Disconnects the given [NetCopyClient] but wrap all exceptions beneath, so callers are free + * from the hassles of handling thrown exceptions. + * + * @param client [NetCopyClient] instance + */ + private fun tryDisconnect(client: NetCopyClient<*>) { + if (client.isConnectionValid()) { + client.expire() + } + } + + /** + * Decide the SSH URL depends on password/selected KeyPair. + */ + @Suppress("LongParameterList") + fun deriveUriFrom( + prefix: String, + hostname: String, + port: Int, + defaultPath: String? = null, + username: String, + password: String? = null, + explicitTls: Boolean = false, + edit: Boolean = false, + ): String { + // FIXME: should be caller's responsibility + var pathSuffix = defaultPath + if (pathSuffix == null) pathSuffix = SLASH.toString() + if (explicitTls) pathSuffix = "$pathSuffix?$ARG_TLS=$TLS_EXPLICIT" + val thisPassword = + if (password == "" || password == null) { + "" + } else { + ":${if (edit) { + password + } else { + password.urlEncoded() + }}" + } + return if (username == "") { + "$prefix$hostname:$port$pathSuffix" + } else { + "$prefix$username$thisPassword@$hostname:$port$pathSuffix" + } + } + + /** + * Check folder existence on remote. + */ + @FolderState + fun checkFolder(path: String): Int { + val template: NetCopyClientTemplate<*, Int> = + if (path.startsWith(SSH_URI_PREFIX)) { + object : SFtpClientTemplate(extractBaseUriFrom(path), false) { + @FolderState + @Throws(IOException::class) + override fun execute(client: SFTPClient): Int { + return if (client.statExistence(extractRemotePathFrom(path)) == null) { + WRITABLE_ON_REMOTE + } else { + DOESNT_EXIST + } + } + } + } else { + object : FtpClientTemplate(extractBaseUriFrom(path), false) { + override fun executeWithFtpClient(ftpClient: FTPClient): Int { + return if (ftpClient.stat(extractRemotePathFrom(path)) + == FTPReply.DIRECTORY_STATUS + ) { + WRITABLE_ON_REMOTE + } else { + DOESNT_EXIST + } + } + } + } + return execute(template) ?: DOESNT_EXIST + } + + /** + * Return the default port used by different protocols. + * + * Reserved for future use. + */ + fun defaultPort(prefix: String) = + when (prefix) { + SSH_URI_PREFIX -> SSH_DEFAULT_PORT + FTPS_URI_PREFIX -> FTPS_DEFAULT_PORT + FTP_URI_PREFIX -> FTP_DEFAULT_PORT + SMB_URI_PREFIX -> 0 // SMB never requires explicit port number at URL + else -> throw IllegalArgumentException("Cannot derive default port") + } + + /** + * Convenience method to format given UNIX timestamp to yyyyMMddHHmmss format. + */ + @JvmStatic + fun getTimestampForTouch(date: Long): String { + val calendar = Calendar.getInstance() + calendar.timeInMillis = date + val df: DateFormat = SimpleDateFormat("yyyyMMddHHmmss", Locale.US) + df.calendar = calendar + return df.format(calendar.time) + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftp/NetCopyConnectionInfo.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ftp/NetCopyConnectionInfo.kt new file mode 100644 index 0000000..1b7bc4a --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/ftp/NetCopyConnectionInfo.kt @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.ftp + +import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.FTPS_URI_PREFIX +import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.FTP_URI_PREFIX +import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.SSH_URI_PREFIX +import com.amaze.filemanager.filesystem.ftp.NetCopyConnectionInfo.Companion.COLON +import com.amaze.filemanager.filesystem.smb.CifsContexts.SMB_URI_PREFIX + +/** + * Container object for SSH/FTP/FTPS URL, encapsulating logic for splitting information from given + * URL. `Uri.parse()` only parse URL that is compliant to RFC2396, but we have to deal with + * URL that is not compliant, since usernames and/or strong passwords usually have special + * characters included, like `ssh://user@example.com:P@##w0rd@127.0.0.1:22`. + * + * A design decision to keep database schema slim, by the way... -TranceLove + * + * @param url URI to break down. + * + * For credentials, can be base64 or URL encoded, but if both username and password is provided, + * must use plain colon character [COLON] as separator. + * + * For paths and query strings, **always** use URL encoded paths, or undesired behaviour will + * occur. No validation is made at this point, so proceed at your own risk. + */ +class NetCopyConnectionInfo(url: String) { + val prefix: String + val host: String + val port: Int + val username: String + val password: String? + var defaultPath: String? = null + private set + var queryString: String? = null + private set + var arguments: Map? + private set + var filename: String? = null + private set + + companion object { + // Regex taken from https://blog.stevenlevithan.com/archives/parseuri + // (No, don't break it down to lines) + + @Suppress("ktlint:standard:max-line-length") + private const val URI_REGEX = "^(?:(?![^:@]+:[^:@/]*@)([^:/?#.]+):)?(?://)?((?:(([^:@]*)(?::([^:@]*))?)?@)?([^:/?#]*)(?::(\\d*))?)(((/(?:[^?#](?![^?#/]*\\.[^?#/.]+(?:[?#]|$)))*/?)?([^?#/]*))(?:\\?([^#]*))?(?:#(.*))?)" + + const val MULTI_SLASH = "(?<=[^:])(//+)" + + const val AND = '&' + const val AT = '@' + const val SLASH = '/' + const val COLON = ':' + const val QUESTION_MARK = '?' + } + + init { + require( + url.startsWith(SSH_URI_PREFIX) or + url.startsWith(FTP_URI_PREFIX) or + url.startsWith(FTPS_URI_PREFIX) or + url.startsWith(SMB_URI_PREFIX), + ) { + "Argument is not a supported remote URI: $url" + } + val regex = Regex(URI_REGEX) + val matches = regex.find(url) + if (matches == null) { + throw IllegalArgumentException("Unable to parse URI") + } else { + matches.groupValues.let { + prefix = "${it[1]}://" + host = it[6] + val credential = it[3] + if (!credential.contains(COLON)) { + username = credential + password = null + } else { + username = credential.substringBefore(COLON) + password = credential.substringAfter(COLON) + } + port = + if (it[7].isNotEmpty()) { + /* + * Invalid string would have been trapped to other branches. Strings fell into + * this branch must be integer + */ + it[7].toInt() + } else { + 0 + } + queryString = it[12].ifEmpty { null } + arguments = + if (it[12].isNotEmpty()) { + it[12].split(AND).associate { valuePair -> + val pair = valuePair.split('=') + Pair( + pair[0], + pair[1].ifEmpty { + "" + }, + ) + } + } else { + null + } + defaultPath = + ( + if (it[9].isEmpty()) { + null + } else if (it[9] == SLASH.toString()) { + SLASH.toString() + } else if (!it[9].endsWith(SLASH)) { + if (it[11].isEmpty()) { + it[10] + } else { + it[10].substringBeforeLast(SLASH) + } + } else { + it[9] + } + )?.replace(Regex(MULTI_SLASH), SLASH.toString()) + filename = it[11].ifEmpty { null } + } + } + } + + /** + * Returns the last segment of the URL's path element. + */ + fun lastPathSegment(): String? { + return if (filename != null && true == filename?.isNotEmpty()) { + filename + } else if (defaultPath != null && true == defaultPath?.isNotEmpty()) { + defaultPath!!.substringAfterLast(SLASH) + } else { + null + } + } + + override fun toString(): String { + return if (username.isNotBlank() && username.isNotEmpty()) { + "$prefix$username@$host${if (port == 0) "" else ":$port"}${defaultPath ?: ""}" + } else { + "$prefix$host${if (port == 0) "" else ":$port"}${defaultPath ?: ""}" + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftp/SSHClientImpl.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ftp/SSHClientImpl.kt new file mode 100644 index 0000000..ebcf17e --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/ftp/SSHClientImpl.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.ftp + +import net.schmizz.sshj.SSHClient +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +class SSHClientImpl(private val sshClient: SSHClient) : NetCopyClient { + companion object { + @JvmStatic + private val logger: Logger = LoggerFactory.getLogger(SSHClientImpl::class.java) + } + + override fun getClientImpl() = sshClient + + override fun isConnectionValid(): Boolean = sshClient.isConnected && sshClient.isAuthenticated + + override fun expire() { + if (sshClient.isConnected) { + runCatching { + sshClient.disconnect() + }.onFailure { + logger.warn("Error closing SSHClient connection", it) + } + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftp/Slf4jPrintCommandListener.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ftp/Slf4jPrintCommandListener.kt new file mode 100644 index 0000000..8ac5595 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/ftp/Slf4jPrintCommandListener.kt @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.ftp + +import org.apache.commons.net.ProtocolCommandEvent +import org.apache.commons.net.ProtocolCommandListener +import org.apache.commons.net.SocketClient +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.slf4j.event.Level + +/** + * [ProtocolCommandListener] that logs output to a slf4j [Logger]. + * + * Can adjust the logger level by specifying the [loggerLevel] parameter. + */ +internal class Slf4jPrintCommandListener( + private val nologin: Boolean = true, + private val eolMarker: Char = 0.toChar(), + private val directionMarker: Boolean = false, + private val loggerLevel: Level = Level.DEBUG, +) : + ProtocolCommandListener { + private val logger: Logger = LoggerFactory.getLogger(SocketClient::class.java) + + private val logMessage: (String) -> Unit = { msg -> + when (loggerLevel) { + Level.INFO -> logger.info(msg) + Level.DEBUG -> logger.debug(msg) + Level.ERROR -> logger.error(msg) + Level.WARN -> logger.warn(msg) + Level.TRACE -> logger.trace(msg) + } + } + + override fun protocolCommandSent(event: ProtocolCommandEvent) { + val sb = StringBuilder() + if (directionMarker) { + sb.append("> ") + } + if (nologin) { + val cmd = event.command + if ("PASS".equals(cmd, ignoreCase = true) || "USER".equals(cmd, ignoreCase = true)) { + sb.append(cmd) + sb.append(" *******") // Don't bother with EOL marker for this! + } else { + sb.append(getPrintableString(event.message)) + } + } else { + sb.append(getPrintableString(event.message)) + } + logMessage.invoke(sb.toString()) + } + + override fun protocolReplyReceived(event: ProtocolCommandEvent) { + val msg = + if (directionMarker) { + "< ${event.message}" + } else { + event.message + } + logMessage.invoke(msg) + } + + private fun getPrintableString(msg: String): String { + if (eolMarker.code == 0) { + return msg + } + val pos = msg.indexOf(SocketClient.NETASCII_EOL) + if (pos > 0) { + val sb = StringBuilder() + sb.append(msg.substring(0, pos)) + sb.append(eolMarker) + sb.append(msg.substring(pos)) + return sb.toString() + } + return msg + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFileSystemFactory.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFileSystemFactory.kt new file mode 100644 index 0000000..af53162 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFileSystemFactory.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.ftpserver + +import android.content.Context +import android.os.Build.VERSION_CODES.KITKAT +import androidx.annotation.RequiresApi +import com.amaze.filemanager.asynchronous.services.ftp.FtpService +import org.apache.ftpserver.ftplet.FileSystemFactory +import org.apache.ftpserver.ftplet.FileSystemView +import org.apache.ftpserver.ftplet.User + +@RequiresApi(KITKAT) +class AndroidFileSystemFactory(private val context: Context) : FileSystemFactory { + override fun createFileSystemView(user: User?): FileSystemView = + AndroidFtpFileSystemView(context, user?.homeDirectory ?: FtpService.defaultPath(context)) +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFtpFile.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFtpFile.kt new file mode 100644 index 0000000..9f10594 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFtpFile.kt @@ -0,0 +1,219 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.ftpserver + +import android.content.ContentResolver +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import android.os.Build.VERSION_CODES.KITKAT +import android.provider.DocumentsContract +import androidx.annotation.RequiresApi +import androidx.documentfile.provider.DocumentFile +import org.apache.ftpserver.ftplet.FtpFile +import java.io.FileNotFoundException +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.lang.ref.WeakReference + +@RequiresApi(KITKAT) +@Suppress("TooManyFunctions") // Don't ask me. Ask Apache why. +class AndroidFtpFile( + context: Context, + private val parentDocument: DocumentFile, + private val backingDocument: DocumentFile?, + private val path: String, +) : FtpFile { + private val _context: WeakReference = WeakReference(context) + private val context: Context + get() = _context.get()!! + + override fun getAbsolutePath(): String { + return path + } + + /** + * @see FtpFile.getName + * @see DocumentFile.getName + */ + override fun getName(): String = backingDocument?.name ?: path.substringAfterLast('/') + + /** + * @see FtpFile.isHidden + */ + override fun isHidden(): Boolean = name.startsWith(".") && name != "." + + /** + * @see FtpFile.isDirectory + * @see DocumentFile.isDirectory + */ + override fun isDirectory(): Boolean = backingDocument?.isDirectory ?: false + + /** + * @see FtpFile.isFile + * @see DocumentFile.isFile + */ + override fun isFile(): Boolean = backingDocument?.isFile ?: false + + /** + * @see FtpFile.doesExist + * @see DocumentFile.exists + */ + override fun doesExist(): Boolean = backingDocument?.exists() ?: false + + /** + * @see FtpFile.isReadable + * @see DocumentFile.canRead + */ + override fun isReadable(): Boolean = backingDocument?.canRead() ?: false + + /** + * @see FtpFile.isWritable + * @see DocumentFile.canWrite + */ + override fun isWritable(): Boolean = backingDocument?.canWrite() ?: true + + /** + * @see FtpFile.isRemovable + * @see DocumentFile.canWrite + */ + override fun isRemovable(): Boolean = backingDocument?.canWrite() ?: true + + /** + * @see FtpFile.getOwnerName + */ + override fun getOwnerName(): String = "user" + + /** + * @see FtpFile.getGroupName + */ + override fun getGroupName(): String = "user" + + /** + * @see FtpFile.getLinkCount + */ + override fun getLinkCount(): Int = 0 + + /** + * @see FtpFile.getLastModified + * @see DocumentFile.lastModified + */ + override fun getLastModified(): Long = backingDocument?.lastModified() ?: 0L + + /** + * @see FtpFile.setLastModified + * @see DocumentsContract.Document.COLUMN_LAST_MODIFIED + * @see ContentResolver.update + */ + override fun setLastModified(time: Long): Boolean { + return if (doesExist()) { + val updateValues = + ContentValues().also { + it.put(DocumentsContract.Document.COLUMN_LAST_MODIFIED, time) + } + val docUri: Uri = backingDocument!!.uri + val updated: Int = + context.contentResolver.update( + docUri, + updateValues, + null, + null, + ) + return updated == 1 + } else { + false + } + } + + /** + * @see FtpFile.getSize + * @see DocumentFile.length + */ + override fun getSize(): Long = backingDocument?.length() ?: 0L + + /** + * @see FtpFile.getPhysicalFile + */ + override fun getPhysicalFile(): Any = backingDocument!! + + /** + * @see FtpFile.mkdir + * @see DocumentFile.createDirectory + */ + override fun mkdir(): Boolean = parentDocument.createDirectory(name) != null + + /** + * @see FtpFile.delete + * @see DocumentFile.delete + */ + override fun delete(): Boolean = backingDocument?.delete() ?: false + + /** + * @see FtpFile.move + * @see DocumentFile.renameTo + */ + override fun move(destination: FtpFile): Boolean = backingDocument?.renameTo(destination.name) ?: false + + /** + * @see FtpFile.listFiles + * @see DocumentFile.listFiles + */ + override fun listFiles(): MutableList = + if (doesExist()) { + backingDocument!!.listFiles().map { + AndroidFtpFile(context, backingDocument, it, it.name!!) + }.toMutableList() + } else { + mutableListOf() + } + + /** + * @see FtpFile.createOutputStream + * @see ContentResolver.openOutputStream + */ + override fun createOutputStream(offset: Long): OutputStream? = + runCatching { + val uri = + if (doesExist()) { + backingDocument!!.uri + } else { + val newFile = parentDocument.createFile("", name) + newFile?.uri ?: throw IOException("Cannot create file at $path") + } + context.contentResolver.openOutputStream(uri) + }.getOrThrow() + + /** + * @see FtpFile.createInputStream + * @see ContentResolver.openInputStream + */ + override fun createInputStream(offset: Long): InputStream? = + runCatching { + if (doesExist()) { + context.contentResolver.openInputStream(backingDocument!!.uri).also { + it?.skip(offset) + } + } else { + throw FileNotFoundException(path) + } + }.getOrThrow() +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFtpFileSystemView.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFtpFileSystemView.kt new file mode 100644 index 0000000..1a25824 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFtpFileSystemView.kt @@ -0,0 +1,156 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.ftpserver + +import android.content.Context +import android.net.Uri +import android.os.Build +import android.os.Build.VERSION_CODES.KITKAT +import android.os.Build.VERSION_CODES.M +import androidx.annotation.RequiresApi +import androidx.documentfile.provider.DocumentFile +import org.apache.ftpserver.ftplet.FileSystemView +import org.apache.ftpserver.ftplet.FtpFile +import java.io.File +import java.net.URI + +@RequiresApi(KITKAT) +class AndroidFtpFileSystemView(private var context: Context, root: String) : FileSystemView { + private val rootPath = root + private val rootDocumentFile = createDocumentFileFrom(rootPath) + private var currentPath: String? = "/" + + override fun getHomeDirectory(): FtpFile = AndroidFtpFile(context, rootDocumentFile, resolveDocumentFileFromRoot("/"), "/") + + override fun getWorkingDirectory(): FtpFile { + return AndroidFtpFile( + context, + rootDocumentFile, + resolveDocumentFileFromRoot(currentPath!!), + currentPath!!, + ) + } + + override fun changeWorkingDirectory(dir: String?): Boolean { + return when { + dir.isNullOrBlank() -> false + dir == "/" -> { + currentPath = "/" + true + } + dir.startsWith("..") -> { + if (currentPath.isNullOrEmpty() || currentPath == "/") { + false + } else { + currentPath = normalizePath("$currentPath/$dir") + resolveDocumentFileFromRoot(currentPath) != null + } + } + else -> { + currentPath = + when { + currentPath.isNullOrEmpty() || currentPath == "/" -> dir + !dir.startsWith("/") -> normalizePath("$currentPath/$dir") + else -> normalizePath(dir) + } + resolveDocumentFileFromRoot(currentPath) != null + } + } + } + + override fun getFile(file: String): FtpFile { + val path = + if (currentPath.isNullOrEmpty() || currentPath == "/") { + "/$file" + } else if (file.startsWith('/')) { + file + } else { + "$currentPath/$file" + } + return normalizePath(path).let { normalizedPath -> + AndroidFtpFile( + context, + resolveDocumentFileFromRoot(getParentFrom(normalizedPath))!!, // rootDocumentFile, + resolveDocumentFileFromRoot(normalizedPath), + normalizedPath, + ) + } + } + + override fun isRandomAccessible(): Boolean = false + + override fun dispose() { + // context = null!! + } + + private fun normalizePath(path: String): String { + return when { + path == "\\" || path == "/" -> { + "/" + } + path.length <= 1 -> { + path + } + else -> { + Uri.decode( + URI(Uri.encode(path, "/")) + .normalize() + .toString(), + ).replace("//", "/") + } + } + } + + private fun getParentFrom(normalizedPath: String): String { + return if (normalizedPath.length <= 1) { + normalizedPath + } else { + normalizedPath.substringBeforeLast('/') + } + } + + private fun createDocumentFileFrom(path: String): DocumentFile { + return if (Build.VERSION.SDK_INT in KITKAT until M) { + DocumentFile.fromFile(File(path)) + } else { + DocumentFile.fromTreeUri(context, Uri.parse(path))!! + } + } + + private fun resolveDocumentFileFromRoot(path: String?): DocumentFile? { + return if (path.isNullOrBlank() or ("/" == path) or ("./" == path)) { + rootDocumentFile + } else { + val pathElements = path!!.split('/') + if (pathElements.isEmpty()) { + rootDocumentFile + } else { + var retval: DocumentFile? = rootDocumentFile + pathElements.forEach { pathElement -> + if (pathElement.isNotBlank()) { + retval = retval?.findFile(pathElement) + } + } + retval + } + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/RootFileSystemFactory.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/RootFileSystemFactory.kt new file mode 100644 index 0000000..808b8dc --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/RootFileSystemFactory.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.ftpserver + +import org.apache.ftpserver.ftplet.FileSystemFactory +import org.apache.ftpserver.ftplet.FileSystemView +import org.apache.ftpserver.ftplet.User + +class RootFileSystemFactory( + private val fileFactory: RootFileSystemView.SuFileFactory = + RootFileSystemView.DefaultSuFileFactory(), +) : FileSystemFactory { + override fun createFileSystemView(user: User): FileSystemView = RootFileSystemView(user, fileFactory) +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/RootFileSystemView.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/RootFileSystemView.kt new file mode 100644 index 0000000..90f4eaa --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/RootFileSystemView.kt @@ -0,0 +1,279 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.ftpserver + +import android.util.Log +import com.topjohnwu.superuser.io.SuFile +import org.apache.ftpserver.ftplet.FileSystemView +import org.apache.ftpserver.ftplet.FtpFile +import org.apache.ftpserver.ftplet.User +import java.io.File +import java.net.URI +import java.util.StringTokenizer + +class RootFileSystemView( + private val user: User, + private val fileFactory: SuFileFactory, +) : FileSystemView { + private var currDir: String + private var rootDir: String + + companion object { + private const val TAG = "RootFileSystemView" + } + + init { + requireNotNull(user.homeDirectory) { "User home directory can not be null" } + + // add last '/' if necessary + var rootDir = user.homeDirectory + rootDir = normalizeSeparateChar(rootDir) + rootDir = appendSlash(rootDir) + + Log.d( + TAG, + "Native filesystem view created for user \"${user.name}\" with root \"${rootDir}\"", + ) + + this.rootDir = rootDir + currDir = "/" + } + + override fun getHomeDirectory(): FtpFile { + return RootFtpFile("/", fileFactory.create(rootDir), user) + } + + override fun getWorkingDirectory(): FtpFile { + return if (currDir == "/") { + RootFtpFile("/", fileFactory.create(rootDir), user) + } else { + val file = fileFactory.create(rootDir, currDir.substring(1)) + RootFtpFile(currDir, file, user) + } + } + + override fun changeWorkingDirectory(dirArg: String): Boolean { + var dir = dirArg + + // not a directory - return false + dir = getPhysicalName(rootDir, currDir, dir) + val dirObj = fileFactory.create(dir) + if (!dirObj.isDirectory) { + return false + } + + // strip user root and add last '/' if necessary + dir = dir.substring(rootDir.length - 1) + if (dir[dir.length - 1] != '/') { + dir = "$dir/" + } + + currDir = dir + return true + } + + override fun getFile(file: String): FtpFile { + // get actual file object + val physicalName = getPhysicalName(rootDir, currDir, file) + val fileObj = fileFactory.create(physicalName) + + // strip the root directory and return + val userFileName = physicalName.substring(rootDir.length - 1) + return RootFtpFile(userFileName, fileObj, user) + } + + override fun isRandomAccessible(): Boolean = false + + override fun dispose() = Unit + + /** + * Get the physical canonical file name. It works like + * File.getCanonicalPath(). + * + * @param rootDir + * The root directory. + * @param currDir + * The current directory. It will always be with respect to the + * root directory. + * @param fileName + * The input file name. + * @return The return string will always begin with the root directory. It + * will never be null. + */ + private fun getPhysicalName( + rootDir: String, + currDir: String, + fileName: String, + ): String { + // normalize root dir + var normalizedRootDir: String = normalizeSeparateChar(rootDir) + normalizedRootDir = appendSlash(normalizedRootDir) + + // normalize file name + val normalizedFileName = normalizeSeparateChar(fileName) + var result: String? + + // if file name is relative, set resArg to root dir + curr dir + // if file name is absolute, set resArg to root dir + result = + if (normalizedFileName[0] != '/') { + // file name is relative + val normalizedCurrDir = normalize(currDir) + normalizedRootDir + normalizedCurrDir.substring(1) + } else { + normalizedRootDir + } + + // strip last '/' + result = trimTrailingSlash(result) + + // replace ., ~ and .. + // in this loop resArg will never end with '/' + val st = StringTokenizer(normalizedFileName, "/") + while (st.hasMoreTokens()) { + val tok = st.nextToken() + + // . => current directory + if (tok == ".") { + // ignore and move on + } else if (tok == "..") { + // .. => parent directory (if not root) + if (result!!.startsWith(normalizedRootDir)) { + val slashIndex = result.lastIndexOf('/') + if (slashIndex != -1) { + result = result.substring(0, slashIndex) + } + } + } else if (tok == "~") { + // ~ => home directory (in this case the root directory) + result = trimTrailingSlash(normalizedRootDir) + continue + } else { + result = "$result/$tok" + } + } + + // add last slash if necessary + if (result!!.length + 1 == normalizedRootDir.length) { + result += '/' + } + + // make sure we did not end up above root dir + if (!result.startsWith(normalizedRootDir)) { + result = normalizedRootDir + } + return result + } + + /** + * Append trailing slash ('/') if missing + */ + private fun appendSlash(path: String): String { + return if (!path.endsWith("/")) { + "$path/" + } else { + path + } + } + + /** + * Prepend leading slash ('/') if missing + */ + private fun prependSlash(path: String): String { + return if (!path.startsWith("/")) { + "/$path" + } else { + path + } + } + + /** + * Trim trailing slash ('/') if existing + */ + private fun trimTrailingSlash(path: String?): String { + return if (path!![path.length - 1] == '/') { + path.substring(0, path.length - 1) + } else { + path + } + } + + /** + * Normalize separate character. Separate character should be '/' always. + */ + private fun normalizeSeparateChar(pathName: String): String { + return pathName + .replace(File.separatorChar, '/') + .replace('\\', '/') + } + + /** + * Normalize separator char, append and prepend slashes. Default to + * defaultPath if null or empty + */ + private fun normalize(pathArg: String?): String { + var path: String? = pathArg + if (path == null || path.trim { it <= ' ' }.isEmpty()) { + path = "/" + } + path = normalizeSeparateChar(path) + path = prependSlash(appendSlash(path)) + return path + } + + /** + * Interface responsible for creating [SuFile] instances. + * + * Mainly for facilitating tests. + */ + interface SuFileFactory { + /** + * Create SuFile. + */ + fun create(pathname: String): SuFile = SuFile(pathname) + + /** + * Create SuFile. + */ + fun create( + parent: String, + child: String, + ): SuFile = SuFile(parent, child) + + /** + * Create SuFile. + */ + fun create( + parent: File, + child: String, + ): SuFile = SuFile(parent, child) + + /** + * Create SuFile. + */ + fun create(uri: URI): SuFile = SuFile(uri) + } + + /** + * Marker class as default implementation of [SuFileFactory]. + */ + class DefaultSuFileFactory : SuFileFactory +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/RootFtpFile.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/RootFtpFile.kt new file mode 100644 index 0000000..0a85fac --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/RootFtpFile.kt @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.ftpserver + +import com.topjohnwu.superuser.io.SuFile +import com.topjohnwu.superuser.io.SuFileInputStream +import com.topjohnwu.superuser.io.SuFileOutputStream +import org.apache.ftpserver.ftplet.FtpFile +import org.apache.ftpserver.ftplet.User +import org.apache.ftpserver.usermanager.impl.WriteRequest +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.InputStream +import java.io.OutputStream + +class RootFtpFile( + private val fileName: String, + private val backingFile: SuFile, + private val user: User, +) : FtpFile { + companion object { + @JvmStatic + private val logger: Logger = LoggerFactory.getLogger(RootFtpFile::class.java) + } + + override fun getAbsolutePath(): String = backingFile.absolutePath + + override fun getName(): String = backingFile.name + + override fun isHidden(): Boolean = backingFile.isHidden + + override fun isDirectory(): Boolean = backingFile.isDirectory + + override fun isFile(): Boolean = backingFile.isFile + + override fun doesExist(): Boolean = backingFile.exists() + + override fun isReadable(): Boolean = backingFile.canRead() + + override fun isWritable(): Boolean { + logger.debug("Checking authorization for $absolutePath") + if (user.authorize(WriteRequest(absolutePath)) == null) { + logger.debug("Not authorized") + return false + } + + logger.debug("Checking if file exists") + if (backingFile.exists()) { + logger.debug("Checking can write: " + backingFile.canWrite()) + return backingFile.canWrite() + } + + logger.debug("Authorized") + return true + } + + override fun isRemovable(): Boolean { + // root cannot be deleted + if ("/" == fileName) { + return false + } + + val fullName = absolutePath + // we check FTPServer's write permission for this file. + if (user.authorize(WriteRequest(fullName)) == null) { + return false + } + // In order to maintain consistency, when possible we delete the last '/' character in the String + val indexOfSlash = fullName.lastIndexOf('/') + val parentFullName: String = + if (indexOfSlash == 0) { + "/" + } else { + fullName.substring(0, indexOfSlash) + } + + // we check if the parent FileObject is writable. + return backingFile.absoluteFile.parentFile?.run { + RootFtpFile( + parentFullName, + this, + user, + ).isWritable + } ?: false + } + + override fun getOwnerName(): String = "user" + + override fun getGroupName(): String = "user" + + override fun getLinkCount(): Int = if (backingFile.isDirectory) 3 else 1 + + override fun getLastModified(): Long = backingFile.lastModified() + + override fun setLastModified(time: Long): Boolean = backingFile.setLastModified(time) + + override fun getSize(): Long = backingFile.length() + + override fun getPhysicalFile(): Any = backingFile + + override fun mkdir(): Boolean = backingFile.mkdirs() + + override fun delete(): Boolean = backingFile.delete() + + override fun move(destination: FtpFile): Boolean = backingFile.renameTo(destination.physicalFile as SuFile) + + override fun listFiles(): MutableList = + backingFile.listFiles()?.map { + RootFtpFile(it.name, it, user) + }?.toMutableList() ?: emptyList().toMutableList() + + override fun createOutputStream(offset: Long): OutputStream = SuFileOutputStream.open(backingFile.absolutePath) + + override fun createInputStream(offset: Long): InputStream = SuFileInputStream.open(backingFile.absolutePath) +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/commands/AVBL.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/commands/AVBL.kt new file mode 100644 index 0000000..8d5809b --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/commands/AVBL.kt @@ -0,0 +1,135 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.ftpserver.commands + +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.filesystem.ftpserver.AndroidFileSystemFactory +import org.apache.ftpserver.command.AbstractCommand +import org.apache.ftpserver.ftplet.DefaultFtpReply +import org.apache.ftpserver.ftplet.FtpFile +import org.apache.ftpserver.ftplet.FtpReply.REPLY_213_FILE_STATUS +import org.apache.ftpserver.ftplet.FtpReply.REPLY_502_COMMAND_NOT_IMPLEMENTED +import org.apache.ftpserver.ftplet.FtpReply.REPLY_550_REQUESTED_ACTION_NOT_TAKEN +import org.apache.ftpserver.ftplet.FtpRequest +import org.apache.ftpserver.impl.FtpIoSession +import org.apache.ftpserver.impl.FtpServerContext +import org.apache.ftpserver.usermanager.impl.WriteRequest +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.File + +/** + * Implements FTP extension AVBL command, to answer device remaining space in FTP command. + * + * Only supports [com.amaze.filemanager.filesystem.ftpserver.RootFileSystemFactory] and + * [org.apache.ftpserver.filesystem.nativefs.NativeFileSystemFactory]. Otherwise will simply return + * 550 Access Denied. + * + * See [Draft spec](https://www.ietf.org/archive/id/draft-peterson-streamlined-ftp-command-extensions-10.txt) + */ +class AVBL : AbstractCommand() { + companion object { + private val LOG: Logger = LoggerFactory.getLogger(AVBL::class.java) + } + + override fun execute( + session: FtpIoSession, + context: FtpServerContext, + request: FtpRequest, + ) { + // argument check + val fileName: String? = request.argument + if (context.fileSystemManager is AndroidFileSystemFactory) { + doWriteReply( + session, + REPLY_502_COMMAND_NOT_IMPLEMENTED, + "AVBL.notimplemented", + ) + } else { + val ftpFile: FtpFile? = + if (true == fileName?.isNotBlank()) { + runCatching { + session.fileSystemView.getFile(fileName) + }.getOrNull() + } else { + session.fileSystemView.homeDirectory + } + if (ftpFile != null) { + if (session.user.authorize( + if (true == fileName?.isNotBlank()) { + WriteRequest(fileName) + } else { + WriteRequest() + }, + ) != null || + !(ftpFile.physicalFile as File).canWrite() + ) { + (ftpFile.physicalFile as File).apply { + if (this.isDirectory) { + runCatching { + freeSpace.let { + session.write( + DefaultFtpReply(REPLY_213_FILE_STATUS, it.toString()), + ) + } + }.onFailure { + LOG.error("Error getting directory free space", it) + replyError(session, "AVBL.accessdenied") + return + } + } else { + replyError(session, "AVBL.isafile") + } + } + } else { + replyError(session, "AVBL.accessdenied") + } + } else { + replyError(session, "AVBL.missing", fileName) + } + } + } + + private fun replyError( + session: FtpIoSession, + subId: String, + fileName: String? = null, + ) = doWriteReply(session, REPLY_550_REQUESTED_ACTION_NOT_TAKEN, subId, fileName) + + private fun doWriteReply( + session: FtpIoSession, + code: Int, + subId: String, + fileName: String? = null, + ) { + val packageName = AppConfig.getInstance().packageName + val resources = AppConfig.getInstance().resources + session.write( + DefaultFtpReply( + code, + resources.getString( + resources.getIdentifier("$packageName:string/ftp_error_$subId", null, null), + fileName, + ), + ), + ) + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/commands/FEAT.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/commands/FEAT.kt new file mode 100644 index 0000000..ee284fd --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/commands/FEAT.kt @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.ftpserver.commands + +import com.amaze.filemanager.R +import com.amaze.filemanager.application.AppConfig +import org.apache.ftpserver.command.AbstractCommand +import org.apache.ftpserver.ftplet.DefaultFtpReply +import org.apache.ftpserver.ftplet.FtpReply +import org.apache.ftpserver.ftplet.FtpRequest +import org.apache.ftpserver.impl.FtpIoSession +import org.apache.ftpserver.impl.FtpServerContext + +/** + * Custom [org.apache.ftpserver.command.impl.FEAT] to add [AVBL] command to the list. + */ +class FEAT : AbstractCommand() { + override fun execute( + session: FtpIoSession, + context: FtpServerContext, + request: FtpRequest, + ) { + session.resetState() + session.write( + DefaultFtpReply( + FtpReply.REPLY_211_SYSTEM_STATUS_REPLY, + AppConfig.getInstance().getString(R.string.ftp_command_FEAT), + ), + ) + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/commands/PWD.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/commands/PWD.kt new file mode 100644 index 0000000..eb270d1 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/commands/PWD.kt @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.ftpserver.commands + +import org.apache.ftpserver.command.AbstractCommand +import org.apache.ftpserver.ftplet.FtpException +import org.apache.ftpserver.ftplet.FtpReply +import org.apache.ftpserver.ftplet.FtpRequest +import org.apache.ftpserver.impl.FtpIoSession +import org.apache.ftpserver.impl.FtpServerContext +import org.apache.ftpserver.impl.LocalizedFtpReply +import java.io.IOException + +/** + * Monkey-patch [org.apache.ftpserver.command.impl.PWD] to prevent true path exposed to end user. + */ +class PWD : AbstractCommand() { + @Throws(IOException::class, FtpException::class) + override fun execute( + session: FtpIoSession, + context: FtpServerContext, + request: FtpRequest, + ) { + session.resetState() + val fsView = session.fileSystemView + var currDir = + fsView.workingDirectory.absolutePath + .substringAfter(fsView.homeDirectory.absolutePath) + if (currDir.isEmpty()) { + currDir = "/" + } + if (!currDir.startsWith("/")) { + currDir = "/$currDir" + } + session.write( + LocalizedFtpReply.translate( + session, + request, + context, + FtpReply.REPLY_257_PATHNAME_CREATED, + "PWD", + currDir, + ), + ) + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/root/ChangeFilePermissionsCommand.kt b/app/src/main/java/com/amaze/filemanager/filesystem/root/ChangeFilePermissionsCommand.kt new file mode 100644 index 0000000..19715c0 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/root/ChangeFilePermissionsCommand.kt @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.root + +import com.amaze.filemanager.fileoperations.exceptions.ShellNotRunningException +import com.amaze.filemanager.filesystem.RootHelper +import com.amaze.filemanager.filesystem.root.base.IRootCommand +import com.topjohnwu.superuser.Shell + +object ChangeFilePermissionsCommand : IRootCommand() { + private const val CHMOD_COMMAND = "chmod %s %o \"%s\"" + + /** + * Change permissions for a given file path - requires root + * + * @param filePath given file path + * @param updatedPermissions octal notation for permissions + * @param isDirectory is given path a directory or file + */ + @Throws(ShellNotRunningException::class) + fun changeFilePermissions( + filePath: String, + updatedPermissions: Int, + isDirectory: Boolean, + onOperationPerform: (Boolean) -> Unit, + ) { + val mountPoint = MountPathCommand.mountPath(filePath, MountPathCommand.READ_WRITE) + + val options = if (isDirectory) "-R" else "" + val command = + String.format( + CHMOD_COMMAND, + options, + updatedPermissions, + RootHelper.getCommandLineString(filePath), + ) + + runShellCommand(command).let { result: Shell.Result -> + if (result.code < 0) { + onOperationPerform(false) + } else { + onOperationPerform(true) + } + } + + mountPoint?.let { MountPathCommand.mountPath(it, MountPathCommand.READ_ONLY) } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/root/ConcatenateFileCommand.kt b/app/src/main/java/com/amaze/filemanager/filesystem/root/ConcatenateFileCommand.kt new file mode 100644 index 0000000..b53d8b4 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/root/ConcatenateFileCommand.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.root + +import com.amaze.filemanager.fileoperations.exceptions.ShellNotRunningException +import com.amaze.filemanager.filesystem.RootHelper +import com.amaze.filemanager.filesystem.root.base.IRootCommand + +object ConcatenateFileCommand : IRootCommand() { + /** + * Concatenates (cat) file data to destination + */ + @Throws(ShellNotRunningException::class) + fun concatenateFile( + sourcePath: String, + destinationPath: String, + ) { + val mountPoint = MountPathCommand.mountPath(destinationPath, MountPathCommand.READ_WRITE) + runShellCommand( + "cat \"${RootHelper.getCommandLineString(sourcePath)}\"" + + " > \"${RootHelper.getCommandLineString(destinationPath)}\"", + ) + mountPoint?.let { MountPathCommand.mountPath(it, MountPathCommand.READ_ONLY) } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/root/CopyFilesCommand.kt b/app/src/main/java/com/amaze/filemanager/filesystem/root/CopyFilesCommand.kt new file mode 100644 index 0000000..b92db33 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/root/CopyFilesCommand.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.root + +import com.amaze.filemanager.fileoperations.exceptions.ShellNotRunningException +import com.amaze.filemanager.filesystem.RootHelper +import com.amaze.filemanager.filesystem.root.MountPathCommand.mountPath +import com.amaze.filemanager.filesystem.root.base.IRootCommand + +object CopyFilesCommand : IRootCommand() { + /** + * Copies files using root + * @param source given source + * @param destination given destination + */ + @Throws(ShellNotRunningException::class) + fun copyFiles( + source: String, + destination: String, + ) { + // remounting destination as rw + val mountPoint = mountPath(destination, MountPathCommand.READ_WRITE) + + runShellCommand( + "cp -r \"${RootHelper.getCommandLineString(source)}\" " + + "\"${RootHelper.getCommandLineString(destination)}\"", + ) + + // we mounted the filesystem as rw, let's mount it back to ro + mountPoint?.let { mountPath(it, MountPathCommand.READ_ONLY) } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/root/DeleteFileCommand.kt b/app/src/main/java/com/amaze/filemanager/filesystem/root/DeleteFileCommand.kt new file mode 100644 index 0000000..228a2e5 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/root/DeleteFileCommand.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.root + +import com.amaze.filemanager.fileoperations.exceptions.ShellNotRunningException +import com.amaze.filemanager.filesystem.RootHelper +import com.amaze.filemanager.filesystem.root.base.IRootCommand + +object DeleteFileCommand : IRootCommand() { + /** + * Recursively removes a path with it's contents (if any) + * + * @return boolean whether file was deleted or not + */ + @Throws(ShellNotRunningException::class) + fun deleteFile(path: String): Boolean { + val mountPoint = MountPathCommand.mountPath(path, MountPathCommand.READ_WRITE) + val result = + runShellCommandToList( + "rm -rf \"${RootHelper.getCommandLineString(path)}\"", + ) + + mountPoint?.let { MountPathCommand.mountPath(it, MountPathCommand.READ_ONLY) } + + return result.isNotEmpty() + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/root/FindFileCommand.kt b/app/src/main/java/com/amaze/filemanager/filesystem/root/FindFileCommand.kt new file mode 100644 index 0000000..33ad38b --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/root/FindFileCommand.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.root + +import com.amaze.filemanager.fileoperations.exceptions.ShellNotRunningException +import com.amaze.filemanager.filesystem.RootHelper +import com.amaze.filemanager.filesystem.root.base.IRootCommand + +object FindFileCommand : IRootCommand() { + /** + * find file at given path in root + * + * @return boolean whether file was deleted or not + */ + @Throws(ShellNotRunningException::class) + fun findFile(path: String): Boolean { + val result = + runShellCommandToList( + "find \"${RootHelper.getCommandLineString(path)}\"", + ) + return result.isNotEmpty() + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/root/ListFilesCommand.kt b/app/src/main/java/com/amaze/filemanager/filesystem/root/ListFilesCommand.kt new file mode 100644 index 0000000..a3ecf77 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/root/ListFilesCommand.kt @@ -0,0 +1,275 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.root + +import androidx.preference.PreferenceManager +import com.amaze.filemanager.R +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.exceptions.ShellCommandInvalidException +import com.amaze.filemanager.fileoperations.exceptions.ShellNotRunningException +import com.amaze.filemanager.fileoperations.filesystem.OpenMode +import com.amaze.filemanager.fileoperations.filesystem.root.NativeOperations +import com.amaze.filemanager.filesystem.HybridFileParcelable +import com.amaze.filemanager.filesystem.RootHelper +import com.amaze.filemanager.filesystem.files.FileUtils +import com.amaze.filemanager.filesystem.root.base.IRootCommand +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.File + +object ListFilesCommand : IRootCommand() { + private val log: Logger = LoggerFactory.getLogger(ListFilesCommand::class.java) + + /** + * list files in given directory and invoke callback + */ + fun listFiles( + path: String, + root: Boolean, + showHidden: Boolean, + openModeCallback: (openMode: OpenMode) -> Unit, + onFileFoundCallback: (file: HybridFileParcelable) -> Unit, + ) { + val mode: OpenMode + if (root && FileUtils.isRunningAboveStorage(path)) { + // we're rooted and we're trying to load file with superuser + // we're at the root directories, superuser is required! + val result = executeRootCommand(path, showHidden) + result.first.forEach { + if (!it.contains("Permission denied")) { + parseStringForHybridFile( + rawFile = it, + path = path, + isStat = !result.second, + ) + ?.let(onFileFoundCallback) + } + } + mode = OpenMode.ROOT + openModeCallback(mode) + } else if (FileUtils.canListFiles(File(path))) { + // we're taking a chance to load files using basic java filesystem + getFilesList(path, showHidden, onFileFoundCallback) + mode = OpenMode.FILE + } else { + // we couldn't load files using native java filesystem callbacks + // maybe the access is not allowed due to android system restrictions, we'll see later + mode = OpenMode.FILE + } + openModeCallback(mode) + } + + /** + * Get open mode for path if it's a root path or normal storage + */ + fun getOpenMode( + path: String, + root: Boolean, + ): OpenMode { + val mode: OpenMode = + if (root && FileUtils.isRunningAboveStorage(path)) { + OpenMode.ROOT + } else { + OpenMode.FILE + } + return mode + } + + /** + * executes list files root command directory and return each line item + * returns pair with first denoting the result array and second if run with ls (true) or stat (false) + */ + @Throws(ShellNotRunningException::class) + fun executeRootCommand( + path: String, + showHidden: Boolean, + retryWithLs: Boolean = false, + ): Pair, Boolean> { + try { + /** + * If path is root keep command `stat -c *` + * Else keep `stat -c /path/file/\*` + */ + var appendedPath = path + val sanitizedPath = RootHelper.getCommandLineString(appendedPath) + appendedPath = + when (path) { + "/" -> sanitizedPath.replace("/", "") + else -> sanitizedPath.plus("/") + } + + val command = + "stat -c '%A %h %G %U %B %Y %N' " + + "$appendedPath*" + (if (showHidden) " $appendedPath.* " else "") + val enforceLegacyFileListing: Boolean = + PreferenceManager.getDefaultSharedPreferences(AppConfig.getInstance()) + .getBoolean( + PreferencesConstants.PREFERENCE_ROOT_LEGACY_LISTING, + false, + ) + // #3476: Check current working dir, change back to / before proceeding + runShellCommand("pwd").run { + if (out.first() != "/") { + runShellCommand("cd /") + } + } + return if (!retryWithLs && !enforceLegacyFileListing) { + log.info("Using stat for list parsing") + Pair( + first = + runShellCommandToList(command).map { + it.replace(appendedPath, "") + }, + second = enforceLegacyFileListing, + ) + } else { + log.info("Using ls for list parsing") + Pair( + first = + runShellCommandToList( + "ls -l " + (if (showHidden) "-a " else "") + + "\"$sanitizedPath\"", + ), + second = + if (retryWithLs) { + true + } else { + enforceLegacyFileListing + }, + ) + } + } catch (invalidCommand: ShellCommandInvalidException) { + log.warn("Command not found - ${invalidCommand.message}") + return if (retryWithLs) { + Pair(first = emptyList(), second = true) + } else { + executeRootCommand(path, showHidden, true) + } + } catch (exception: ShellNotRunningException) { + log.warn("failed to execute root command", exception) + return Pair(first = emptyList(), second = false) + } + } + + private fun isDirectory(path: HybridFileParcelable): Boolean { + return path.permission.startsWith("d") || File(path.path).isDirectory + } + + /** + * Loads files in a path using basic filesystem callbacks + * + * @param path the path + */ + private fun getFilesList( + path: String, + showHidden: Boolean, + listener: (HybridFileParcelable) -> Unit, + ): ArrayList { + val pathFile = File(path) + val files = ArrayList() + if (pathFile.exists() && pathFile.isDirectory) { + val filesInPathFile = pathFile.listFiles() + if (filesInPathFile != null) { + filesInPathFile.forEach { currentFile -> + var size: Long = 0 + if (!currentFile.isDirectory) size = currentFile.length() + HybridFileParcelable( + currentFile.path, + RootHelper.parseFilePermission(currentFile), + currentFile.lastModified(), + size, + currentFile.isDirectory, + ).let { baseFile -> + baseFile.name = currentFile.name + baseFile.mode = OpenMode.FILE + if (showHidden) { + files.add(baseFile) + listener(baseFile) + } else { + if (!currentFile.isHidden) { + files.add(baseFile) + listener(baseFile) + } + } + } + } + } else { + log.error("Error listing files at [$path]. Access permission denied?") + AppConfig.getInstance().run { + AppConfig.toast(this, this.getString(R.string.error_permission_denied)) + } + } + } + return files + } + + /** + * Parses listing command result for HybridFile + */ + private fun parseStringForHybridFile( + rawFile: String, + path: String, + isStat: Boolean, + ): HybridFileParcelable? { + return FileUtils.parseName( + if (isStat) { + rawFile.replace( + "('|`)".toRegex(), + "", + ) + } else { + rawFile + }, + isStat, + )?.apply { + this.mode = OpenMode.ROOT + this.name = this.path + if (path != "/") { + this.path = path + "/" + this.path + } else { + // root of filesystem, don't concat another '/' + this.path = path + this.path + } + if (this.link.trim { it <= ' ' }.isNotEmpty()) { + if (isStat) { + isDirectory(this).let { + this.isDirectory = it + if (it) { + // stat command symlink includes time stamp at the end + // also, stat follows symlink by default if listing is invoked on it + // so we don't need link for stat + this.link = "" + } + } + } else { + NativeOperations.isDirectory(this.link).let { + this.isDirectory = it + } + } + } else { + this.isDirectory = isDirectory(this) + } + } + } + + data class ExecuteRootCommandResult(val lines: List, val forcedLs: Boolean) +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/root/MakeDirectoryCommand.kt b/app/src/main/java/com/amaze/filemanager/filesystem/root/MakeDirectoryCommand.kt new file mode 100644 index 0000000..4382039 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/root/MakeDirectoryCommand.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.root + +import com.amaze.filemanager.fileoperations.exceptions.ShellNotRunningException +import com.amaze.filemanager.filesystem.RootHelper +import com.amaze.filemanager.filesystem.root.base.IRootCommand + +object MakeDirectoryCommand : IRootCommand() { + /** + * Creates an empty directory using root + * + * @param path path to new directory + * @param name name of directory + */ + @Throws(ShellNotRunningException::class) + fun makeDirectory( + path: String, + name: String, + ) { + val mountPoint = MountPathCommand.mountPath(path, MountPathCommand.READ_WRITE) + val filePath = "$path/$name" + runShellCommand("mkdir \"${RootHelper.getCommandLineString(filePath)}\"") + mountPoint?.let { MountPathCommand.mountPath(it, MountPathCommand.READ_ONLY) } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/root/MakeFileCommand.kt b/app/src/main/java/com/amaze/filemanager/filesystem/root/MakeFileCommand.kt new file mode 100644 index 0000000..fa0efb6 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/root/MakeFileCommand.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.root + +import com.amaze.filemanager.fileoperations.exceptions.ShellNotRunningException +import com.amaze.filemanager.filesystem.RootHelper +import com.amaze.filemanager.filesystem.root.base.IRootCommand + +object MakeFileCommand : IRootCommand() { + /** + * Creates an empty file using root + * + * @param path path to new file + */ + @Throws(ShellNotRunningException::class) + fun makeFile(path: String) { + val mountPoint = MountPathCommand.mountPath(path, MountPathCommand.READ_WRITE) + runShellCommand("touch \"${RootHelper.getCommandLineString(path)}\"") + mountPoint?.let { MountPathCommand.mountPath(it, MountPathCommand.READ_ONLY) } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/root/MountPathCommand.kt b/app/src/main/java/com/amaze/filemanager/filesystem/root/MountPathCommand.kt new file mode 100644 index 0000000..fd2e61e --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/root/MountPathCommand.kt @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.root + +import android.os.Build +import com.amaze.filemanager.fileoperations.exceptions.ShellNotRunningException +import com.amaze.filemanager.filesystem.RootHelper +import com.amaze.filemanager.filesystem.root.base.IRootCommand + +object MountPathCommand : IRootCommand() { + const val READ_ONLY = "RO" + const val READ_WRITE = "RW" + + /** + * Mount filesystem associated with path for writable access (rw) Since we don't have the root of + * filesystem to remount, we need to parse output of # mount command. + * + * @param path the path on which action to perform + * @param operation RO or RW + * @return String the root of mount point that was ro, and mounted to rw; null otherwise + */ + @Throws(ShellNotRunningException::class) + fun mountPath( + pathArg: String, + operation: String, + ): String? { + val path = RootHelper.getCommandLineString(pathArg) + return when (operation) { + READ_WRITE -> mountReadWrite(path) + READ_ONLY -> { + val command = "umount -r \"$path\"" + runShellCommand(command) + null + } + else -> null + } + } + + private fun mountReadWrite(path: String): String? { + val command = "mount" + val output = runShellCommandToList(command) + var mountPoint = "" + var types: String? = null + var mountArgument: String? = null + for (line in output) { + val words = line.split(" ").toTypedArray() + + // mount command output for older Androids + // /dev/block/vda /system ext4 ro,seclabel,relatime,data=ordered 0 0 + var mountPointOutputFromShell = words[1] + var mountPointFileSystemTypeFromShell = words[2] + var mountPointArgumentFromShell = words[3] + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + // mount command output for Android version >= 7 + // /dev/block/bootdevice/by-name/system on /system type ext4 + // (ro,seclabel,relatime,data=ordered) + mountPointOutputFromShell = words[2] + mountPointFileSystemTypeFromShell = words[4] + mountPointArgumentFromShell = words[5] + } + if (path.startsWith(mountPointOutputFromShell)) { + // current found point is bigger than last one, hence not a conflicting one + // we're finding the best match, this omits for eg. / and /sys when we're actually + // looking for /system + if (mountPointOutputFromShell.length > mountPoint.length) { + mountPoint = mountPointOutputFromShell + types = mountPointFileSystemTypeFromShell + mountArgument = mountPointArgumentFromShell + } + } + } + + if (mountPoint != "" && types != null && mountArgument != null) { + // we have the mountpoint, check for mount options if already rw + if (mountArgument.contains("rw")) { + // already a rw filesystem return + return null + } else if (mountArgument.contains("ro")) { + // read-only file system, remount as rw + val mountCommand = "mount -o rw,remount $mountPoint" + val mountOutput = runShellCommandToList(mountCommand) + return if (mountOutput.isNotEmpty()) { + // command failed, and we got a reason echo'ed + null + } else { + mountPoint + } + } + } + return null + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/root/MoveFileCommand.kt b/app/src/main/java/com/amaze/filemanager/filesystem/root/MoveFileCommand.kt new file mode 100644 index 0000000..b3ff0a5 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/root/MoveFileCommand.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.root + +import com.amaze.filemanager.fileoperations.exceptions.ShellNotRunningException +import com.amaze.filemanager.filesystem.RootHelper +import com.amaze.filemanager.filesystem.root.base.IRootCommand + +object MoveFileCommand : IRootCommand() { + /** + * Move files using root + * @param path source path + * @param destination + */ + @Throws(ShellNotRunningException::class) + fun moveFile( + path: String, + destination: String, + ) { + // remounting destination as rw + val mountPoint = MountPathCommand.mountPath(destination, MountPathCommand.READ_WRITE) + val command = + "mv \"${RootHelper.getCommandLineString(path)}\"" + + " \"${RootHelper.getCommandLineString(destination)}\"" + runShellCommand(command) + mountPoint?.let { MountPathCommand.mountPath(it, MountPathCommand.READ_ONLY) } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/root/RenameFileCommand.kt b/app/src/main/java/com/amaze/filemanager/filesystem/root/RenameFileCommand.kt new file mode 100644 index 0000000..e3e0e4f --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/root/RenameFileCommand.kt @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.root + +import com.amaze.filemanager.exceptions.ShellCommandInvalidException +import com.amaze.filemanager.fileoperations.exceptions.ShellNotRunningException +import com.amaze.filemanager.filesystem.RootHelper +import com.amaze.filemanager.filesystem.root.base.IRootCommand +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +object RenameFileCommand : IRootCommand() { + private val log: Logger = LoggerFactory.getLogger(RenameFileCommand::class.java) + + /** + * Renames file using root + * + * @param oldPath path to file before rename + * @param newPath path to file after rename + * @return if rename was successful or not + */ + @Throws(ShellNotRunningException::class) + fun renameFile( + oldPath: String, + newPath: String, + ): Boolean { + val mountPoint = MountPathCommand.mountPath(oldPath, MountPathCommand.READ_WRITE) + val command = + "mv \"${RootHelper.getCommandLineString(oldPath)}\"" + + " \"${RootHelper.getCommandLineString(newPath)}\"" + return try { + val output = runShellCommandToList(command) + mountPoint?.let { MountPathCommand.mountPath(it, MountPathCommand.READ_ONLY) } + output.isEmpty() + } catch (e: ShellCommandInvalidException) { + log.warn("failed to rename file", e) + false + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/root/base/IRootCommand.kt b/app/src/main/java/com/amaze/filemanager/filesystem/root/base/IRootCommand.kt new file mode 100644 index 0000000..5449613 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/root/base/IRootCommand.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.root.base + +import com.amaze.filemanager.exceptions.ShellCommandInvalidException +import com.amaze.filemanager.fileoperations.exceptions.ShellNotRunningException +import com.amaze.filemanager.ui.activities.MainActivity +import com.topjohnwu.superuser.Shell + +open class IRootCommand { + /** + * Runs the command and stores output in a list. The listener is set on the handler thread [ ] + * [MainActivity.handlerThread] thus any code run in callback must be thread safe. Command is run + * from the root context (u:r:SuperSU0) + * + * @param cmd the command + * @return a list of results. Null only if the command passed is a blocking call or no output is + * there for the command passed + */ + @Throws(ShellNotRunningException::class, ShellCommandInvalidException::class) + fun runShellCommandToList(cmd: String): List { + var interrupt = false + var errorCode: Int = -1 + // callback being called on a background handler thread + val commandResult = runShellCommand(cmd) + if (commandResult.code in 1..127) { + interrupt = true + errorCode = commandResult.code + } + val result = commandResult.out + if (interrupt) { + throw ShellCommandInvalidException("$cmd , error code - $errorCode") + } + return result + } + + /** + * Command is run from the root context (u:r:SuperSU0) + * + * @param cmd the command + */ + @Throws(ShellNotRunningException::class) + fun runShellCommand(cmd: String): Shell.Result { + if (!Shell.getShell().isRoot) { + throw ShellNotRunningException() + } + return Shell.su(cmd).exec() + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/smb/CifsContexts.kt b/app/src/main/java/com/amaze/filemanager/filesystem/smb/CifsContexts.kt new file mode 100644 index 0000000..286beb4 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/smb/CifsContexts.kt @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.smb + +import android.net.Uri +import android.text.TextUtils +import android.util.Log +import io.reactivex.Single +import io.reactivex.schedulers.Schedulers +import jcifs.CIFSException +import jcifs.config.PropertyConfiguration +import jcifs.context.BaseContext +import jcifs.context.SingletonContext +import java.util.Properties +import java.util.concurrent.ConcurrentHashMap + +object CifsContexts { + const val SMB_URI_PREFIX = "smb://" + + private val TAG = CifsContexts::class.java.simpleName + + private val defaultProperties: Properties = + Properties().apply { + setProperty("jcifs.resolveOrder", "BCAST") + setProperty("jcifs.smb.client.responseTimeout", "30000") + setProperty("jcifs.netbios.retryTimeout", "5000") + setProperty("jcifs.netbios.cachePolicy", "-1") + } + + private val contexts: MutableMap = ConcurrentHashMap() + + @JvmStatic + fun clearBaseContexts() { + contexts.forEach { + try { + it.value.close() + } catch (e: CIFSException) { + Log.w(TAG, "Error closing SMB connection", e) + } + } + contexts.clear() + } + + @JvmStatic + fun createWithDisableIpcSigningCheck( + basePath: String, + disableIpcSigningCheck: Boolean, + ): BaseContext { + return if (disableIpcSigningCheck) { + val extraProperties = Properties() + extraProperties["jcifs.smb.client.ipcSigningEnforced"] = "false" + create(basePath, extraProperties) + } else { + create(basePath, null) + } + } + + @JvmStatic + fun create( + basePath: String, + extraProperties: Properties?, + ): BaseContext { + val basePathKey: String = + Uri.parse(basePath).run { + val prefix = "$scheme://$authority" + val suffix = if (TextUtils.isEmpty(query)) "" else "?$query" + "$prefix$suffix" + } + return if (contexts.containsKey(basePathKey)) { + contexts.getValue(basePathKey) + } else { + val context = + Single.fromCallable { + try { + val p = Properties(defaultProperties) + if (extraProperties != null) p.putAll(extraProperties) + BaseContext(PropertyConfiguration(p)) + } catch (e: CIFSException) { + Log.e(TAG, "Error initialize jcifs BaseContext, returning default", e) + SingletonContext.getInstance() + } + }.subscribeOn(Schedulers.io()) + .blockingGet() + contexts[basePathKey] = context + context + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ssh/CustomSshJConfig.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ssh/CustomSshJConfig.kt new file mode 100644 index 0000000..285dbfe --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/ssh/CustomSshJConfig.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.ssh + +import net.schmizz.sshj.DefaultConfig +import org.bouncycastle.jce.provider.BouncyCastleProvider +import java.security.Security + +/** + * sshj [net.schmizz.sshj.Config] for our own use. + * + * + * Borrowed from original AndroidConfig, but also use vanilla BouncyCastle from the start + * altogether. + * + * @see net.schmizz.sshj.Config + * + * @see net.schmizz.sshj.AndroidConfig + */ +class CustomSshJConfig : DefaultConfig() { + companion object { + /** + * This is where we different from the original AndroidConfig. Found it only work if we remove + * BouncyCastle bundled with Android before registering our BouncyCastle provider + */ + @JvmStatic + fun init() { + Security.removeProvider("BC") + Security.insertProviderAt(BouncyCastleProvider(), 0) + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ssh/SFTPClientExt.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ssh/SFTPClientExt.kt new file mode 100644 index 0000000..7241f3b --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/ssh/SFTPClientExt.kt @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2014-2023 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.ssh + +import net.schmizz.sshj.sftp.FileAttributes +import net.schmizz.sshj.sftp.OpenMode +import net.schmizz.sshj.sftp.PacketType +import net.schmizz.sshj.sftp.RemoteFile +import net.schmizz.sshj.sftp.SFTPClient +import net.schmizz.sshj.sftp.SFTPEngine +import java.io.IOException +import java.util.EnumSet +import java.util.concurrent.TimeUnit + +const val READ_AHEAD_MAX_UNCONFIRMED_READS: Int = 16 + +/** + * Monkey-patch [SFTPEngine.open] until sshj adds back read ahead support in [RemoteFile]. + */ +@Throws(IOException::class) +fun SFTPEngine.openWithReadAheadSupport( + path: String, + modes: Set, + fa: FileAttributes, +): RemoteFile { + val handle: ByteArray = + request( + newRequest(PacketType.OPEN).putString(path, subsystem.remoteCharset) + .putUInt32(OpenMode.toMask(modes).toLong()).putFileAttributes(fa), + ).retrieve(timeoutMs.toLong(), TimeUnit.MILLISECONDS) + .ensurePacketTypeIs(PacketType.HANDLE).readBytes() + return RemoteFile(this, path, handle) +} + +/** + * Monkey-patch [SFTPClient.open] until sshj adds back read ahead support in [RemoteFile]. + */ +fun SFTPClient.openWithReadAheadSupport(path: String): RemoteFile { + return sftpEngine.openWithReadAheadSupport( + path, + EnumSet.of(OpenMode.READ), + FileAttributes.EMPTY, + ) +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ssh/SFtpClientTemplate.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ssh/SFtpClientTemplate.kt new file mode 100644 index 0000000..d46d99d --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/ssh/SFtpClientTemplate.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.ssh + +import net.schmizz.sshj.SSHClient +import net.schmizz.sshj.sftp.SFTPClient +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.IOException + +/** + * Template class for executing actions with [SFTPClient] while leave the complexities of + * handling connection and session setup/teardown to [SshClientUtils]. + */ +abstract class SFtpClientTemplate(url: String, closeClientOnFinish: Boolean = true) : + SshClientTemplate(url, closeClientOnFinish) { + private val LOG: Logger = LoggerFactory.getLogger(javaClass) + + override fun executeWithSSHClient(sshClient: SSHClient): T? { + var sftpClient: SFTPClient? = null + var retval: T? = null + try { + sftpClient = sshClient.newSFTPClient() + retval = execute(sftpClient) + } catch (e: IOException) { + LOG.error("Error executing template method", e) + } finally { + if (sftpClient != null && closeClientOnFinish) { + try { + sftpClient.close() + } catch (e: IOException) { + LOG.warn("Error closing SFTP client", e) + } + } + } + return retval + } + + /** + * Implement logic here. + * + * @param client [SFTPClient] instance, with connection opened and authenticated, and SSH + * session had been set up. + * @param Requested return type + * @return Result of the execution of the type requested + */ + @Throws(IOException::class) + abstract fun execute(client: SFTPClient): T? +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ssh/SshClientSessionTemplate.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ssh/SshClientSessionTemplate.kt new file mode 100644 index 0000000..d07c7d7 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/ssh/SshClientSessionTemplate.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.ssh + +import net.schmizz.sshj.connection.channel.direct.Session +import java.io.IOException + +abstract class SshClientSessionTemplate/** + * Constructor. + * + * @param url SSH connection URL, in the form of ` + * ssh://:@:` or ` + * ssh://@:` + */( + @JvmField val url: String, +) { + /** + * Implement logic here. + * + * @param sshClientSession [Session] instance, with connection opened and authenticated + * @param Requested return type + * @return Result of the execution of the type requested + */ + @Throws(IOException::class) + abstract fun execute(sshClientSession: Session): T +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ssh/SshClientTemplate.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ssh/SshClientTemplate.kt new file mode 100644 index 0000000..41d695a --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/ssh/SshClientTemplate.kt @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.ssh + +import com.amaze.filemanager.filesystem.ftp.NetCopyClient +import com.amaze.filemanager.filesystem.ftp.NetCopyClientTemplate +import net.schmizz.sshj.SSHClient +import java.io.IOException + +/** + * Template class for executing actions with [SSHClient] while leave the complexities of + * handling connection setup/teardown to [SshClientUtils]. + */ +abstract class SshClientTemplate(url: String, closeClientOnFinish: Boolean = true) : + NetCopyClientTemplate(url, closeClientOnFinish) { + @Throws(IOException::class) + final override fun execute(client: NetCopyClient): T? { + val sshClient: SSHClient = client.getClientImpl() + return executeWithSSHClient(sshClient) + } + + /** + * Implement logic here. + * + * @param client [SSHClient] instance, with connection opened and authenticated + * @param Requested return type + * @return Result of the execution of the type requested + */ + @Throws(IOException::class) + abstract fun executeWithSSHClient(client: SSHClient): T? +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ssh/SshClientUtils.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ssh/SshClientUtils.kt new file mode 100644 index 0000000..b70ab11 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/ssh/SshClientUtils.kt @@ -0,0 +1,231 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.ssh + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import com.amaze.filemanager.R +import com.amaze.filemanager.fileoperations.filesystem.cloud.CloudStreamer +import com.amaze.filemanager.filesystem.HybridFile +import com.amaze.filemanager.filesystem.ftp.NetCopyClientUtils +import com.amaze.filemanager.filesystem.ftp.NetCopyClientUtils.extractRemotePathFrom +import com.amaze.filemanager.ui.activities.MainActivity +import com.amaze.filemanager.ui.icons.MimeTypes +import net.schmizz.sshj.SSHClient +import net.schmizz.sshj.connection.channel.direct.Session +import net.schmizz.sshj.sftp.FileMode +import net.schmizz.sshj.sftp.RemoteResourceInfo +import net.schmizz.sshj.sftp.SFTPClient +import org.slf4j.LoggerFactory +import java.io.File +import java.io.IOException +import kotlin.concurrent.thread + +object SshClientUtils { + @JvmStatic + private val LOG = LoggerFactory.getLogger(SshClientUtils::class.java) + + @JvmField + val sftpGetSize: (String) -> Long? = { path -> + NetCopyClientUtils.execute( + object : SFtpClientTemplate(path, true) { + override fun execute(client: SFTPClient): Long { + return client.size(extractRemotePathFrom(path)) + } + }, + ) + } + + /** + * Execute the given template with SshClientTemplate. + * + * @param template [SshClientSessionTemplate] to execute + * @param Type of return value + * @return Template execution results + */ + @JvmStatic + fun execute(template: SshClientSessionTemplate): T? { + return NetCopyClientUtils.execute( + object : SshClientTemplate(template.url, false) { + override fun executeWithSSHClient(sshClient: SSHClient): T? { + var session: Session? = null + var retval: T? = null + try { + session = sshClient.startSession() + retval = template.execute(session) + } catch (e: IOException) { + LOG.error("Error executing template method", e) + } finally { + if (session != null && session.isOpen) { + try { + session.close() + } catch (e: IOException) { + LOG.warn("Error closing SFTP client", e) + } + } + } + return retval + } + }, + ) + } + + /** + * Execute the given template with SshClientTemplate. + * + * @param template [SFtpClientTemplate] to execute + * @param Type of return value + * @return Template execution results + */ + @JvmStatic + fun execute(template: SFtpClientTemplate): T? { + return NetCopyClientUtils.execute(template) + } + + /** + * Converts plain path smb://127.0.0.1/test.pdf to authorized path + * smb://test:123@127.0.0.1/test.pdf from server list + * + * @param path + * @return + */ + @JvmStatic + fun formatPlainServerPathToAuthorised( + servers: ArrayList>, + path: String, + ): String { + for (serverEntry in servers) { + val inputUri = Uri.parse(path) + val serverUri = Uri.parse(serverEntry[1]) + if (inputUri.scheme.equals(serverUri.scheme, ignoreCase = true) && + serverUri.authority!!.contains(inputUri.authority!!) + ) { + val output = + inputUri + .buildUpon() + .encodedAuthority(serverUri.encodedAuthority) + .build() + .toString() + LOG.info("build authorised path {} from plain path {}", output, path) + return output + } + } + return path + } + + /** + * Disconnects the given [SSHClient] but wrap all exceptions beneath, so callers are free + * from the hassles of handling thrown exceptions. + * + * @param client [SSHClient] instance + */ + fun tryDisconnect(client: SSHClient?) { + if (client != null && client.isConnected) { + try { + client.disconnect() + } catch (e: IOException) { + LOG.warn("Error closing SSHClient connection", e) + } + } + } + + /** + * Open a remote SSH file on local Android device. It uses the [CloudStreamer] to stream the + * file. + */ + @JvmStatic + @Suppress("Detekt.TooGenericExceptionCaught") + fun launchFtp( + baseFile: HybridFile, + activity: MainActivity, + ) { + val streamer = CloudStreamer.getInstance() + thread { + try { + val isDirectory = baseFile.isDirectory(activity) + val fileLength = baseFile.length(activity) + streamer.setStreamSrc( + baseFile.getInputStream(activity), + baseFile.getName(activity), + fileLength, + ) + activity.runOnUiThread { + try { + val file = + File( + extractRemotePathFrom( + baseFile.path, + ), + ) + val uri = Uri.parse(CloudStreamer.URL + Uri.fromFile(file).encodedPath) + val i = Intent(Intent.ACTION_VIEW) + i.setDataAndType( + uri, + MimeTypes.getMimeType(baseFile.path, isDirectory), + ) + val packageManager = activity.packageManager + val resInfos = packageManager.queryIntentActivities(i, 0) + if (resInfos != null && resInfos.size > 0) { + activity.startActivity(i) + } else { + Toast.makeText( + activity, + activity.resources.getString(R.string.smb_launch_error), + Toast.LENGTH_SHORT, + ) + .show() + } + } catch (e: ActivityNotFoundException) { + LOG.warn("failed to launch sftp file", e) + } + } + } catch (e: Exception) { + LOG.warn("failed to launch sftp file", e) + } + } + } + + /** + * Reads given [RemoteResourceInfo] and determines if the path it's related to is a directory. + * + * Will descend into corresponding target if given RemoteResourceInfo represents a symlink. + */ + @JvmStatic + @Throws(IOException::class) + fun isDirectory( + client: SFTPClient, + info: RemoteResourceInfo, + ): Boolean { + var isDirectory = info.isDirectory + if (info.attributes.type == FileMode.Type.SYMLINK) { + try { + val symlinkAttrs = client.stat(info.path) + isDirectory = symlinkAttrs.type == FileMode.Type.DIRECTORY + } catch (ifSymlinkIsBroken: IOException) { + LOG.warn("Symbolic link {} is broken, skipping", info.path) + throw ifSymlinkIsBroken + } + } + return isDirectory + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ssh/Statvfs.java b/app/src/main/java/com/amaze/filemanager/filesystem/ssh/Statvfs.java new file mode 100644 index 0000000..3f60c32 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/ssh/Statvfs.java @@ -0,0 +1,237 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.filesystem.ssh; + +import java.math.BigInteger; + +import net.schmizz.sshj.common.Buffer; +import net.schmizz.sshj.sftp.PacketType; +import net.schmizz.sshj.sftp.Request; +import net.schmizz.sshj.sftp.SFTPClient; +import net.schmizz.sshj.sftp.SFTPException; + +/** + * Wrapper for SSH statvfs@openssh.com request/response. + * + *

This is not specified in official SSH protocol; it is implemented by OpenSSH (which is the + * most used SSH server implementation around. + */ +public class Statvfs { + private static final String STATVFS_OPENSSH_COM = "statvfs@openssh.com"; + + /** + * Convenience method for creating statvfs@openssh.com {@link Request}s. + * + * @param sftpClient {@link SFTPClient} instance + * @param path remote path + * @return {@link Request} + */ + public static final Request request(final SFTPClient sftpClient, final String path) { + return sftpClient + .getSFTPEngine() + .newRequest(PacketType.EXTENDED) + .putString(STATVFS_OPENSSH_COM) + .putString(path, sftpClient.getSFTPEngine().getSubsystem().getRemoteCharset()); + } + + /** + * Wrapper object for {@link net.schmizz.sshj.sftp.Response}. + * + *

PROTOCOL specification defines the packet structure as + * uint64 f_bsize // file system block size + * uint64 f_frsize // fundamental fs block size + * uint64 f_blocks // number of blocks (unit f_frsize) + * uint64 f_bfree // free blocks in file system + * uint64 f_bavail // free blocks for non-root + * uint64 f_files // total file inodes + * uint64 f_ffree // free file inodes + * uint64 f_favail // free file inodes for to non-root + * uint64 f_fsid // file system id + * uint64 f_flag // bit mask of f_flag values + * uint64 f_namemax // maximum filename length + * whereas f_flag is defined as + * #define SSH_FXE_STATVFS_ST_RDONLY 0x1 // read-only + * #define SSH_FXE_STATVFS_ST_NOSUID 0x2 // no setuid + * + * + * @see PROTOCOL + * specification + */ + public static class Response { + public final String remotePath; + + public final net.schmizz.sshj.sftp.Response response; + + // f_bsize + public final int fileSystemBlockSize; + + // f_frsize + public final int fundamentalFileSystemBlockSize; + + // f_blocks + public final long fileSystemBlocks; + + // f_bfree + public final long freeFileSystemBlocks; + + // f_bavail + public final long availableFileSystemBlocks; + + // f_files + public final long totalFileInodes; + + // f_ffree + public final long freeFileInodes; + + // f_favail + public final long availableFileInodes; + + // f_fsid + private final long fileSystemId; + + // f_flag + public final int fileSystemFlag; + + // f_namemax + public final int filenameMaxLength; + + public Response(String remotePath, net.schmizz.sshj.sftp.Response response) + throws SFTPException, Buffer.BufferException { + response.ensurePacketTypeIs(PacketType.EXTENDED_REPLY); + + if (!response.readStatusCode().equals(net.schmizz.sshj.sftp.Response.StatusCode.OK)) { + throw new SFTPException("Bad response code: " + response.readStatusCode()); + } + + this.remotePath = remotePath; + this.response = response; + + fileSystemBlockSize = (int) this.response.readUInt32(); + fundamentalFileSystemBlockSize = (int) this.response.readUInt64(); + fileSystemBlocks = this.response.readUInt64(); + freeFileSystemBlocks = this.response.readUInt64(); + availableFileSystemBlocks = this.response.readUInt64(); + totalFileInodes = this.response.readUInt64(); + freeFileInodes = this.response.readUInt64(); + availableFileInodes = this.response.readUInt64(); + fileSystemId = readUInt64FromBuffer(this.response); + fileSystemFlag = (int) this.response.readUInt64(); + filenameMaxLength = (int) this.response.readUInt64(); + } + + /** + * Return disk size (filesystem block size * total filesystem blocks). + * + *

Depending on the target SSH server implementation, the result may be the disk size of the + * physical disk where the queried path resides at. + * + * @return disk size in bytes + */ + public long diskSize() { + return fileSystemBlocks * fileSystemBlockSize; + } + + /** + * Return disk free space (filesystem block size * available filesystem blocks). + * + *

Depending on the target SSH server implementation, the result may be the disk free space + * of the physical disk where the queried path resides at. + * + * @return disk free space in bytes + */ + public long diskFreeSpace() { + return availableFileSystemBlocks * fileSystemBlockSize; + } + + /** + * Returns fileSystemId wrapped in {@link BigInteger}. + * + * @return {@link BigInteger} version of fileSystemId + */ + public BigInteger getFileSystemId() { + return BigInteger.valueOf(fileSystemId); + } + + /** + * Returns fileSystemId as is. + * + * @return fileSystemId + */ + public long getRawFileSystemId() { + return fileSystemId; + } + + /** + * Read an uint64 from the buffer. This workarounds sshj's original method to prevent exception + * thrown which calls for {@link BigInteger} to store negative {@link Long}s. + * + *

Many thanks to Alexander--@github for the comments. + * + * @see Buffer#readUInt64() + */ + private long readUInt64FromBuffer(Buffer buffer) throws Buffer.BufferException { + long uint64 = (buffer.readUInt32() << 32) + (buffer.readUInt32() & 0xffffffffL); + return uint64; + } + + @Override + public String toString() { + return new StringBuilder() + .append("Response statvfs@openssh.com query for [") + .append(remotePath) + .append("], ") + .append("fileSystemBlockSize=") + .append(fileSystemBlockSize) + .append(',') + .append("fundamentalFileSystemBlockSize=") + .append(fundamentalFileSystemBlockSize) + .append(',') + .append("fileSystemBlocks=") + .append(fileSystemBlocks) + .append(',') + .append("freeFileSystemBlocks=") + .append(freeFileSystemBlocks) + .append(',') + .append("availableFileSystemBlocks=") + .append(availableFileSystemBlocks) + .append(',') + .append("totalFileInodes=") + .append(totalFileInodes) + .append(',') + .append("freeFileInodes=") + .append(freeFileInodes) + .append(',') + .append("availableFileInodes=") + .append(availableFileInodes) + .append(',') + .append("fileSystemId=") + .append(getFileSystemId()) + .append(',') + .append("fileSystemFlag=") + .append(fileSystemFlag) + .append(',') + .append("filenameMaxLength=") + .append(filenameMaxLength) + .toString(); + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/ColorCircleDrawable.java b/app/src/main/java/com/amaze/filemanager/ui/ColorCircleDrawable.java new file mode 100644 index 0000000..b7a2334 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/ColorCircleDrawable.java @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui; + +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; + +/** Created by yaroslav on 26.01.16. */ +public class ColorCircleDrawable extends Drawable { + private final Paint mPaint; + private int mRadius = 0; + + public ColorCircleDrawable(final int color) { + this.mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + this.mPaint.setColor(color); + this.mPaint.setStyle(Paint.Style.FILL); + } + + @Override + public void draw(final Canvas canvas) { + final Rect bounds = getBounds(); + canvas.drawCircle(bounds.centerX(), bounds.centerY(), mRadius, mPaint); + } + + @Override + protected void onBoundsChange(final Rect bounds) { + super.onBoundsChange(bounds); + mRadius = Math.min(bounds.width(), bounds.height()) / 2; + } + + @Override + public void setAlpha(final int alpha) { + mPaint.setAlpha(alpha); + } + + @Override + public void setColorFilter(final ColorFilter cf) { + mPaint.setColorFilter(cf); + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/Extensions.kt b/app/src/main/java/com/amaze/filemanager/ui/Extensions.kt new file mode 100644 index 0000000..eced7e2 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/Extensions.kt @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.text.TextUtils +import android.view.View +import android.view.inputmethod.InputMethodManager +import android.widget.Toast +import androidx.appcompat.widget.AppCompatEditText +import com.amaze.filemanager.R +import com.amaze.filemanager.application.AppConfig +import com.google.android.material.textfield.TextInputLayout +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +private val log: Logger = LoggerFactory.getLogger(AppConfig::class.java) + +/** + * Marks a text input field as mandatory (appends * at end) + * + */ +fun TextInputLayout.makeRequired() { + hint = TextUtils.concat(hint, " *") +} + +/** + * Makes the [Activity] starting not crash in case the app is + * not meant to deal with this kind of intent + */ +fun Context.startActivityCatchingSecurityException(intent: Intent) { + try { + startActivity(intent) + } catch (e: SecurityException) { + log.error("Error when starting activity: ", e) + Toast.makeText(this, R.string.security_error, Toast.LENGTH_SHORT).show() + } +} + +/** + * Update the alias, based on if Amaze Utils is installed, then we disable the alias intent-filters + * if not installed then we enable the amaze utilities alias + */ +fun Context.updateAUAlias(shouldEnable: Boolean) { + val component = ComponentName(this, "com.amaze.filemanager.amazeutilsalias") + if (!shouldEnable) { + packageManager.setComponentEnabledSetting( + component, + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.DONT_KILL_APP, + ) + } else { + packageManager.setComponentEnabledSetting( + component, + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, + PackageManager.DONT_KILL_APP, + ) + } +} + +/** + * Force keyboard pop up on focus + */ +fun AppCompatEditText.openKeyboard(context: Context) { + this.requestFocus() + + this.postDelayed( + { + (context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager) + .showSoftInput( + this, + InputMethodManager.SHOW_IMPLICIT, + ) + }, + 100, + ) +} + +/** + * Hides view with fade animation + */ +fun View.hideFade(duration: Long) { + this.animate().alpha(0f).duration = duration + this.visibility = View.GONE +} + +/** + * Shows view with fade animation + */ +fun View.showFade(duration: Long) { + this.animate().alpha(1f).duration = duration + this.visibility = View.VISIBLE +} + +/** + * Extension function to check for activity in package manager before triggering code + */ +fun Intent.runIfDocumentsUIExists( + context: Context, + callback: Runnable, +) { + if (this.resolveActivity(context.packageManager) != null) { + callback.run() + } else { + AppConfig.toast(context, R.string.no_app_found_intent) + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/ItemPopupMenu.java b/app/src/main/java/com/amaze/filemanager/ui/ItemPopupMenu.java new file mode 100644 index 0000000..2320ddb --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/ItemPopupMenu.java @@ -0,0 +1,262 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui; + +import java.io.File; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.ArrayList; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.adapters.data.LayoutElementParcelable; +import com.amaze.filemanager.asynchronous.services.EncryptService; +import com.amaze.filemanager.fileoperations.filesystem.OpenMode; +import com.amaze.filemanager.filesystem.HybridFileParcelable; +import com.amaze.filemanager.filesystem.PasteHelper; +import com.amaze.filemanager.filesystem.files.EncryptDecryptUtils; +import com.amaze.filemanager.filesystem.files.FileUtils; +import com.amaze.filemanager.ui.activities.MainActivity; +import com.amaze.filemanager.ui.dialogs.EncryptAuthenticateDialog; +import com.amaze.filemanager.ui.dialogs.EncryptWithPresetPasswordSaveAsDialog; +import com.amaze.filemanager.ui.dialogs.GeneralDialogCreation; +import com.amaze.filemanager.ui.fragments.MainFragment; +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants; +import com.amaze.filemanager.ui.provider.UtilitiesProvider; +import com.amaze.filemanager.utils.DataUtils; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.view.MenuItem; +import android.view.View; +import android.widget.PopupMenu; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.documentfile.provider.DocumentFile; +import androidx.preference.PreferenceManager; + +/** + * This class contains the functionality of the PopupMenu for each file in the MainFragment + * + * @author Emmanuel on 25/5/2017, at 16:39. Edited by bowiechen on 2019-10-19. + */ +public class ItemPopupMenu extends PopupMenu implements PopupMenu.OnMenuItemClickListener { + + @NonNull private final Context context; + @NonNull private final MainActivity mainActivity; + @NonNull private final UtilitiesProvider utilitiesProvider; + @NonNull private final MainFragment mainFragment; + @NonNull private final SharedPreferences sharedPrefs; + @NonNull private final LayoutElementParcelable rowItem; + private final int accentColor; + + public ItemPopupMenu( + @NonNull Context c, + @NonNull MainActivity ma, + @NonNull UtilitiesProvider up, + @NonNull MainFragment mainFragment, + @NonNull LayoutElementParcelable ri, + @NonNull View anchor, + @NonNull SharedPreferences sharedPreferences) { + super(c, anchor); + + context = c; + mainActivity = ma; + utilitiesProvider = up; + this.mainFragment = mainFragment; + sharedPrefs = sharedPreferences; + rowItem = ri; + accentColor = mainActivity.getAccent(); + + setOnMenuItemClickListener(this); + } + + @Override + public boolean onMenuItemClick(MenuItem item) { + switch (item.getItemId()) { + case R.id.about: + GeneralDialogCreation.showPropertiesDialogWithPermissions( + (rowItem).generateBaseFile(), + rowItem.permissions, + mainActivity, + mainFragment, + mainActivity.isRootExplorer(), + utilitiesProvider.getAppTheme()); + return true; + case R.id.share: + switch (rowItem.getMode()) { + case DROPBOX: + case BOX: + case GDRIVE: + case ONEDRIVE: + FileUtils.shareCloudFile(rowItem.desc, rowItem.getMode(), context); + break; + default: + ArrayList arrayList = new ArrayList<>(); + arrayList.add(new File(rowItem.desc)); + FileUtils.shareFiles( + arrayList, mainActivity, utilitiesProvider.getAppTheme(), accentColor); + break; + } + return true; + case R.id.rename: + mainFragment.rename(rowItem.generateBaseFile()); + return true; + case R.id.cpy: + case R.id.cut: + { + int op = + item.getItemId() == R.id.cpy ? PasteHelper.OPERATION_COPY : PasteHelper.OPERATION_CUT; + PasteHelper pasteHelper = + new PasteHelper( + mainActivity, op, new HybridFileParcelable[] {rowItem.generateBaseFile()}); + mainActivity.setPaste(pasteHelper); + return true; + } + case R.id.ex: + mainActivity.mainActivityHelper.extractFile(new File(rowItem.desc)); + return true; + case R.id.book: + DataUtils dataUtils = DataUtils.getInstance(); + if (dataUtils.addBook(new String[] {rowItem.title, rowItem.desc}, true)) { + mainActivity.getDrawer().refreshDrawer(); + Toast.makeText( + mainFragment.getActivity(), + mainFragment.getString(R.string.bookmarks_added), + Toast.LENGTH_LONG) + .show(); + } else { + Toast.makeText( + mainFragment.getActivity(), + mainFragment.getString(R.string.bookmark_exists), + Toast.LENGTH_LONG) + .show(); + } + return true; + case R.id.delete: + ArrayList positions = new ArrayList<>(); + positions.add(rowItem); + GeneralDialogCreation.deleteFilesDialog( + context, mainActivity, positions, utilitiesProvider.getAppTheme()); + return true; + case R.id.restore: + ArrayList p2 = new ArrayList<>(); + p2.add(rowItem); + GeneralDialogCreation.restoreFilesDialog( + context, mainActivity, p2, utilitiesProvider.getAppTheme()); + return true; + case R.id.open_with: + boolean useNewStack = + sharedPrefs.getBoolean(PreferencesConstants.PREFERENCE_TEXTEDITOR_NEWSTACK, false); + + if (OpenMode.DOCUMENT_FILE.equals(rowItem.getMode())) { + + @Nullable Uri fullUri = rowItem.generateBaseFile().getFullUri(); + + if (fullUri != null) { + + DocumentFile documentFile = DocumentFile.fromSingleUri(context, fullUri); + + if (documentFile != null) { + FileUtils.openWith(documentFile, mainActivity, useNewStack); + return true; + } + } + } + + FileUtils.openWith(new File(rowItem.desc), mainActivity, useNewStack); + + return true; + case R.id.encrypt: + final Intent encryptIntent = new Intent(context, EncryptService.class); + encryptIntent.putExtra(EncryptService.TAG_OPEN_MODE, rowItem.getMode().ordinal()); + encryptIntent.putExtra(EncryptService.TAG_SOURCE, rowItem.generateBaseFile()); + + final EncryptDecryptUtils.EncryptButtonCallbackInterface + encryptButtonCallbackInterfaceAuthenticate = + new EncryptDecryptUtils.EncryptButtonCallbackInterface() { + @Override + public void onButtonPressed(Intent intent, String password) + throws GeneralSecurityException, IOException { + EncryptDecryptUtils.startEncryption( + context, rowItem.generateBaseFile().getPath(), password, intent); + } + }; + + final SharedPreferences preferences = + PreferenceManager.getDefaultSharedPreferences(context); + + if (!preferences + .getString( + PreferencesConstants.PREFERENCE_CRYPT_MASTER_PASSWORD, + PreferencesConstants.PREFERENCE_CRYPT_MASTER_PASSWORD_DEFAULT) + .equals("")) { + EncryptWithPresetPasswordSaveAsDialog.show( + context, + encryptIntent, + mainActivity, + PreferencesConstants.ENCRYPT_PASSWORD_MASTER, + encryptButtonCallbackInterfaceAuthenticate); + } else if (preferences.getBoolean( + PreferencesConstants.PREFERENCE_CRYPT_FINGERPRINT, + PreferencesConstants.PREFERENCE_CRYPT_FINGERPRINT_DEFAULT)) { + EncryptWithPresetPasswordSaveAsDialog.show( + context, + encryptIntent, + mainActivity, + PreferencesConstants.ENCRYPT_PASSWORD_FINGERPRINT, + encryptButtonCallbackInterfaceAuthenticate); + } else { + EncryptAuthenticateDialog.show( + context, + encryptIntent, + mainActivity, + utilitiesProvider.getAppTheme(), + encryptButtonCallbackInterfaceAuthenticate); + } + return true; + case R.id.decrypt: + EncryptDecryptUtils.decryptFile( + context, + mainActivity, + mainFragment, + mainFragment.getMainFragmentViewModel().getOpenMode(), + rowItem.generateBaseFile(), + rowItem.generateBaseFile().getParent(context), + utilitiesProvider, + false); + return true; + case R.id.compress: + GeneralDialogCreation.showCompressDialog( + mainActivity, + rowItem.generateBaseFile(), + mainActivity.getCurrentMainFragment().getMainFragmentViewModel().getCurrentPath()); + return true; + case R.id.return_select: + mainFragment.returnIntentResults(new HybridFileParcelable[] {rowItem.generateBaseFile()}); + return true; + } + return false; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/activities/AboutActivity.java b/app/src/main/java/com/amaze/filemanager/ui/activities/AboutActivity.java new file mode 100644 index 0000000..e74c7f4 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/activities/AboutActivity.java @@ -0,0 +1,316 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.activities; + +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_COLORED_NAVIGATION; +import static com.amaze.filemanager.utils.Utils.openURL; + +import java.io.File; +import java.util.ArrayList; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.amaze.filemanager.LogHelper; +import com.amaze.filemanager.R; +import com.amaze.filemanager.ui.activities.superclasses.ThemedActivity; +import com.amaze.filemanager.ui.dialogs.share.ShareTask; +import com.amaze.filemanager.ui.theme.AppTheme; +import com.amaze.filemanager.utils.Billing; +import com.amaze.filemanager.utils.PreferenceUtils; +import com.amaze.filemanager.utils.Utils; +import com.google.android.material.appbar.AppBarLayout; +import com.google.android.material.appbar.CollapsingToolbarLayout; +import com.mikepenz.aboutlibraries.Libs; +import com.mikepenz.aboutlibraries.LibsBuilder; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.view.MenuItem; +import android.view.View; + +import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatTextView; +import androidx.appcompat.widget.Toolbar; +import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.core.content.FileProvider; +import androidx.palette.graphics.Palette; + +/** Created by vishal on 27/7/16. */ +public class AboutActivity extends ThemedActivity implements View.OnClickListener { + private final Logger LOG = LoggerFactory.getLogger(AboutActivity.class); + + private static final int HEADER_HEIGHT = 1024; + private static final int HEADER_WIDTH = 500; + + private AppBarLayout mAppBarLayout; + private CollapsingToolbarLayout mCollapsingToolbarLayout; + private AppCompatTextView mTitleTextView; + private View mAuthorsDivider, mDeveloper1Divider, mDeveloper2Divider; + private Billing billing; + + private static final String URL_AUTHOR1_GITHUB = "https://github.com/arpitkh96"; + private static final String URL_AUTHOR2_GITHUB = "https://github.com/VishalNehra"; + private static final String URL_DEVELOPER1_GITHUB = "https://github.com/EmmanuelMess"; + private static final String URL_DEVELOPER2_GITHUB = "https://github.com/TranceLove"; + private static final String URL_DEVELOPER3_GITHUB = "https://github.com/VishnuSanal"; + private static final String URL_REPO_CHANGELOG = + "https://github.com/TeamAmaze/AmazeFileManager/commits/master"; + private static final String URL_REPO = "https://github.com/TeamAmaze/AmazeFileManager"; + private static final String URL_REPO_ISSUES = + "https://github.com/TeamAmaze/AmazeFileManager/issues"; + private static final String URL_REPO_TRANSLATE = + "https://www.transifex.com/amaze/amaze-file-manager/"; + private static final String URL_REPO_XDA = + "http://forum.xda-developers.com/android/apps-games/app-amaze-file-managermaterial-theme-t2937314"; + private static final String URL_REPO_RATE = "market://details?id=com.amaze.filemanager"; + public static final String PACKAGE_AMAZE_UTILS = "com.amaze.fileutilities"; + public static final String URL_AMAZE_UTILS = "market://details?id=" + PACKAGE_AMAZE_UTILS; + public static final String URL_AMAZE_UTILS_FDROID = + "https://f-droid.org/en/packages/" + PACKAGE_AMAZE_UTILS + "/"; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + if (getAppTheme().equals(AppTheme.DARK)) { + setTheme(R.style.aboutDark); + } else if (getAppTheme().equals(AppTheme.BLACK)) { + setTheme(R.style.aboutBlack); + } else { + setTheme(R.style.aboutLight); + } + } + setContentView(R.layout.activity_about); + + mAppBarLayout = findViewById(R.id.appBarLayout); + mCollapsingToolbarLayout = findViewById(R.id.collapsing_toolbar_layout); + mTitleTextView = findViewById(R.id.text_view_title); + mAuthorsDivider = findViewById(R.id.view_divider_authors); + mDeveloper1Divider = findViewById(R.id.view_divider_developers_1); + mDeveloper2Divider = findViewById(R.id.view_divider_developers_2); + + mAppBarLayout.setLayoutParams(calculateHeaderViewParams()); + + Toolbar mToolbar = findViewById(R.id.toolBar); + setSupportActionBar(mToolbar); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setHomeAsUpIndicator(getResources().getDrawable(R.drawable.md_nav_back)); + getSupportActionBar().setDisplayShowTitleEnabled(false); + + switchIcons(); + + Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.about_header); + + // It will generate colors based on the image in an AsyncTask. + Palette.from(bitmap) + .generate( + palette -> { + int mutedColor = + palette.getMutedColor(Utils.getColor(AboutActivity.this, R.color.primary_blue)); + int darkMutedColor = + palette.getDarkMutedColor( + Utils.getColor(AboutActivity.this, R.color.primary_blue)); + mCollapsingToolbarLayout.setContentScrimColor(mutedColor); + mCollapsingToolbarLayout.setStatusBarScrimColor(darkMutedColor); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + getWindow().setStatusBarColor(darkMutedColor); + } + }); + + mAppBarLayout.addOnOffsetChangedListener( + (appBarLayout, verticalOffset) -> { + mTitleTextView.setAlpha( + Math.abs(verticalOffset / (float) appBarLayout.getTotalScrollRange())); + }); + mAppBarLayout.setOnFocusChangeListener( + (v, hasFocus) -> { + mAppBarLayout.setExpanded(hasFocus, true); + }); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + if (getBoolean(PREFERENCE_COLORED_NAVIGATION)) { + getWindow().setNavigationBarColor(PreferenceUtils.getStatusColor(getPrimary())); + } else { + if (getAppTheme().equals(AppTheme.LIGHT)) { + getWindow().setNavigationBarColor(Utils.getColor(this, android.R.color.white)); + } else if (getAppTheme().equals(AppTheme.BLACK)) { + getWindow().setNavigationBarColor(Utils.getColor(this, android.R.color.black)); + } else { + getWindow().setNavigationBarColor(Utils.getColor(this, R.color.holo_dark_background)); + } + } + } + } + + /** + * Calculates aspect ratio for the Amaze header + * + * @return the layout params with new set of width and height attribute + */ + private CoordinatorLayout.LayoutParams calculateHeaderViewParams() { + + // calculating cardview height as per the youtube video thumb aspect ratio + CoordinatorLayout.LayoutParams layoutParams = + (CoordinatorLayout.LayoutParams) mAppBarLayout.getLayoutParams(); + float vidAspectRatio = (float) HEADER_WIDTH / (float) HEADER_HEIGHT; + LOG.debug(vidAspectRatio + ""); + int screenWidth = getResources().getDisplayMetrics().widthPixels; + float reqHeightAsPerAspectRatio = (float) screenWidth * vidAspectRatio; + LOG.debug(reqHeightAsPerAspectRatio + ""); + + LOG.debug("new width: " + screenWidth + " and height: " + reqHeightAsPerAspectRatio); + + layoutParams.width = screenWidth; + layoutParams.height = (int) reqHeightAsPerAspectRatio; + return layoutParams; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + onBackPressed(); + break; + } + return super.onOptionsItemSelected(item); + } + + /** Method switches icon resources as per current theme */ + private void switchIcons() { + if (getAppTheme().equals(AppTheme.DARK) || getAppTheme().equals(AppTheme.BLACK)) { + // dark theme + mAuthorsDivider.setBackgroundColor(Utils.getColor(this, R.color.divider_dark_card)); + mDeveloper1Divider.setBackgroundColor(Utils.getColor(this, R.color.divider_dark_card)); + mDeveloper2Divider.setBackgroundColor(Utils.getColor(this, R.color.divider_dark_card)); + } + } + + @Override + public void onClick(View v) { + switch (v.getId()) { + case R.id.relative_layout_source: + openURL(URL_REPO, this); + break; + + case R.id.relative_layout_issues: + openURL(URL_REPO_ISSUES, this); + break; + + case R.id.relative_layout_share_logs: + try { + File logFile = + new File( + "/data/data/" + getApplicationContext().getPackageName() + "/cache/logs.txt"); + Uri logUri = + FileProvider.getUriForFile( + getApplicationContext(), getApplicationContext().getPackageName(), logFile); + ArrayList logUriList = new ArrayList<>(); + logUriList.add(logUri); + new ShareTask(this, logUriList, this.getAppTheme(), getAccent()).execute("*/*"); + } catch (Exception e) { + LOG.warn("failed to share logs", e); + } + break; + + case R.id.relative_layout_changelog: + openURL(URL_REPO_CHANGELOG, this); + break; + + case R.id.relative_layout_licenses: + LibsBuilder libsBuilder = + new LibsBuilder() + .withLibraries("apachemina") // Not auto-detected for some reason + .withActivityTitle(getString(R.string.libraries)) + .withAboutIconShown(true) + .withAboutVersionShownName(true) + .withAboutVersionShownCode(false) + .withAboutDescription(getString(R.string.about_amaze)) + .withAboutSpecial1(getString(R.string.license)) + .withAboutSpecial1Description(getString(R.string.amaze_license)) + .withLicenseShown(true); + + switch (getAppTheme()) { + case LIGHT: + libsBuilder.withActivityStyle(Libs.ActivityStyle.LIGHT_DARK_TOOLBAR); + break; + case DARK: + libsBuilder.withActivityStyle(Libs.ActivityStyle.DARK); + break; + case BLACK: + libsBuilder.withActivityTheme(R.style.AboutLibrariesTheme_Black); + break; + default: + LogHelper.logOnProductionOrCrash("Incorrect value for switch"); + } + + libsBuilder.start(this); + + break; + + case R.id.text_view_author_1_github: + openURL(URL_AUTHOR1_GITHUB, this); + break; + + case R.id.text_view_author_2_github: + openURL(URL_AUTHOR2_GITHUB, this); + break; + + case R.id.text_view_developer_1_github: + openURL(URL_DEVELOPER1_GITHUB, this); + break; + + case R.id.text_view_developer_2_github: + openURL(URL_DEVELOPER2_GITHUB, this); + break; + + case R.id.text_view_developer_3_github: + openURL(URL_DEVELOPER3_GITHUB, this); + break; + + case R.id.relative_layout_translate: + openURL(URL_REPO_TRANSLATE, this); + break; + + case R.id.relative_layout_xda: + openURL(URL_REPO_XDA, this); + break; + + case R.id.relative_layout_rate: + openURL(URL_REPO_RATE, this); + break; + case R.id.relative_layout_donate: + billing = new Billing(this); + break; + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + LOG.debug("Destroying the manager."); + if (billing != null) { + billing.destroyBillingInstance(); + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/activities/DatabaseViewerActivity.java b/app/src/main/java/com/amaze/filemanager/ui/activities/DatabaseViewerActivity.java new file mode 100644 index 0000000..23c464b --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/activities/DatabaseViewerActivity.java @@ -0,0 +1,184 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.activities; + +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_TEXTEDITOR_NEWSTACK; + +import java.io.File; +import java.util.ArrayList; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.fileoperations.exceptions.ShellNotRunningException; +import com.amaze.filemanager.filesystem.root.CopyFilesCommand; +import com.amaze.filemanager.ui.activities.superclasses.ThemedActivity; +import com.amaze.filemanager.ui.fragments.DbViewerFragment; + +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuItem; +import android.widget.ArrayAdapter; +import android.widget.ListView; +import android.widget.Toast; + +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.FragmentTransaction; + +/** Created by Vishal on 02-02-2015. */ +public class DatabaseViewerActivity extends ThemedActivity { + + private static final Logger LOG = LoggerFactory.getLogger(DatabaseViewerActivity.class); + + private String path; + private ListView listView; + private ArrayList arrayList; + private ArrayAdapter arrayAdapter; + private Cursor c; + + // the copy of db file which is to be opened, in the app cache + private File pathFile; + boolean delete = false; + public Toolbar toolbar; + public SQLiteDatabase sqLiteDatabase; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_db_viewer); + toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + + boolean useNewStack = getBoolean(PREFERENCE_TEXTEDITOR_NEWSTACK); + getSupportActionBar().setDisplayHomeAsUpEnabled(!useNewStack); + + path = getIntent().getStringExtra("path"); + + if (path == null) { + Toast.makeText(this, R.string.operation_not_supported, Toast.LENGTH_SHORT).show(); + finish(); + return; + } + + pathFile = new File(path); + listView = findViewById(R.id.listView); + + load(pathFile); + listView.setOnItemClickListener( + (parent, view, position, id) -> { + FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction(); + DbViewerFragment fragment = new DbViewerFragment(); + Bundle bundle = new Bundle(); + bundle.putString("table", arrayList.get(position)); + fragment.setArguments(bundle); + fragmentTransaction.add(R.id.content_frame, fragment); + fragmentTransaction.addToBackStack(null); + fragmentTransaction.commit(); + }); + initStatusBarResources(findViewById(R.id.parentdb)); + } + + private ArrayList getDbTableNames(Cursor c) { + ArrayList result = new ArrayList<>(); + for (c.moveToFirst(); !c.isAfterLast(); c.moveToNext()) { + for (int i = 0; i < c.getColumnCount(); i++) { + result.add(c.getString(i)); + } + } + return result; + } + + private void load(final File file) { + new Thread( + () -> { + File file1 = getExternalCacheDir(); + + // if the db can't be read, and we have root enabled, try reading it by + // first copying it in cache dir + if (!file.canRead() && isRootExplorer()) { + + try { + CopyFilesCommand.INSTANCE.copyFiles( + pathFile.getPath(), new File(file1.getPath(), file.getName()).getPath()); + pathFile = new File(file1.getPath(), file.getName()); + } catch (ShellNotRunningException e) { + LOG.warn("failed to copy file while showing database file", e); + } + delete = true; + } + try { + sqLiteDatabase = + SQLiteDatabase.openDatabase( + pathFile.getPath(), null, SQLiteDatabase.OPEN_READONLY); + + c = + sqLiteDatabase.rawQuery( + "SELECT name FROM sqlite_master WHERE type='table'", null); + arrayList = getDbTableNames(c); + arrayAdapter = + new ArrayAdapter( + DatabaseViewerActivity.this, + android.R.layout.simple_list_item_1, + arrayList); + } catch (Exception e) { + LOG.warn("failed to load file in database viewer", e); + finish(); + } + runOnUiThread( + () -> { + listView.setAdapter(arrayAdapter); + }); + }) + .start(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (sqLiteDatabase != null) sqLiteDatabase.close(); + if (c != null) c.close(); + if (delete) pathFile.delete(); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + toolbar.setTitle(pathFile.getName()); + return super.onPrepareOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + onBackPressed(); + toolbar.setTitle(pathFile.getName()); + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onBackPressed() { + super.onBackPressed(); + toolbar.setTitle(pathFile.getName()); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/activities/MainActivity.java b/app/src/main/java/com/amaze/filemanager/ui/activities/MainActivity.java new file mode 100644 index 0000000..fc6947f --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/activities/MainActivity.java @@ -0,0 +1,2578 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.activities; + +import static android.os.Build.VERSION.SDK_INT; +import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1; +import static android.os.Build.VERSION_CODES.KITKAT; +import static android.os.Build.VERSION_CODES.KITKAT_WATCH; +import static android.os.Build.VERSION_CODES.LOLLIPOP; +import static android.os.Build.VERSION_CODES.M; +import static android.os.Build.VERSION_CODES.N; +import static com.amaze.filemanager.fileoperations.filesystem.FolderStateKt.WRITABLE_OR_ON_SDCARD; +import static com.amaze.filemanager.fileoperations.filesystem.OperationTypeKt.COMPRESS; +import static com.amaze.filemanager.fileoperations.filesystem.OperationTypeKt.COPY; +import static com.amaze.filemanager.fileoperations.filesystem.OperationTypeKt.DELETE; +import static com.amaze.filemanager.fileoperations.filesystem.OperationTypeKt.EXTRACT; +import static com.amaze.filemanager.fileoperations.filesystem.OperationTypeKt.MOVE; +import static com.amaze.filemanager.fileoperations.filesystem.OperationTypeKt.NEW_FILE; +import static com.amaze.filemanager.fileoperations.filesystem.OperationTypeKt.NEW_FOLDER; +import static com.amaze.filemanager.fileoperations.filesystem.OperationTypeKt.RENAME; +import static com.amaze.filemanager.fileoperations.filesystem.OperationTypeKt.SAVE_FILE; +import static com.amaze.filemanager.fileoperations.filesystem.OperationTypeKt.UNDEFINED; +import static com.amaze.filemanager.filesystem.ftp.FTPClientImpl.ARG_TLS; +import static com.amaze.filemanager.filesystem.ftp.FTPClientImpl.TLS_EXPLICIT; +import static com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.FTPS_URI_PREFIX; +import static com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.FTP_URI_PREFIX; +import static com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.SSH_URI_PREFIX; +import static com.amaze.filemanager.ui.dialogs.SftpConnectDialog.ARG_ADDRESS; +import static com.amaze.filemanager.ui.dialogs.SftpConnectDialog.ARG_DEFAULT_PATH; +import static com.amaze.filemanager.ui.dialogs.SftpConnectDialog.ARG_EDIT; +import static com.amaze.filemanager.ui.dialogs.SftpConnectDialog.ARG_HAS_PASSWORD; +import static com.amaze.filemanager.ui.dialogs.SftpConnectDialog.ARG_KEYPAIR_NAME; +import static com.amaze.filemanager.ui.dialogs.SftpConnectDialog.ARG_NAME; +import static com.amaze.filemanager.ui.dialogs.SftpConnectDialog.ARG_PASSWORD; +import static com.amaze.filemanager.ui.dialogs.SftpConnectDialog.ARG_PORT; +import static com.amaze.filemanager.ui.dialogs.SftpConnectDialog.ARG_PROTOCOL; +import static com.amaze.filemanager.ui.dialogs.SftpConnectDialog.ARG_USERNAME; +import static com.amaze.filemanager.ui.fragments.FtpServerFragment.REQUEST_CODE_SAF_FTP; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_BOOKMARKS_ADDED; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_COLORED_NAVIGATION; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_NEED_TO_SET_HOME; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_VIEW; + +import java.io.File; +import java.io.IOException; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.regex.Pattern; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.afollestad.materialdialogs.DialogAction; +import com.afollestad.materialdialogs.MaterialDialog; +import com.afollestad.materialdialogs.folderselector.FolderChooserDialog; +import com.amaze.filemanager.BuildConfig; +import com.amaze.filemanager.LogHelper; +import com.amaze.filemanager.R; +import com.amaze.filemanager.adapters.data.LayoutElementParcelable; +import com.amaze.filemanager.adapters.data.StorageDirectoryParcelable; +import com.amaze.filemanager.application.AppConfig; +import com.amaze.filemanager.asynchronous.SaveOnDataUtilsChange; +import com.amaze.filemanager.asynchronous.asynctasks.CloudLoaderAsyncTask; +import com.amaze.filemanager.asynchronous.asynctasks.DeleteTask; +import com.amaze.filemanager.asynchronous.asynctasks.TaskKt; +import com.amaze.filemanager.asynchronous.asynctasks.movecopy.MoveFilesTask; +import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil; +import com.amaze.filemanager.asynchronous.services.CopyService; +import com.amaze.filemanager.database.CloudContract; +import com.amaze.filemanager.database.CloudHandler; +import com.amaze.filemanager.database.SortHandler; +import com.amaze.filemanager.database.TabHandler; +import com.amaze.filemanager.database.UtilsHandler; +import com.amaze.filemanager.database.models.OperationData; +import com.amaze.filemanager.database.models.explorer.CloudEntry; +import com.amaze.filemanager.fileoperations.exceptions.CloudPluginException; +import com.amaze.filemanager.fileoperations.filesystem.OpenMode; +import com.amaze.filemanager.fileoperations.filesystem.StorageNaming; +import com.amaze.filemanager.fileoperations.filesystem.usb.SingletonUsbOtg; +import com.amaze.filemanager.fileoperations.filesystem.usb.UsbOtgRepresentation; +import com.amaze.filemanager.filesystem.ExternalSdCardOperation; +import com.amaze.filemanager.filesystem.FileUtil; +import com.amaze.filemanager.filesystem.HybridFile; +import com.amaze.filemanager.filesystem.HybridFileParcelable; +import com.amaze.filemanager.filesystem.MakeFileOperation; +import com.amaze.filemanager.filesystem.PasteHelper; +import com.amaze.filemanager.filesystem.RootHelper; +import com.amaze.filemanager.filesystem.files.FileUtils; +import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool; +import com.amaze.filemanager.filesystem.ftp.NetCopyConnectionInfo; +import com.amaze.filemanager.filesystem.ssh.SshClientUtils; +import com.amaze.filemanager.ui.ExtensionsKt; +import com.amaze.filemanager.ui.activities.superclasses.PermissionsActivity; +import com.amaze.filemanager.ui.dialogs.AlertDialog; +import com.amaze.filemanager.ui.dialogs.GeneralDialogCreation; +import com.amaze.filemanager.ui.dialogs.HiddenFilesDialog; +import com.amaze.filemanager.ui.dialogs.HistoryDialog; +import com.amaze.filemanager.ui.dialogs.RenameBookmark; +import com.amaze.filemanager.ui.dialogs.RenameBookmark.BookmarkCallback; +import com.amaze.filemanager.ui.dialogs.SftpConnectDialog; +import com.amaze.filemanager.ui.dialogs.SmbConnectDialog; +import com.amaze.filemanager.ui.dialogs.SmbConnectDialog.SmbConnectionListener; +import com.amaze.filemanager.ui.drag.TabFragmentBottomDragListener; +import com.amaze.filemanager.ui.fragments.AppsListFragment; +import com.amaze.filemanager.ui.fragments.CloudSheetFragment; +import com.amaze.filemanager.ui.fragments.CloudSheetFragment.CloudConnectionCallbacks; +import com.amaze.filemanager.ui.fragments.CompressedExplorerFragment; +import com.amaze.filemanager.ui.fragments.FtpServerFragment; +import com.amaze.filemanager.ui.fragments.MainFragment; +import com.amaze.filemanager.ui.fragments.ProcessViewerFragment; +import com.amaze.filemanager.ui.fragments.TabFragment; +import com.amaze.filemanager.ui.fragments.data.MainFragmentViewModel; +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants; +import com.amaze.filemanager.ui.strings.StorageNamingHelper; +import com.amaze.filemanager.ui.theme.AppTheme; +import com.amaze.filemanager.ui.views.CustomZoomFocusChange; +import com.amaze.filemanager.ui.views.appbar.AppBar; +import com.amaze.filemanager.ui.views.drawer.Drawer; +import com.amaze.filemanager.utils.AppConstants; +import com.amaze.filemanager.utils.BookSorter; +import com.amaze.filemanager.utils.DataUtils; +import com.amaze.filemanager.utils.GenericExtKt; +import com.amaze.filemanager.utils.MainActivityActionMode; +import com.amaze.filemanager.utils.MainActivityHelper; +import com.amaze.filemanager.utils.OTGUtil; +import com.amaze.filemanager.utils.PackageUtils; +import com.amaze.filemanager.utils.PreferenceUtils; +import com.amaze.filemanager.utils.Utils; +import com.cloudrail.si.CloudRail; +import com.google.android.material.appbar.AppBarLayout; +import com.google.android.material.snackbar.BaseTransientBottomBar; +import com.google.android.material.snackbar.Snackbar; +import com.leinardi.android.speeddial.FabWithLabelView; +import com.leinardi.android.speeddial.SpeedDialActionItem; +import com.leinardi.android.speeddial.SpeedDialOverlayLayout; +import com.leinardi.android.speeddial.SpeedDialView; +import com.readystatesoftware.systembartint.SystemBarTintManager; +import com.topjohnwu.superuser.Shell; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.res.Configuration; +import android.database.Cursor; +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; +import android.hardware.usb.UsbManager; +import android.media.RingtoneManager; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Bundle; +import android.os.Environment; +import android.os.Handler; +import android.os.storage.StorageManager; +import android.os.storage.StorageVolume; +import android.service.quicksettings.TileService; +import android.text.TextUtils; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.animation.DecelerateInterpolator; +import android.widget.Toast; + +import androidx.annotation.DrawableRes; +import androidx.annotation.IdRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.StringRes; +import androidx.arch.core.util.Function; +import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentTransaction; +import androidx.loader.app.LoaderManager; +import androidx.loader.content.CursorLoader; +import androidx.loader.content.Loader; + +import io.reactivex.Completable; +import io.reactivex.CompletableObserver; +import io.reactivex.Flowable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; +import kotlin.collections.ArraysKt; +import kotlin.jvm.functions.Function1; +import kotlin.text.Charsets; + +public class MainActivity extends PermissionsActivity + implements SmbConnectionListener, + BookmarkCallback, + CloudConnectionCallbacks, + LoaderManager.LoaderCallbacks, + FolderChooserDialog.FolderCallback, + PermissionsActivity.OnPermissionGranted { + + private static final Logger LOG = LoggerFactory.getLogger(MainActivity.class); + + public static final Pattern DIR_SEPARATOR = Pattern.compile("/"); + public static final String TAG_ASYNC_HELPER = "async_helper"; + + private DataUtils dataUtils; + + public String path = ""; + public boolean mReturnIntent = false; + public boolean isCompressedOpen = false; + public boolean mRingtonePickerIntent = false; + public int skinStatusBar; + + private SpeedDialView floatingActionButton; + + private SpeedDialView fabConfirmSelection; + + public MainActivityHelper mainActivityHelper; + + public int operation = -1; + public ArrayList oparrayList; + public ArrayList> oparrayListList; + + // oppathe - the path at which certain operation needs to be performed + // oppathe1 - the new path which user wants to create/modify + // oppathList - the paths at which certain operation needs to be performed (pairs with + // oparrayList) + public String oppathe, oppathe1; + public ArrayList oppatheList; + + // This holds the Uris to be written at initFabToSave() + private List urisToBeSaved; + + public static final String PASTEHELPER_BUNDLE = "pasteHelper"; + + private static final String KEY_DRAWER_SELECTED = "selectitem"; + private static final String KEY_OPERATION_PATH = "oppathe"; + private static final String KEY_OPERATED_ON_PATH = "oppathe1"; + private static final String KEY_OPERATIONS_PATH_LIST = "oparraylist"; + private static final String KEY_OPERATION = "operation"; + private static final String KEY_SELECTED_LIST_ITEM = "select_list_item"; + + private AppBar appbar; + private Drawer drawer; + // private HistoryManager history, grid; + private MainActivity mainActivity = this; + private String pathInCompressedArchive; + private boolean openProcesses = false; + private MaterialDialog materialDialog; + private boolean backPressedToExitOnce = false; + private WeakReference toast = new WeakReference<>(null); + private Intent intent; + private View indicator_layout; + + private AppBarLayout appBarLayout; + + private SpeedDialOverlayLayout fabBgView; + private UtilsHandler utilsHandler; + private CloudHandler cloudHandler; + private CloudLoaderAsyncTask cloudLoaderAsyncTask; + + /** + * This is for a hack. + * + * @see MainActivity#onLoadFinished(Loader, Cursor) + */ + private Cursor cloudCursorData = null; + + public static final int REQUEST_CODE_SAF = 223; + + public static final String KEY_INTENT_PROCESS_VIEWER = "openprocesses"; + public static final String TAG_INTENT_FILTER_FAILED_OPS = "failedOps"; + public static final String TAG_INTENT_FILTER_GENERAL = "general_communications"; + public static final String ARGS_KEY_LOADER = "loader_cloud_args_service"; + + /** + * Broadcast which will be fired after every file operation, will denote list loading Registered + * by {@link MainFragment} + */ + public static final String KEY_INTENT_LOAD_LIST = "loadlist"; + + /** + * Extras carried by the list loading intent Contains path of parent directory in which operation + * took place, so that we can run media scanner on it + */ + public static final String KEY_INTENT_LOAD_LIST_FILE = "loadlist_file"; + + /** + * Mime type in intent that apps need to pass when trying to open file manager from a specific + * directory Should be clubbed with {@link Intent#ACTION_VIEW} and send in path to open in intent + * data field + */ + public static final String ARGS_INTENT_ACTION_VIEW_MIME_FOLDER = "resource/folder"; + + public static final String ARGS_INTENT_ACTION_VIEW_APPLICATION_ALL = "application/*"; + + public static final String CLOUD_AUTHENTICATOR_GDRIVE = "android.intent.category.BROWSABLE"; + public static final String CLOUD_AUTHENTICATOR_REDIRECT_URI = "com.amaze.filemanager:/auth"; + + // the current visible tab, either 0 or 1 + public static int currentTab; + private boolean listItemSelected = false; + + private String scrollToFileName = null; + + public static final int REQUEST_CODE_CLOUD_LIST_KEYS = 5463; + public static final int REQUEST_CODE_CLOUD_LIST_KEY = 5472; + + private PasteHelper pasteHelper; + public MainActivityActionMode mainActivityActionMode; + + private static final String DEFAULT_FALLBACK_STORAGE_PATH = "/storage/sdcard0"; + private static final String INTERNAL_SHARED_STORAGE = "Internal shared storage"; + private static final String INTENT_ACTION_OPEN_QUICK_ACCESS = + "com.amaze.filemanager.openQuickAccess"; + private static final String INTENT_ACTION_OPEN_RECENT = "com.amaze.filemanager.openRecent"; + private static final String INTENT_ACTION_OPEN_FTP_SERVER = "com.amaze.filemanager.openFTPServer"; + private static final String INTENT_ACTION_OPEN_APP_MANAGER = + "com.amaze.filemanager.openAppManager"; + + /** Called when the activity is first created. */ + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.main_toolbar); + + intent = getIntent(); + + dataUtils = DataUtils.getInstance(); + if (savedInstanceState != null) { + listItemSelected = savedInstanceState.getBoolean(KEY_SELECTED_LIST_ITEM, false); + } + + initialisePreferences(); + initializeInteractiveShell(); + + dataUtils.registerOnDataChangedListener(new SaveOnDataUtilsChange(drawer)); + + AppConfig.getInstance().setMainActivityContext(this); + + initialiseViews(); + utilsHandler = AppConfig.getInstance().getUtilsHandler(); + cloudHandler = new CloudHandler(this, AppConfig.getInstance().getExplorerDatabase()); + + initialiseFab(); // TODO: 7/12/2017 not init when actionIntent != null + mainActivityHelper = new MainActivityHelper(this); + mainActivityActionMode = new MainActivityActionMode(new WeakReference<>(MainActivity.this)); + + if (CloudSheetFragment.isCloudProviderAvailable(this)) { + try { + LoaderManager.getInstance(this).initLoader(REQUEST_CODE_CLOUD_LIST_KEYS, null, this); + } catch (Exception errorRaised) { + LOG.error("Error initializing cloud connections", errorRaised); + cloudHandler.clearAllCloudConnections(); + AlertDialog.show( + this, + R.string.cloud_connection_credentials_cleared_msg, + R.string.cloud_connection_credentials_cleared, + android.R.string.ok, + null, + false); + LoaderManager.getInstance(this).initLoader(REQUEST_CODE_CLOUD_LIST_KEYS, null, this); + } + } + + path = intent.getStringExtra("path"); + openProcesses = intent.getBooleanExtra(KEY_INTENT_PROCESS_VIEWER, false); + + if (intent.getStringArrayListExtra(TAG_INTENT_FILTER_FAILED_OPS) != null) { + ArrayList failedOps = + intent.getParcelableArrayListExtra(TAG_INTENT_FILTER_FAILED_OPS); + if (failedOps != null) { + mainActivityHelper.showFailedOperationDialog(failedOps, this); + } + } + + checkForExternalIntent(intent); + + initialiseFabConfirmSelection(); + + drawer.setDrawerIndicatorEnabled(); + + if (!getBoolean(PREFERENCE_BOOKMARKS_ADDED)) { + utilsHandler.addCommonBookmarks(); + getPrefs().edit().putBoolean(PREFERENCE_BOOKMARKS_ADDED, true).commit(); + } + + checkForExternalPermission(); + + Completable.fromRunnable( + () -> { + dataUtils.setHiddenFiles(utilsHandler.getHiddenFilesConcurrentRadixTree()); + dataUtils.setHistory(utilsHandler.getHistoryLinkedList()); + dataUtils.setGridfiles(utilsHandler.getGridViewList()); + dataUtils.setListfiles(utilsHandler.getListViewList()); + dataUtils.setBooks(utilsHandler.getBookmarksList()); + ArrayList servers = new ArrayList<>(); + servers.addAll(utilsHandler.getSmbList()); + servers.addAll(utilsHandler.getSftpList()); + dataUtils.setServers(servers); + + ExtensionsKt.updateAUAlias( + this, + !PackageUtils.Companion.appInstalledOrNot( + AboutActivity.PACKAGE_AMAZE_UTILS, mainActivity.getPackageManager()) + && !getBoolean( + PreferencesConstants.PREFERENCE_DISABLE_PLAYER_INTENT_FILTERS)); + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + new CompletableObserver() { + @Override + public void onSubscribe(@NonNull Disposable d) {} + + @Override + public void onComplete() { + drawer.refreshDrawer(); + invalidateFragmentAndBundle(savedInstanceState, false); + } + + @Override + public void onError(@NonNull Throwable e) { + LOG.error("Error setting up DataUtils", e); + drawer.refreshDrawer(); + invalidateFragmentAndBundle(savedInstanceState, false); + } + }); + initStatusBarResources(findViewById(R.id.drawer_layout)); + } + + public void invalidateFragmentAndBundle(Bundle savedInstanceState, boolean isCloudRefresh) { + if (savedInstanceState == null) { + if (openProcesses) { + FragmentTransaction transaction = getSupportFragmentManager().beginTransaction(); + transaction.replace( + R.id.content_frame, new ProcessViewerFragment(), KEY_INTENT_PROCESS_VIEWER); + // transaction.addToBackStack(null); + openProcesses = false; + // title.setText(utils.getString(con, R.string.process_viewer)); + // Commit the transaction + transaction.commit(); + supportInvalidateOptionsMenu(); + } else if (intent.getAction() != null + && (intent.getAction().equals(TileService.ACTION_QS_TILE_PREFERENCES) + || INTENT_ACTION_OPEN_FTP_SERVER.equals(intent.getAction()))) { + // tile preferences, open ftp fragment + + FragmentTransaction transaction2 = getSupportFragmentManager().beginTransaction(); + transaction2.replace(R.id.content_frame, new FtpServerFragment()); + appBarLayout + .animate() + .translationY(0) + .setInterpolator(new DecelerateInterpolator(2)) + .start(); + + drawer.deselectEverything(); + transaction2.commit(); + } else if (intent.getAction() != null + && INTENT_ACTION_OPEN_APP_MANAGER.equals(intent.getAction())) { + FragmentTransaction transaction3 = getSupportFragmentManager().beginTransaction(); + transaction3.replace(R.id.content_frame, new AppsListFragment()); + appBarLayout + .animate() + .translationY(0) + .setInterpolator(new DecelerateInterpolator(2)) + .start(); + + drawer.deselectEverything(); + transaction3.commit(); + } else { + if (path != null && path.length() > 0) { + HybridFile file = new HybridFile(OpenMode.UNKNOWN, path); + file.generateMode(MainActivity.this); + if (file.isCloudDriveFile() && dataUtils.getAccounts().size() == 0) { + // not ready to serve cloud files + goToMain(null); + } else if (file.isDirectory(MainActivity.this) && !isCloudRefresh) { + goToMain(path); + } else { + if (!isCloudRefresh) { + goToMain(null); + } + if (file.isSmb() || file.isSftp()) { + String authorisedPath = + SshClientUtils.formatPlainServerPathToAuthorised(dataUtils.getServers(), path); + file.setPath(authorisedPath); + LOG.info( + "Opening smb file from deeplink, modify plain path to authorised path {}", + authorisedPath); + } + file.openFile(this, true); + } + } else if (!isCloudRefresh) { + goToMain(null); + } + } + } else { + pasteHelper = savedInstanceState.getParcelable(PASTEHELPER_BUNDLE); + oppathe = savedInstanceState.getString(KEY_OPERATION_PATH); + oppathe1 = savedInstanceState.getString(KEY_OPERATED_ON_PATH); + oparrayList = savedInstanceState.getParcelableArrayList(KEY_OPERATIONS_PATH_LIST); + operation = savedInstanceState.getInt(KEY_OPERATION); + int selectedStorage = savedInstanceState.getInt(KEY_DRAWER_SELECTED, 0); + getDrawer().selectCorrectDrawerItem(selectedStorage); + } + } + + @Override + @SuppressLint("CheckResult") + public void onPermissionGranted() { + drawer.refreshDrawer(); + TabFragment tabFragment = getTabFragment(); + boolean b = getBoolean(PREFERENCE_NEED_TO_SET_HOME); + // reset home and current paths according to new storages + if (b) { + TabHandler tabHandler = TabHandler.getInstance(); + tabHandler + .clear() + .subscribe( + () -> { + if (tabFragment != null) { + tabFragment.refactorDrawerStorages(false, false); + Fragment main = tabFragment.getFragmentAtIndex(0); + if (main != null) ((MainFragment) main).updateTabWithDb(tabHandler.findTab(1)); + Fragment main1 = tabFragment.getFragmentAtIndex(1); + if (main1 != null) ((MainFragment) main1).updateTabWithDb(tabHandler.findTab(2)); + } + getPrefs().edit().putBoolean(PREFERENCE_NEED_TO_SET_HOME, false).commit(); + }); + } else { + // just refresh list + if (tabFragment != null) { + Fragment main = tabFragment.getFragmentAtIndex(0); + if (main != null) ((MainFragment) main).updateList(false); + Fragment main1 = tabFragment.getFragmentAtIndex(1); + if (main1 != null) ((MainFragment) main1).updateList(false); + } + } + } + + private void checkForExternalPermission() { + if (SDK_INT >= Build.VERSION_CODES.M) { + if (!checkStoragePermission()) { + if (SDK_INT >= Build.VERSION_CODES.R) { + requestAllFilesAccess(this); + } else { + requestStoragePermission(this, true); + } + } + if (SDK_INT >= Build.VERSION_CODES.TIRAMISU && !checkNotificationPermission()) { + requestNotificationPermission(true); + } + } + } + + /** Checks for the action to take when Amaze receives an intent from external source */ + private void checkForExternalIntent(Intent intent) { + final String actionIntent = intent.getAction(); + if (actionIntent == null) { + return; + } + + final String type = intent.getType(); + + if (actionIntent.equals(Intent.ACTION_GET_CONTENT)) { + // file picker intent + mReturnIntent = true; + String text = + intent.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false) + ? getString(R.string.pick_files) + : getString(R.string.pick_a_file); + Toast.makeText(this, text, Toast.LENGTH_LONG).show(); + + // disable screen rotation just for convenience purpose + // TODO: Support screen rotation when picking file + Utils.disableScreenRotation(this); + } else if (actionIntent.equals(RingtoneManager.ACTION_RINGTONE_PICKER)) { + // ringtone picker intent + mReturnIntent = true; + mRingtonePickerIntent = true; + Toast.makeText(this, getString(R.string.pick_a_file), Toast.LENGTH_LONG).show(); + + // disable screen rotation just for convenience purpose + // TODO: Support screen rotation when picking file + Utils.disableScreenRotation(this); + } else if (actionIntent.equals(Intent.ACTION_VIEW)) { + // zip viewer intent + Uri uri = intent.getData(); + + if (type != null + && (type.equals(ARGS_INTENT_ACTION_VIEW_MIME_FOLDER) + || type.equals(ARGS_INTENT_ACTION_VIEW_APPLICATION_ALL))) { + // support for syncting or intents from external apps that + // need to start file manager from a specific path + + if (uri != null) { + + path = Utils.sanitizeInput(FileUtils.fromContentUri(uri).getAbsolutePath()); + scrollToFileName = intent.getStringExtra("com.amaze.fileutilities.AFM_LOCATE_FILE_NAME"); + } else { + // no data field, open home for the tab in later processing + path = null; + } + } else if (FileUtils.isCompressedFile(Utils.sanitizeInput(uri.toString()))) { + // we don't have folder resource mime type set, supposed to be zip/rar + isCompressedOpen = true; + pathInCompressedArchive = Utils.sanitizeInput(uri.toString()); + openCompressed(pathInCompressedArchive); + } else if (uri.getPath().startsWith("/open_file")) { + /** + * Deeplink to open files directly through amaze using following format: + * http://teamamaze.xyz/open_file?path=path-to-file + */ + path = Utils.sanitizeInput(uri.getQueryParameter("path")); + } else { + LOG.warn(getString(R.string.error_cannot_find_way_open)); + } + + } else if (actionIntent.equals(Intent.ACTION_SEND)) { + if ("text/plain".equals(type)) { + showSaveSnackbar(null); + } else { + // save a single file to filesystem + Uri uri = intent.getParcelableExtra(Intent.EXTRA_STREAM); + if (uri != null && uri.getScheme() != null) { + List uris = new ArrayList<>(); + uris.add(uri); + showSaveSnackbar(uris); + } else { + Toast.makeText(this, R.string.error_unsupported_or_null_uri, Toast.LENGTH_LONG).show(); + } + } + // disable screen rotation just for convenience purpose + // TODO: Support screen rotation when saving a file + Utils.disableScreenRotation(this); + + } else if (actionIntent.equals(Intent.ACTION_SEND_MULTIPLE) && type != null) { + // save multiple files to filesystem + + ArrayList arrayList = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); + showSaveSnackbar(arrayList); + + // disable screen rotation just for convenience purpose + // TODO: Support screen rotation when saving a file + Utils.disableScreenRotation(this); + } + } + + /** Initializes the floating action button to act as to save data from an external intent */ + private void showSaveSnackbar(final List uris) { + Utils.showThemedSnackbar( + this, + getString(R.string.select_save_location), + BaseTransientBottomBar.LENGTH_INDEFINITE, + R.string.save, + () -> saveExternalIntent(uris)); + } + + private void saveExternalIntent(final List uris) { + executeWithMainFragment( + mainFragment -> { + if (uris != null && uris.size() > 0) { + if (SDK_INT >= LOLLIPOP) { + File folder = new File(mainFragment.getCurrentPath()); + int result = mainActivityHelper.checkFolder(folder, MainActivity.this); + if (result == WRITABLE_OR_ON_SDCARD) { + FileUtil.writeUriToStorage( + MainActivity.this, uris, getContentResolver(), mainFragment.getCurrentPath()); + finish(); + } else { + // Trigger SAF intent, keep uri until finish + operation = SAVE_FILE; + urisToBeSaved = uris; + mainActivityHelper.checkFolder(folder, MainActivity.this); + } + } else { + FileUtil.writeUriToStorage( + MainActivity.this, uris, getContentResolver(), mainFragment.getCurrentPath()); + } + } else { + saveExternalIntentExtras(); + } + Toast.makeText( + MainActivity.this, + getResources().getString(R.string.saving) + + " to " + + mainFragment.getCurrentPath(), + Toast.LENGTH_LONG) + .show(); + finish(); + return null; + }); + } + + private void saveExternalIntentExtras() { + executeWithMainFragment( + mainFragment -> { + Bundle extras = intent.getExtras(); + StringBuilder data = new StringBuilder(); + if (!Utils.isNullOrEmpty(extras.getString(Intent.EXTRA_SUBJECT))) { + data.append(extras.getString(Intent.EXTRA_SUBJECT)); + } + if (!Utils.isNullOrEmpty(extras.getString(Intent.EXTRA_TEXT))) { + data.append(AppConstants.NEW_LINE).append(extras.getString(Intent.EXTRA_TEXT)); + } + String fileName = Long.toString(System.currentTimeMillis()); + AppConfig.getInstance() + .runInBackground( + () -> + MakeFileOperation.mktextfile( + data.toString(), mainFragment.getCurrentPath(), fileName)); + return null; + }); + } + + public void clearFabActionItems() { + floatingActionButton.removeActionItemById(R.id.menu_new_folder); + floatingActionButton.removeActionItemById(R.id.menu_new_file); + floatingActionButton.removeActionItemById(R.id.menu_new_cloud); + } + + /** Initializes an interactive shell, which will stay throughout the app lifecycle. */ + private void initializeInteractiveShell() { + if (isRootExplorer()) { + // Enable mount-master flag when invoking su command, to force su run in the global mount + // namespace. See https://github.com/topjohnwu/libsu/issues/75 + Shell.setDefaultBuilder(Shell.Builder.create().setFlags(Shell.FLAG_MOUNT_MASTER)); + Shell.getShell(); + } + } + + /** + * @return paths to all available volumes in the system (include emulated) + */ + public synchronized ArrayList getStorageDirectories() { + ArrayList volumes; + if (SDK_INT >= N) { + volumes = getStorageDirectoriesNew(); + } else { + volumes = getStorageDirectoriesLegacy(); + } + if (isRootExplorer()) { + volumes.add( + new StorageDirectoryParcelable( + "/", + getResources().getString(R.string.root_directory), + R.drawable.ic_drawer_root_white)); + } + return volumes; + } + + /** + * @return All available storage volumes (including internal storage, SD-Cards and USB devices) + */ + @TargetApi(N) + public synchronized ArrayList getStorageDirectoriesNew() { + // Final set of paths + ArrayList volumes = new ArrayList<>(); + StorageManager sm = getSystemService(StorageManager.class); + for (StorageVolume volume : sm.getStorageVolumes()) { + if (!volume.getState().equalsIgnoreCase(Environment.MEDIA_MOUNTED) + && !volume.getState().equalsIgnoreCase(Environment.MEDIA_MOUNTED_READ_ONLY)) { + continue; + } + File path = Utils.getVolumeDirectory(volume); + String name = volume.getDescription(this); + if (INTERNAL_SHARED_STORAGE.equalsIgnoreCase(name)) { + name = getString(R.string.storage_internal); + } + int icon; + if (!volume.isRemovable()) { + icon = R.drawable.ic_phone_android_white_24dp; + } else { + // HACK: There is no reliable way to distinguish USB and SD external storage + // However it is often enough to check for "USB" String + if (name.toUpperCase().contains("USB") || path.getPath().toUpperCase().contains("USB")) { + icon = R.drawable.ic_usb_white_24dp; + } else { + icon = R.drawable.ic_sd_storage_white_24dp; + } + } + volumes.add(new StorageDirectoryParcelable(path.getPath(), name, icon)); + } + return volumes; + } + + /** + * Returns all available SD-Cards in the system (include emulated) + * + *

Warning: Hack! Based on Android source code of version 4.3 (API 18) Because there was no + * standard way to get it before android N + * + * @return All available SD-Cards in the system (include emulated) + */ + public synchronized ArrayList getStorageDirectoriesLegacy() { + List rv = new ArrayList<>(); + + // Primary physical SD-CARD (not emulated) + final String rawExternalStorage = System.getenv("EXTERNAL_STORAGE"); + // All Secondary SD-CARDs (all exclude primary) separated by ":" + final String rawSecondaryStoragesStr = System.getenv("SECONDARY_STORAGE"); + // Primary emulated SD-CARD + final String rawEmulatedStorageTarget = System.getenv("EMULATED_STORAGE_TARGET"); + if (TextUtils.isEmpty(rawEmulatedStorageTarget)) { + // Device has physical external storage; use plain paths. + if (TextUtils.isEmpty(rawExternalStorage)) { + // EXTERNAL_STORAGE undefined; falling back to default. + // Check for actual existence of the directory before adding to list + if (new File(DEFAULT_FALLBACK_STORAGE_PATH).exists()) { + rv.add(DEFAULT_FALLBACK_STORAGE_PATH); + } else { + // We know nothing else, use Environment's fallback + rv.add(Environment.getExternalStorageDirectory().getAbsolutePath()); + } + } else { + rv.add(rawExternalStorage); + } + } else { + // Device has emulated storage; external storage paths should have + // userId burned into them. + final String rawUserId; + if (SDK_INT < JELLY_BEAN_MR1) { + rawUserId = ""; + } else { + final String path = Environment.getExternalStorageDirectory().getAbsolutePath(); + final String[] folders = DIR_SEPARATOR.split(path); + final String lastFolder = folders[folders.length - 1]; + boolean isDigit = false; + try { + Integer.valueOf(lastFolder); + isDigit = true; + } catch (NumberFormatException ignored) { + } + rawUserId = isDigit ? lastFolder : ""; + } + // /storage/emulated/0[1,2,...] + if (TextUtils.isEmpty(rawUserId)) { + rv.add(rawEmulatedStorageTarget); + } else { + rv.add(rawEmulatedStorageTarget + File.separator + rawUserId); + } + } + // Add all secondary storages + if (!TextUtils.isEmpty(rawSecondaryStoragesStr)) { + // All Secondary SD-CARDs splited into array + final String[] rawSecondaryStorages = rawSecondaryStoragesStr.split(File.pathSeparator); + Collections.addAll(rv, rawSecondaryStorages); + } + if (SDK_INT >= M && checkStoragePermission()) rv.clear(); + if (SDK_INT >= KITKAT) { + String strings[] = ExternalSdCardOperation.getExtSdCardPathsForActivity(this); + for (String s : strings) { + File f = new File(s); + if (!rv.contains(s) && FileUtils.canListFiles(f)) rv.add(s); + } + } + File usb = getUsbDrive(); + if (usb != null && !rv.contains(usb.getPath())) rv.add(usb.getPath()); + + if (SDK_INT >= KITKAT) { + if (SingletonUsbOtg.getInstance().isDeviceConnected()) { + rv.add(OTGUtil.PREFIX_OTG + "/"); + } + } + + // Assign a label and icon to each directory + ArrayList volumes = new ArrayList<>(); + for (String file : rv) { + File f = new File(file); + @DrawableRes int icon; + + if ("/storage/emulated/legacy".equals(file) + || "/storage/emulated/0".equals(file) + || "/mnt/sdcard".equals(file)) { + icon = R.drawable.ic_phone_android_white_24dp; + } else if ("/storage/sdcard1".equals(file)) { + icon = R.drawable.ic_sd_storage_white_24dp; + } else if ("/".equals(file)) { + icon = R.drawable.ic_drawer_root_white; + } else { + icon = R.drawable.ic_sd_storage_white_24dp; + } + + @StorageNaming.DeviceDescription + int deviceDescription = StorageNaming.getDeviceDescriptionLegacy(f); + String name = StorageNamingHelper.getNameForDeviceDescription(this, f, deviceDescription); + + volumes.add(new StorageDirectoryParcelable(file, name, icon)); + } + + return volumes; + } + + @Override + public void onBackPressed() { + if (!drawer.isLocked() && drawer.isOpen()) { + drawer.close(); + return; + } + + Fragment fragment = getFragmentAtFrame(); + if (getAppbar().getSearchView().isShown()) { + // hide search view if visible, with an animation + getAppbar().getSearchView().hideSearchView(); + } else if (fragment instanceof TabFragment) { + if (floatingActionButton.isOpen()) { + floatingActionButton.close(true); + } else { + executeWithMainFragment( + mainFragment -> { + mainFragment.goBack(); + return null; + }); + } + } else if (fragment instanceof CompressedExplorerFragment) { + CompressedExplorerFragment compressedExplorerFragment = + (CompressedExplorerFragment) getFragmentAtFrame(); + if (compressedExplorerFragment.mActionMode == null) { + if (compressedExplorerFragment.canGoBack()) { + compressedExplorerFragment.goBack(); + } else if (isCompressedOpen) { + isCompressedOpen = false; + finish(); + } else { + FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction(); + fragmentTransaction.setCustomAnimations(R.anim.slide_out_bottom, R.anim.slide_out_bottom); + fragmentTransaction.remove(compressedExplorerFragment); + fragmentTransaction.commit(); + supportInvalidateOptionsMenu(); + showFab(); + } + } else { + compressedExplorerFragment.mActionMode.finish(); + } + } else if (fragment instanceof FtpServerFragment) { + // returning back from FTP server + if (path != null && path.length() > 0) { + HybridFile file = new HybridFile(OpenMode.UNKNOWN, path); + file.generateMode(this); + if (file.isDirectory(this)) goToMain(path); + else { + goToMain(null); + FileUtils.openFile(new File(path), this, getPrefs()); + } + } else { + goToMain(null); + } + } else { + goToMain(null); + } + } + + public void invalidatePasteSnackbar(boolean showSnackbar) { + if (pasteHelper != null) { + pasteHelper.invalidateSnackbar(this, showSnackbar); + } + } + + public void exit() { + if (backPressedToExitOnce) { + NetCopyClientConnectionPool.INSTANCE.shutdown(); + finish(); + if (isRootExplorer()) { + closeInteractiveShell(); + } + } else { + this.backPressedToExitOnce = true; + final Toast toast = Toast.makeText(this, getString(R.string.press_again), Toast.LENGTH_SHORT); + this.toast = new WeakReference<>(toast); + toast.show(); + new Handler() + .postDelayed( + () -> { + backPressedToExitOnce = false; + }, + 2000); + } + } + + public void goToMain(String path) { + goToMain(path, false); + } + + /** + * Sets up the main view with a {@link MainFragment} + * + * @param path The path to which to go in the {@link MainFragment} + * @param hideFab Whether the FAB should be hidden in the new created {@link MainFragment} or not + */ + public void goToMain(String path, boolean hideFab) { + FragmentTransaction transaction = getSupportFragmentManager().beginTransaction(); + // title.setText(R.string.app_name); + TabFragment tabFragment = new TabFragment(); + if (intent != null && intent.getAction() != null) { + if (INTENT_ACTION_OPEN_QUICK_ACCESS.equals(intent.getAction())) { + path = "5"; + } else if (INTENT_ACTION_OPEN_RECENT.equals(intent.getAction())) { + path = "6"; + } + } + Bundle b = new Bundle(); + if (path != null && path.length() > 0) { + b.putString("path", path); + } + // This boolean will be given to the newly created MainFragment + b.putBoolean(MainFragment.BUNDLE_HIDE_FAB, hideFab); + tabFragment.setArguments(b); + transaction.replace(R.id.content_frame, tabFragment); + // Commit the transaction + transaction.addToBackStack("tabt" + 1); + transaction.commitAllowingStateLoss(); + appbar.setTitle(null); + + if (isCompressedOpen && pathInCompressedArchive != null) { + openCompressed(pathInCompressedArchive); + pathInCompressedArchive = null; + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + MenuInflater menuInflater = getMenuInflater(); + menuInflater.inflate(R.menu.activity_extra, menu); + /* + SearchManager searchManager = (SearchManager) getSystemService(Context.SEARCH_SERVICE); + SearchView searchView = (SearchView) menu.findItem(R.id.search).getActionView(); + searchView.setSearchableInfo(searchManager.getSearchableInfo(getComponentName())); + searchView.setIconifiedByDefault(false); + + MenuItem search = menu.findItem(R.id.search); + MenuItemCompat.setOnActionExpandListener(search, new MenuItemCompat.OnActionExpandListener() { + @Override + public boolean onMenuItemActionExpand(MenuItem item) { + // Stretching the SearchView across width of the Toolbar + toolbar.setContentInsetsRelative(0, 0); + return true; + } + + @Override + public boolean onMenuItemActionCollapse(MenuItem item) { + // Restoring + toolbar.setContentInsetsRelative(TOOLBAR_START_INSET, 0); + return true; + } + }); + */ + return super.onCreateOptionsMenu(menu); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + MenuItem s = menu.findItem(R.id.view); + MenuItem search = menu.findItem(R.id.search); + Fragment fragment = getFragmentAtFrame(); + if (fragment instanceof TabFragment) { + appbar.setTitle(R.string.appbar_name); + if (getBoolean(PREFERENCE_VIEW)) { + s.setTitle(getResources().getString(R.string.gridview)); + } else { + s.setTitle(getResources().getString(R.string.listview)); + } + try { + executeWithMainFragment( + mainFragment -> { + if (mainFragment.getMainFragmentViewModel().isList()) { + s.setTitle(R.string.gridview); + } else { + s.setTitle(R.string.listview); + } + appbar + .getBottomBar() + .updatePath( + mainFragment.getCurrentPath(), + mainFragment.getMainFragmentViewModel().getOpenMode(), + mainFragment.getMainFragmentViewModel().getFolderCount(), + mainFragment.getMainFragmentViewModel().getFileCount(), + mainFragment); + return null; + }); + } catch (Exception e) { + LOG.warn("failure while preparing options menu", e); + } + + appbar.getBottomBar().setClickListener(); + + search.setVisible(true); + if (indicator_layout != null) indicator_layout.setVisibility(View.VISIBLE); + menu.findItem(R.id.search).setVisible(true); + menu.findItem(R.id.home).setVisible(true); + menu.findItem(R.id.history).setVisible(true); + menu.findItem(R.id.sethome).setVisible(true); + menu.findItem(R.id.sort).setVisible(true); + menu.findItem(R.id.hiddenitems).setVisible(true); + menu.findItem(R.id.view).setVisible(true); + menu.findItem(R.id.extract).setVisible(false); + invalidatePasteSnackbar(true); + findViewById(R.id.buttonbarframe).setVisibility(View.VISIBLE); + } else if (fragment instanceof AppsListFragment + || fragment instanceof ProcessViewerFragment + || fragment instanceof FtpServerFragment) { + appBarLayout.setExpanded(true); + menu.findItem(R.id.sethome).setVisible(false); + if (indicator_layout != null) indicator_layout.setVisibility(View.GONE); + findViewById(R.id.buttonbarframe).setVisibility(View.GONE); + menu.findItem(R.id.search).setVisible(false); + menu.findItem(R.id.home).setVisible(false); + menu.findItem(R.id.history).setVisible(false); + menu.findItem(R.id.extract).setVisible(false); + if (fragment instanceof ProcessViewerFragment) { + menu.findItem(R.id.sort).setVisible(false); + } else if (fragment instanceof FtpServerFragment) { + menu.findItem(R.id.sort).setVisible(false); + } else { + menu.findItem(R.id.dsort).setVisible(false); + menu.findItem(R.id.sortby).setVisible(false); + } + menu.findItem(R.id.hiddenitems).setVisible(false); + menu.findItem(R.id.view).setVisible(false); + invalidatePasteSnackbar(false); + } else if (fragment instanceof CompressedExplorerFragment) { + appbar.setTitle(R.string.appbar_name); + menu.findItem(R.id.sethome).setVisible(false); + if (indicator_layout != null) indicator_layout.setVisibility(View.GONE); + getAppbar().getBottomBar().resetClickListener(); + menu.findItem(R.id.search).setVisible(false); + menu.findItem(R.id.home).setVisible(false); + menu.findItem(R.id.history).setVisible(false); + menu.findItem(R.id.sort).setVisible(false); + menu.findItem(R.id.hiddenitems).setVisible(false); + menu.findItem(R.id.view).setVisible(false); + menu.findItem(R.id.extract).setVisible(true); + invalidatePasteSnackbar(false); + } + return super.onPrepareOptionsMenu(menu); + } + + // called when the user exits the action mode + @Override + public boolean onOptionsItemSelected(MenuItem item) { + // The action bar home/up action should open or close the drawer. + // ActionBarDrawerToggle will take care of this. + if (drawer.onOptionsItemSelected(item)) return true; + // Same thing goes to other Fragments loaded. + // If they have handled the options, we don't need to. + if (getFragmentAtFrame().onOptionsItemSelected(item)) return true; + + // Handle action buttons + executeWithMainFragment( + mainFragment -> { + switch (item.getItemId()) { + case R.id.home: + mainFragment.home(); + break; + case R.id.history: + HistoryDialog.showHistoryDialog(this, mainFragment); + break; + case R.id.sethome: + if (mainFragment.getMainFragmentViewModel().getOpenMode() != OpenMode.FILE + && mainFragment.getMainFragmentViewModel().getOpenMode() != OpenMode.ROOT) { + Toast.makeText(mainActivity, R.string.not_allowed, Toast.LENGTH_SHORT).show(); + break; + } + final MaterialDialog dialog = + GeneralDialogCreation.showBasicDialog( + mainActivity, + R.string.question_set_path_as_home, + R.string.set_as_home, + R.string.yes, + R.string.no); + dialog + .getActionButton(DialogAction.POSITIVE) + .setOnClickListener( + (v) -> { + mainFragment + .getMainFragmentViewModel() + .setHome(mainFragment.getCurrentPath()); + updatePaths(mainFragment.getMainFragmentViewModel().getNo()); + dialog.dismiss(); + }); + dialog.show(); + break; + case R.id.exit: + finish(); + break; + case R.id.sortby: + GeneralDialogCreation.showSortDialog(mainFragment, getAppTheme(), getPrefs()); + break; + case R.id.dsort: + String[] sort = getResources().getStringArray(R.array.directorysortmode); + MaterialDialog.Builder builder = new MaterialDialog.Builder(mainActivity); + builder.theme(getAppTheme().getMaterialDialogTheme()); + builder.title(R.string.directorysort); + int current = + Integer.parseInt( + getPrefs() + .getString(PreferencesConstants.PREFERENCE_DIRECTORY_SORT_MODE, "0")); + + builder + .items(sort) + .itemsCallbackSingleChoice( + current, + (dialog1, view, which, text) -> { + getPrefs() + .edit() + .putString( + PreferencesConstants.PREFERENCE_DIRECTORY_SORT_MODE, "" + which) + .commit(); + mainFragment + .getMainFragmentViewModel() + .initSortModes( + SortHandler.getSortType( + this, mainFragment.getMainFragmentViewModel().getCurrentPath()), + getPrefs()); + mainFragment.updateList(false); + dialog1.dismiss(); + return true; + }); + builder.build().show(); + break; + case R.id.hiddenitems: + HiddenFilesDialog.showHiddenDialog(this, mainFragment); + break; + case R.id.view: + int pathLayout = + dataUtils.getListOrGridForPath(mainFragment.getCurrentPath(), DataUtils.LIST); + if (mainFragment.getMainFragmentViewModel().isList()) { + if (pathLayout == DataUtils.LIST) { + AppConfig.getInstance() + .runInBackground( + () -> { + utilsHandler.removeFromDatabase( + new OperationData( + UtilsHandler.Operation.LIST, mainFragment.getCurrentPath())); + }); + } + utilsHandler.saveToDatabase( + new OperationData(UtilsHandler.Operation.GRID, mainFragment.getCurrentPath())); + + dataUtils.setPathAsGridOrList(mainFragment.getCurrentPath(), DataUtils.GRID); + } else { + if (pathLayout == DataUtils.GRID) { + AppConfig.getInstance() + .runInBackground( + () -> { + utilsHandler.removeFromDatabase( + new OperationData( + UtilsHandler.Operation.GRID, mainFragment.getCurrentPath())); + }); + } + + utilsHandler.saveToDatabase( + new OperationData(UtilsHandler.Operation.LIST, mainFragment.getCurrentPath())); + + dataUtils.setPathAsGridOrList(mainFragment.getCurrentPath(), DataUtils.LIST); + } + mainFragment.switchView(); + break; + case R.id.extract: + Fragment fragment1 = getFragmentAtFrame(); + if (fragment1 instanceof CompressedExplorerFragment) { + mainActivityHelper.extractFile( + ((CompressedExplorerFragment) fragment1).compressedFile); + } + break; + case R.id.search: + getAppbar().getSearchView().revealSearchView(); + break; + } + return null; + }, + false); + + return super.onOptionsItemSelected(item); + } + + /*@Override + public void onRestoreInstanceState(Bundle savedInstanceState){ + COPY_PATH=savedInstanceState.getStringArrayList("COPY_PATH"); + MOVE_PATH=savedInstanceState.getStringArrayList("MOVE_PATH"); + oppathe = savedInstanceState.getString(KEY_OPERATION_PATH); + oppathe1 = savedInstanceState.getString(KEY_OPERATED_ON_PATH); + oparrayList = savedInstanceState.getStringArrayList(KEY_OPERATIONS_PATH_LIST); + opnameList=savedInstanceState.getStringArrayList("opnameList"); + operation = savedInstanceState.getInt(KEY_OPERATION); + selectedStorage = savedInstanceState.getInt(KEY_DRAWER_SELECTED, 0); + }*/ + + @Override + protected void onPostCreate(Bundle savedInstanceState) { + super.onPostCreate(savedInstanceState); + // Sync the toggle state after onRestoreInstanceState has occurred. + drawer.syncState(); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + // Pass any configuration change to the drawer toggls + drawer.onConfigurationChanged(newConfig); + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putInt(KEY_DRAWER_SELECTED, getDrawer().getDrawerSelectedItem()); + outState.putBoolean(KEY_SELECTED_LIST_ITEM, listItemSelected); + if (pasteHelper != null) { + outState.putParcelable(PASTEHELPER_BUNDLE, pasteHelper); + } + + if (oppathe != null) { + outState.putString(KEY_OPERATION_PATH, oppathe); + outState.putString(KEY_OPERATED_ON_PATH, oppathe1); + outState.putParcelableArrayList(KEY_OPERATIONS_PATH_LIST, (oparrayList)); + outState.putInt(KEY_OPERATION, operation); + } + } + + @Override + protected void onPause() { + super.onPause(); + unregisterReceiver(mainActivityHelper.mNotificationReceiver); + unregisterReceiver(receiver2); + + if (SDK_INT >= KITKAT) { + unregisterReceiver(mOtgReceiver); + } + + final Toast toast = this.toast.get(); + if (toast != null) { + toast.cancel(); + } + this.toast = new WeakReference<>(null); + } + + @Override + public void onResume() { + super.onResume(); + if (materialDialog != null && !materialDialog.isShowing()) { + materialDialog.show(); + materialDialog = null; + } + + drawer.refreshDrawer(); + drawer.refactorDrawerLockMode(); + + IntentFilter newFilter = new IntentFilter(); + newFilter.addAction(Intent.ACTION_MEDIA_MOUNTED); + newFilter.addAction(Intent.ACTION_MEDIA_UNMOUNTED); + newFilter.addDataScheme(ContentResolver.SCHEME_FILE); + registerReceiver(mainActivityHelper.mNotificationReceiver, newFilter); + registerReceiver(receiver2, new IntentFilter(TAG_INTENT_FILTER_GENERAL)); + + if (SDK_INT >= Build.VERSION_CODES.KITKAT) { + updateUsbInformation(); + } + } + + /** Updates everything related to USB devices MUST ALWAYS be called after onResume() */ + @RequiresApi(api = Build.VERSION_CODES.KITKAT) + private void updateUsbInformation() { + boolean isInformationUpdated = false; + List connectedDevices = OTGUtil.getMassStorageDevicesConnected(this); + + if (!connectedDevices.isEmpty()) { + if (SingletonUsbOtg.getInstance().getUsbOtgRoot() != null + && OTGUtil.isUsbUriAccessible(this)) { + for (UsbOtgRepresentation device : connectedDevices) { + if (SingletonUsbOtg.getInstance().checkIfRootIsFromDevice(device)) { + isInformationUpdated = true; + break; + } + } + + if (!isInformationUpdated) { + SingletonUsbOtg.getInstance().resetUsbOtgRoot(); + } + } + + if (!isInformationUpdated) { + SingletonUsbOtg.getInstance().setConnectedDevice(connectedDevices.get(0)); + isInformationUpdated = true; + } + } + + if (!isInformationUpdated) { + SingletonUsbOtg.getInstance().resetUsbOtgRoot(); + drawer.refreshDrawer(); + } + + // Registering intent filter for OTG + IntentFilter otgFilter = new IntentFilter(); + otgFilter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED); + otgFilter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED); + registerReceiver(mOtgReceiver, otgFilter); + } + + /** Receiver to check if a USB device is connected at the runtime of application */ + BroadcastReceiver mOtgReceiver = + new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (intent.getAction().equals(UsbManager.ACTION_USB_DEVICE_ATTACHED)) { + List connectedDevices = + OTGUtil.getMassStorageDevicesConnected(MainActivity.this); + if (!connectedDevices.isEmpty()) { + SingletonUsbOtg.getInstance().resetUsbOtgRoot(); + SingletonUsbOtg.getInstance().setConnectedDevice(connectedDevices.get(0)); + drawer.refreshDrawer(); + } + } else if (intent.getAction().equals(UsbManager.ACTION_USB_DEVICE_DETACHED)) { + SingletonUsbOtg.getInstance().resetUsbOtgRoot(); + drawer.refreshDrawer(); + goToMain(null); + } + } + }; + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + if (keyCode == KeyEvent.KEYCODE_MENU) { + /* + ImageView ib = findViewById(R.id.action_overflow); + if (ib.getVisibility() == View.VISIBLE) { + ib.performClick(); + } + */ + // return 'true' to prevent further propagation of the key event + return true; + } + + return super.onKeyDown(keyCode, event); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + // TODO: 6/5/2017 Android may choose to not call this method before destruction + // TODO: https://developer.android.com/reference/android/app/Activity.html#onDestroy%28%29 + closeInteractiveShell(); + NetCopyClientConnectionPool.INSTANCE.shutdown(); + if (drawer != null && drawer.getBilling() != null) { + drawer.getBilling().destroyBillingInstance(); + } + } + + /** Closes the interactive shell and threads associated */ + private void closeInteractiveShell() { + if (isRootExplorer()) { + // close interactive shell + try { + Shell.getShell().close(); + } catch (IOException e) { + LOG.error("Error closing Shell", e); + } + } + } + + public void updatePaths(int pos) { + TabFragment tabFragment = getTabFragment(); + if (tabFragment != null) tabFragment.updatePaths(pos); + } + + public void openCompressed(String path) { + appBarLayout.animate().translationY(0).setInterpolator(new DecelerateInterpolator(2)).start(); + FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction(); + fragmentTransaction.setCustomAnimations(R.anim.slide_in_top, R.anim.slide_in_bottom); + Fragment zipFragment = new CompressedExplorerFragment(); + Bundle bundle = new Bundle(); + bundle.putString(CompressedExplorerFragment.KEY_PATH, path); + zipFragment.setArguments(bundle); + fragmentTransaction.add(R.id.content_frame, zipFragment); + fragmentTransaction.commitAllowingStateLoss(); + } + + public @Nullable MainFragment getCurrentMainFragment() { + TabFragment tab = getTabFragment(); + + if (tab != null && tab.getCurrentTabFragment() instanceof MainFragment) { + return (MainFragment) tab.getCurrentTabFragment(); + } else return null; + } + + public TabFragment getTabFragment() { + Fragment fragment = getFragmentAtFrame(); + + if (!(fragment instanceof TabFragment)) return null; + else return (TabFragment) fragment; + } + + public Fragment getFragmentAtFrame() { + return getSupportFragmentManager().findFragmentById(R.id.content_frame); + } + + public void setPagingEnabled(boolean b) { + getTabFragment().setPagingEnabled(b); + } + + public File getUsbDrive() { + File parent = new File("/storage"); + + try { + for (File f : parent.listFiles()) + if (f.exists() && f.getName().toLowerCase().contains("usb") && f.canExecute()) return f; + } catch (Exception e) { + } + + parent = new File("/mnt/sdcard/usbStorage"); + if (parent.exists() && parent.canExecute()) return parent; + parent = new File("/mnt/sdcard/usb_storage"); + if (parent.exists() && parent.canExecute()) return parent; + + return null; + } + + public SpeedDialView getFAB() { + return floatingActionButton; + } + + public void showFab() { + if (getCurrentMainFragment() != null && getCurrentMainFragment().getHideFab()) { + hideFab(); + } else { + showFab(getFAB()); + } + } + + private void showFab(SpeedDialView fab) { + fab.setVisibility(View.VISIBLE); + fab.show(); + CoordinatorLayout.LayoutParams params = (CoordinatorLayout.LayoutParams) fab.getLayoutParams(); + params.setBehavior(new SpeedDialView.ScrollingViewSnackbarBehavior()); + fab.requestLayout(); + } + + public void hideFab() { + hideFab(getFAB()); + } + + private void hideFab(SpeedDialView fab) { + fab.setVisibility(View.GONE); + fab.hide(); + CoordinatorLayout.LayoutParams params = (CoordinatorLayout.LayoutParams) fab.getLayoutParams(); + params.setBehavior(new SpeedDialView.NoBehavior()); + fab.requestLayout(); + } + + public AppBar getAppbar() { + return appbar; + } + + public Drawer getDrawer() { + return drawer; + } + + protected void onActivityResult(int requestCode, int responseCode, Intent intent) { + super.onActivityResult(requestCode, responseCode, intent); + if (requestCode == 3) { + Uri treeUri; + if (responseCode == Activity.RESULT_OK) { + // Get Uri from Storage Access Framework. + treeUri = intent.getData(); + // Persist URI - this is required for verification of writability. + if (treeUri != null) + getPrefs() + .edit() + .putString(PreferencesConstants.PREFERENCE_URI, treeUri.toString()) + .apply(); + } else { + // If not confirmed SAF, or if still not writable, then revert settings. + /* DialogUtil.displayError(getActivity(), R.string.message_dialog_cannot_write_to_folder_saf, false, currentFolder); + ||!FileUtil.isWritableNormalOrSaf(currentFolder)*/ + return; + } + + // After confirmation, update stored value of folder. + // Persist access permissions. + + if (SDK_INT >= KITKAT) { + getContentResolver() + .takePersistableUriPermission( + treeUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + } + + executeWithMainFragment( + mainFragment -> { + switch (operation) { + case DELETE: // deletion + new DeleteTask(mainActivity, true).execute((oparrayList)); + break; + case COPY: // copying + // legacy compatibility + if (oparrayList != null && oparrayList.size() != 0) { + oparrayListList = new ArrayList<>(); + oparrayListList.add(oparrayList); + oparrayList = null; + oppatheList = new ArrayList<>(); + oppatheList.add(oppathe); + oppathe = ""; + } + for (int i = 0; i < oparrayListList.size(); i++) { + ArrayList sourceList = oparrayListList.get(i); + Intent intent1 = new Intent(this, CopyService.class); + intent1.putExtra(CopyService.TAG_COPY_SOURCES, sourceList); + intent1.putExtra(CopyService.TAG_COPY_TARGET, oppatheList.get(i)); + ServiceWatcherUtil.runService(this, intent1); + } + break; + case MOVE: // moving + // legacy compatibility + if (oparrayList != null && oparrayList.size() != 0) { + oparrayListList = new ArrayList<>(); + oparrayListList.add(oparrayList); + oparrayList = null; + oppatheList = new ArrayList<>(); + oppatheList.add(oppathe); + oppathe = ""; + } + + TaskKt.fromTask( + new MoveFilesTask( + oparrayListList, + isRootExplorer(), + mainFragment.getCurrentPath(), + this, + OpenMode.FILE, + oppatheList)); + break; + case NEW_FOLDER: // mkdir + mainActivityHelper.mkDir( + new HybridFile(OpenMode.FILE, oppathe), + RootHelper.generateBaseFile(new File(oppathe), true), + mainFragment); + break; + case RENAME: + mainActivityHelper.rename( + mainFragment.getMainFragmentViewModel().getOpenMode(), + (oppathe), + (oppathe1), + null, + false, + mainActivity, + isRootExplorer()); + mainFragment.updateList(false); + break; + case NEW_FILE: + mainActivityHelper.mkFile( + new HybridFile(OpenMode.FILE, oppathe), + new HybridFile(OpenMode.FILE, oppathe), + mainFragment); + break; + case EXTRACT: + mainActivityHelper.extractFile(new File(oppathe)); + break; + case COMPRESS: + mainActivityHelper.compressFiles(new File(oppathe), oparrayList); + break; + case SAVE_FILE: + FileUtil.writeUriToStorage( + this, urisToBeSaved, getContentResolver(), mainFragment.getCurrentPath()); + urisToBeSaved = null; + finish(); + break; + default: + LogHelper.logOnProductionOrCrash("Incorrect value for switch"); + } + return null; + }, + true); + operation = UNDEFINED; + } else if (requestCode == REQUEST_CODE_SAF) { + executeWithMainFragment( + mainFragment -> { + if (responseCode == Activity.RESULT_OK && intent.getData() != null) { + // otg access + Uri usbOtgRoot = intent.getData(); + SingletonUsbOtg.getInstance().setUsbOtgRoot(usbOtgRoot); + mainFragment.loadlist(OTGUtil.PREFIX_OTG, false, OpenMode.OTG, true); + drawer.closeIfNotLocked(); + if (drawer.isLocked()) drawer.onDrawerClosed(); + } else if (requestCode == REQUEST_CODE_SAF_FTP) { + FtpServerFragment ftpServerFragment = (FtpServerFragment) getFragmentAtFrame(); + ftpServerFragment.changeFTPServerPath(intent.getData().toString()); + Toast.makeText(this, R.string.ftp_path_change_success, Toast.LENGTH_SHORT).show(); + + } else { + Toast.makeText(this, R.string.error, Toast.LENGTH_SHORT).show(); + // otg access not provided + drawer.resetPendingPath(); + } + return null; + }, + true); + } + } + + void initialisePreferences() { + currentTab = getCurrentTab(); + skinStatusBar = PreferenceUtils.getStatusColor(getPrimary()); + } + + void initialiseViews() { + + appbar = new AppBar(this, getPrefs()); + appBarLayout = getAppbar().getAppbarLayout(); + + setSupportActionBar(getAppbar().getToolbar()); + drawer = new Drawer(this); + + indicator_layout = findViewById(R.id.indicator_layout); + + getSupportActionBar().setDisplayShowTitleEnabled(false); + fabBgView = findViewById(R.id.fabs_overlay_layout); + + switch (getAppTheme()) { + case DARK: + fabBgView.setBackgroundResource(R.drawable.fab_shadow_dark); + break; + case BLACK: + fabBgView.setBackgroundResource(R.drawable.fab_shadow_black); + break; + } + + fabBgView.setOnClickListener( + view -> { + if (getAppbar().getSearchView().isEnabled()) getAppbar().getSearchView().hideSearchView(); + }); + + // drawer.setDrawerHeaderBackground(); + } + + /** + * Call this method when you need to update the MainActivity view components' colors based on + * update in the {@link MainActivity#currentTab} Warning - All the variables should be initialised + * before calling this method! + */ + public void updateViews(ColorDrawable colorDrawable) { + // appbar view color + appbar.getBottomBar().setBackgroundColor(colorDrawable.getColor()); + // action bar color + mainActivity.getSupportActionBar().setBackgroundDrawable(colorDrawable); + + drawer.setBackgroundColor(colorDrawable.getColor()); + + if (SDK_INT >= LOLLIPOP) { + // for lollipop devices, the status bar color + mainActivity.getWindow().setStatusBarColor(colorDrawable.getColor()); + if (getBoolean(PREFERENCE_COLORED_NAVIGATION)) { + mainActivity + .getWindow() + .setNavigationBarColor(PreferenceUtils.getStatusColor(colorDrawable.getColor())); + } else { + if (getAppTheme().equals(AppTheme.LIGHT)) { + mainActivity + .getWindow() + .setNavigationBarColor(Utils.getColor(this, android.R.color.white)); + } else if (getAppTheme().equals(AppTheme.BLACK)) { + mainActivity + .getWindow() + .setNavigationBarColor(Utils.getColor(this, android.R.color.black)); + } else { + mainActivity + .getWindow() + .setNavigationBarColor(Utils.getColor(this, R.color.holo_dark_background)); + } + } + } else if (SDK_INT == KITKAT_WATCH || SDK_INT == KITKAT) { + + // for kitkat devices, the status bar color + SystemBarTintManager tintManager = new SystemBarTintManager(this); + tintManager.setStatusBarTintEnabled(true); + tintManager.setStatusBarTintColor(colorDrawable.getColor()); + } + } + + void initialiseFab() { + int colorAccent = getAccent(); + + floatingActionButton = findViewById(R.id.fabs_menu); + floatingActionButton.setMainFabClosedBackgroundColor(colorAccent); + floatingActionButton.setMainFabOpenedBackgroundColor(colorAccent); + initializeFabActionViews(); + } + + public void initializeFabActionViews() { + // NOTE: SpeedDial inverts insert index than FABsmenu + FabWithLabelView cloudFab = + initFabTitle( + R.id.menu_new_cloud, R.string.cloud_connection, R.drawable.ic_cloud_white_24dp); + FabWithLabelView newFileFab = + initFabTitle(R.id.menu_new_file, R.string.file, R.drawable.ic_insert_drive_file_white_48dp); + FabWithLabelView newFolderFab = + initFabTitle(R.id.menu_new_folder, R.string.folder, R.drawable.folder_fab); + + floatingActionButton.setOnActionSelectedListener( + actionItem -> { + MainFragment mainFragment = getCurrentMainFragment(); + + if (mainFragment == null) return false; + + String path = mainFragment.getCurrentPath(); + + MainFragmentViewModel mainFragmentViewModel = mainFragment.getMainFragmentViewModel(); + + if (mainFragmentViewModel == null) return false; + + OpenMode openMode = mainFragmentViewModel.getOpenMode(); + + int id = actionItem.getId(); + + if (id == R.id.menu_new_folder) + mainActivity.mainActivityHelper.mkdir(openMode, path, mainFragment); + else if (id == R.id.menu_new_file) + mainActivity.mainActivityHelper.mkfile(openMode, path, mainFragment); + else if (id == R.id.menu_new_cloud) + new CloudSheetFragment() + .show(mainActivity.getSupportFragmentManager(), CloudSheetFragment.TAG_FRAGMENT); + + floatingActionButton.close(true); + return true; + }); + + floatingActionButton.setOnClickListener( + view -> { + fabButtonClick(cloudFab); + }); + floatingActionButton.setOnFocusChangeListener(new CustomZoomFocusChange()); + floatingActionButton.getMainFab().setOnFocusChangeListener(new CustomZoomFocusChange()); + floatingActionButton.setNextFocusUpId(cloudFab.getId()); + floatingActionButton.getMainFab().setNextFocusUpId(cloudFab.getId()); + floatingActionButton.setOnKeyListener( + (v, keyCode, event) -> { + if (event.getAction() == KeyEvent.ACTION_DOWN) { + if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_RIGHT) { + if (getCurrentTab() == 0 && getFAB().isFocused()) { + getTabFragment().setCurrentItem(1); + } + } else if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_LEFT) { + findViewById(R.id.content_frame).requestFocus(); + } else if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_DOWN) { + if (pasteHelper != null + && pasteHelper.getSnackbar() != null + && pasteHelper.getSnackbar().isShown()) + ((Snackbar.SnackbarLayout) pasteHelper.getSnackbar().getView()) + .findViewById(R.id.snackBarActionButton) + .requestFocus(); + } else if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_CENTER) { + fabButtonClick(cloudFab); + } else if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) { + onBackPressed(); + } else { + return false; + } + } + return true; + }); + cloudFab.setNextFocusDownId(floatingActionButton.getMainFab().getId()); + cloudFab.setNextFocusUpId(newFileFab.getId()); + cloudFab.setOnFocusChangeListener(new CustomZoomFocusChange()); + newFileFab.setNextFocusDownId(cloudFab.getId()); + newFileFab.setNextFocusUpId(newFolderFab.getId()); + newFileFab.setOnFocusChangeListener(new CustomZoomFocusChange()); + newFolderFab.setNextFocusDownId(newFileFab.getId()); + newFolderFab.setOnFocusChangeListener(new CustomZoomFocusChange()); + } + + private void fabButtonClick(FabWithLabelView cloudFab) { + if (floatingActionButton.isOpen()) { + floatingActionButton.close(true); + } else { + floatingActionButton.open(true); + cloudFab.requestFocus(); + } + } + + private FabWithLabelView initFabTitle( + @IdRes int id, @StringRes int fabTitle, @DrawableRes int icon) { + int iconSkin = getCurrentColorPreference().getIconSkin(); + + SpeedDialActionItem.Builder builder = + new SpeedDialActionItem.Builder(id, icon) + .setLabel(fabTitle) + .setFabBackgroundColor(iconSkin) + .setFabImageTintColor(Color.WHITE); + + switch (getAppTheme()) { + case LIGHT: + fabBgView.setBackgroundResource(R.drawable.fab_shadow_light); + break; + case DARK: + builder + .setLabelBackgroundColor(Utils.getColor(this, R.color.holo_dark_background)) + .setLabelColor(Utils.getColor(this, R.color.text_dark)); + fabBgView.setBackgroundResource(R.drawable.fab_shadow_dark); + break; + case BLACK: + builder + .setLabelBackgroundColor(Color.BLACK) + .setLabelColor(Utils.getColor(this, R.color.text_dark)); + fabBgView.setBackgroundResource(R.drawable.fab_shadow_black); + break; + } + + return floatingActionButton.addActionItem(builder.create()); + } + + private void initialiseFabConfirmSelection() { + fabConfirmSelection = findViewById(R.id.fabs_confirm_selection); + hideFabConfirmSelection(); + if (mReturnIntent) { + int colorAccent = getAccent(); + fabConfirmSelection.setMainFabClosedBackgroundColor(colorAccent); + fabConfirmSelection.setMainFabOpenedBackgroundColor(colorAccent); + + fabConfirmSelection.setOnChangeListener( + new SpeedDialView.OnChangeListener() { + @Override + public boolean onMainActionSelected() { + if (getCurrentMainFragment() != null + && getCurrentMainFragment().getMainFragmentViewModel() != null) { + ArrayList checkedItems = + getCurrentMainFragment().getMainFragmentViewModel().getCheckedItems(); + ArrayList baseFiles = new ArrayList<>(); + for (LayoutElementParcelable item : checkedItems) { + baseFiles.add(item.generateBaseFile()); + } + getCurrentMainFragment() + .returnIntentResults(baseFiles.toArray(new HybridFileParcelable[0])); + } + return false; + } + + @Override + public void onToggleChanged(boolean isOpen) {} + }); + } + } + + /** + * If a intent should be returned, shows the floating action button which confirms the selection + */ + public void showFabConfirmSelection() { + if (mReturnIntent) { + showFab(fabConfirmSelection); + } + } + + /** Hides the floating action button which confirms the selection */ + public void hideFabConfirmSelection() { + hideFab(fabConfirmSelection); + } + + public boolean copyToClipboard(Context context, String text) { + try { + android.content.ClipboardManager clipboard = + (android.content.ClipboardManager) context.getSystemService(CLIPBOARD_SERVICE); + android.content.ClipData clip = + android.content.ClipData.newPlainText("Path copied to clipboard", text); + clipboard.setPrimaryClip(clip); + return true; + } catch (Exception e) { + return false; + } + } + + public void renameBookmark(final String title, final String path) { + if (dataUtils.containsBooks(new String[] {title, path}) != -1) { + RenameBookmark renameBookmark = RenameBookmark.getInstance(title, path, getAccent()); + if (renameBookmark != null) renameBookmark.show(getFragmentManager(), "renamedialog"); + } + } + + public PasteHelper getPaste() { + return pasteHelper; + } + + public MainActivityActionMode getActionModeHelper() { + return this.mainActivityActionMode; + } + + public void setPaste(PasteHelper p) { + pasteHelper = p; + } + + @Override + public void onNewIntent(Intent i) { + super.onNewIntent(i); + intent = i; + path = i.getStringExtra("path"); + + if (path != null) { + if (new File(path).isDirectory()) { + final MainFragment mainFragment = getCurrentMainFragment(); + if (mainFragment != null) { + mainFragment.loadlist(path, false, OpenMode.FILE, true); + } else { + goToMain(path); + } + } else FileUtils.openFile(new File(path), mainActivity, getPrefs()); + } else if (i.getStringArrayListExtra(TAG_INTENT_FILTER_FAILED_OPS) != null) { + ArrayList failedOps = + i.getParcelableArrayListExtra(TAG_INTENT_FILTER_FAILED_OPS); + if (failedOps != null) { + mainActivityHelper.showFailedOperationDialog(failedOps, this); + } + } else if (i.getCategories() != null + && i.getCategories().contains(CLOUD_AUTHENTICATOR_GDRIVE)) { + // we used an external authenticator instead of APIs. Probably for Google Drive + CloudRail.setAuthenticationResponse(intent); + if (intent.getAction() != null) { + checkForExternalIntent(intent); + invalidateFragmentAndBundle(null, false); + } + } else if ((openProcesses = i.getBooleanExtra(KEY_INTENT_PROCESS_VIEWER, false))) { + FragmentTransaction transaction = getSupportFragmentManager().beginTransaction(); + transaction.replace( + R.id.content_frame, new ProcessViewerFragment(), KEY_INTENT_PROCESS_VIEWER); + // transaction.addToBackStack(null); + openProcesses = false; + // title.setText(utils.getString(con, R.string.process_viewer)); + // Commit the transaction + transaction.commitAllowingStateLoss(); + supportInvalidateOptionsMenu(); + } else if (intent.getAction() != null) { + checkForExternalIntent(intent); + invalidateFragmentAndBundle(null, false); + + if (SDK_INT >= KITKAT) { + if (intent.getAction().equals(UsbManager.ACTION_USB_DEVICE_DETACHED)) { + SingletonUsbOtg.getInstance().resetUsbOtgRoot(); + drawer.refreshDrawer(); + } + } + } + } + + private BroadcastReceiver receiver2 = + new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent i) { + if (i.getStringArrayListExtra(TAG_INTENT_FILTER_FAILED_OPS) != null) { + ArrayList failedOps = + i.getParcelableArrayListExtra(TAG_INTENT_FILTER_FAILED_OPS); + if (failedOps != null) { + mainActivityHelper.showFailedOperationDialog(failedOps, mainActivity); + } + } + } + }; + + public void showSMBDialog(String name, String path, boolean edit) { + if (path.length() > 0 && name.length() == 0) { + int i = dataUtils.containsServer(new String[] {name, path}); + if (i != -1) name = dataUtils.getServers().get(i)[0]; + } + SmbConnectDialog smbConnectDialog = new SmbConnectDialog(); + Bundle bundle = new Bundle(); + bundle.putString(SmbConnectDialog.ARG_NAME, name); + bundle.putString(SmbConnectDialog.ARG_PATH, path); + bundle.putBoolean(SmbConnectDialog.ARG_EDIT, edit); + smbConnectDialog.setArguments(bundle); + smbConnectDialog.show(getSupportFragmentManager(), SmbConnectDialog.TAG); + } + + @SuppressLint("CheckResult") + public void showSftpDialog(String name, String path, boolean edit) { + if (path.length() > 0 && name.length() == 0) { + int i = dataUtils.containsServer(new String[] {name, path}); + if (i != -1) name = dataUtils.getServers().get(i)[0]; + } + SftpConnectDialog sftpConnectDialog = new SftpConnectDialog(); + sftpConnectDialog.setCancelable(false); + String finalName = name; + Flowable.fromCallable(() -> new NetCopyConnectionInfo(path)) + .flatMap( + connectionInfo -> { + Bundle retval = new Bundle(); + retval.putString(ARG_PROTOCOL, connectionInfo.getPrefix()); + retval.putString(ARG_NAME, finalName); + retval.putString(ARG_ADDRESS, connectionInfo.getHost()); + retval.putInt(ARG_PORT, connectionInfo.getPort()); + if (!TextUtils.isEmpty(connectionInfo.getDefaultPath())) { + retval.putString( + ARG_DEFAULT_PATH, + ArraysKt.joinToString( + connectionInfo.getDefaultPath().split("/"), + "/", + "", + "", + -1, + "", + (Function1) + s -> GenericExtKt.urlDecoded(s, Charsets.UTF_8))); + } + if (!TextUtils.isEmpty(connectionInfo.getUsername())) { + retval.putString(ARG_USERNAME, connectionInfo.getUsername()); + } + + if (connectionInfo.getPassword() == null) { + retval.putBoolean(ARG_HAS_PASSWORD, false); + if (SSH_URI_PREFIX.equals(connectionInfo.getPrefix())) { + retval.putString(ARG_KEYPAIR_NAME, utilsHandler.getSshAuthPrivateKeyName(path)); + } + } else { + retval.putBoolean(ARG_HAS_PASSWORD, true); + retval.putString(ARG_PASSWORD, connectionInfo.getPassword()); + } + retval.putBoolean(ARG_EDIT, edit); + + if ((FTP_URI_PREFIX.equals(connectionInfo.getPrefix()) + || FTPS_URI_PREFIX.equals(connectionInfo.getPrefix())) + && connectionInfo.getArguments() != null + && TLS_EXPLICIT.equals(connectionInfo.getArguments().get(ARG_TLS))) { + retval.putString(ARG_TLS, TLS_EXPLICIT); + } + + return Flowable.just(retval); + }) + .subscribeOn(Schedulers.computation()) + .subscribe( + bundle -> { + sftpConnectDialog.setArguments(bundle); + sftpConnectDialog.setCancelable(true); + sftpConnectDialog.show(getSupportFragmentManager(), SftpConnectDialog.TAG); + }); + } + + /** + * Shows a view that goes from white at it's lowest part to transparent a the top. It covers the + * fragment. + */ + public void showSmokeScreen() { + fabBgView.show(); + } + + public void hideSmokeScreen() { + fabBgView.hide(); + } + + @Override + @SuppressLint("CheckResult") + public void addConnection( + boolean edit, + @NonNull final String name, + @NonNull final String encryptedPath, + @Nullable final String oldname, + @Nullable final String oldPath) { + String[] s = new String[] {name, encryptedPath}; + if (!edit) { + if ((dataUtils.containsServer(encryptedPath)) == -1) { + Completable.fromRunnable( + () -> + utilsHandler.saveToDatabase( + new OperationData(UtilsHandler.Operation.SMB, name, encryptedPath))) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + () -> { + dataUtils.addServer(s); + drawer.refreshDrawer(); + // grid.addPath(name, encryptedPath, DataUtils.SMB, 1); + executeWithMainFragment( + mainFragment -> { + mainFragment.loadlist(encryptedPath, false, OpenMode.UNKNOWN, true); + return null; + }, + true); + }); + } else { + Snackbar.make( + findViewById(R.id.navigation), + getString(R.string.connection_exists), + Snackbar.LENGTH_SHORT) + .show(); + } + } else { + int i = dataUtils.containsServer(new String[] {oldname, oldPath}); + if (i != -1) { + dataUtils.removeServer(i); + Flowable.fromCallable( + () -> { + utilsHandler.renameSMB(oldname, oldPath, name, encryptedPath); + return true; + }) + .subscribeOn(Schedulers.io()) + .subscribe(); + // mainActivity.grid.removePath(oldname, oldPath, DataUtils.SMB); + } + dataUtils.addServer(s); + Collections.sort(dataUtils.getServers(), new BookSorter()); + drawer.refreshDrawer(); + // mainActivity.grid.addPath(name, encryptedPath, DataUtils.SMB, 1); + } + } + + @Override + @SuppressLint("CheckResult") + public void deleteConnection(final String name, final String path) { + int i = dataUtils.containsServer(new String[] {name, path}); + if (i != -1) { + dataUtils.removeServer(i); + Completable.fromCallable( + () -> { + utilsHandler.removeFromDatabase( + new OperationData(UtilsHandler.Operation.SMB, name, path)); + return true; + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(() -> drawer.refreshDrawer()); + } + } + + @Override + @SuppressLint("CheckResult") + public void delete(String title, String path) { + Completable.fromCallable( + () -> { + utilsHandler.removeFromDatabase( + new OperationData(UtilsHandler.Operation.BOOKMARKS, title, path)); + return true; + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(() -> drawer.refreshDrawer()); + } + + @Override + @SuppressLint("CheckResult") + public void modify(String oldpath, String oldname, String newPath, String newname) { + Completable.fromCallable( + () -> { + utilsHandler.renameBookmark(oldname, oldpath, newname, newPath); + return true; + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(() -> drawer.refreshDrawer()); + } + + @Override + public void addConnection(OpenMode service) { + try { + if (cloudHandler.findEntry(service) != null) { + // cloud entry already exists + Toast.makeText( + this, getResources().getString(R.string.connection_exists), Toast.LENGTH_LONG) + .show(); + } else if (BuildConfig.IS_VERSION_FDROID) { + Toast.makeText( + this, getResources().getString(R.string.cloud_error_fdroid), Toast.LENGTH_LONG) + .show(); + } else { + Toast.makeText( + MainActivity.this, + getResources().getString(R.string.please_wait), + Toast.LENGTH_LONG) + .show(); + Bundle args = new Bundle(); + args.putInt(ARGS_KEY_LOADER, service.ordinal()); + + // check if we already had done some work on the loader + Loader loader = getSupportLoaderManager().getLoader(REQUEST_CODE_CLOUD_LIST_KEY); + if (loader != null && loader.isStarted()) { + + // making sure that loader is not started + getSupportLoaderManager().destroyLoader(REQUEST_CODE_CLOUD_LIST_KEY); + } + + getSupportLoaderManager().initLoader(REQUEST_CODE_CLOUD_LIST_KEY, args, this); + } + } catch (CloudPluginException e) { + LOG.warn("failure when adding cloud plugin connections", e); + Toast.makeText(this, getResources().getString(R.string.cloud_error_plugin), Toast.LENGTH_LONG) + .show(); + } + } + + @Override + public void deleteConnection(OpenMode service) { + cloudHandler.clear(service); + dataUtils.removeAccount(service); + + runOnUiThread(drawer::refreshDrawer); + } + + @NonNull + @Override + public Loader onCreateLoader(int id, Bundle args) { + Uri uri = + Uri.withAppendedPath( + Uri.parse("content://" + CloudContract.PROVIDER_AUTHORITY), "/keys.db/secret_keys"); + + String[] projection = + new String[] { + CloudContract.COLUMN_ID, + CloudContract.COLUMN_CLIENT_ID, + CloudContract.COLUMN_CLIENT_SECRET_KEY + }; + + switch (id) { + case REQUEST_CODE_CLOUD_LIST_KEY: + Uri uriAppendedPath = uri; + switch (OpenMode.getOpenMode(args.getInt(ARGS_KEY_LOADER, 2))) { + case GDRIVE: + uriAppendedPath = ContentUris.withAppendedId(uri, 2); + break; + case DROPBOX: + uriAppendedPath = ContentUris.withAppendedId(uri, 3); + break; + case BOX: + uriAppendedPath = ContentUris.withAppendedId(uri, 4); + break; + case ONEDRIVE: + uriAppendedPath = ContentUris.withAppendedId(uri, 5); + break; + } + return new CursorLoader(this, uriAppendedPath, projection, null, null, null); + case REQUEST_CODE_CLOUD_LIST_KEYS: + // we need a list of all secret keys + + try { + List cloudEntries = cloudHandler.getAllEntries(); + + // we want keys for services saved in database, and the cloudrail app key which + // is at index 1 + String ids[] = new String[cloudEntries.size() + 1]; + + ids[0] = 1 + ""; + for (int i = 1; i <= cloudEntries.size(); i++) { + + // we need to get only those cloud details which user wants + switch (cloudEntries.get(i - 1).getServiceType()) { + case GDRIVE: + ids[i] = 2 + ""; + break; + case DROPBOX: + ids[i] = 3 + ""; + break; + case BOX: + ids[i] = 4 + ""; + break; + case ONEDRIVE: + ids[i] = 5 + ""; + break; + } + } + return new CursorLoader(this, uri, projection, CloudContract.COLUMN_ID, ids, null); + } catch (CloudPluginException e) { + LOG.warn("failure when fetching cloud connections", e); + Toast.makeText( + this, getResources().getString(R.string.cloud_error_plugin), Toast.LENGTH_LONG) + .show(); + } + default: + Uri undefinedUriAppendedPath = ContentUris.withAppendedId(uri, 7); + return new CursorLoader(this, undefinedUriAppendedPath, projection, null, null, null); + } + } + + @Override + public void onLoadFinished(Loader loader, final Cursor data) { + if (data == null) { + Toast.makeText( + this, + getResources().getString(R.string.cloud_error_failed_restart), + Toast.LENGTH_LONG) + .show(); + return; + } + + /* + * This is hack for repeated calls to onLoadFinished(), + * we take the Cursor provided to check if the function + * has already been called on it. + * + * TODO: find a fix for repeated callbacks to onLoadFinished() + */ + if (cloudCursorData == null + || cloudCursorData == data + || data.isClosed() + || cloudCursorData.isClosed()) return; + cloudCursorData = data; + + if (cloudLoaderAsyncTask != null + && cloudLoaderAsyncTask.getStatus() == AsyncTask.Status.RUNNING) { + return; + } + cloudLoaderAsyncTask = new CloudLoaderAsyncTask(this, cloudHandler, cloudCursorData); + cloudLoaderAsyncTask.execute(); + } + + @Override + public void onLoaderReset(Loader loader) { + // For passing code check + } + + public void initCornersDragListener(boolean destroy, boolean shouldInvokeLeftAndRight) { + initBottomDragListener(destroy); + initLeftRightAndTopDragListeners(destroy, shouldInvokeLeftAndRight); + } + + private void initBottomDragListener(boolean destroy) { + View bottomPlaceholder = findViewById(R.id.placeholder_drag_bottom); + if (destroy) { + bottomPlaceholder.setOnDragListener(null); + bottomPlaceholder.setVisibility(View.GONE); + } else { + bottomPlaceholder.setVisibility(View.VISIBLE); + bottomPlaceholder.setOnDragListener( + new TabFragmentBottomDragListener( + () -> { + getCurrentMainFragment().smoothScrollListView(false); + return null; + }, + () -> { + getCurrentMainFragment().stopSmoothScrollListView(); + return null; + })); + } + } + + private void initLeftRightAndTopDragListeners(boolean destroy, boolean shouldInvokeLeftAndRight) { + TabFragment tabFragment = getTabFragment(); + tabFragment.initLeftRightAndTopDragListeners(destroy, shouldInvokeLeftAndRight); + } + + /** + * Invoke {@link FtpServerFragment#changeFTPServerPath(String)} to change FTP server share path. + * + * @see FtpServerFragment#changeFTPServerPath(String) + * @see FolderChooserDialog + * @see com.afollestad.materialdialogs.folderselector.FolderChooserDialog.FolderCallback + * @param dialog + * @param folder selected folder + */ + @Override + public void onFolderSelection(@NonNull FolderChooserDialog dialog, @NonNull File folder) { + switch (dialog.getTag()) { + case FtpServerFragment.TAG: + FtpServerFragment ftpServerFragment = (FtpServerFragment) getFragmentAtFrame(); + if (folder.exists() && folder.isDirectory()) { + if (FileUtils.isRunningAboveStorage(folder.getAbsolutePath())) { + if (!isRootExplorer()) { + AlertDialog.show( + this, + R.string.ftp_server_root_unavailable, + R.string.error, + android.R.string.ok, + null, + false); + } else { + MaterialDialog confirmDialog = + GeneralDialogCreation.showBasicDialog( + this, + R.string.ftp_server_root_filesystem_warning, + R.string.warning, + android.R.string.ok, + android.R.string.cancel); + confirmDialog + .getActionButton(DialogAction.POSITIVE) + .setOnClickListener( + v -> { + ftpServerFragment.changeFTPServerPath(folder.getPath()); + Toast.makeText(this, R.string.ftp_path_change_success, Toast.LENGTH_SHORT) + .show(); + confirmDialog.dismiss(); + }); + confirmDialog + .getActionButton(DialogAction.NEGATIVE) + .setOnClickListener(v -> confirmDialog.dismiss()); + confirmDialog.show(); + } + } else { + ftpServerFragment.changeFTPServerPath(folder.getPath()); + Toast.makeText(this, R.string.ftp_path_change_success, Toast.LENGTH_SHORT).show(); + } + } else { + // try to get parent + String pathParentFilePath = folder.getParent(); + if (pathParentFilePath == null) { + dialog.dismiss(); + return; + } + File pathParentFile = new File(pathParentFilePath); + if (pathParentFile.exists() && pathParentFile.isDirectory()) { + ftpServerFragment.changeFTPServerPath(pathParentFile.getPath()); + Toast.makeText(this, R.string.ftp_path_change_success, Toast.LENGTH_SHORT).show(); + } else { + // don't have access, print error + Toast.makeText(this, R.string.ftp_path_change_error_invalid, Toast.LENGTH_SHORT).show(); + } + } + dialog.dismiss(); + break; + default: + dialog.dismiss(); + break; + } + } + + /** + * Get whether list item is selected for action mode or not + * + * @return value + */ + public boolean getListItemSelected() { + return this.listItemSelected; + } + + public String getScrollToFileName() { + return this.scrollToFileName; + } + + /** + * Set list item selected value + * + * @param value value + */ + public void setListItemSelected(boolean value) { + this.listItemSelected = value; + } + + /** + * Do nothing other than dismissing the folder selection dialog. + * + * @see com.afollestad.materialdialogs.folderselector.FolderChooserDialog.FolderCallback + * @param dialog + */ + @Override + public void onFolderChooserDismissed(@NonNull FolderChooserDialog dialog) { + dialog.dismiss(); + } + + private void executeWithMainFragment(@NonNull Function lambda) { + executeWithMainFragment(lambda, false); + } + + @Nullable + private void executeWithMainFragment( + @NonNull Function lambda, boolean showToastIfMainFragmentIsNull) { + final MainFragment mainFragment = getCurrentMainFragment(); + if (mainFragment != null && mainFragment.getMainFragmentViewModel() != null) { + lambda.apply(mainFragment); + } else { + LOG.warn("MainFragment is null"); + if (showToastIfMainFragmentIsNull) { + AppConfig.toast(this, R.string.operation_unsuccesful); + } + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/activities/MainActivityViewModel.kt b/app/src/main/java/com/amaze/filemanager/ui/activities/MainActivityViewModel.kt new file mode 100644 index 0000000..e9c8c83 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/activities/MainActivityViewModel.kt @@ -0,0 +1,331 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.activities + +import android.app.Application +import android.content.Intent +import android.provider.MediaStore +import androidx.collection.LruCache +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import androidx.preference.PreferenceManager +import com.amaze.filemanager.R +import com.amaze.filemanager.adapters.data.LayoutElementParcelable +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.asynchronous.asynctasks.searchfilesystem.BasicSearch +import com.amaze.filemanager.asynchronous.asynctasks.searchfilesystem.DeepSearch +import com.amaze.filemanager.asynchronous.asynctasks.searchfilesystem.IndexedSearch +import com.amaze.filemanager.asynchronous.asynctasks.searchfilesystem.SearchParameters +import com.amaze.filemanager.asynchronous.asynctasks.searchfilesystem.SearchResult +import com.amaze.filemanager.asynchronous.asynctasks.searchfilesystem.searchParametersFromBoolean +import com.amaze.filemanager.fileoperations.filesystem.OpenMode +import com.amaze.filemanager.filesystem.HybridFile +import com.amaze.filemanager.filesystem.files.MediaConnectionUtils.scanFile +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_REGEX +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_REGEX_MATCHES +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_HIDDENFILES +import com.amaze.trashbin.MoveFilesCallback +import com.amaze.trashbin.TrashBinFile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory +import java.io.File + +class MainActivityViewModel(val applicationContext: Application) : + AndroidViewModel(applicationContext) { + var mediaCacheHash: List?> = List(5) { null } + var listCache: LruCache> = LruCache(50) + var trashBinFilesLiveData: MutableLiveData?>? = null + + /** The [LiveData] of the last triggered search */ + var lastSearchLiveData: LiveData> = MutableLiveData(listOf()) + private set + + /** The [Job] of the last triggered search */ + var lastSearchJob: Job? = null + private set + + companion object { + /** + * size of list to be cached for local files + */ + val CACHE_LOCAL_LIST_THRESHOLD: Int = 100 + private val LOG = LoggerFactory.getLogger(MainActivityViewModel::class.java) + } + + /** + * Put list for a given path in cache + */ + fun putInCache( + path: String, + listToCache: List, + ) { + viewModelScope.launch(Dispatchers.Default) { + listCache.put(path, listToCache) + } + } + + /** + * Removes cache for a given path + */ + fun evictPathFromListCache(path: String) { + viewModelScope.launch(Dispatchers.Default) { + listCache.remove(path) + } + } + + /** + * Get cache from a given path and updates files / folder count + */ + fun getFromListCache(path: String): List? { + return listCache.get(path) + } + + /** + * Get cache from a given path and updates files / folder count + */ + fun getFromMediaFilesCache(mediaType: Int): List? { + return mediaCacheHash[mediaType] + } + + /** + * Perform basic search: searches on the current directory + */ + fun basicSearch( + mainActivity: MainActivity, + query: String, + ): LiveData> { + val searchParameters = createSearchParameters(mainActivity) + + val path = mainActivity.currentMainFragment?.currentPath ?: "" + + val basicSearch = BasicSearch(query, path, searchParameters, this.applicationContext) + + lastSearchJob = + viewModelScope.launch(Dispatchers.IO) { + basicSearch.search() + } + + lastSearchLiveData = basicSearch.foundFilesLiveData + return basicSearch.foundFilesLiveData + } + + /** + * Perform indexed search: on MediaStore items from the current directory & it's children + */ + fun indexedSearch( + mainActivity: MainActivity, + query: String, + ): LiveData> { + val projection = + arrayOf( + MediaStore.Files.FileColumns.DATA, + MediaStore.Files.FileColumns.DISPLAY_NAME, + ) + val cursor = + mainActivity + .contentResolver + .query(MediaStore.Files.getContentUri("external"), projection, null, null, null) + ?: return MutableLiveData() + + val searchParameters = createSearchParameters(mainActivity) + + val path = mainActivity.currentMainFragment?.currentPath ?: "" + + val indexedSearch = IndexedSearch(query, path, searchParameters, cursor) + + lastSearchJob = + viewModelScope.launch(Dispatchers.IO) { + indexedSearch.search() + } + + lastSearchLiveData = indexedSearch.foundFilesLiveData + return indexedSearch.foundFilesLiveData + } + + /** + * Perform deep search: search recursively for files matching [query] in the current path + */ + fun deepSearch( + mainActivity: MainActivity, + query: String, + ): LiveData> { + val searchParameters = createSearchParameters(mainActivity) + + val path = mainActivity.currentMainFragment?.currentPath ?: "" + val openMode = + mainActivity.currentMainFragment?.mainFragmentViewModel?.openMode ?: OpenMode.FILE + + val context = this.applicationContext + + val deepSearch = + DeepSearch( + query, + path, + searchParameters, + context, + openMode, + ) + + lastSearchJob = + viewModelScope.launch(Dispatchers.IO) { + deepSearch.search() + } + + lastSearchLiveData = deepSearch.foundFilesLiveData + return deepSearch.foundFilesLiveData + } + + private fun createSearchParameters(mainActivity: MainActivity): SearchParameters { + val sharedPref = PreferenceManager.getDefaultSharedPreferences(mainActivity) + return searchParametersFromBoolean( + showHiddenFiles = sharedPref.getBoolean(PREFERENCE_SHOW_HIDDENFILES, false), + isRegexEnabled = sharedPref.getBoolean(PREFERENCE_REGEX, false), + isRegexMatchesEnabled = sharedPref.getBoolean(PREFERENCE_REGEX_MATCHES, false), + isRoot = mainActivity.isRootExplorer, + ) + } + + /** + * TODO: Documentation + */ + fun moveToBinLightWeight(mediaFileInfoList: List) { + viewModelScope.launch(Dispatchers.IO) { + val trashBinFilesList = + mediaFileInfoList.map { + it.generateBaseFile() + .toTrashBinFile(applicationContext) + } + AppConfig.getInstance().trashBinInstance.moveToBin( + trashBinFilesList, + true, + object : MoveFilesCallback { + override fun invoke( + originalFilePath: String, + trashBinDestination: String, + ): Boolean { + val source = File(originalFilePath) + val dest = File(trashBinDestination) + if (!source.renameTo(dest)) { + return false + } + val hybridFile = + HybridFile( + OpenMode.TRASH_BIN, + originalFilePath, + ) + scanFile(applicationContext, arrayOf(hybridFile)) + val intent = Intent(MainActivity.KEY_INTENT_LOAD_LIST) + hybridFile.getParent(applicationContext)?.let { + intent.putExtra(MainActivity.KEY_INTENT_LOAD_LIST_FILE, it) + applicationContext.sendBroadcast(intent) + } + return true + } + }, + ) + } + } + + /** + * Restore files from trash bin + */ + fun restoreFromBin(mediaFileInfoList: List) { + viewModelScope.launch(Dispatchers.IO) { + LOG.info("Restoring media files from bin $mediaFileInfoList") + val filesToRestore = mutableListOf() + for (element in mediaFileInfoList) { + val restoreFile = + element.generateBaseFile() + .toTrashBinRestoreFile(applicationContext) + if (restoreFile != null) { + filesToRestore.add(restoreFile) + } + } + AppConfig.getInstance().trashBinInstance.restore( + filesToRestore, + true, + object : MoveFilesCallback { + override fun invoke( + source: String, + dest: String, + ): Boolean { + val sourceFile = File(source) + val destFile = File(dest) + if (destFile.exists()) { + AppConfig.toast( + applicationContext, + applicationContext.getString(R.string.fileexist), + ) + return false + } + if (destFile.parentFile != null && !destFile.parentFile!!.exists()) { + destFile.parentFile?.mkdirs() + } + if (!sourceFile.renameTo(destFile)) { + return false + } + val hybridFile = + HybridFile( + OpenMode.TRASH_BIN, + source, + ) + scanFile(applicationContext, arrayOf(hybridFile)) + val intent = Intent(MainActivity.KEY_INTENT_LOAD_LIST) + hybridFile.getParent(applicationContext)?.let { + intent.putExtra(MainActivity.KEY_INTENT_LOAD_LIST_FILE, it) + applicationContext.sendBroadcast(intent) + } + return true + } + }, + ) + } + } + + /** + * TODO: Documentation + */ + fun progressTrashBinFilesLiveData(): MutableLiveData?> { + if (trashBinFilesLiveData == null) { + trashBinFilesLiveData = MutableLiveData() + trashBinFilesLiveData?.value = null + viewModelScope.launch(Dispatchers.IO) { + trashBinFilesLiveData?.postValue( + ArrayList( + AppConfig.getInstance().trashBinInstance.listFilesInBin() + .map { + HybridFile(OpenMode.FILE, it.path, it.fileName, it.isDirectory) + .generateLayoutElement( + applicationContext, + false, + ) + }, + ), + ) + } + } + return trashBinFilesLiveData!! + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/activities/PreferencesActivity.kt b/app/src/main/java/com/amaze/filemanager/ui/activities/PreferencesActivity.kt new file mode 100644 index 0000000..7566845 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/activities/PreferencesActivity.kt @@ -0,0 +1,186 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.activities + +import android.content.Intent +import android.graphics.Color +import android.os.Build +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import androidx.appcompat.app.ActionBar +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.FragmentTransaction +import androidx.fragment.app.commit +import com.afollestad.materialdialogs.folderselector.FolderChooserDialog +import com.amaze.filemanager.R +import com.amaze.filemanager.ui.activities.superclasses.ThemedActivity +import com.amaze.filemanager.ui.colors.ColorPreferenceHelper +import com.amaze.filemanager.ui.fragments.preferencefragments.BasePrefsFragment +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants +import com.amaze.filemanager.ui.fragments.preferencefragments.PrefsFragment +import com.amaze.filemanager.ui.theme.AppTheme +import com.amaze.filemanager.utils.PreferenceUtils +import com.amaze.filemanager.utils.Utils +import com.readystatesoftware.systembartint.SystemBarTintManager +import java.io.File + +class PreferencesActivity : ThemedActivity(), FolderChooserDialog.FolderCallback { + private companion object { + const val SAVED_INSTANCE_STATE_KEY = "savedInstanceState" + } + + lateinit var layout: View + + override fun onCreate(savedInstanceState: Bundle?) { + var savedInstanceState = savedInstanceState + if (savedInstanceState == null && intent.hasExtra(SAVED_INSTANCE_STATE_KEY)) { + savedInstanceState = intent.getBundleExtra(SAVED_INSTANCE_STATE_KEY) + } + + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_preferences) + + layout = findViewById(R.id.activity_preferences) + + val toolbar = findViewById(R.id.toolbar) + setSupportActionBar(toolbar) + supportActionBar?.displayOptions = + ActionBar.DISPLAY_HOME_AS_UP or ActionBar.DISPLAY_SHOW_TITLE + initStatusBarResources(layout) + + if (savedInstanceState == null) { + val fragment = PrefsFragment() + supportFragmentManager + .beginTransaction() + .replace(R.id.preferences_container, fragment) + .commit() + } + } + + override fun onBackPressed() { + if (supportFragmentManager.backStackEntryCount > 0) { + supportFragmentManager.popBackStack() + return + } + + val intent = Intent(this, MainActivity::class.java) + intent.action = Intent.ACTION_MAIN + intent.action = Intent.CATEGORY_LAUNCHER + finish() + startActivity(intent) + overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return if (item.itemId == android.R.id.home) { + onBackPressed() + true + } else { + false + } + } + + override fun recreate() { + val bundle = Bundle() + onSaveInstanceState(bundle) + val intent = Intent(this, javaClass) + intent.putExtra(SAVED_INSTANCE_STATE_KEY, bundle) + + finish() + startActivity(intent) + overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out) + } + + /** + * Push a new fragment into the stack + */ + fun pushFragment(fragment: BasePrefsFragment) { + supportFragmentManager.commit { + setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + + replace(R.id.preferences_container, fragment) + supportActionBar?.title = getString(fragment.title) + addToBackStack(null) + } + } + + /** + * Rebuild the nav bar + * + * Used to update color + */ + fun invalidateNavBar() { + val primaryColor = + ColorPreferenceHelper + .getPrimary(currentColorPreference, MainActivity.currentTab) + if (Build.VERSION.SDK_INT == 20 || Build.VERSION.SDK_INT == 19) { + val tintManager = SystemBarTintManager(this) + tintManager.isStatusBarTintEnabled = true + tintManager.setStatusBarTintColor(primaryColor) + val layoutParams = + findViewById(R.id.activity_preferences).layoutParams + as ViewGroup.MarginLayoutParams + val config = tintManager.config + layoutParams.setMargins(0, config.statusBarHeight, 0, 0) + } else if (Build.VERSION.SDK_INT >= 21) { + val colouredNavigation = getBoolean(PreferencesConstants.PREFERENCE_COLORED_NAVIGATION) + window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) + window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) + val tabStatusColor = PreferenceUtils.getStatusColor(primaryColor) + window.statusBarColor = tabStatusColor + when { + colouredNavigation -> { + window.navigationBarColor = tabStatusColor + } + appTheme == AppTheme.BLACK -> { + window.navigationBarColor = Color.BLACK + } + appTheme == AppTheme.DARK -> { + window.navigationBarColor = Utils.getColor(this, R.color.holo_dark_background) + } + appTheme == AppTheme.LIGHT -> { + window.navigationBarColor = Color.WHITE + } + } + } + if (appTheme == AppTheme.BLACK) { + window.decorView.setBackgroundColor(Utils.getColor(this, android.R.color.black)) + } + } + + override fun onFolderSelection( + dialog: FolderChooserDialog, + folder: File, + ) { + supportFragmentManager.fragments.lastOrNull { it is BasePrefsFragment }?.let { + (it as BasePrefsFragment).onFolderSelection(dialog, folder) + } + } + + override fun onFolderChooserDismissed(dialog: FolderChooserDialog) { + supportFragmentManager.fragments.lastOrNull { it is BasePrefsFragment }?.let { + (it as BasePrefsFragment).onFolderChooserDismissed(dialog) + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/activities/UtilitiesAliasActivity.kt b/app/src/main/java/com/amaze/filemanager/ui/activities/UtilitiesAliasActivity.kt new file mode 100644 index 0000000..173c457 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/activities/UtilitiesAliasActivity.kt @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.activities + +import android.content.ActivityNotFoundException +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.amaze.filemanager.BuildConfig +import com.amaze.filemanager.R +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.databinding.UtilitiesAliasLayoutBinding +import com.amaze.filemanager.ui.updateAUAlias +import com.amaze.filemanager.utils.PackageUtils +import com.amaze.filemanager.utils.Utils +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +class UtilitiesAliasActivity : AppCompatActivity() { + private val log: Logger = LoggerFactory.getLogger(UtilitiesAliasActivity::class.java) + + private val _binding by lazy(LazyThreadSafetyMode.NONE) { + UtilitiesAliasLayoutBinding.inflate(layoutInflater) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(_binding.root) + _binding.downloadButton.setOnClickListener { + Utils.openURL( + if (BuildConfig.IS_VERSION_FDROID) { + AboutActivity.URL_AMAZE_UTILS_FDROID + } else { + AboutActivity.URL_AMAZE_UTILS + }, + this, + ) + } + _binding.cancelButton.setOnClickListener { + finish() + } + val isAUInstalled = + PackageUtils.appInstalledOrNot( + AboutActivity.PACKAGE_AMAZE_UTILS, + packageManager, + ) + if (isAUInstalled) { + AppConfig.toast(this, R.string.amaze_utils_installed_alias) + val intent = + packageManager.getLaunchIntentForPackage( + AboutActivity.PACKAGE_AMAZE_UTILS, + ) + try { + if (intent != null) { + this.updateAUAlias(false) + startActivity(intent) + finish() + } + } catch (e: ActivityNotFoundException) { + log.warn("Amaze utils not installed", e) + } + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/activities/superclasses/BasicActivity.java b/app/src/main/java/com/amaze/filemanager/ui/activities/superclasses/BasicActivity.java new file mode 100644 index 0000000..a5ebc37 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/activities/superclasses/BasicActivity.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.activities.superclasses; + +import com.amaze.filemanager.application.AppConfig; +import com.amaze.filemanager.ui.colors.ColorPreferenceHelper; +import com.amaze.filemanager.ui.provider.UtilitiesProvider; +import com.amaze.filemanager.ui.theme.AppTheme; + +import androidx.appcompat.app.AppCompatActivity; + +/** Created by rpiotaix on 17/10/16. */ +public class BasicActivity extends AppCompatActivity { + + protected AppConfig getAppConfig() { + return (AppConfig) getApplication(); + } + + public ColorPreferenceHelper getColorPreference() { + return getAppConfig().getUtilsProvider().getColorPreference(); + } + + public AppTheme getAppTheme() { + return getAppConfig().getUtilsProvider().getAppTheme(); + } + + public UtilitiesProvider getUtilsProvider() { + return getAppConfig().getUtilsProvider(); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/activities/superclasses/PermissionsActivity.java b/app/src/main/java/com/amaze/filemanager/ui/activities/superclasses/PermissionsActivity.java new file mode 100644 index 0000000..821f433 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/activities/superclasses/PermissionsActivity.java @@ -0,0 +1,297 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.activities.superclasses; + +import static android.os.Build.VERSION.SDK_INT; +import static android.os.Build.VERSION_CODES.TIRAMISU; + +import com.afollestad.materialdialogs.DialogAction; +import com.afollestad.materialdialogs.MaterialDialog; +import com.amaze.filemanager.R; +import com.amaze.filemanager.application.AppConfig; +import com.amaze.filemanager.ui.dialogs.GeneralDialogCreation; +import com.amaze.filemanager.utils.Utils; +import com.google.android.material.snackbar.BaseTransientBottomBar; +import com.google.android.material.snackbar.Snackbar; + +import android.Manifest; +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.provider.Settings; +import android.util.Log; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.core.app.ActivityCompat; + +public class PermissionsActivity extends ThemedActivity + implements ActivityCompat.OnRequestPermissionsResultCallback { + + private static final String TAG = PermissionsActivity.class.getSimpleName(); + + public static final int PERMISSION_LENGTH = 4; + public static final int STORAGE_PERMISSION = 0, + INSTALL_APK_PERMISSION = 1, + ALL_FILES_PERMISSION = 2, + NOTIFICATION_PERMISSION = 3; + + private final OnPermissionGranted[] permissionCallbacks = + new OnPermissionGranted[PERMISSION_LENGTH]; + + @Override + public void onRequestPermissionsResult( + int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode == STORAGE_PERMISSION) { + if (isGranted(grantResults)) { + Utils.enableScreenRotation(this); + permissionCallbacks[STORAGE_PERMISSION].onPermissionGranted(); + permissionCallbacks[STORAGE_PERMISSION] = null; + } else { + Toast.makeText(this, R.string.grantfailed, Toast.LENGTH_SHORT).show(); + requestStoragePermission(permissionCallbacks[STORAGE_PERMISSION], false); + } + } else if (requestCode == NOTIFICATION_PERMISSION && SDK_INT >= TIRAMISU) { + if (isGranted(grantResults)) { + Utils.enableScreenRotation(this); + } else { + Toast.makeText(this, R.string.grantfailed, Toast.LENGTH_SHORT).show(); + requestNotificationPermission(false); + } + } else if (requestCode == INSTALL_APK_PERMISSION) { + if (isGranted(grantResults)) { + permissionCallbacks[INSTALL_APK_PERMISSION].onPermissionGranted(); + permissionCallbacks[INSTALL_APK_PERMISSION] = null; + } + } + } + + public boolean checkStoragePermission() { + // Verify that all required contact permissions have been granted. + if (SDK_INT >= Build.VERSION_CODES.R) { + return (ActivityCompat.checkSelfPermission( + this, Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) + == PackageManager.PERMISSION_GRANTED) + || (ActivityCompat.checkSelfPermission( + this, Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION) + == PackageManager.PERMISSION_GRANTED) + || Environment.isExternalStorageManager(); + } else { + return ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) + == PackageManager.PERMISSION_GRANTED; + } + } + + @RequiresApi(TIRAMISU) + public boolean checkNotificationPermission() { + return ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + == PackageManager.PERMISSION_GRANTED; + } + + @RequiresApi(TIRAMISU) + public void requestNotificationPermission(boolean isInitialStart) { + Utils.disableScreenRotation(this); + final MaterialDialog materialDialog = + GeneralDialogCreation.showBasicDialog( + this, + R.string.grant_notification_permission, + R.string.grantper, + R.string.grant, + R.string.cancel); + materialDialog.getActionButton(DialogAction.NEGATIVE).setOnClickListener(v -> finish()); + materialDialog.setCancelable(false); + + requestPermission( + Manifest.permission.POST_NOTIFICATIONS, + NOTIFICATION_PERMISSION, + materialDialog, + () -> { + // do nothing + }, + isInitialStart); + } + + public void requestStoragePermission( + @NonNull final OnPermissionGranted onPermissionGranted, boolean isInitialStart) { + Utils.disableScreenRotation(this); + final MaterialDialog materialDialog = + GeneralDialogCreation.showBasicDialog( + this, + R.string.grant_storage_permission, + R.string.grantper, + R.string.grant, + R.string.cancel); + materialDialog.getActionButton(DialogAction.NEGATIVE).setOnClickListener(v -> finish()); + materialDialog.setCancelable(false); + + requestPermission( + Manifest.permission.WRITE_EXTERNAL_STORAGE, + STORAGE_PERMISSION, + materialDialog, + onPermissionGranted, + isInitialStart); + } + + @RequiresApi(api = Build.VERSION_CODES.M) + public void requestInstallApkPermission( + @NonNull final OnPermissionGranted onPermissionGranted, boolean isInitialStart) { + final MaterialDialog materialDialog = + GeneralDialogCreation.showBasicDialog( + this, + R.string.grant_apkinstall_permission, + R.string.grantper, + R.string.grant, + R.string.cancel); + materialDialog + .getActionButton(DialogAction.NEGATIVE) + .setOnClickListener(v -> materialDialog.dismiss()); + materialDialog.setCancelable(false); + + requestPermission( + Manifest.permission.REQUEST_INSTALL_PACKAGES, + INSTALL_APK_PERMISSION, + materialDialog, + onPermissionGranted, + isInitialStart); + } + + /** + * Requests permission, overrides {@param rationale}'s POSITIVE button dialog action. + * + * @param permission The permission to ask for + * @param code {@link #STORAGE_PERMISSION} or {@link #INSTALL_APK_PERMISSION} + * @param rationale MaterialLayout to provide an additional rationale to the user if the + * permission was not granted and the user would benefit from additional context for the use + * of the permission. For example, if the request has been denied previously. + * @param isInitialStart is the permission being requested for the first time in the application + * lifecycle + */ + private void requestPermission( + final String permission, + final int code, + @NonNull final MaterialDialog rationale, + @NonNull final OnPermissionGranted onPermissionGranted, + boolean isInitialStart) { + permissionCallbacks[code] = onPermissionGranted; + + if (ActivityCompat.shouldShowRequestPermissionRationale(this, permission)) { + rationale + .getActionButton(DialogAction.POSITIVE) + .setOnClickListener( + v -> { + ActivityCompat.requestPermissions( + PermissionsActivity.this, new String[] {permission}, code); + rationale.dismiss(); + }); + rationale.show(); + } else if (isInitialStart) { + ActivityCompat.requestPermissions(this, new String[] {permission}, code); + } else { + if (SDK_INT >= Build.VERSION_CODES.R) { + Snackbar.make( + findViewById(R.id.content_frame), + R.string.grantfailed, + BaseTransientBottomBar.LENGTH_INDEFINITE) + .setAction(R.string.grant, v -> requestAllFilesAccessPermission(onPermissionGranted)) + .show(); + } else { + Snackbar.make( + findViewById(R.id.content_frame), + R.string.grantfailed, + BaseTransientBottomBar.LENGTH_INDEFINITE) + .setAction( + R.string.grant, + v -> + startActivity( + new Intent( + android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse(String.format("package:%s", getPackageName()))))) + .show(); + } + } + } + + /** + * Request all files access on android 11+ + * + * @param onPermissionGranted permission granted callback + */ + public void requestAllFilesAccess(@NonNull final OnPermissionGranted onPermissionGranted) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !Environment.isExternalStorageManager()) { + final MaterialDialog materialDialog = + GeneralDialogCreation.showBasicDialog( + this, + R.string.grant_all_files_permission, + R.string.grantper, + R.string.grant, + R.string.cancel); + materialDialog.getActionButton(DialogAction.NEGATIVE).setOnClickListener(v -> finish()); + materialDialog + .getActionButton(DialogAction.POSITIVE) + .setOnClickListener( + v -> { + requestAllFilesAccessPermission(onPermissionGranted); + materialDialog.dismiss(); + }); + materialDialog.setCancelable(false); + materialDialog.show(); + } + } + + @RequiresApi(api = Build.VERSION_CODES.R) + private void requestAllFilesAccessPermission( + @NonNull final OnPermissionGranted onPermissionGranted) { + Utils.disableScreenRotation(this); + permissionCallbacks[ALL_FILES_PERMISSION] = onPermissionGranted; + try { + Intent intent = + new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) + .setData(Uri.parse("package:" + getPackageName())); + startActivity(intent); + } catch (ActivityNotFoundException anf) { + // fallback + try { + Intent intent = + new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION) + .setData(Uri.parse("package:$packageName")); + startActivity(intent); + } catch (Exception e) { + AppConfig.toast(this, getString(R.string.grantfailed)); + } + } catch (Exception e) { + Log.e(TAG, "Failed to initial activity to grant all files access", e); + AppConfig.toast(this, getString(R.string.grantfailed)); + } + } + + private boolean isGranted(int[] grantResults) { + return grantResults.length == 1 && grantResults[0] == PackageManager.PERMISSION_GRANTED; + } + + public interface OnPermissionGranted { + void onPermissionGranted(); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/activities/superclasses/PreferenceActivity.java b/app/src/main/java/com/amaze/filemanager/ui/activities/superclasses/PreferenceActivity.java new file mode 100644 index 0000000..5262641 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/activities/superclasses/PreferenceActivity.java @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.activities.superclasses; + +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_BOOKMARKS_ADDED; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_CHANGEPATHS; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_COLORED_NAVIGATION; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_COLORIZE_ICONS; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_DISABLE_PLAYER_INTENT_FILTERS; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_ENABLE_MARQUEE_FILENAME; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_NEED_TO_SET_HOME; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_ROOTMODE; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_ROOT_LEGACY_LISTING; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_DIVIDERS; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_FILE_SIZE; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_GOBACK_BUTTON; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_HEADERS; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_HIDDENFILES; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_LAST_MODIFIED; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_PERMISSIONS; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_SIDEBAR_FOLDERS; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_SIDEBAR_QUICKACCESSES; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_THUMB; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_TEXTEDITOR_NEWSTACK; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_USE_CIRCULAR_IMAGES; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_VIEW; + +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants; +import com.amaze.filemanager.utils.PreferenceUtils; + +import android.content.SharedPreferences; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.preference.PreferenceManager; + +/** + * @author Emmanuel on 24/8/2017, at 23:13. + */ +public class PreferenceActivity extends BasicActivity { + + private SharedPreferences sharedPrefs; + + @Override + public void onCreate(final Bundle savedInstanceState) { + // Fragments are created before the super call returns, so we must + // initialize sharedPrefs before the super call otherwise it cannot be used by fragments + sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this); + + super.onCreate(savedInstanceState); + } + + @NonNull + public SharedPreferences getPrefs() { + return sharedPrefs; + } + + public boolean isRootExplorer() { + return getBoolean(PREFERENCE_ROOTMODE); + } + + public int getCurrentTab() { + return getPrefs() + .getInt(PreferencesConstants.PREFERENCE_CURRENT_TAB, PreferenceUtils.DEFAULT_CURRENT_TAB); + } + + public boolean getBoolean(String key) { + boolean defaultValue; + + switch (key) { + case PREFERENCE_SHOW_PERMISSIONS: + case PREFERENCE_SHOW_GOBACK_BUTTON: + case PREFERENCE_SHOW_HIDDENFILES: + case PREFERENCE_BOOKMARKS_ADDED: + case PREFERENCE_ROOTMODE: + case PREFERENCE_COLORED_NAVIGATION: + case PREFERENCE_TEXTEDITOR_NEWSTACK: + case PREFERENCE_CHANGEPATHS: + case PREFERENCE_ROOT_LEGACY_LISTING: + case PREFERENCE_DISABLE_PLAYER_INTENT_FILTERS: + defaultValue = false; + break; + case PREFERENCE_SHOW_FILE_SIZE: + case PREFERENCE_SHOW_DIVIDERS: + case PREFERENCE_SHOW_HEADERS: + case PREFERENCE_USE_CIRCULAR_IMAGES: + case PREFERENCE_COLORIZE_ICONS: + case PREFERENCE_SHOW_THUMB: + case PREFERENCE_SHOW_SIDEBAR_QUICKACCESSES: + case PREFERENCE_NEED_TO_SET_HOME: + case PREFERENCE_SHOW_SIDEBAR_FOLDERS: + case PREFERENCE_VIEW: + case PREFERENCE_SHOW_LAST_MODIFIED: + case PREFERENCE_ENABLE_MARQUEE_FILENAME: + defaultValue = true; + break; + default: + throw new IllegalArgumentException("Please map \'" + key + "\'"); + } + + return sharedPrefs.getBoolean(key, defaultValue); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/activities/superclasses/ThemedActivity.java b/app/src/main/java/com/amaze/filemanager/ui/activities/superclasses/ThemedActivity.java new file mode 100644 index 0000000..cbee76f --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/activities/superclasses/ThemedActivity.java @@ -0,0 +1,370 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.activities.superclasses; + +import static android.os.Build.VERSION.SDK_INT; +import static android.os.Build.VERSION_CODES.LOLLIPOP; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_COLORED_NAVIGATION; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.ui.colors.ColorPreferenceHelper; +import com.amaze.filemanager.ui.colors.UserColorPreferences; +import com.amaze.filemanager.ui.dialogs.ColorPickerDialog; +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants; +import com.amaze.filemanager.ui.theme.AppTheme; +import com.amaze.filemanager.ui.theme.AppThemePreference; +import com.amaze.filemanager.utils.PreferenceUtils; +import com.amaze.filemanager.utils.Utils; +import com.readystatesoftware.systembartint.SystemBarTintManager; + +import android.app.ActivityManager; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.graphics.drawable.BitmapDrawable; +import android.os.Build; +import android.os.Bundle; +import android.os.PowerManager; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; +import android.widget.FrameLayout; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; +import androidx.core.content.ContextCompat; +import androidx.preference.PreferenceManager; + +/** Created by arpitkh996 on 03-03-2016. */ +public class ThemedActivity extends PreferenceActivity { + private int uiModeNight = -1; + + /** + * BroadcastReceiver responsible for updating the theme if battery saver mode is turned on or off + */ + private final BroadcastReceiver powerModeReceiver = + new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent i) { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); + boolean followBatterySaver = + preferences.getBoolean(PreferencesConstants.FRAGMENT_FOLLOW_BATTERY_SAVER, false); + + AppThemePreference theme = + AppThemePreference.getTheme( + Integer.parseInt( + preferences.getString(PreferencesConstants.FRAGMENT_THEME, "4"))); + + if (followBatterySaver && theme.getCanBeLight()) { + recreate(); + } + } + }; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + registerPowerModeReceiver(); + + // setting window background color instead of each item, in order to reduce pixel overdraw + if (getAppTheme().equals(AppTheme.LIGHT)) { + getWindow().setBackgroundDrawableResource(android.R.color.white); + } else if (getAppTheme().equals(AppTheme.BLACK)) { + getWindow().setBackgroundDrawableResource(android.R.color.black); + } else { + getWindow().setBackgroundDrawableResource(R.color.holo_dark_background); + } + + // checking if theme should be set light/dark or automatic + int colorPickerPref = + getPrefs().getInt(PreferencesConstants.PREFERENCE_COLOR_CONFIG, ColorPickerDialog.NO_DATA); + if (colorPickerPref == ColorPickerDialog.RANDOM_INDEX) { + getColorPreference().saveColorPreferences(getPrefs(), ColorPreferenceHelper.randomize(this)); + } + + if (SDK_INT >= 21) { + ActivityManager.TaskDescription taskDescription = + new ActivityManager.TaskDescription( + getString(R.string.appbar_name), + ((BitmapDrawable) ContextCompat.getDrawable(this, R.mipmap.ic_launcher)).getBitmap(), + getPrimary()); + setTaskDescription(taskDescription); + } + + setTheme(); + } + + /** + * Set status bar and navigation bar colors based on sdk + * + * @param parentView parent view required to set margin on kitkat top + */ + public void initStatusBarResources(View parentView) { + + if (getToolbar() != null) { + getToolbar().setBackgroundColor(getPrimary()); + } + + Window window = getWindow(); + if (SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + if (findViewById(R.id.tab_frame) != null || findViewById(R.id.drawer_layout) == null) { + window.setStatusBarColor(PreferenceUtils.getStatusColor(getPrimary())); + window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); + } else { + window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); + } + if (getBoolean(PREFERENCE_COLORED_NAVIGATION)) { + window.setNavigationBarColor(PreferenceUtils.getStatusColor(getPrimary())); + } else { + if (getAppTheme().equals(AppTheme.LIGHT)) { + window.setNavigationBarColor(Utils.getColor(this, android.R.color.white)); + } else if (getAppTheme().equals(AppTheme.BLACK)) { + window.setNavigationBarColor(Utils.getColor(this, android.R.color.black)); + } else { + window.setNavigationBarColor(Utils.getColor(this, R.color.holo_dark_background)); + } + } + } else if (SDK_INT == Build.VERSION_CODES.KITKAT_WATCH + || SDK_INT == Build.VERSION_CODES.KITKAT) { + setKitkatStatusBarMargin(parentView); + setKitkatStatusBarTint(); + } + } + + @Override + public void onConfigurationChanged(@NonNull Configuration newConfig) { + super.onConfigurationChanged(newConfig); + + final int newUiModeNight = newConfig.uiMode & Configuration.UI_MODE_NIGHT_MASK; + + // System theme change + if (uiModeNight != newUiModeNight) { + uiModeNight = newUiModeNight; + + if (getPrefs().getString(PreferencesConstants.FRAGMENT_THEME, "4").equals("4")) { + getUtilsProvider().getThemeManager().setAppThemePreference(AppThemePreference.getTheme(4)); + // Recreate activity, handling saved state + // + // Not smooth, but will only be called if the user changes the system theme, not + // the app theme. + recreate(); + } + } + } + + public UserColorPreferences getCurrentColorPreference() { + return getColorPreference().getCurrentUserColorPreferences(this, getPrefs()); + } + + public @ColorInt int getAccent() { + return getColorPreference().getCurrentUserColorPreferences(this, getPrefs()).getAccent(); + } + + private void setKitkatStatusBarMargin(View parentView) { + SystemBarTintManager tintManager = new SystemBarTintManager(this); + tintManager.setStatusBarTintEnabled(true); + // tintManager.setStatusBarTintColor(Color.parseColor((currentTab==1 ? skinTwo : skin))); + FrameLayout.MarginLayoutParams p = (ViewGroup.MarginLayoutParams) parentView.getLayoutParams(); + SystemBarTintManager.SystemBarConfig config = tintManager.getConfig(); + p.setMargins(0, config.getStatusBarHeight(), 0, 0); + } + + private void setKitkatStatusBarTint() { + SystemBarTintManager tintManager = new SystemBarTintManager(this); + tintManager.setStatusBarTintEnabled(true); + tintManager.setStatusBarTintColor(getPrimary()); + } + + public @ColorInt int getPrimary() { + return ColorPreferenceHelper.getPrimary(getCurrentColorPreference(), getCurrentTab()); + } + + @Nullable + private Toolbar getToolbar() { + return findViewById(R.id.toolbar); + } + + void setTheme() { + AppTheme theme = getAppTheme(); + if (Build.VERSION.SDK_INT >= 21) { + + String stringRepresentation = String.format("#%06X", (0xFFFFFF & getAccent())); + + switch (stringRepresentation.toUpperCase()) { + case "#F44336": + if (theme.equals(AppTheme.LIGHT)) setTheme(R.style.pref_accent_light_red); + else if (theme.equals(AppTheme.BLACK)) setTheme(R.style.pref_accent_black_red); + else setTheme(R.style.pref_accent_dark_red); + break; + + case "#E91E63": + if (theme.equals(AppTheme.LIGHT)) setTheme(R.style.pref_accent_light_pink); + else if (theme.equals(AppTheme.BLACK)) setTheme(R.style.pref_accent_black_pink); + else setTheme(R.style.pref_accent_dark_pink); + break; + + case "#9C27B0": + if (theme.equals(AppTheme.LIGHT)) setTheme(R.style.pref_accent_light_purple); + else if (theme.equals(AppTheme.BLACK)) setTheme(R.style.pref_accent_black_purple); + else setTheme(R.style.pref_accent_dark_purple); + break; + + case "#673AB7": + if (theme.equals(AppTheme.LIGHT)) setTheme(R.style.pref_accent_light_deep_purple); + else if (theme.equals(AppTheme.BLACK)) setTheme(R.style.pref_accent_black_deep_purple); + else setTheme(R.style.pref_accent_dark_deep_purple); + break; + + case "#3F51B5": + if (theme.equals(AppTheme.LIGHT)) setTheme(R.style.pref_accent_light_indigo); + else if (theme.equals(AppTheme.BLACK)) setTheme(R.style.pref_accent_black_indigo); + else setTheme(R.style.pref_accent_dark_indigo); + break; + + case "#2196F3": + if (theme.equals(AppTheme.LIGHT)) setTheme(R.style.pref_accent_light_blue); + else if (theme.equals(AppTheme.BLACK)) setTheme(R.style.pref_accent_black_blue); + else setTheme(R.style.pref_accent_dark_blue); + break; + + case "#03A9F4": + if (theme.equals(AppTheme.LIGHT)) setTheme(R.style.pref_accent_light_light_blue); + else if (theme.equals(AppTheme.BLACK)) setTheme(R.style.pref_accent_black_light_blue); + else setTheme(R.style.pref_accent_dark_light_blue); + break; + + case "#00BCD4": + if (theme.equals(AppTheme.LIGHT)) setTheme(R.style.pref_accent_light_cyan); + else if (theme.equals(AppTheme.BLACK)) setTheme(R.style.pref_accent_black_cyan); + else setTheme(R.style.pref_accent_dark_cyan); + break; + + case "#009688": + if (theme.equals(AppTheme.LIGHT)) setTheme(R.style.pref_accent_light_teal); + else if (theme.equals(AppTheme.BLACK)) setTheme(R.style.pref_accent_black_teal); + else setTheme(R.style.pref_accent_dark_teal); + break; + + case "#4CAF50": + if (theme.equals(AppTheme.LIGHT)) setTheme(R.style.pref_accent_light_green); + else if (theme.equals(AppTheme.BLACK)) setTheme(R.style.pref_accent_black_green); + else setTheme(R.style.pref_accent_dark_green); + break; + + case "#8BC34A": + if (theme.equals(AppTheme.LIGHT)) setTheme(R.style.pref_accent_light_light_green); + else if (theme.equals(AppTheme.BLACK)) setTheme(R.style.pref_accent_black_light_green); + else setTheme(R.style.pref_accent_dark_light_green); + break; + + case "#FFC107": + if (theme.equals(AppTheme.LIGHT)) setTheme(R.style.pref_accent_light_amber); + else if (theme.equals(AppTheme.BLACK)) setTheme(R.style.pref_accent_black_amber); + else setTheme(R.style.pref_accent_dark_amber); + break; + + case "#FF9800": + if (theme.equals(AppTheme.LIGHT)) setTheme(R.style.pref_accent_light_orange); + else if (theme.equals(AppTheme.BLACK)) setTheme(R.style.pref_accent_black_orange); + else setTheme(R.style.pref_accent_dark_orange); + break; + + case "#FF5722": + if (theme.equals(AppTheme.LIGHT)) setTheme(R.style.pref_accent_light_deep_orange); + else if (theme.equals(AppTheme.BLACK)) setTheme(R.style.pref_accent_black_deep_orange); + else setTheme(R.style.pref_accent_dark_deep_orange); + break; + + case "#795548": + if (theme.equals(AppTheme.LIGHT)) setTheme(R.style.pref_accent_light_brown); + else if (theme.equals(AppTheme.BLACK)) setTheme(R.style.pref_accent_black_brown); + else setTheme(R.style.pref_accent_dark_brown); + break; + + case "#212121": + if (theme.equals(AppTheme.LIGHT)) setTheme(R.style.pref_accent_light_black); + else if (theme.equals(AppTheme.BLACK)) setTheme(R.style.pref_accent_black_black); + else setTheme(R.style.pref_accent_dark_black); + break; + + case "#607D8B": + if (theme.equals(AppTheme.LIGHT)) setTheme(R.style.pref_accent_light_blue_grey); + else if (theme.equals(AppTheme.BLACK)) setTheme(R.style.pref_accent_black_blue_grey); + else setTheme(R.style.pref_accent_dark_blue_grey); + break; + + case "#004D40": + if (theme.equals(AppTheme.LIGHT)) setTheme(R.style.pref_accent_light_super_su); + else if (theme.equals(AppTheme.BLACK)) setTheme(R.style.pref_accent_black_super_su); + else setTheme(R.style.pref_accent_dark_super_su); + break; + } + } else { + if (theme.equals(AppTheme.LIGHT)) { + setTheme(R.style.appCompatLight); + } else if (theme.equals(AppTheme.BLACK)) { + setTheme(R.style.appCompatBlack); + } else { + setTheme(R.style.appCompatDark); + } + } + } + + @Override + protected void onResume() { + super.onResume(); + + uiModeNight = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; + setTheme(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + + unregisterPowerModeReceiver(); + } + + /** + * Registers the BroadcastReceiver \`powerModeReceiver\` to listen to broadcasts that the battery + * save mode has been changed + */ + private void registerPowerModeReceiver() { + if (SDK_INT >= LOLLIPOP) { + registerReceiver( + powerModeReceiver, new IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED)); + } + } + + /** Unregisters the BroadcastReceiver \`powerModeReceiver\` */ + private void unregisterPowerModeReceiver() { + if (SDK_INT >= LOLLIPOP) { + unregisterReceiver(powerModeReceiver); + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/ReturnedValueOnReadFile.kt b/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/ReturnedValueOnReadFile.kt new file mode 100644 index 0000000..99ddc35 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/ReturnedValueOnReadFile.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.activities.texteditor + +import java.io.File + +data class ReturnedValueOnReadFile( + val fileContents: String, + val cachedFile: File?, + val fileIsTooLong: Boolean, +) diff --git a/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/SearchResultIndex.kt b/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/SearchResultIndex.kt new file mode 100644 index 0000000..13da44d --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/SearchResultIndex.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.activities.texteditor + +class SearchResultIndex(val startCharNumber: Int, val endCharNumber: Int, val lineNumber: Int) diff --git a/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/TextEditorActivity.java b/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/TextEditorActivity.java new file mode 100644 index 0000000..97b5186 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/TextEditorActivity.java @@ -0,0 +1,627 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.activities.texteditor; + +import static com.amaze.filemanager.filesystem.EditableFileAbstraction.Scheme.CONTENT; +import static com.amaze.filemanager.filesystem.EditableFileAbstraction.Scheme.FILE; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_TEXTEDITOR_NEWSTACK; + +import java.io.File; +import java.lang.ref.WeakReference; +import java.util.Collections; +import java.util.List; +import java.util.Timer; +import java.util.TimerTask; + +import com.afollestad.materialdialogs.MaterialDialog; +import com.amaze.filemanager.R; +import com.amaze.filemanager.asynchronous.asynctasks.SearchTextTask; +import com.amaze.filemanager.asynchronous.asynctasks.TaskKt; +import com.amaze.filemanager.asynchronous.asynctasks.texteditor.read.ReadTextFileTask; +import com.amaze.filemanager.asynchronous.asynctasks.texteditor.write.WriteTextFileTask; +import com.amaze.filemanager.fileoperations.filesystem.OpenMode; +import com.amaze.filemanager.filesystem.EditableFileAbstraction; +import com.amaze.filemanager.filesystem.HybridFileParcelable; +import com.amaze.filemanager.filesystem.files.FileUtils; +import com.amaze.filemanager.ui.activities.superclasses.ThemedActivity; +import com.amaze.filemanager.ui.dialogs.GeneralDialogCreation; +import com.amaze.filemanager.ui.theme.AppTheme; +import com.amaze.filemanager.utils.OnAsyncTaskFinished; +import com.amaze.filemanager.utils.OnProgressUpdate; +import com.amaze.filemanager.utils.Utils; +import com.google.android.material.snackbar.Snackbar; + +import android.content.Context; +import android.graphics.Typeface; +import android.net.Uri; +import android.os.Bundle; +import android.text.Editable; +import android.text.Spanned; +import android.text.TextWatcher; +import android.text.style.BackgroundColorSpan; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputMethodManager; +import android.widget.ScrollView; +import android.widget.Toast; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.widget.AppCompatEditText; +import androidx.appcompat.widget.AppCompatImageButton; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.lifecycle.ViewModelProvider; + +public class TextEditorActivity extends ThemedActivity + implements TextWatcher, View.OnClickListener { + + public AppCompatEditText mainTextView; + public AppCompatEditText searchEditText; + private Typeface inputTypefaceDefault; + private Typeface inputTypefaceMono; + private androidx.appcompat.widget.Toolbar toolbar; + ScrollView scrollView; + + private SearchTextTask searchTextTask; + private static final String KEY_MODIFIED_TEXT = "modified"; + private static final String KEY_INDEX = "index"; + private static final String KEY_ORIGINAL_TEXT = "original"; + private static final String KEY_MONOFONT = "monofont"; + + private ConstraintLayout searchViewLayout; + public AppCompatImageButton upButton; + public AppCompatImageButton downButton; + + private Snackbar loadingSnackbar; + + private TextEditorActivityViewModel viewModel; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.search); + toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + + viewModel = new ViewModelProvider(this).get(TextEditorActivityViewModel.class); + + searchViewLayout = findViewById(R.id.textEditorSearchBar); + + searchViewLayout.setBackgroundColor(getPrimary()); + + searchEditText = searchViewLayout.findViewById(R.id.textEditorSearchBox); + upButton = searchViewLayout.findViewById(R.id.textEditorSearchPrevButton); + downButton = searchViewLayout.findViewById(R.id.textEditorSearchNextButton); + + searchEditText.addTextChangedListener(this); + + upButton.setOnClickListener(this); + // upButton.setEnabled(false); + downButton.setOnClickListener(this); + // downButton.setEnabled(false); + + if (getSupportActionBar() != null) { + boolean useNewStack = getBoolean(PREFERENCE_TEXTEDITOR_NEWSTACK); + getSupportActionBar().setDisplayHomeAsUpEnabled(!useNewStack); + } + mainTextView = findViewById(R.id.textEditorMainEditText); + scrollView = findViewById(R.id.textEditorScrollView); + + final Uri uri = getIntent().getData(); + if (uri != null) { + viewModel.setFile(new EditableFileAbstraction(this, uri)); + } else { + Toast.makeText(this, R.string.no_file_error, Toast.LENGTH_LONG).show(); + finish(); + return; + } + + ActionBar actionBar = getSupportActionBar(); + + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(!getBoolean(PREFERENCE_TEXTEDITOR_NEWSTACK)); + actionBar.setTitle(viewModel.getFile().name); + } + + mainTextView.addTextChangedListener(this); + + if (getAppTheme().equals(AppTheme.DARK)) { + mainTextView.setBackgroundColor(Utils.getColor(this, R.color.holo_dark_action_mode)); + mainTextView.setTextColor(Utils.getColor(this, R.color.primary_white)); + } else if (getAppTheme().equals(AppTheme.BLACK)) { + mainTextView.setBackgroundColor(Utils.getColor(this, android.R.color.black)); + mainTextView.setTextColor(Utils.getColor(this, R.color.primary_white)); + } else { + mainTextView.setTextColor(Utils.getColor(this, R.color.primary_grey_900)); + } + + if (mainTextView.getTypeface() == null) { + mainTextView.setTypeface(Typeface.DEFAULT); + } + + inputTypefaceDefault = mainTextView.getTypeface(); + inputTypefaceMono = Typeface.MONOSPACE; + + if (savedInstanceState != null) { + viewModel.setOriginal(savedInstanceState.getString(KEY_ORIGINAL_TEXT)); + int index = savedInstanceState.getInt(KEY_INDEX); + mainTextView.setText(savedInstanceState.getString(KEY_MODIFIED_TEXT)); + mainTextView.setScrollY(index); + if (savedInstanceState.getBoolean(KEY_MONOFONT)) { + mainTextView.setTypeface(inputTypefaceMono); + } + } else { + load(this); + } + initStatusBarResources(findViewById(R.id.textEditorRootView)); + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + final TextEditorActivityViewModel viewModel = + new ViewModelProvider(this).get(TextEditorActivityViewModel.class); + + outState.putString( + KEY_MODIFIED_TEXT, mainTextView.getText() != null ? mainTextView.getText().toString() : ""); + outState.putInt(KEY_INDEX, mainTextView.getScrollY()); + outState.putString(KEY_ORIGINAL_TEXT, viewModel.getOriginal()); + outState.putBoolean(KEY_MONOFONT, inputTypefaceMono.equals(mainTextView.getTypeface())); + } + + private void checkUnsavedChanges() { + final TextEditorActivityViewModel viewModel = + new ViewModelProvider(this).get(TextEditorActivityViewModel.class); + + if (viewModel.getOriginal() != null + && mainTextView.isShown() + && mainTextView.getText() != null + && !viewModel.getOriginal().equals(mainTextView.getText().toString())) { + new MaterialDialog.Builder(this) + .title(R.string.unsaved_changes) + .content(R.string.unsaved_changes_description) + .positiveText(R.string.yes) + .negativeText(R.string.no) + .positiveColor(getAccent()) + .negativeColor(getAccent()) + .onPositive( + (dialog, which) -> { + saveFile(this, mainTextView.getText().toString()); + finish(); + }) + .onNegative((dialog, which) -> finish()) + .build() + .show(); + } else { + finish(); + } + } + + /** + * Method initiates a worker thread which writes the {@link #mainTextView} bytes to the defined + * file/uri 's output stream + * + * @param activity a reference to the current activity + * @param editTextString the edit text string + */ + private static void saveFile(final TextEditorActivity activity, final String editTextString) { + final WeakReference textEditorActivityWR = new WeakReference<>(activity); + final WeakReference appContextWR = + new WeakReference<>(activity.getApplicationContext()); + + TaskKt.fromTask( + new WriteTextFileTask(activity, editTextString, textEditorActivityWR, appContextWR)); + } + + /** + * Initiates loading of file/uri by getting an input stream associated with it on a worker thread + */ + private static void load(final TextEditorActivity activity) { + activity.dismissLoadingSnackbar(); + + activity.loadingSnackbar = + Snackbar.make(activity.scrollView, R.string.loading, Snackbar.LENGTH_SHORT); + activity.loadingSnackbar.show(); + + final WeakReference textEditorActivityWR = new WeakReference<>(activity); + final WeakReference appContextWR = + new WeakReference<>(activity.getApplicationContext()); + + TaskKt.fromTask(new ReadTextFileTask(activity, textEditorActivityWR, appContextWR)); + } + + public void setReadOnly() { + mainTextView.setInputType(EditorInfo.TYPE_NULL); + mainTextView.setSingleLine(false); + mainTextView.setImeOptions(EditorInfo.IME_FLAG_NO_ENTER_ACTION); + } + + public void dismissLoadingSnackbar() { + if (loadingSnackbar != null) { + loadingSnackbar.dismiss(); + loadingSnackbar = null; + } + } + + @Override + public void onBackPressed() { + checkUnsavedChanges(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.text, menu); + return super.onCreateOptionsMenu(menu); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + final TextEditorActivityViewModel viewModel = + new ViewModelProvider(this).get(TextEditorActivityViewModel.class); + + menu.findItem(R.id.save).setVisible(viewModel.getModified()); + menu.findItem(R.id.monofont).setChecked(inputTypefaceMono.equals(mainTextView.getTypeface())); + return super.onPrepareOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + final TextEditorActivityViewModel viewModel = + new ViewModelProvider(this).get(TextEditorActivityViewModel.class); + final EditableFileAbstraction editableFileAbstraction = viewModel.getFile(); + + switch (item.getItemId()) { + case android.R.id.home: + checkUnsavedChanges(); + break; + case R.id.save: + // Make sure EditText is visible before saving! + if (mainTextView.getText() != null) { + saveFile(this, mainTextView.getText().toString()); + } + break; + case R.id.details: + if (editableFileAbstraction.scheme.equals(FILE) + && editableFileAbstraction.hybridFileParcelable.getFile() != null + && editableFileAbstraction.hybridFileParcelable.getFile().exists()) { + GeneralDialogCreation.showPropertiesDialogWithoutPermissions( + editableFileAbstraction.hybridFileParcelable, this, getAppTheme()); + } else if (editableFileAbstraction.scheme.equals(CONTENT)) { + if (getApplicationContext() + .getPackageName() + .equals(editableFileAbstraction.uri.getAuthority())) { + File file = FileUtils.fromContentUri(editableFileAbstraction.uri); + HybridFileParcelable p = new HybridFileParcelable(file.getAbsolutePath()); + if (isRootExplorer()) p.setMode(OpenMode.ROOT); + GeneralDialogCreation.showPropertiesDialogWithoutPermissions(p, this, getAppTheme()); + } + } else { + Toast.makeText(this, R.string.no_obtainable_info, Toast.LENGTH_SHORT).show(); + } + break; + case R.id.openwith: + if (editableFileAbstraction.scheme.equals(FILE)) { + File currentFile = editableFileAbstraction.hybridFileParcelable.getFile(); + if (currentFile != null && currentFile.exists()) { + boolean useNewStack = getBoolean(PREFERENCE_TEXTEDITOR_NEWSTACK); + FileUtils.openWith(currentFile, this, useNewStack); + } else { + Toast.makeText(this, R.string.not_allowed, Toast.LENGTH_SHORT).show(); + } + } else { + Toast.makeText(this, R.string.reopen_from_source, Toast.LENGTH_SHORT).show(); + } + break; + case R.id.find: + if (searchViewLayout.isShown()) hideSearchView(); + else revealSearchView(); + break; + case R.id.monofont: + item.setChecked(!item.isChecked()); + mainTextView.setTypeface(item.isChecked() ? inputTypefaceMono : inputTypefaceDefault); + break; + default: + return false; + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onDestroy() { + super.onDestroy(); + final TextEditorActivityViewModel viewModel = + new ViewModelProvider(this).get(TextEditorActivityViewModel.class); + final File cacheFile = viewModel.getCacheFile(); + + if (cacheFile != null && cacheFile.exists()) { + cacheFile.delete(); + } + } + + @Override + public void beforeTextChanged(CharSequence charSequence, int i, int i2, int i3) { + // condition to check if callback is called in search editText + if (searchEditText.getText() != null + && charSequence.hashCode() == searchEditText.getText().hashCode()) { + final TextEditorActivityViewModel viewModel = + new ViewModelProvider(this).get(TextEditorActivityViewModel.class); + + // clearing before adding new values + if (searchTextTask != null) { + searchTextTask.cancel(true); + searchTextTask = null; // dereference the task for GC + } + + cleanSpans(viewModel); + } + } + + @Override + public void onTextChanged(CharSequence charSequence, int i, int i2, int i3) { + if (mainTextView.getText() != null + && charSequence.hashCode() == mainTextView.getText().hashCode()) { + final TextEditorActivityViewModel viewModel = + new ViewModelProvider(this).get(TextEditorActivityViewModel.class); + final Timer oldTimer = viewModel.getTimer(); + viewModel.setTimer(null); + + if (oldTimer != null) { + oldTimer.cancel(); + oldTimer.purge(); + } + + final WeakReference textEditorActivityWR = new WeakReference<>(this); + + Timer newTimer = new Timer(); + newTimer.schedule( + new TimerTask() { + boolean modified; + + @Override + public void run() { + final TextEditorActivity textEditorActivity = textEditorActivityWR.get(); + if (textEditorActivity == null) { + return; + } + + final TextEditorActivityViewModel viewModel = + new ViewModelProvider(textEditorActivity).get(TextEditorActivityViewModel.class); + + modified = + textEditorActivity.mainTextView.getText() != null + && !textEditorActivity + .mainTextView + .getText() + .toString() + .equals(viewModel.getOriginal()); + if (viewModel.getModified() != modified) { + viewModel.setModified(modified); + invalidateOptionsMenu(); + } + } + }, + 250); + + viewModel.setTimer(newTimer); + } + } + + @Override + public void afterTextChanged(Editable editable) { + // searchBox callback block + if (searchEditText.getText() != null + && editable.hashCode() == searchEditText.getText().hashCode()) { + final WeakReference textEditorActivityWR = new WeakReference<>(this); + + final OnProgressUpdate onProgressUpdate = + index -> { + final TextEditorActivity textEditorActivity = textEditorActivityWR.get(); + if (textEditorActivity == null) { + return; + } + textEditorActivity.colorSearchResult(index, getPrimary()); + }; + + final OnAsyncTaskFinished> onAsyncTaskFinished = + data -> { + final TextEditorActivity textEditorActivity = textEditorActivityWR.get(); + + if (textEditorActivity == null) { + return; + } + + final TextEditorActivityViewModel viewModel = + new ViewModelProvider(textEditorActivity).get(TextEditorActivityViewModel.class); + viewModel.setSearchResultIndices(data); + + for (SearchResultIndex searchResultIndex : data) { + textEditorActivity.colorSearchResult(searchResultIndex, getPrimary()); + } + + if (data.size() != 0) { + textEditorActivity.upButton.setEnabled(true); + textEditorActivity.downButton.setEnabled(true); + + // downButton + textEditorActivity.onClick(textEditorActivity.downButton); + } else { + textEditorActivity.upButton.setEnabled(false); + textEditorActivity.downButton.setEnabled(false); + } + }; + + if (mainTextView.getText() != null) { + searchTextTask = + new SearchTextTask( + mainTextView.getText().toString(), + editable.toString(), + onProgressUpdate, + onAsyncTaskFinished); + searchTextTask.execute(); + } + } + } + + private void revealSearchView() { + + searchViewLayout.setVisibility(View.VISIBLE); + + Animation animation = AnimationUtils.loadAnimation(this, R.anim.fade_in_top); + + animation.setAnimationListener( + new Animation.AnimationListener() { + @Override + public void onAnimationStart(Animation animation) {} + + @Override + public void onAnimationEnd(Animation animation) { + + searchEditText.requestFocus(); + + ((InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE)) + .showSoftInput(searchEditText, InputMethodManager.SHOW_IMPLICIT); + } + + @Override + public void onAnimationRepeat(Animation animation) {} + }); + + searchViewLayout.startAnimation(animation); + } + + private void hideSearchView() { + + Animation animation = AnimationUtils.loadAnimation(this, R.anim.fade_out_top); + + animation.setAnimationListener( + new Animation.AnimationListener() { + @Override + public void onAnimationStart(Animation animation) {} + + @Override + public void onAnimationEnd(Animation animation) { + + searchViewLayout.setVisibility(View.GONE); + + cleanSpans(viewModel); + searchEditText.setText(""); + + ((InputMethodManager) getSystemService(INPUT_METHOD_SERVICE)) + .hideSoftInputFromWindow( + searchEditText.getWindowToken(), InputMethodManager.HIDE_IMPLICIT_ONLY); + } + + @Override + public void onAnimationRepeat(Animation animation) {} + }); + + searchViewLayout.startAnimation(animation); + } + + @Override + public void onClick(View v) { + final TextEditorActivityViewModel viewModel = + new ViewModelProvider(this).get(TextEditorActivityViewModel.class); + + switch (v.getId()) { + case R.id.textEditorSearchPrevButton: + // upButton + if (viewModel.getCurrent() > 0) { + unhighlightCurrentSearchResult(viewModel); + + // highlighting previous element in list + viewModel.setCurrent(viewModel.getCurrent() - 1); + + highlightCurrentSearchResult(viewModel); + } + break; + case R.id.textEditorSearchNextButton: + // downButton + if (viewModel.getCurrent() < viewModel.getSearchResultIndices().size() - 1) { + unhighlightCurrentSearchResult(viewModel); + + viewModel.setCurrent(viewModel.getCurrent() + 1); + + highlightCurrentSearchResult(viewModel); + } + break; + default: + throw new IllegalStateException(); + } + } + + private void unhighlightCurrentSearchResult(final TextEditorActivityViewModel viewModel) { + if (viewModel.getCurrent() == -1) { + return; + } + + SearchResultIndex resultIndex = viewModel.getSearchResultIndices().get(viewModel.getCurrent()); + colorSearchResult(resultIndex, getPrimary()); + } + + private void highlightCurrentSearchResult(final TextEditorActivityViewModel viewModel) { + SearchResultIndex keyValueNew = viewModel.getSearchResultIndices().get(viewModel.getCurrent()); + colorSearchResult(keyValueNew, getAccent()); + + // scrolling to the highlighted element + if (getSupportActionBar() != null) { + scrollView.scrollTo( + 0, + (Integer) keyValueNew.getLineNumber() + + mainTextView.getLineHeight() + + Math.round(mainTextView.getLineSpacingExtra()) + - getSupportActionBar().getHeight()); + } + } + + private void colorSearchResult(SearchResultIndex resultIndex, @ColorInt int color) { + if (mainTextView.getText() != null) { + mainTextView + .getText() + .setSpan( + new BackgroundColorSpan(color), + (Integer) resultIndex.getStartCharNumber(), + (Integer) resultIndex.getEndCharNumber(), + Spanned.SPAN_INCLUSIVE_INCLUSIVE); + } + } + + private void cleanSpans(TextEditorActivityViewModel viewModel) { + // resetting current highlight and line number + viewModel.setSearchResultIndices(Collections.emptyList()); + viewModel.setCurrent(-1); + viewModel.setLine(0); + + // clearing textView spans + if (mainTextView.getText() != null) { + BackgroundColorSpan[] colorSpans = + mainTextView.getText().getSpans(0, mainTextView.length(), BackgroundColorSpan.class); + for (BackgroundColorSpan colorSpan : colorSpans) { + mainTextView.getText().removeSpan(colorSpan); + } + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/TextEditorActivityViewModel.kt b/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/TextEditorActivityViewModel.kt new file mode 100644 index 0000000..83a57ef --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/TextEditorActivityViewModel.kt @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.activities.texteditor + +import androidx.lifecycle.ViewModel +import com.amaze.filemanager.filesystem.EditableFileAbstraction +import java.io.File +import java.util.Timer + +class TextEditorActivityViewModel : ViewModel() { + var original: String? = null + + /** + * represents a file saved in cache + */ + var cacheFile: File? = null + + var modified = false + + /** + * variable to maintain the position of index + * while pressing next/previous button in the searchBox + */ + var current = -1 + + /** + * variable to maintain line number of the searched phrase + * further used to calculate the scroll position + */ + var line = 0 + + /** + * List maintaining the searched text's start/end index as key/value pair + */ + var searchResultIndices = listOf() + + var timer: Timer? = null + + var file: EditableFileAbstraction? = null +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/base/BaseBottomSheetFragment.kt b/app/src/main/java/com/amaze/filemanager/ui/base/BaseBottomSheetFragment.kt new file mode 100644 index 0000000..aee378a --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/base/BaseBottomSheetFragment.kt @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.base + +import android.app.Dialog +import android.os.Bundle +import android.view.View +import com.amaze.filemanager.R +import com.amaze.filemanager.ui.activities.superclasses.ThemedActivity +import com.amaze.filemanager.ui.theme.AppTheme +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +open class BaseBottomSheetFragment : BottomSheetDialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return super.onCreateDialog(savedInstanceState) as BottomSheetDialog + } + + /** + * Initializes bottom sheet ui resources based on current theme + */ + fun initDialogResources(rootView: View) { + when ((requireActivity() as ThemedActivity).appTheme!!) { + AppTheme.DARK -> { + rootView.setBackgroundDrawable( + context?.resources?.getDrawable( + R.drawable.shape_dialog_bottomsheet_dark, + ), + ) + } + AppTheme.BLACK -> { + rootView.setBackgroundDrawable( + context?.resources?.getDrawable( + R.drawable.shape_dialog_bottomsheet_black, + ), + ) + } + AppTheme.LIGHT -> { + rootView + .setBackgroundDrawable( + context?.resources?.getDrawable( + R.drawable.shape_dialog_bottomsheet_white, + ), + ) + } + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/colors/ColorPreference.java b/app/src/main/java/com/amaze/filemanager/ui/colors/ColorPreference.java new file mode 100644 index 0000000..988af9e --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/colors/ColorPreference.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.colors; + +import com.amaze.filemanager.R; + +public class ColorPreference { + public static final Integer[] availableColors = { + R.color.primary_red, + R.color.primary_pink, + R.color.primary_purple, + R.color.primary_deep_purple, + R.color.primary_indigo, + R.color.primary_blue, + R.color.primary_light_blue, + R.color.primary_cyan, + R.color.primary_teal, + R.color.primary_green, + R.color.primary_light_green, + R.color.primary_amber, + R.color.primary_orange, + R.color.primary_deep_orange, + R.color.primary_brown, + R.color.primary_grey_900, + R.color.primary_blue_grey, + R.color.primary_teal_900 + }; +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/colors/ColorPreferenceHelper.java b/app/src/main/java/com/amaze/filemanager/ui/colors/ColorPreferenceHelper.java new file mode 100644 index 0000000..85066ee --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/colors/ColorPreferenceHelper.java @@ -0,0 +1,183 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.colors; + +import java.util.Arrays; +import java.util.List; +import java.util.Random; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants; +import com.amaze.filemanager.utils.Utils; + +import android.content.Context; +import android.content.SharedPreferences; + +import androidx.annotation.ColorInt; +import androidx.annotation.ColorRes; + +public class ColorPreferenceHelper { + + public static final @ColorRes int DEFAULT_PRIMARY_FIRST_TAB = R.color.primary_indigo, + DEFAULT_PRIMARY_SECOND_TAB = R.color.primary_indigo, + DEFAULT_ACCENT = R.color.primary_pink, + DEFAULT_ICON_SKIN = R.color.primary_pink; + + /** Combinations used when randomizing color selection at startup. */ + private static final @ColorRes int[][] RANDOM_COMBINATIONS = + new int[][] { + {R.color.primary_brown, R.color.primary_amber, R.color.primary_orange}, + {R.color.primary_indigo, R.color.primary_pink, R.color.primary_indigo}, + {R.color.primary_teal, R.color.primary_orange, R.color.primary_teal}, + {R.color.primary_teal_900, R.color.primary_amber, R.color.primary_orange}, + {R.color.primary_deep_purple, R.color.primary_pink, R.color.primary_deep_purple}, + {R.color.primary_blue_grey, R.color.primary_brown, R.color.primary_blue_grey}, + {R.color.primary_pink, R.color.primary_orange, R.color.primary_pink}, + {R.color.primary_blue_grey, R.color.primary_red, R.color.primary_blue_grey}, + {R.color.primary_red, R.color.primary_orange, R.color.primary_red}, + {R.color.primary_light_blue, R.color.primary_pink, R.color.primary_light_blue}, + {R.color.primary_cyan, R.color.primary_pink, R.color.primary_cyan} + }; + + /** + * Randomizes (but does not save) the colors used by the interface. + * + * @return The {@link ColorPreference} object itself. + */ + public static UserColorPreferences randomize(Context c) { + @ColorRes + int[] colorPos = RANDOM_COMBINATIONS[new Random().nextInt(RANDOM_COMBINATIONS.length)]; + + return new UserColorPreferences( + Utils.getColor(c, colorPos[0]), + Utils.getColor(c, colorPos[0]), + Utils.getColor(c, colorPos[1]), + Utils.getColor(c, colorPos[2])); + } + + /** + * Eases the retrieval of primary colors ColorUsage. If the index is out of bounds, the first + * primary color is returned as default. + * + * @param num The primary color index + * @return The ColorUsage for the given primary color. + */ + public static @ColorInt int getPrimary(UserColorPreferences currentColors, int num) { + return num == 1 ? currentColors.getPrimarySecondTab() : currentColors.getPrimaryFirstTab(); + } + + private UserColorPreferences currentColors; + + public UserColorPreferences getCurrentUserColorPreferences( + Context context, SharedPreferences prefs) { + if (currentColors == null) currentColors = getColorPreferences(context, prefs); + return currentColors; + } + + public void saveColorPreferences(SharedPreferences prefs, UserColorPreferences userPrefs) { + SharedPreferences.Editor editor = prefs.edit(); + editor.putInt(PreferencesConstants.PREFERENCE_SKIN, userPrefs.getPrimaryFirstTab()); + editor.putInt(PreferencesConstants.PREFERENCE_SKIN_TWO, userPrefs.getPrimarySecondTab()); + editor.putInt(PreferencesConstants.PREFERENCE_ACCENT, userPrefs.getAccent()); + editor.putInt(PreferencesConstants.PREFERENCE_ICON_SKIN, userPrefs.getIconSkin()); + editor.apply(); + + currentColors = userPrefs; + } + + private UserColorPreferences getColorPreferences(Context c, SharedPreferences prefs) { + if (isUsingOldColorsSystem(prefs)) correctToNewColorsSystem(c, prefs); + + int tabOne = + prefs.getInt( + PreferencesConstants.PREFERENCE_SKIN, Utils.getColor(c, DEFAULT_PRIMARY_FIRST_TAB)); + int tabTwo = + prefs.getInt( + PreferencesConstants.PREFERENCE_SKIN_TWO, + Utils.getColor(c, DEFAULT_PRIMARY_SECOND_TAB)); + int accent = + prefs.getInt(PreferencesConstants.PREFERENCE_ACCENT, Utils.getColor(c, DEFAULT_ACCENT)); + int iconSkin = + prefs.getInt( + PreferencesConstants.PREFERENCE_ICON_SKIN, Utils.getColor(c, DEFAULT_ICON_SKIN)); + + return new UserColorPreferences(tabOne, tabTwo, accent, iconSkin); + } + + /** + * The old system used indexes, from here on in this file a correction is made so that the indexes + * are converted into ColorInts + */ + private boolean isUsingOldColorsSystem(SharedPreferences prefs) { + int tabOne = prefs.getInt(PreferencesConstants.PREFERENCE_SKIN, R.color.primary_indigo); + int tabTwo = prefs.getInt(PreferencesConstants.PREFERENCE_SKIN_TWO, R.color.primary_indigo); + int accent = prefs.getInt(PreferencesConstants.PREFERENCE_ACCENT, R.color.primary_pink); + int iconSkin = prefs.getInt(PreferencesConstants.PREFERENCE_ICON_SKIN, R.color.primary_pink); + + boolean r1 = tabOne >= 0 && tabTwo >= 0 && accent >= 0 && iconSkin >= 0; + boolean r2 = tabOne < 22 && tabTwo < 22 && accent < 22 && iconSkin < 22; + return r1 && r2; + } + + private static final List OLD_SYSTEM_LIST = + Arrays.asList( + R.color.primary_red, + R.color.primary_pink, + R.color.primary_purple, + R.color.primary_deep_purple, + R.color.primary_indigo, + R.color.primary_blue, + R.color.primary_light_blue, + R.color.primary_cyan, + R.color.primary_teal, + R.color.primary_green, + R.color.primary_light_green, + R.color.primary_amber, + R.color.primary_orange, + R.color.primary_deep_orange, + R.color.primary_brown, + R.color.primary_grey_900, + R.color.primary_blue_grey, + R.color.primary_teal_900, + R.color.accent_pink, + R.color.accent_amber, + R.color.accent_light_blue, + R.color.accent_light_green); + + private void correctToNewColorsSystem(Context c, SharedPreferences prefs) { + int tabOne = prefs.getInt(PreferencesConstants.PREFERENCE_SKIN, -1); + int tabTwo = prefs.getInt(PreferencesConstants.PREFERENCE_SKIN_TWO, -1); + int accent = prefs.getInt(PreferencesConstants.PREFERENCE_ACCENT, -1); + int iconSkin = prefs.getInt(PreferencesConstants.PREFERENCE_ICON_SKIN, -1); + + SharedPreferences.Editor editor = prefs.edit(); + editor.putInt(PreferencesConstants.PREFERENCE_SKIN, correctForIndex(c, tabOne)); + editor.putInt(PreferencesConstants.PREFERENCE_SKIN_TWO, correctForIndex(c, tabTwo)); + editor.putInt(PreferencesConstants.PREFERENCE_ACCENT, correctForIndex(c, accent)); + editor.putInt(PreferencesConstants.PREFERENCE_ICON_SKIN, correctForIndex(c, iconSkin)); + editor.apply(); + } + + private @ColorInt int correctForIndex(Context c, int color) { + if (color != -1) return Utils.getColor(c, OLD_SYSTEM_LIST.get(color)); + else return Utils.getColor(c, R.color.primary_indigo); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/colors/ColorUtils.java b/app/src/main/java/com/amaze/filemanager/ui/colors/ColorUtils.java new file mode 100644 index 0000000..bd358d1 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/colors/ColorUtils.java @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.colors; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.ui.icons.Icons; +import com.amaze.filemanager.utils.Utils; + +import android.content.Context; +import android.graphics.drawable.GradientDrawable; + +import androidx.annotation.ColorInt; + +/** + * @author Emmanuel on 24/5/2017, at 18:56. + */ +public class ColorUtils { + + public static void colorizeIcons( + Context context, int iconType, GradientDrawable background, @ColorInt int defaultColor) { + switch (iconType) { + case Icons.VIDEO: + case Icons.IMAGE: + background.setColor(Utils.getColor(context, R.color.video_item)); + break; + case Icons.AUDIO: + background.setColor(Utils.getColor(context, R.color.audio_item)); + break; + case Icons.PDF: + background.setColor(Utils.getColor(context, R.color.pdf_item)); + break; + case Icons.CODE: + background.setColor(Utils.getColor(context, R.color.code_item)); + break; + case Icons.TEXT: + background.setColor(Utils.getColor(context, R.color.text_item)); + break; + case Icons.COMPRESSED: + background.setColor(Utils.getColor(context, R.color.archive_item)); + break; + case Icons.APK: + background.setColor(Utils.getColor(context, R.color.apk_item)); + break; + case Icons.NOT_KNOWN: + background.setColor(Utils.getColor(context, R.color.generic_item)); + break; + default: + background.setColor(defaultColor); + break; + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/colors/UserColorPreferences.kt b/app/src/main/java/com/amaze/filemanager/ui/colors/UserColorPreferences.kt new file mode 100644 index 0000000..b8ada95 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/colors/UserColorPreferences.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.colors + +import android.os.Parcelable +import androidx.annotation.ColorInt +import kotlinx.android.parcel.Parcelize + +@Parcelize +class UserColorPreferences( + @ColorInt val primaryFirstTab: Int, + @ColorInt val primarySecondTab: Int, + @ColorInt val accent: Int, + @ColorInt val iconSkin: Int, +) : Parcelable diff --git a/app/src/main/java/com/amaze/filemanager/ui/dialogs/AlertDialog.kt b/app/src/main/java/com/amaze/filemanager/ui/dialogs/AlertDialog.kt new file mode 100644 index 0000000..91e0aef --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/dialogs/AlertDialog.kt @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.dialogs + +import androidx.annotation.Nullable +import androidx.annotation.StringRes +import com.afollestad.materialdialogs.MaterialDialog +import com.amaze.filemanager.ui.activities.superclasses.ThemedActivity + +/** + * Alert Dialog. + */ +object AlertDialog { + /** + * Display an alert dialog. Optionally accepts a [MaterialDialog.SingleButtonCallback] to + * provide additional behaviour when dialog button is pressed. + * + * Button default text is OK, but can be customized too. + */ + @JvmStatic + fun show( + activity: ThemedActivity, + @StringRes content: Int, + @StringRes title: Int, + @StringRes positiveButtonText: Int = android.R.string.ok, + @Nullable onPositive: MaterialDialog.SingleButtonCallback? = null, + contentIsHtml: Boolean = false, + ) { + val accentColor: Int = activity.accent + val a = + MaterialDialog.Builder(activity) + .content(content, contentIsHtml) + .widgetColor(accentColor) + .theme( + activity + .appTheme + .getMaterialDialogTheme(), + ) + .title(title) + .positiveText(positiveButtonText) + .positiveColor(accentColor) + + if (onPositive != null) { + a.onPositive(onPositive) + } + a.build().show() + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/dialogs/ColorPickerDialog.java b/app/src/main/java/com/amaze/filemanager/ui/dialogs/ColorPickerDialog.java new file mode 100644 index 0000000..93cb540 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/dialogs/ColorPickerDialog.java @@ -0,0 +1,352 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.dialogs; + +import com.afollestad.materialdialogs.Theme; +import com.amaze.filemanager.R; +import com.amaze.filemanager.application.AppConfig; +import com.amaze.filemanager.ui.colors.UserColorPreferences; +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants; +import com.amaze.filemanager.ui.theme.AppTheme; +import com.amaze.filemanager.ui.views.CircularColorsView; +import com.amaze.filemanager.utils.Utils; + +import android.app.Dialog; +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.ColorStateList; +import android.content.res.Resources; +import android.graphics.Color; +import android.os.Build; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.RadioButton; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.appcompat.widget.AppCompatButton; +import androidx.appcompat.widget.AppCompatTextView; +import androidx.core.util.Pair; +import androidx.preference.Preference.BaseSavedState; +import androidx.preference.PreferenceDialogFragmentCompat; +import androidx.preference.PreferenceManager; + +/** + * This is only the dialog, that shows a list of color combinations and a customization and random + * one. + * + * @author Emmanuel on 11/10/2017, at 12:48. + */ +public class ColorPickerDialog extends PreferenceDialogFragmentCompat { + + public static final int DEFAULT = 0; + public static final int NO_DATA = -1; + public static final int CUSTOM_INDEX = -2; + public static final int RANDOM_INDEX = -3; + + /** ONLY add new elements to the end of the array */ + private static final ColorItemPair[] COLORS = + new ColorItemPair[] { + new ColorItemPair( + R.string.default_string, + new int[] { + R.color.primary_indigo, + R.color.primary_indigo, + R.color.primary_pink, + R.color.accent_pink + }), + new ColorItemPair( + R.string.orange, + new int[] { + R.color.primary_orange, + R.color.primary_orange, + R.color.primary_deep_orange, + R.color.accent_amber + }), + new ColorItemPair( + R.string.blue, + new int[] { + R.color.primary_blue, + R.color.primary_blue, + R.color.primary_deep_purple, + R.color.accent_light_blue + }), + new ColorItemPair( + R.string.green, + new int[] { + R.color.primary_green, + R.color.primary_green, + R.color.primary_teal_900, + R.color.accent_light_green + }) + }; + + private static final String ARG_COLOR_PREF = "colorPref"; + private static final String ARG_APP_THEME = "appTheme"; + + private SharedPreferences sharedPrefs; + private OnAcceptedConfig listener; + private View selectedItem = null; + private int selectedIndex = -1; + + public static ColorPickerDialog newInstance( + String key, UserColorPreferences color, AppTheme theme) { + ColorPickerDialog retval = new ColorPickerDialog(); + final Bundle b = new Bundle(2); + b.putString(ARG_KEY, key); + b.putParcelable(ARG_COLOR_PREF, color); + b.putString(ARG_APP_THEME, theme.toString()); + retval.setArguments(b); + return retval; + } + + public void setListener(OnAcceptedConfig l) { + listener = l; + } + + @Override + public void onBindDialogView(View view) { + sharedPrefs = PreferenceManager.getDefaultSharedPreferences(requireContext()); + int accentColor = + ((UserColorPreferences) requireArguments().getParcelable(ARG_COLOR_PREF)).getAccent(); + if (selectedIndex == NO_DATA) { // if instance was restored the value is already set + boolean isUsingDefault = + sharedPrefs.getInt(PreferencesConstants.PREFERENCE_COLOR_CONFIG, NO_DATA) == NO_DATA + && sharedPrefs.getInt(PreferencesConstants.PREFERENCE_SKIN, R.color.primary_indigo) + == R.color.primary_indigo + && sharedPrefs.getInt( + PreferencesConstants.PREFERENCE_SKIN_TWO, R.color.primary_indigo) + == R.color.primary_indigo + && sharedPrefs.getInt(PreferencesConstants.PREFERENCE_ACCENT, R.color.primary_pink) + == R.color.primary_pink + && sharedPrefs.getInt(PreferencesConstants.PREFERENCE_ICON_SKIN, R.color.primary_pink) + == R.color.primary_pink; + + if (isUsingDefault) { + sharedPrefs.edit().putInt(PreferencesConstants.PREFERENCE_COLOR_CONFIG, DEFAULT).apply(); + } + + if (sharedPrefs.getBoolean("random_checkbox", false)) { + sharedPrefs + .edit() + .putInt(PreferencesConstants.PREFERENCE_COLOR_CONFIG, RANDOM_INDEX) + .apply(); + } + sharedPrefs.edit().remove("random_checkbox").apply(); + selectedIndex = + sharedPrefs.getInt(PreferencesConstants.PREFERENCE_COLOR_CONFIG, CUSTOM_INDEX); + } + + LinearLayout container = view.findViewById(R.id.container); + for (int i = 0; i < COLORS.length; i++) { + View child = inflateItem(container, i, accentColor); + + if (selectedIndex == i) { + selectedItem = child; + select(selectedItem, true); + } + + ((AppCompatTextView) child.findViewById(R.id.text)).setText(COLORS[i].first); + CircularColorsView colorsView = child.findViewById(R.id.circularColorsView); + colorsView.setColors(getColor(i, 0), getColor(i, 1), getColor(i, 2), getColor(i, 3)); + + AppTheme appTheme = AppTheme.valueOf(requireArguments().getString(ARG_APP_THEME)); + if (appTheme.getMaterialDialogTheme() == Theme.LIGHT) colorsView.setDividerColor(Color.WHITE); + else colorsView.setDividerColor(Color.BLACK); + container.addView(child); + } + /*CUSTOM*/ { + View child = inflateItem(container, CUSTOM_INDEX, accentColor); + + if (selectedIndex == CUSTOM_INDEX) { + selectedItem = child; + select(selectedItem, true); + } + + ((AppCompatTextView) child.findViewById(R.id.text)).setText(R.string.custom); + child.findViewById(R.id.circularColorsView).setVisibility(View.INVISIBLE); + container.addView(child); + } + /*RANDOM*/ { + View child = inflateItem(container, RANDOM_INDEX, accentColor); + + if (selectedIndex == RANDOM_INDEX) { + selectedItem = child; + select(selectedItem, true); + } + + ((AppCompatTextView) child.findViewById(R.id.text)).setText(R.string.random); + child.findViewById(R.id.circularColorsView).setVisibility(View.INVISIBLE); + container.addView(child); + } + } + + private void select(View listChild, boolean checked) { + RadioButton button = listChild.findViewById(R.id.select); + button.setChecked(checked); + } + + private View inflateItem(LinearLayout container, final int index, int accentColor) { + View.OnClickListener clickListener = + v -> { + if (!v.isSelected()) { + select(selectedItem, false); + select(v, true); + selectedItem = v; + selectedIndex = index; + } + }; + + LayoutInflater inflater = + (LayoutInflater) requireContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE); + View child = inflater.inflate(R.layout.item_colorpicker, container, false); + child.setOnClickListener(clickListener); + + RadioButton radio = child.findViewById(R.id.select); + radio.setOnClickListener(clickListener); + if (Build.VERSION.SDK_INT >= 21) { + ColorStateList colorStateList = + new ColorStateList( + new int[][] { + {-android.R.attr.state_enabled}, // disabled + {android.R.attr.state_enabled} // enabled + }, + new int[] {accentColor, accentColor}); + radio.setButtonTintList(colorStateList); + } + return child; + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + Dialog dialog = super.onCreateDialog(savedInstanceState); + dialog.show(); + Resources res = requireContext().getResources(); + int accentColor = + ((UserColorPreferences) requireArguments().getParcelable(ARG_COLOR_PREF)).getAccent(); + + // Button views + ((AppCompatButton) dialog.findViewById(res.getIdentifier("button1", "id", "android"))) + .setTextColor(accentColor); + ((AppCompatButton) dialog.findViewById(res.getIdentifier("button2", "id", "android"))) + .setTextColor(accentColor); + + return dialog; + } + + @Override + public void onDialogClosed(boolean positiveResult) { + // When the user selects "OK", persist the new value + if (positiveResult) { + sharedPrefs + .edit() + .putInt(PreferencesConstants.PREFERENCE_COLOR_CONFIG, selectedIndex) + .apply(); + + if (selectedIndex != CUSTOM_INDEX && selectedIndex != RANDOM_INDEX) { + AppConfig.getInstance() + .getUtilsProvider() + .getColorPreference() + .saveColorPreferences( + sharedPrefs, + new UserColorPreferences( + getColor(selectedIndex, 0), + getColor(selectedIndex, 1), + getColor(selectedIndex, 2), + getColor(selectedIndex, 3))); + } + + listener.onAcceptedConfig(); + } else { + selectedIndex = sharedPrefs.getInt(PreferencesConstants.PREFERENCE_COLOR_CONFIG, NO_DATA); + } + } + + public static @StringRes int getTitle(int index) { + if (index == RANDOM_INDEX) { + return R.string.random; + } else if (index == CUSTOM_INDEX) { + return R.string.custom; + } else if (index >= 0 && index < COLORS.length) { + return COLORS[index].first; + } else { + return COLORS[0].first; + } + } + + private int getColor(int i, int pos) { + return Utils.getColor(getContext(), COLORS[i].second[pos]); + } + + /** typedef Pair ColorItemPair */ + private static class ColorItemPair extends Pair { + + /** + * Constructor for a Pair. + * + * @param first the first object in the Pair + * @param second the second object in the pair + */ + public ColorItemPair(Integer first, int[] second) { + super(first, second); + } + } + + public interface OnAcceptedConfig { + void onAcceptedConfig(); + } + + public static class SavedState extends BaseSavedState { + + public int selectedItem; + + public SavedState(Parcel source) { + super(source); + selectedItem = source.readInt(); + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + super.writeToParcel(dest, flags); + dest.writeInt(selectedItem); + } + + public SavedState(Parcelable superState) { + super(superState); + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + public SavedState createFromParcel(Parcel in) { + return new SavedState(in); + } + + public SavedState[] newArray(int size) { + return new SavedState[size]; + } + }; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/dialogs/DecryptFingerprintDialog.kt b/app/src/main/java/com/amaze/filemanager/ui/dialogs/DecryptFingerprintDialog.kt new file mode 100644 index 0000000..0fb2355 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/dialogs/DecryptFingerprintDialog.kt @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.dialogs + +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK +import androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS +import androidx.biometric.BiometricPrompt +import com.amaze.filemanager.R +import com.amaze.filemanager.filesystem.files.CryptUtil +import com.amaze.filemanager.filesystem.files.EncryptDecryptUtils.DecryptButtonCallbackInterface +import com.amaze.filemanager.ui.activities.MainActivity +import com.amaze.filemanager.utils.FingerprintHandler +import java.io.IOException +import java.security.GeneralSecurityException + +/** + * Decrypt dialog prompt for user fingerprint. + */ +object DecryptFingerprintDialog { + /** + * Display dialog prompting user for fingerprint in order to decrypt file. + */ + @JvmStatic + @RequiresApi(api = Build.VERSION_CODES.M) + @Throws( + GeneralSecurityException::class, + IOException::class, + ) + fun show( + c: Context, + main: MainActivity, + intent: Intent, + decryptButtonCallbackInterface: DecryptButtonCallbackInterface, + ) { + val manager = BiometricManager.from(c) + if (manager.canAuthenticate(BIOMETRIC_STRONG or BIOMETRIC_WEAK) == BIOMETRIC_SUCCESS) { + val promptInfo = + BiometricPrompt.PromptInfo.Builder() + .setTitle(c.getString(R.string.crypt_decrypt)) + .setDescription(c.getString(R.string.crypt_fingerprint_authenticate)) + .setConfirmationRequired(false) + .setNegativeButtonText(c.getString(android.R.string.cancel)) + .build() + + val handler = FingerprintHandler(main, intent, promptInfo, decryptButtonCallbackInterface) + val `object` = BiometricPrompt.CryptoObject(CryptUtil.initCipher()) + handler.authenticate(`object`) + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/dialogs/DragAndDropDialog.kt b/app/src/main/java/com/amaze/filemanager/ui/dialogs/DragAndDropDialog.kt new file mode 100644 index 0000000..f532ca4 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/dialogs/DragAndDropDialog.kt @@ -0,0 +1,216 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.dialogs + +import android.app.Dialog +import android.content.Context +import android.os.Bundle +import androidx.appcompat.widget.AppCompatButton +import androidx.appcompat.widget.AppCompatCheckBox +import androidx.fragment.app.DialogFragment +import com.afollestad.materialdialogs.DialogAction +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.Theme +import com.amaze.filemanager.R +import com.amaze.filemanager.asynchronous.asynctasks.movecopy.PreparePasteTask +import com.amaze.filemanager.filesystem.HybridFileParcelable +import com.amaze.filemanager.ui.activities.MainActivity +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants +import com.amaze.filemanager.utils.safeLet +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +class DragAndDropDialog : DialogFragment() { + var pasteLocation: String? = null + var operationFiles: ArrayList? = null + var mainActivity: MainActivity? = null + + companion object { + private val log: Logger = LoggerFactory.getLogger(DragAndDropDialog::class.java) + private const val KEY_PASTE_LOCATION = "pasteLocation" + private const val KEY_FILES = "files" + + /** + * Show move / copy dialog on drop or perform the operation directly based on + * remember preference selected by user previously in this dialog + */ + fun showDialogOrPerformOperation( + pasteLocation: String, + files: ArrayList, + activity: MainActivity, + ) { + val dragAndDropPref = + activity.prefs + .getInt( + PreferencesConstants.PREFERENCE_DRAG_AND_DROP_PREFERENCE, + PreferencesConstants.PREFERENCE_DRAG_DEFAULT, + ) + if (dragAndDropPref == PreferencesConstants.PREFERENCE_DRAG_TO_MOVE_COPY) { + val dragAndDropCopy = + activity.prefs + .getString(PreferencesConstants.PREFERENCE_DRAG_AND_DROP_REMEMBERED, "") + if (dragAndDropCopy != "") { + startCopyOrMoveTask( + pasteLocation, + files, + PreferencesConstants.PREFERENCE_DRAG_REMEMBER_MOVE + .equals(dragAndDropCopy, ignoreCase = true), + activity, + ) + } else { + val dragAndDropDialog = newInstance(pasteLocation, files) + dragAndDropDialog.show( + activity.supportFragmentManager, + javaClass.simpleName, + ) + } + } else { + log.warn( + "Trying to drop for copy / move while setting " + + "is drag select", + ) + } + } + + private fun newInstance( + pasteLocation: String, + files: ArrayList, + ): DragAndDropDialog { + val dragAndDropDialog = DragAndDropDialog() + val args = Bundle() + args.putString(KEY_PASTE_LOCATION, pasteLocation) + args.putParcelableArrayList(KEY_FILES, files) + dragAndDropDialog.arguments = args + return dragAndDropDialog + } + + private fun startCopyOrMoveTask( + pasteLocation: String, + files: ArrayList, + move: Boolean, + mainActivity: MainActivity, + ) { + val openMode = + mainActivity.currentMainFragment?.mainFragmentViewModel?.openMode ?: return + PreparePasteTask(mainActivity) + .execute( + pasteLocation, + move, + mainActivity.isRootExplorer, + openMode, + files, + ) + } + } + + override fun onAttach(context: Context) { + super.onAttach(context) + mainActivity = context as MainActivity + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + pasteLocation = arguments?.getString(KEY_PASTE_LOCATION) + operationFiles = arguments?.getParcelableArrayList(KEY_FILES) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + safeLet( + context, + mainActivity?.appTheme?.getMaterialDialogTheme(), + mainActivity?.accent, + pasteLocation, + operationFiles, + ) { + context, dialogTheme, accent, pasteLocation, operationFiles -> + val dialog: MaterialDialog = + MaterialDialog.Builder(context) + .title(getString(R.string.choose_operation)) + .customView(R.layout.dialog_drag_drop, true) + .theme(dialogTheme) + .negativeText(getString(R.string.cancel).toUpperCase()) + .negativeColor(accent) + .cancelable(false) + .onNeutral { _: MaterialDialog?, _: DialogAction? -> + dismiss() + } + .build() + + dialog.customView?.run { + // Get views from custom layout to set text values. + val rememberCheckbox = this.findViewById(R.id.remember_drag) + val moveButton = this.findViewById(R.id.button_move) + moveButton.setOnClickListener { + mainActivity?.run { + if (rememberCheckbox.isChecked) { + rememberDragOperation(true) + } + startCopyOrMoveTask(pasteLocation, operationFiles, true, this) + dismiss() + } + } + val copyButton = this.findViewById(R.id.button_copy) + copyButton.setOnClickListener { + mainActivity?.run { + if (rememberCheckbox.isChecked) { + rememberDragOperation(false) + } + startCopyOrMoveTask(pasteLocation, operationFiles, false, this) + dismiss() + } + } + if (dialogTheme == Theme.LIGHT) { + moveButton.setCompoundDrawablesWithIntrinsicBounds( + R.drawable.ic_baseline_content_cut_24, + 0, + 0, + 0, + ) + copyButton.setCompoundDrawablesWithIntrinsicBounds( + R.drawable.ic_baseline_content_copy_24, + 0, + 0, + 0, + ) + } + } + return dialog + } + log.warn("Failed to show drag drop dialog view") + return super.onCreateDialog(savedInstanceState) + } + + override fun isCancelable(): Boolean { + return false + } + + private fun rememberDragOperation(shouldMove: Boolean) { + mainActivity?.prefs?.edit() + ?.putString( + PreferencesConstants.PREFERENCE_DRAG_AND_DROP_REMEMBERED, + if (shouldMove) { + PreferencesConstants.PREFERENCE_DRAG_REMEMBER_MOVE + } else { + PreferencesConstants.PREFERENCE_DRAG_REMEMBER_COPY + }, + )?.apply() + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/dialogs/EncryptAuthenticateDialog.kt b/app/src/main/java/com/amaze/filemanager/ui/dialogs/EncryptAuthenticateDialog.kt new file mode 100644 index 0000000..11763e5 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/dialogs/EncryptAuthenticateDialog.kt @@ -0,0 +1,299 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.dialogs + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.view.LayoutInflater +import android.view.View +import android.view.View.INVISIBLE +import android.view.View.VISIBLE +import android.widget.CompoundButton +import android.widget.Toast +import androidx.appcompat.widget.AppCompatCheckBox +import androidx.appcompat.widget.AppCompatTextView +import androidx.core.text.HtmlCompat +import androidx.core.text.HtmlCompat.FROM_HTML_MODE_COMPACT +import androidx.preference.PreferenceManager +import com.afollestad.materialdialogs.DialogAction +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.internal.MDButton +import com.amaze.filemanager.R +import com.amaze.filemanager.asynchronous.services.EncryptService +import com.amaze.filemanager.asynchronous.services.EncryptService.TAG_AESCRYPT +import com.amaze.filemanager.asynchronous.services.EncryptService.TAG_ENCRYPT_TARGET +import com.amaze.filemanager.asynchronous.services.EncryptService.TAG_PASSWORD +import com.amaze.filemanager.databinding.DialogEncryptAuthenticateBinding +import com.amaze.filemanager.filesystem.HybridFileParcelable +import com.amaze.filemanager.filesystem.files.CryptUtil.AESCRYPT_EXTENSION +import com.amaze.filemanager.filesystem.files.CryptUtil.CRYPT_EXTENSION +import com.amaze.filemanager.filesystem.files.EncryptDecryptUtils.EncryptButtonCallbackInterface +import com.amaze.filemanager.ui.activities.MainActivity +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_CRYPT_WARNING_REMEMBER +import com.amaze.filemanager.ui.openKeyboard +import com.amaze.filemanager.ui.theme.AppTheme +import com.amaze.filemanager.ui.views.WarnableTextInputLayout +import com.amaze.filemanager.ui.views.WarnableTextInputValidator +import com.amaze.filemanager.ui.views.WarnableTextInputValidator.ReturnState +import com.amaze.filemanager.ui.views.WarnableTextInputValidator.ReturnState.STATE_ERROR +import com.google.android.material.textfield.TextInputEditText +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +/** + * Encrypt file password dialog. + */ +object EncryptAuthenticateDialog { + private val log: Logger = LoggerFactory.getLogger(EncryptAuthenticateDialog::class.java) + + /** + * Display file encryption password dialog. + */ + @JvmStatic + @SuppressLint("SetTextI18n") + @Suppress("LongMethod") + fun show( + c: Context, + intent: Intent, + main: MainActivity, + appTheme: AppTheme, + encryptButtonCallbackInterface: EncryptButtonCallbackInterface, + ) { + intent.getParcelableExtra(EncryptService.TAG_SOURCE)?.run { + val preferences = PreferenceManager.getDefaultSharedPreferences(c) + val accentColor = main.accent + val builder = MaterialDialog.Builder(c) + builder.title(main.getString(R.string.crypt_encrypt)) + val vb: DialogEncryptAuthenticateBinding = + DialogEncryptAuthenticateBinding.inflate(LayoutInflater.from(c)) + val rootView: View = vb.root + val passwordEditText: TextInputEditText = vb.editTextDialogEncryptPassword + val passwordConfirmEditText: TextInputEditText = + vb + .editTextDialogEncryptPasswordConfirm + val encryptSaveAsEditText: TextInputEditText = vb.editTextEncryptSaveAs + val useAzeEncrypt: AppCompatCheckBox = vb.checkboxUseAze + val usageTextInfo: AppCompatTextView = + vb.textViewCryptInfo.apply { + text = + HtmlCompat.fromHtml( + main.getString(R.string.encrypt_option_use_aescrypt_desc), + FROM_HTML_MODE_COMPACT, + ) + } + useAzeEncrypt.setOnCheckedChangeListener( + createUseAzeEncryptCheckboxOnCheckedChangeListener( + c, + this, + preferences, + main, + encryptSaveAsEditText, + usageTextInfo, + ), + ) + val textInputLayoutPassword: WarnableTextInputLayout = vb.tilEncryptPassword + val textInputLayoutPasswordConfirm: WarnableTextInputLayout = + vb + .tilEncryptPasswordConfirm + val textInputLayoutEncryptSaveAs: WarnableTextInputLayout = vb.tilEncryptSaveAs + encryptSaveAsEditText.setText(this.getName(c) + AESCRYPT_EXTENSION) + textInputLayoutEncryptSaveAs.hint = + if (this.isDirectory) { + c.getString(R.string.encrypt_folder_save_as) + } else { + c.getString(R.string.encrypt_file_save_as) + } + builder + .customView(rootView, true) + .positiveText(c.getString(R.string.ok)) + .negativeText(c.getString(R.string.cancel)) + .theme(appTheme.getMaterialDialogTheme()) + .positiveColor(accentColor) + .negativeColor(accentColor) + .autoDismiss(false) + .onNegative { dialog, _ -> dialog.cancel() } + .onPositive { dialog, _ -> + intent.putExtra(TAG_ENCRYPT_TARGET, encryptSaveAsEditText.text.toString()) + .putExtra(TAG_AESCRYPT, !useAzeEncrypt.isChecked) + .putExtra(TAG_PASSWORD, passwordEditText.text.toString()) + runCatching { + encryptButtonCallbackInterface.onButtonPressed( + intent, + passwordEditText.text.toString(), + ) + }.onFailure { + log.error("Failed to encrypt", it) + Toast.makeText( + c, + c.getString(R.string.crypt_encryption_fail), + Toast.LENGTH_LONG, + ).show() + }.also { + dialog.dismiss() + } + } + val dialog = builder.show() + val btnOK = dialog.getActionButton(DialogAction.POSITIVE) + btnOK.isEnabled = false + rootView.post { passwordEditText.openKeyboard(main.applicationContext) } + createPasswordFieldValidator( + c, + passwordEditText, + passwordConfirmEditText, + textInputLayoutPassword, + encryptSaveAsEditText, + useAzeEncrypt, + btnOK, + ) + createPasswordFieldValidator( + c, + passwordConfirmEditText, + passwordEditText, + textInputLayoutPasswordConfirm, + encryptSaveAsEditText, + useAzeEncrypt, + btnOK, + ) + WarnableTextInputValidator( + c, + encryptSaveAsEditText, + textInputLayoutEncryptSaveAs, + btnOK, + createFilenameValidator(useAzeEncrypt, extraCondition = { + true == passwordEditText.text?.isNotBlank() && + passwordEditText.text.toString() == passwordConfirmEditText.text.toString() + }), + ) + } ?: throw IllegalArgumentException("No TAG_SOURCE parameter specified") + } + + private fun createPasswordFieldValidator( + c: Context, + passwordField: TextInputEditText, + comparingPasswordField: TextInputEditText, + warningTextInputLayout: WarnableTextInputLayout, + encryptSaveAsEditText: TextInputEditText, + useAzeEncrypt: AppCompatCheckBox, + btnOK: MDButton, + ) = WarnableTextInputValidator( + c, + passwordField, + warningTextInputLayout, + btnOK, + ) { text: String -> + if (text.isNotBlank() && + text == comparingPasswordField.text.toString() && + filenameIsValid(encryptSaveAsEditText.text.toString(), useAzeEncrypt) + ) { + ReturnState() + } else if (text.isBlank()) { + ReturnState(STATE_ERROR, R.string.field_empty) + } else { + ReturnState(STATE_ERROR, R.string.password_no_match) + } + } + + /** + * Convenient method to create an [CompoundButton.OnCheckedChangeListener] + * for the aze encryption format selection checkbox. + */ + @JvmStatic + @SuppressLint("SetTextI18n") + fun createUseAzeEncryptCheckboxOnCheckedChangeListener( + c: Context, + file: HybridFileParcelable, + preferences: SharedPreferences, + main: MainActivity, + encryptSaveAsEditText: TextInputEditText, + usageTextInfo: AppCompatTextView, + ) = { _: CompoundButton?, isChecked: Boolean -> + if (isChecked && + !preferences.getBoolean( + PREFERENCE_CRYPT_WARNING_REMEMBER, + false, + ) + ) { + EncryptWarningDialog.show(main, main.appTheme) + } + encryptSaveAsEditText.setText( + "${file.getName(c)}${if (isChecked) { + CRYPT_EXTENSION + } else { + AESCRYPT_EXTENSION + }}", + ) + usageTextInfo.text = + HtmlCompat.fromHtml( + main.getString( + if (isChecked) { + R.string.encrypt_option_use_azecrypt_desc + } else { + R.string.encrypt_option_use_aescrypt_desc + }, + ), + FROM_HTML_MODE_COMPACT, + ) + } + + /** + * Create a [WarnableTextInputValidator.OnTextValidate] for filename field. + */ + @JvmStatic + fun createFilenameValidator( + useAzeEncrypt: AppCompatCheckBox, + extraCondition: () -> Boolean = { true }, + ) = { text: String -> + if (text.isNotBlank() && filenameIsValid(text, useAzeEncrypt) && extraCondition.invoke()) { + ReturnState() + } else if (text.isBlank()) { + ReturnState(STATE_ERROR, R.string.field_empty) + } else if (!text.endsWith(CRYPT_EXTENSION) && + (useAzeEncrypt.visibility == INVISIBLE || useAzeEncrypt.isChecked) + ) { + ReturnState(STATE_ERROR, R.string.encrypt_file_must_end_with_aze) + } else if (!text.endsWith(AESCRYPT_EXTENSION) && + useAzeEncrypt.visibility == VISIBLE && !useAzeEncrypt.isChecked + ) { + ReturnState(STATE_ERROR, R.string.encrypt_file_must_end_with_aes) + } else { + ReturnState(STATE_ERROR, R.string.empty_string) + } + } + + /** + * Utility method to check if the given filename is valid as encrypted file + */ + @JvmStatic + fun filenameIsValid( + filename: String?, + useAzeEncrypt: AppCompatCheckBox, + ): Boolean { + return ( + true == filename?.isNotBlank() && filename.endsWith(CRYPT_EXTENSION) && + (useAzeEncrypt.visibility == INVISIBLE || useAzeEncrypt.isChecked) + ) || ( + true == filename?.isNotBlank() && filename.endsWith(AESCRYPT_EXTENSION) && + (useAzeEncrypt.visibility == VISIBLE && !useAzeEncrypt.isChecked) + ) + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/dialogs/EncryptWarningDialog.kt b/app/src/main/java/com/amaze/filemanager/ui/dialogs/EncryptWarningDialog.kt new file mode 100644 index 0000000..6c2339b --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/dialogs/EncryptWarningDialog.kt @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.dialogs + +import androidx.preference.PreferenceManager +import com.afollestad.materialdialogs.MaterialDialog +import com.amaze.filemanager.R +import com.amaze.filemanager.ui.activities.MainActivity +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants +import com.amaze.filemanager.ui.theme.AppTheme + +/** + * File encryption warning dialog. + * + * This dialog is to warn users of the caveat of using Amaze's own encryption format. + */ +object EncryptWarningDialog { + /** + * Display warning dialog on use of Amaze's own encryption format. + */ + @JvmStatic + fun show( + main: MainActivity, + appTheme: AppTheme, + ) { + val accentColor: Int = main.accent + val preferences = PreferenceManager.getDefaultSharedPreferences(main) + MaterialDialog.Builder(main).run { + title(main.getString(R.string.warning)) + content(main.getString(R.string.crypt_warning_key)) + theme(appTheme.getMaterialDialogTheme()) + negativeText(main.getString(R.string.warning_never_show)) + positiveText(main.getString(R.string.warning_confirm)) + positiveColor(accentColor) + onPositive { dialog, _ -> + dialog.dismiss() + } + onNegative { dialog, _ -> + preferences + .edit() + .putBoolean(PreferencesConstants.PREFERENCE_CRYPT_WARNING_REMEMBER, true) + .apply() + dialog.dismiss() + } + show() + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/dialogs/EncryptWithPresetPasswordSaveAsDialog.kt b/app/src/main/java/com/amaze/filemanager/ui/dialogs/EncryptWithPresetPasswordSaveAsDialog.kt new file mode 100644 index 0000000..baa5207 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/dialogs/EncryptWithPresetPasswordSaveAsDialog.kt @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.dialogs + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.view.LayoutInflater +import android.view.View +import android.widget.Toast +import androidx.core.text.HtmlCompat +import androidx.preference.PreferenceManager +import com.afollestad.materialdialogs.DialogAction +import com.afollestad.materialdialogs.MaterialDialog +import com.amaze.filemanager.R +import com.amaze.filemanager.asynchronous.services.EncryptService +import com.amaze.filemanager.asynchronous.services.EncryptService.TAG_ENCRYPT_TARGET +import com.amaze.filemanager.asynchronous.services.EncryptService.TAG_PASSWORD +import com.amaze.filemanager.databinding.DialogEncryptWithMasterPasswordBinding +import com.amaze.filemanager.filesystem.HybridFileParcelable +import com.amaze.filemanager.filesystem.files.CryptUtil +import com.amaze.filemanager.filesystem.files.EncryptDecryptUtils.EncryptButtonCallbackInterface +import com.amaze.filemanager.ui.activities.MainActivity +import com.amaze.filemanager.ui.dialogs.EncryptAuthenticateDialog.createFilenameValidator +import com.amaze.filemanager.ui.dialogs.EncryptAuthenticateDialog.createUseAzeEncryptCheckboxOnCheckedChangeListener +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.ENCRYPT_PASSWORD_FINGERPRINT +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.ENCRYPT_PASSWORD_MASTER +import com.amaze.filemanager.ui.views.WarnableTextInputValidator + +/** + * Encryption save as file dialog, for us when fingerprint or master password is set. + */ +object EncryptWithPresetPasswordSaveAsDialog { + /** + * Displays the save as dialog. + */ + @JvmStatic + @SuppressLint("SetTextI18n") + @Suppress("LongMethod") + fun show( + c: Context, + intent: Intent, + main: MainActivity, + password: String, + encryptButtonCallbackInterface: EncryptButtonCallbackInterface, + ) { + intent.getParcelableExtra(EncryptService.TAG_SOURCE)?.run { + val preferences = PreferenceManager.getDefaultSharedPreferences(c) + val accentColor = main.accent + val vb = DialogEncryptWithMasterPasswordBinding.inflate(LayoutInflater.from(c)) + val rootView = vb.root + val encryptSaveAsEditText = + vb.editTextEncryptSaveAs.also { + when (password) { + ENCRYPT_PASSWORD_FINGERPRINT -> { + // Fingerprint not supported for AESCrypt + it.setText(this.getName(c) + CryptUtil.CRYPT_EXTENSION) + } + ENCRYPT_PASSWORD_MASTER -> { + it.setText(this.getName(c) + CryptUtil.AESCRYPT_EXTENSION) + } + else -> { + throw IllegalArgumentException( + "Must be either " + + "ENCRYPT_PASSWORD_FINGERPRINT or ENCRYPT_PASSWORD_MASTER", + ) + } + } + } + val useAzeEncrypt = vb.checkboxUseAze + val usageTextInfo = + vb.textViewCryptInfo.apply { + text = + HtmlCompat.fromHtml( + main.getString(R.string.encrypt_option_use_aescrypt_desc), + HtmlCompat.FROM_HTML_MODE_LEGACY, + ) + } + if (ENCRYPT_PASSWORD_FINGERPRINT != password) { + useAzeEncrypt.setOnCheckedChangeListener( + createUseAzeEncryptCheckboxOnCheckedChangeListener( + c, + this, + preferences, + main, + encryptSaveAsEditText, + usageTextInfo, + ), + ) + } else { + useAzeEncrypt.visibility = View.INVISIBLE + usageTextInfo.visibility = View.INVISIBLE + } + + val saveAsDialog = + MaterialDialog.Builder(c) + .title( + if (isDirectory) { + R.string.encrypt_folder_save_as + } else { + R.string.encrypt_file_save_as + }, + ).customView(rootView, true) + .positiveColor(accentColor) + .negativeColor(accentColor) + .positiveText(android.R.string.ok) + .negativeText(android.R.string.cancel) + .onPositive { dialog, _ -> + intent.putExtra(TAG_ENCRYPT_TARGET, encryptSaveAsEditText.text.toString()) + intent.putExtra(TAG_PASSWORD, password) + runCatching { + encryptButtonCallbackInterface.onButtonPressed(intent, password) + }.onFailure { + Toast.makeText( + c, + c.getString(R.string.crypt_encryption_fail), + Toast.LENGTH_LONG, + ).show() + }.also { + dialog.dismiss() + } + }.build() + WarnableTextInputValidator( + c, + encryptSaveAsEditText, + vb.tilEncryptSaveAs, + saveAsDialog.getActionButton(DialogAction.POSITIVE), + createFilenameValidator(useAzeEncrypt), + ) + saveAsDialog.show() + saveAsDialog.getActionButton(DialogAction.POSITIVE).isEnabled = true + } ?: throw IllegalArgumentException("No TAG_SOURCE parameter specified") + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/dialogs/GeneralDialogCreation.java b/app/src/main/java/com/amaze/filemanager/ui/dialogs/GeneralDialogCreation.java new file mode 100644 index 0000000..0c7a553 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/dialogs/GeneralDialogCreation.java @@ -0,0 +1,1382 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.dialogs; + +import static android.os.Build.VERSION.SDK_INT; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SORTBY_ONLY_THIS; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.afollestad.materialdialogs.DialogAction; +import com.afollestad.materialdialogs.MaterialDialog; +import com.afollestad.materialdialogs.Theme; +import com.amaze.filemanager.R; +import com.amaze.filemanager.adapters.data.LayoutElementParcelable; +import com.amaze.filemanager.application.AppConfig; +import com.amaze.filemanager.asynchronous.asynctasks.CountItemsOrAndSizeTask; +import com.amaze.filemanager.asynchronous.asynctasks.LoadFolderSpaceDataTask; +import com.amaze.filemanager.asynchronous.asynctasks.TaskKt; +import com.amaze.filemanager.asynchronous.asynctasks.hashcalculator.CalculateHashTask; +import com.amaze.filemanager.database.SortHandler; +import com.amaze.filemanager.database.models.explorer.Sort; +import com.amaze.filemanager.databinding.DialogSigninWithGoogleBinding; +import com.amaze.filemanager.fileoperations.exceptions.ShellNotRunningException; +import com.amaze.filemanager.fileoperations.filesystem.OpenMode; +import com.amaze.filemanager.filesystem.FileProperties; +import com.amaze.filemanager.filesystem.HybridFile; +import com.amaze.filemanager.filesystem.HybridFileParcelable; +import com.amaze.filemanager.filesystem.RootHelper; +import com.amaze.filemanager.filesystem.compressed.CompressedHelper; +import com.amaze.filemanager.filesystem.files.EncryptDecryptUtils; +import com.amaze.filemanager.filesystem.files.FileUtils; +import com.amaze.filemanager.filesystem.files.sort.SortBy; +import com.amaze.filemanager.filesystem.files.sort.SortOrder; +import com.amaze.filemanager.filesystem.files.sort.SortType; +import com.amaze.filemanager.filesystem.root.ChangeFilePermissionsCommand; +import com.amaze.filemanager.ui.ExtensionsKt; +import com.amaze.filemanager.ui.activities.MainActivity; +import com.amaze.filemanager.ui.activities.superclasses.ThemedActivity; +import com.amaze.filemanager.ui.fragments.MainFragment; +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants; +import com.amaze.filemanager.ui.theme.AppTheme; +import com.amaze.filemanager.ui.views.WarnableTextInputLayout; +import com.amaze.filemanager.ui.views.WarnableTextInputValidator; +import com.amaze.filemanager.utils.Utils; +import com.github.mikephil.charting.charts.PieChart; +import com.github.mikephil.charting.components.Legend; +import com.github.mikephil.charting.data.Entry; +import com.github.mikephil.charting.data.PieData; +import com.github.mikephil.charting.data.PieDataSet; +import com.github.mikephil.charting.data.PieEntry; +import com.github.mikephil.charting.formatter.IValueFormatter; +import com.github.mikephil.charting.utils.ViewPortHandler; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.graphics.Color; +import android.graphics.Typeface; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Build; +import android.text.InputType; +import android.text.SpannableString; +import android.text.TextUtils; +import android.text.format.Formatter; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.widget.AppCompatButton; +import androidx.appcompat.widget.AppCompatCheckBox; +import androidx.appcompat.widget.AppCompatEditText; +import androidx.appcompat.widget.AppCompatTextView; +import androidx.core.text.TextUtilsCompat; +import androidx.core.view.ViewCompat; +import androidx.preference.PreferenceManager; + +/** + * Here are a lot of function that create material dialogs + * + * @author Emmanuel on 17/5/2017, at 13:27. + */ +public class GeneralDialogCreation { + + private static final Logger LOG = LoggerFactory.getLogger(GeneralDialogCreation.class); + + public static MaterialDialog showBasicDialog( + ThemedActivity themedActivity, + @StringRes int content, + @StringRes int title, + @StringRes int postiveText, + @StringRes int negativeText) { + int accentColor = themedActivity.getAccent(); + MaterialDialog.Builder a = + new MaterialDialog.Builder(themedActivity) + .content(content) + .widgetColor(accentColor) + .theme(themedActivity.getAppTheme().getMaterialDialogTheme()) + .title(title) + .positiveText(postiveText) + .positiveColor(accentColor) + .negativeText(negativeText) + .negativeColor(accentColor); + return a.build(); + } + + public static MaterialDialog showNameDialog( + final MainActivity m, + String hint, + String prefill, + String title, + String positiveButtonText, + String neutralButtonText, + String negativeButtonText, + MaterialDialog.SingleButtonCallback positiveButtonAction, + WarnableTextInputValidator.OnTextValidate validator) { + int accentColor = m.getAccent(); + MaterialDialog.Builder builder = new MaterialDialog.Builder(m); + + View dialogView = m.getLayoutInflater().inflate(R.layout.dialog_singleedittext, null); + AppCompatEditText textfield = dialogView.findViewById(R.id.singleedittext_input); + textfield.setHint(hint); + textfield.setText(prefill); + + WarnableTextInputLayout tilTextfield = + dialogView.findViewById(R.id.singleedittext_warnabletextinputlayout); + + dialogView.post(() -> ExtensionsKt.openKeyboard(textfield, m.getApplicationContext())); + + builder + .customView(dialogView, false) + .widgetColor(accentColor) + .theme(m.getAppTheme().getMaterialDialogTheme()) + .title(title) + .positiveText(positiveButtonText) + .onPositive(positiveButtonAction); + + if (neutralButtonText != null) { + builder.neutralText(neutralButtonText); + } + + if (negativeButtonText != null) { + builder.negativeText(negativeButtonText); + builder.negativeColor(accentColor); + } + + MaterialDialog dialog = builder.show(); + + WarnableTextInputValidator textInputValidator = + new WarnableTextInputValidator( + builder.getContext(), + textfield, + tilTextfield, + dialog.getActionButton(DialogAction.POSITIVE), + validator); + + if (!TextUtils.isEmpty(prefill)) textInputValidator.afterTextChanged(textfield.getText()); + + return dialog; + } + + @SuppressWarnings("ConstantConditions") + public static void deleteFilesDialog( + @NonNull final Context context, + @NonNull final MainActivity mainActivity, + @NonNull final List positions, + @NonNull AppTheme appTheme) { + + final ArrayList itemsToDelete = new ArrayList<>(); + int accentColor = mainActivity.getAccent(); + SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); + boolean needConfirmation = + sharedPreferences.getBoolean( + PreferencesConstants.PREFERENCE_DELETE_CONFIRMATION, + PreferencesConstants.DEFAULT_PREFERENCE_DELETE_CONFIRMATION); + View dialogView = LayoutInflater.from(context).inflate(R.layout.dialog_delete, null); + TextView deleteDisclaimerTextView = dialogView.findViewById(R.id.dialog_delete_disclaimer); + final AppCompatCheckBox deletePermanentlyCheckbox = + dialogView.findViewById(R.id.delete_permanently_checkbox); + if (positions.get(0).generateBaseFile().isLocal()) { + // FIXME: make sure dialog is not shown for zero items + // allow trash bin delete only for local files for now + deletePermanentlyCheckbox.setVisibility(View.VISIBLE); + } else { + deleteDisclaimerTextView.setText(context.getString(R.string.dialog_delete_disclaimer)); + } + // Build dialog with custom view layout and accent color. + MaterialDialog dialog = + new MaterialDialog.Builder(context) + .title(context.getString(R.string.dialog_delete_title)) + .customView(dialogView, true) + .theme(appTheme.getMaterialDialogTheme()) + .negativeText(context.getString(R.string.cancel).toUpperCase()) + .positiveText(context.getString(R.string.delete).toUpperCase()) + .positiveColor(accentColor) + .negativeColor(accentColor) + .onPositive( + (dialog1, which) -> { + Toast.makeText(context, context.getString(R.string.deleting), Toast.LENGTH_SHORT) + .show(); + mainActivity.mainActivityHelper.deleteFiles( + itemsToDelete, + deletePermanentlyCheckbox.isChecked() + || deletePermanentlyCheckbox.getVisibility() == View.GONE); + }) + .build(); + + // Get views from custom layout to set text values. + final AppCompatTextView categoryDirectories = + dialog.getCustomView().findViewById(R.id.category_directories); + final AppCompatTextView categoryFiles = + dialog.getCustomView().findViewById(R.id.category_files); + final AppCompatTextView listDirectories = + dialog.getCustomView().findViewById(R.id.list_directories); + final AppCompatTextView listFiles = dialog.getCustomView().findViewById(R.id.list_files); + final AppCompatTextView total = dialog.getCustomView().findViewById(R.id.total); + + new AsyncTask() { + + long sizeTotal = 0; + StringBuilder files = new StringBuilder(); + StringBuilder directories = new StringBuilder(); + int counterDirectories = 0; + int counterFiles = 0; + + @Override + protected void onPreExecute() { + super.onPreExecute(); + if (needConfirmation) { + listFiles.setText(context.getString(R.string.loading)); + listDirectories.setText(context.getString(R.string.loading)); + total.setText(context.getString(R.string.loading)); + } + } + + @Override + protected Void doInBackground(Void... params) { + + for (int i = 0; i < positions.size(); i++) { + final LayoutElementParcelable layoutElement = positions.get(i); + itemsToDelete.add(layoutElement.generateBaseFile()); + if (needConfirmation) { + // Build list of directories to delete. + if (layoutElement.isDirectory) { + // Don't add newline between category and list. + if (counterDirectories != 0) { + directories.append("\n"); + } + + long sizeDirectory = layoutElement.generateBaseFile().folderSize(context); + + directories + .append(++counterDirectories) + .append(". ") + .append(layoutElement.title) + .append(" (") + .append(Formatter.formatFileSize(context, sizeDirectory)) + .append(")"); + sizeTotal += sizeDirectory; + // Build list of files to delete. + } else { + // Don't add newline between category and list. + if (counterFiles != 0) { + files.append("\n"); + } + + files + .append(++counterFiles) + .append(". ") + .append(layoutElement.title) + .append(" (") + .append(layoutElement.size) + .append(")"); + sizeTotal += layoutElement.longSize; + } + + publishProgress(sizeTotal, counterFiles, counterDirectories, files, directories); + } + } + return null; + } + + @Override + protected void onProgressUpdate(Object... result) { + super.onProgressUpdate(result); + if (needConfirmation) { + int tempCounterFiles = (int) result[1]; + int tempCounterDirectories = (int) result[2]; + long tempSizeTotal = (long) result[0]; + StringBuilder tempFilesStringBuilder = (StringBuilder) result[3]; + StringBuilder tempDirectoriesStringBuilder = (StringBuilder) result[4]; + + updateViews( + tempSizeTotal, + tempFilesStringBuilder, + tempDirectoriesStringBuilder, + tempCounterFiles, + tempCounterDirectories); + } + } + + @Override + protected void onPostExecute(Void aVoid) { + super.onPostExecute(aVoid); + if (needConfirmation) { + updateViews(sizeTotal, files, directories, counterFiles, counterDirectories); + } else { + Toast.makeText(context, context.getString(R.string.deleting), Toast.LENGTH_SHORT).show(); + mainActivity.mainActivityHelper.deleteFiles( + itemsToDelete, + deletePermanentlyCheckbox.isChecked() + || deletePermanentlyCheckbox.getVisibility() == View.GONE); + } + } + + private void updateViews( + long tempSizeTotal, + StringBuilder filesStringBuilder, + StringBuilder directoriesStringBuilder, + int... values) { + + int tempCounterFiles = values[0]; + int tempCounterDirectories = values[1]; + + // Hide category and list for directories when zero. + if (tempCounterDirectories == 0) { + + if (tempCounterDirectories == 0) { + + categoryDirectories.setVisibility(View.GONE); + listDirectories.setVisibility(View.GONE); + } + // Hide category and list for files when zero. + } + + if (tempCounterFiles == 0) { + + categoryFiles.setVisibility(View.GONE); + listFiles.setVisibility(View.GONE); + } + + if (tempCounterDirectories != 0 || tempCounterFiles != 0) { + listDirectories.setText(directoriesStringBuilder); + if (listDirectories.getVisibility() != View.VISIBLE && tempCounterDirectories != 0) + listDirectories.setVisibility(View.VISIBLE); + listFiles.setText(filesStringBuilder); + if (listFiles.getVisibility() != View.VISIBLE && tempCounterFiles != 0) + listFiles.setVisibility(View.VISIBLE); + + if (categoryDirectories.getVisibility() != View.VISIBLE && tempCounterDirectories != 0) + categoryDirectories.setVisibility(View.VISIBLE); + if (categoryFiles.getVisibility() != View.VISIBLE && tempCounterFiles != 0) + categoryFiles.setVisibility(View.VISIBLE); + } + + // Show total size with at least one directory or file and size is not zero. + if (tempCounterFiles + tempCounterDirectories > 1 && tempSizeTotal > 0) { + StringBuilder builderTotal = + new StringBuilder() + .append(context.getString(R.string.total)) + .append(" ") + .append(Formatter.formatFileSize(context, tempSizeTotal)); + total.setText(builderTotal); + if (total.getVisibility() != View.VISIBLE) total.setVisibility(View.VISIBLE); + } else { + total.setVisibility(View.GONE); + } + } + }.execute(); + + // Set category text color for Jelly Bean (API 16) and later. + if (SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + categoryDirectories.setTextColor(accentColor); + categoryFiles.setTextColor(accentColor); + } + + if (needConfirmation) { + // Show dialog on screen. + dialog.show(); + } + } + + /** + * Displays a dialog prompting user to restore files in trash bin. + * + * @param context + * @param mainActivity + * @param positions + * @param appTheme + */ + @SuppressWarnings({"ConstantConditions", "PMD.NPathComplexity"}) + public static void restoreFilesDialog( + @NonNull final Context context, + @NonNull final MainActivity mainActivity, + @NonNull final List positions, + @NonNull AppTheme appTheme) { + + final ArrayList itemsToDelete = new ArrayList<>(); + int accentColor = mainActivity.getAccent(); + View dialogView = LayoutInflater.from(context).inflate(R.layout.dialog_delete, null); + TextView deleteDisclaimerTextView = dialogView.findViewById(R.id.dialog_delete_disclaimer); + deleteDisclaimerTextView.setText(context.getString(R.string.dialog_restore_disclaimer)); + // Build dialog with custom view layout and accent color. + MaterialDialog dialog = + new MaterialDialog.Builder(context) + .title(context.getString(R.string.restore_files)) + .customView(dialogView, true) + .theme(appTheme.getMaterialDialogTheme()) + .negativeText(context.getString(R.string.cancel).toUpperCase()) + .positiveText(context.getString(R.string.done).toUpperCase()) + .positiveColor(accentColor) + .negativeColor(accentColor) + .onPositive( + (dialog1, which) -> { + Toast.makeText( + context, context.getString(R.string.processing), Toast.LENGTH_SHORT) + .show(); + mainActivity + .getCurrentMainFragment() + .getMainActivityViewModel() + .restoreFromBin(positions); + }) + .build(); + + // Get views from custom layout to set text values. + final AppCompatTextView categoryDirectories = + dialog.getCustomView().findViewById(R.id.category_directories); + final AppCompatTextView categoryFiles = + dialog.getCustomView().findViewById(R.id.category_files); + final AppCompatTextView listDirectories = + dialog.getCustomView().findViewById(R.id.list_directories); + final AppCompatTextView listFiles = dialog.getCustomView().findViewById(R.id.list_files); + final AppCompatTextView total = dialog.getCustomView().findViewById(R.id.total); + + new AsyncTask() { + + long sizeTotal = 0; + StringBuilder files = new StringBuilder(); + StringBuilder directories = new StringBuilder(); + int counterDirectories = 0; + int counterFiles = 0; + + @Override + protected void onPreExecute() { + super.onPreExecute(); + listFiles.setText(context.getString(R.string.loading)); + listDirectories.setText(context.getString(R.string.loading)); + total.setText(context.getString(R.string.loading)); + } + + @Override + protected Void doInBackground(Void... params) { + + for (int i = 0; i < positions.size(); i++) { + final LayoutElementParcelable layoutElement = positions.get(i); + itemsToDelete.add(layoutElement.generateBaseFile()); + // Build list of directories to delete. + if (layoutElement.isDirectory) { + // Don't add newline between category and list. + if (counterDirectories != 0) { + directories.append("\n"); + } + + long sizeDirectory = layoutElement.generateBaseFile().folderSize(context); + + directories + .append(++counterDirectories) + .append(". ") + .append(layoutElement.title) + .append(" (") + .append(Formatter.formatFileSize(context, sizeDirectory)) + .append(")"); + sizeTotal += sizeDirectory; + // Build list of files to delete. + } else { + // Don't add newline between category and list. + if (counterFiles != 0) { + files.append("\n"); + } + + files + .append(++counterFiles) + .append(". ") + .append(layoutElement.title) + .append(" (") + .append(layoutElement.size) + .append(")"); + sizeTotal += layoutElement.longSize; + } + + publishProgress(sizeTotal, counterFiles, counterDirectories, files, directories); + } + return null; + } + + @Override + protected void onProgressUpdate(Object... result) { + super.onProgressUpdate(result); + int tempCounterFiles = (int) result[1]; + int tempCounterDirectories = (int) result[2]; + long tempSizeTotal = (long) result[0]; + StringBuilder tempFilesStringBuilder = (StringBuilder) result[3]; + StringBuilder tempDirectoriesStringBuilder = (StringBuilder) result[4]; + + updateViews( + tempSizeTotal, + tempFilesStringBuilder, + tempDirectoriesStringBuilder, + tempCounterFiles, + tempCounterDirectories); + } + + @Override + protected void onPostExecute(Void aVoid) { + super.onPostExecute(aVoid); + // do nothing + } + + private void updateViews( + long tempSizeTotal, + StringBuilder filesStringBuilder, + StringBuilder directoriesStringBuilder, + int... values) { + + int tempCounterFiles = values[0]; + int tempCounterDirectories = values[1]; + + // Hide category and list for directories when zero. + if (tempCounterDirectories == 0) { + + if (tempCounterDirectories == 0) { + + categoryDirectories.setVisibility(View.GONE); + listDirectories.setVisibility(View.GONE); + } + // Hide category and list for files when zero. + } + + if (tempCounterFiles == 0) { + + categoryFiles.setVisibility(View.GONE); + listFiles.setVisibility(View.GONE); + } + + if (tempCounterDirectories != 0 || tempCounterFiles != 0) { + listDirectories.setText(directoriesStringBuilder); + if (listDirectories.getVisibility() != View.VISIBLE && tempCounterDirectories != 0) + listDirectories.setVisibility(View.VISIBLE); + listFiles.setText(filesStringBuilder); + if (listFiles.getVisibility() != View.VISIBLE && tempCounterFiles != 0) + listFiles.setVisibility(View.VISIBLE); + + if (categoryDirectories.getVisibility() != View.VISIBLE && tempCounterDirectories != 0) + categoryDirectories.setVisibility(View.VISIBLE); + if (categoryFiles.getVisibility() != View.VISIBLE && tempCounterFiles != 0) + categoryFiles.setVisibility(View.VISIBLE); + } + + // Show total size with at least one directory or file and size is not zero. + if (tempCounterFiles + tempCounterDirectories > 1 && tempSizeTotal > 0) { + StringBuilder builderTotal = + new StringBuilder() + .append(context.getString(R.string.total)) + .append(" ") + .append(Formatter.formatFileSize(context, tempSizeTotal)); + total.setText(builderTotal); + if (total.getVisibility() != View.VISIBLE) total.setVisibility(View.VISIBLE); + } else { + total.setVisibility(View.GONE); + } + } + }.execute(); + + // Set category text color for Jelly Bean (API 16) and later. + if (SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + categoryDirectories.setTextColor(accentColor); + categoryFiles.setTextColor(accentColor); + } + dialog.show(); + } + + public static void showPropertiesDialogWithPermissions( + @NonNull HybridFileParcelable baseFile, + @Nullable final String permissions, + @NonNull MainActivity themedActivity, + @NonNull MainFragment mainFragment, + boolean isRoot, + @NonNull AppTheme appTheme) { + showPropertiesDialog( + baseFile, themedActivity, mainFragment, permissions, isRoot, appTheme, false); + } + + public static void showPropertiesDialogWithoutPermissions( + @NonNull final HybridFileParcelable f, + @NonNull ThemedActivity themedActivity, + @NonNull AppTheme appTheme) { + showPropertiesDialog(f, themedActivity, null, null, false, appTheme, false); + } + + public static void showPropertiesDialogForStorage( + @NonNull final HybridFileParcelable f, + @NonNull MainActivity themedActivity, + @NonNull AppTheme appTheme) { + showPropertiesDialog(f, themedActivity, null, null, false, appTheme, true); + } + + private static void showPropertiesDialog( + @NonNull final HybridFileParcelable baseFile, + @NonNull ThemedActivity themedActivity, + @Nullable MainFragment mainFragment, + @Nullable final String permissions, + boolean isRoot, + @NonNull AppTheme appTheme, + boolean forStorage) { + final ExecutorService executor = Executors.newFixedThreadPool(3); + final Context c = themedActivity.getApplicationContext(); + int accentColor = themedActivity.getAccent(); + long last = baseFile.getDate(); + final String date = Utils.getDate(themedActivity, last), + items = c.getString(R.string.calculating), + name = baseFile.getName(c), + parent = baseFile.getReadablePath(baseFile.getParent(c)); + + File nomediaFile = + baseFile.isDirectory() ? new File(baseFile.getPath() + "/" + FileUtils.NOMEDIA_FILE) : null; + + MaterialDialog.Builder builder = new MaterialDialog.Builder(themedActivity); + builder.title(c.getString(R.string.properties)); + builder.theme(appTheme.getMaterialDialogTheme()); + + View v = themedActivity.getLayoutInflater().inflate(R.layout.properties_dialog, null); + AppCompatTextView itemsText = v.findViewById(R.id.t7); + AppCompatCheckBox nomediaCheckBox = v.findViewById(R.id.nomediacheckbox); + + /*View setup*/ + { + AppCompatTextView mNameTitle = v.findViewById(R.id.title_name); + mNameTitle.setTextColor(accentColor); + + AppCompatTextView mDateTitle = v.findViewById(R.id.title_date); + mDateTitle.setTextColor(accentColor); + + AppCompatTextView mSizeTitle = v.findViewById(R.id.title_size); + mSizeTitle.setTextColor(accentColor); + + AppCompatTextView mLocationTitle = v.findViewById(R.id.title_location); + mLocationTitle.setTextColor(accentColor); + + AppCompatTextView md5Title = v.findViewById(R.id.title_md5); + md5Title.setTextColor(accentColor); + + AppCompatTextView sha256Title = v.findViewById(R.id.title_sha256); + sha256Title.setTextColor(accentColor); + + ((AppCompatTextView) v.findViewById(R.id.t5)).setText(name); + ((AppCompatTextView) v.findViewById(R.id.t6)).setText(parent); + itemsText.setText(items); + ((AppCompatTextView) v.findViewById(R.id.t8)).setText(date); + + if (baseFile.isDirectory() && baseFile.isLocal()) { + nomediaCheckBox.setVisibility(View.VISIBLE); + if (nomediaFile != null) { + nomediaCheckBox.setChecked(nomediaFile.exists()); + } + } + + LinearLayout mNameLinearLayout = v.findViewById(R.id.properties_dialog_name); + LinearLayout mLocationLinearLayout = v.findViewById(R.id.properties_dialog_location); + LinearLayout mSizeLinearLayout = v.findViewById(R.id.properties_dialog_size); + LinearLayout mDateLinearLayout = v.findViewById(R.id.properties_dialog_date); + + // setting click listeners for long press + mNameLinearLayout.setOnLongClickListener( + v1 -> { + FileUtils.copyToClipboard(c, name); + Toast.makeText( + c, + c.getString(R.string.name) + + " " + + c.getString(R.string.properties_copied_clipboard), + Toast.LENGTH_SHORT) + .show(); + return false; + }); + mLocationLinearLayout.setOnLongClickListener( + v12 -> { + FileUtils.copyToClipboard(c, parent); + Toast.makeText( + c, + c.getString(R.string.location) + + " " + + c.getString(R.string.properties_copied_clipboard), + Toast.LENGTH_SHORT) + .show(); + return false; + }); + mSizeLinearLayout.setOnLongClickListener( + v13 -> { + FileUtils.copyToClipboard(c, items); + Toast.makeText( + c, + c.getString(R.string.size) + + " " + + c.getString(R.string.properties_copied_clipboard), + Toast.LENGTH_SHORT) + .show(); + return false; + }); + mDateLinearLayout.setOnLongClickListener( + v14 -> { + FileUtils.copyToClipboard(c, date); + Toast.makeText( + c, + c.getString(R.string.date) + + " " + + c.getString(R.string.properties_copied_clipboard), + Toast.LENGTH_SHORT) + .show(); + return false; + }); + } + + CountItemsOrAndSizeTask countItemsOrAndSizeTask = + new CountItemsOrAndSizeTask(c, itemsText, baseFile, forStorage); + countItemsOrAndSizeTask.executeOnExecutor(executor); + + TaskKt.fromTask(new CalculateHashTask(baseFile, c, v)); + + /*Chart creation and data loading*/ + { + int layoutDirection = TextUtilsCompat.getLayoutDirectionFromLocale(Locale.getDefault()); + boolean isRightToLeft = layoutDirection == ViewCompat.LAYOUT_DIRECTION_RTL; + boolean isDarkTheme = appTheme.getMaterialDialogTheme() == Theme.DARK; + PieChart chart = v.findViewById(R.id.chart); + + chart.setTouchEnabled(false); + chart.setDrawEntryLabels(false); + chart.setDescription(null); + chart.setNoDataText(c.getString(R.string.loading)); + chart.setRotationAngle(!isRightToLeft ? 0f : 180f); + chart.setHoleColor(Color.TRANSPARENT); + chart.setCenterTextColor(isDarkTheme ? Color.WHITE : Color.BLACK); + + chart.getLegend().setEnabled(true); + chart.getLegend().setForm(Legend.LegendForm.CIRCLE); + chart.getLegend().setHorizontalAlignment(Legend.LegendHorizontalAlignment.CENTER); + chart.getLegend().setTypeface(Typeface.create("sans-serif-medium", Typeface.NORMAL)); + chart.getLegend().setTextColor(isDarkTheme ? Color.WHITE : Color.BLACK); + + chart.animateY(1000); + + if (forStorage) { + final String[] LEGENDS = + new String[] {c.getString(R.string.used), c.getString(R.string.free)}; + final int[] COLORS = { + Utils.getColor(c, R.color.piechart_red), Utils.getColor(c, R.color.piechart_green) + }; + + long totalSpace = baseFile.getTotal(c), + freeSpace = baseFile.getUsableSpace(), + usedSpace = totalSpace - freeSpace; + + List entries = new ArrayList<>(); + entries.add(new PieEntry(usedSpace, LEGENDS[0])); + entries.add(new PieEntry(freeSpace, LEGENDS[1])); + + PieDataSet set = new PieDataSet(entries, null); + set.setColors(COLORS); + set.setXValuePosition(PieDataSet.ValuePosition.OUTSIDE_SLICE); + set.setYValuePosition(PieDataSet.ValuePosition.OUTSIDE_SLICE); + set.setSliceSpace(5f); + set.setAutomaticallyDisableSliceSpacing(true); + set.setValueLinePart2Length(1.05f); + set.setSelectionShift(0f); + + PieData pieData = new PieData(set); + pieData.setValueFormatter(new SizeFormatter(c)); + pieData.setValueTextColor(isDarkTheme ? Color.WHITE : Color.BLACK); + + String totalSpaceFormatted = Formatter.formatFileSize(c, totalSpace); + + chart.setCenterText( + new SpannableString(c.getString(R.string.total) + "\n" + totalSpaceFormatted)); + chart.setData(pieData); + } else { + LoadFolderSpaceDataTask loadFolderSpaceDataTask = + new LoadFolderSpaceDataTask(c, appTheme, chart, baseFile); + loadFolderSpaceDataTask.executeOnExecutor(executor); + } + + chart.invalidate(); + } + + if (!forStorage && permissions != null && mainFragment != null) { + AppCompatButton appCompatButton = v.findViewById(R.id.permissionsButton); + appCompatButton.setAllCaps(true); + + final View permissionsTable = v.findViewById(R.id.permtable); + final View button = v.findViewById(R.id.set); + if (isRoot && permissions.length() > 6) { + appCompatButton.setVisibility(View.VISIBLE); + appCompatButton.setOnClickListener( + v15 -> { + if (permissionsTable.getVisibility() == View.GONE) { + permissionsTable.setVisibility(View.VISIBLE); + button.setVisibility(View.VISIBLE); + setPermissionsDialog( + permissionsTable, button, baseFile, permissions, c, mainFragment); + } else { + button.setVisibility(View.GONE); + permissionsTable.setVisibility(View.GONE); + } + }); + } + } + + builder.customView(v, true); + builder.positiveText(themedActivity.getString(R.string.ok)); + builder.positiveColor(accentColor); + builder.dismissListener(dialog -> executor.shutdown()); + builder.onPositive( + (dialog, which) -> { + if (baseFile.isDirectory() && nomediaFile != null) { + if (nomediaCheckBox.isChecked()) { + // checkbox is checked, create .nomedia + try { + if (!nomediaFile.createNewFile()) { + // failed operation + LOG.warn(".nomedia file creation in {} failed", baseFile.getPath()); + } + } catch (IOException e) { + LOG.warn("failed to create file at path {}", baseFile.getPath(), e); + } + } else { + // checkbox is unchecked, delete .nomedia + if (!nomediaFile.delete()) { + // failed operation + LOG.warn(".nomedia file deletion in {} failed", baseFile.getPath()); + } + } + } + }); + + MaterialDialog materialDialog = builder.build(); + materialDialog.show(); + materialDialog.getActionButton(DialogAction.NEGATIVE).setEnabled(false); + + /* + View bottomSheet = c.findViewById(R.id.design_bottom_sheet); + BottomSheetBehavior bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet); + bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); + bottomSheetBehavior.setPeekHeight(BottomSheetBehavior.STATE_DRAGGING); + */ + } + + public static class SizeFormatter implements IValueFormatter { + + private Context context; + + public SizeFormatter(Context c) { + context = c; + } + + @Override + public String getFormattedValue( + float value, Entry entry, int dataSetIndex, ViewPortHandler viewPortHandler) { + String prefix = + entry.getData() != null && entry.getData() instanceof String + ? (String) entry.getData() + : ""; + + return prefix + Formatter.formatFileSize(context, (long) value); + } + } + + public static void showCloudDialog( + final MainActivity mainActivity, AppTheme appTheme, final OpenMode openMode) { + int accentColor = mainActivity.getAccent(); + final MaterialDialog.Builder builder = new MaterialDialog.Builder(mainActivity); + + switch (openMode) { + case DROPBOX: + builder.title(mainActivity.getString(R.string.cloud_dropbox)); + break; + case BOX: + builder.title(mainActivity.getString(R.string.cloud_box)); + break; + case GDRIVE: + builder.title(mainActivity.getString(R.string.cloud_drive)); + break; + case ONEDRIVE: + builder.title(mainActivity.getString(R.string.cloud_onedrive)); + break; + } + + builder.theme(appTheme.getMaterialDialogTheme()); + builder.content(mainActivity.getString(R.string.cloud_remove)); + + builder.positiveText(mainActivity.getString(R.string.yes)); + builder.positiveColor(accentColor); + builder.negativeText(mainActivity.getString(R.string.no)); + builder.negativeColor(accentColor); + + builder.onPositive((dialog, which) -> mainActivity.deleteConnection(openMode)); + + builder.onNegative((dialog, which) -> dialog.cancel()); + + builder.show(); + } + + public static void showDecryptDialog( + Context c, + final MainActivity main, + final Intent intent, + AppTheme appTheme, + final String password, + final EncryptDecryptUtils.DecryptButtonCallbackInterface decryptButtonCallbackInterface) { + + showPasswordDialog( + c, + main, + appTheme, + R.string.crypt_decrypt, + R.string.authenticate_password, + ((dialog, which) -> { + AppCompatEditText editText = dialog.getView().findViewById(R.id.singleedittext_input); + + if (editText.getText().toString().equals(password)) + decryptButtonCallbackInterface.confirm(intent); + else decryptButtonCallbackInterface.failed(); + + dialog.dismiss(); + }), + null); + } + + public static void showPasswordDialog( + @NonNull Context c, + @NonNull final MainActivity main, + @NonNull AppTheme appTheme, + @StringRes int titleText, + @StringRes int promptText, + @NonNull MaterialDialog.SingleButtonCallback positiveCallback, + @Nullable MaterialDialog.SingleButtonCallback negativeCallback) { + int accentColor = main.getAccent(); + + MaterialDialog.Builder builder = new MaterialDialog.Builder(c); + View dialogLayout = View.inflate(main, R.layout.dialog_singleedittext, null); + WarnableTextInputLayout wilTextfield = + dialogLayout.findViewById(R.id.singleedittext_warnabletextinputlayout); + AppCompatEditText textfield = dialogLayout.findViewById(R.id.singleedittext_input); + textfield.setHint(promptText); + textfield.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); + + dialogLayout.post(() -> ExtensionsKt.openKeyboard(textfield, main.getApplicationContext())); + + builder + .customView(dialogLayout, false) + .theme(appTheme.getMaterialDialogTheme()) + .autoDismiss(false) + .canceledOnTouchOutside(false) + .title(titleText) + .positiveText(R.string.ok) + .positiveColor(accentColor) + .onPositive(positiveCallback) + .negativeText(R.string.cancel) + .negativeColor(accentColor); + + if (negativeCallback != null) builder.onNegative(negativeCallback); + else builder.onNegative((dialog, which) -> dialog.cancel()); + + MaterialDialog dialog = builder.show(); + + new WarnableTextInputValidator( + AppConfig.getInstance().getMainActivityContext(), + textfield, + wilTextfield, + dialog.getActionButton(DialogAction.POSITIVE), + (text) -> { + if (text.length() < 1) { + return new WarnableTextInputValidator.ReturnState( + WarnableTextInputValidator.ReturnState.STATE_ERROR, R.string.field_empty); + } + return new WarnableTextInputValidator.ReturnState(); + }); + } + + public static void showSMBHelpDialog(Context m, int accentColor) { + MaterialDialog.Builder b = new MaterialDialog.Builder(m); + b.content(m.getText(R.string.smb_instructions)); + b.positiveText(R.string.doit); + b.positiveColor(accentColor); + b.build().show(); + } + + public static void showPackageDialog(final File f, final MainActivity m) { + int accentColor = m.getAccent(); + MaterialDialog.Builder mat = new MaterialDialog.Builder(m); + mat.title(R.string.package_installer) + .content(R.string.package_installer_text) + .positiveText(R.string.install) + .negativeText(R.string.view) + .neutralText(R.string.cancel) + .positiveColor(accentColor) + .negativeColor(accentColor) + .neutralColor(accentColor) + .onPositive((dialog, which) -> FileUtils.installApk(f, m)) + .onNegative((dialog, which) -> m.openCompressed(f.getPath())) + .theme(m.getAppTheme().getMaterialDialogTheme()) + .build() + .show(); + } + + public static MaterialDialog showOpenFileDeeplinkDialog( + final HybridFile file, final MainActivity m, final String content, Runnable openCallback) { + int accentColor = m.getAccent(); + return new MaterialDialog.Builder(m) + .title(R.string.confirmation) + .content(content) + .positiveText(R.string.open) + .negativeText(R.string.cancel) + .positiveColor(accentColor) + .negativeColor(accentColor) + .onPositive((dialog, which) -> openCallback.run()) + .onNegative((dialog, which) -> dialog.dismiss()) + .theme(m.getAppTheme().getMaterialDialogTheme()) + .build(); + } + + public static void showArchiveDialog(final File f, final MainActivity m) { + int accentColor = m.getAccent(); + MaterialDialog.Builder mat = new MaterialDialog.Builder(m); + mat.title(R.string.archive) + .content(R.string.archive_text) + .positiveText(R.string.extract) + .negativeText(R.string.view) + .neutralText(R.string.cancel) + .positiveColor(accentColor) + .negativeColor(accentColor) + .neutralColor(accentColor) + .onPositive((dialog, which) -> m.mainActivityHelper.extractFile(f)) + .onNegative((dialog, which) -> m.openCompressed(Uri.fromFile(f).toString())); + if (m.getAppTheme().equals(AppTheme.DARK) || m.getAppTheme().equals(AppTheme.BLACK)) + mat.theme(Theme.DARK); + MaterialDialog b = mat.build(); + + if (!CompressedHelper.isFileExtractable(f.getPath())) { + b.getActionButton(DialogAction.NEGATIVE).setEnabled(false); + } + b.show(); + } + + public static void showCompressDialog( + @NonNull final MainActivity mainActivity, + final HybridFileParcelable baseFile, + final String current) { + ArrayList baseFiles = new ArrayList<>(); + baseFiles.add(baseFile); + showCompressDialog(mainActivity, baseFiles, current); + } + + public static void showCompressDialog( + @NonNull final MainActivity mainActivity, + final ArrayList baseFiles, + final String current) { + int accentColor = mainActivity.getAccent(); + MaterialDialog.Builder a = new MaterialDialog.Builder(mainActivity); + + View dialogView = + mainActivity.getLayoutInflater().inflate(R.layout.dialog_singleedittext, null); + AppCompatEditText etFilename = dialogView.findViewById(R.id.singleedittext_input); + etFilename.setHint(R.string.enterzipname); + etFilename.setText(".zip"); // TODO: Put the file/folder name here + etFilename.setInputType(InputType.TYPE_TEXT_FLAG_CAP_SENTENCES); + etFilename.setSingleLine(); + WarnableTextInputLayout tilFilename = + dialogView.findViewById(R.id.singleedittext_warnabletextinputlayout); + + dialogView.post( + () -> ExtensionsKt.openKeyboard(etFilename, mainActivity.getApplicationContext())); + + a.customView(dialogView, false) + .widgetColor(accentColor) + .theme(mainActivity.getAppTheme().getMaterialDialogTheme()) + .title(mainActivity.getResources().getString(R.string.enterzipname)) + .positiveText(R.string.create) + .positiveColor(accentColor) + .onPositive( + (materialDialog, dialogAction) -> { + String name = current + "/" + etFilename.getText().toString(); + mainActivity.mainActivityHelper.compressFiles(new File(name), baseFiles); + }) + .negativeText(mainActivity.getResources().getString(R.string.cancel)) + .negativeColor(accentColor); + + final MaterialDialog materialDialog = a.build(); + + new WarnableTextInputValidator( + a.getContext(), + etFilename, + tilFilename, + materialDialog.getActionButton(DialogAction.POSITIVE), + (text) -> { + boolean isValidFilename = FileProperties.isValidFilename(text); + + if (isValidFilename && text.length() > 0 && !text.toLowerCase().endsWith(".zip")) { + return new WarnableTextInputValidator.ReturnState( + WarnableTextInputValidator.ReturnState.STATE_WARNING, + R.string.compress_file_suggest_zip_extension); + } else { + if (!isValidFilename) { + return new WarnableTextInputValidator.ReturnState( + WarnableTextInputValidator.ReturnState.STATE_ERROR, R.string.invalid_name); + } else if (text.length() < 1) { + return new WarnableTextInputValidator.ReturnState( + WarnableTextInputValidator.ReturnState.STATE_ERROR, R.string.field_empty); + } + } + + return new WarnableTextInputValidator.ReturnState(); + }); + + materialDialog.show(); + + // place cursor at the starting of edit text by posting a runnable to edit text + // this is done because in case android has not populated the edit text layouts yet, it'll + // reset calls to selection if not posted in message queue + etFilename.post(() -> etFilename.setSelection(0)); + } + + public static void showSortDialog( + final MainFragment m, AppTheme appTheme, final SharedPreferences sharedPref) { + final String path = m.getCurrentPath(); + int accentColor = m.getMainActivity().getAccent(); + String[] sort = m.getResources().getStringArray(R.array.sortby); + SortType current = SortHandler.getSortType(m.getContext(), path); + MaterialDialog.Builder a = new MaterialDialog.Builder(m.getActivity()); + a.theme(appTheme.getMaterialDialogTheme()); + a.items(sort) + .itemsCallbackSingleChoice( + current.getSortBy().getIndex(), (dialog, view, which, text) -> true); + final Set sortbyOnlyThis = + sharedPref.getStringSet(PREFERENCE_SORTBY_ONLY_THIS, Collections.emptySet()); + final Set onlyThisFloders = new HashSet<>(sortbyOnlyThis); + boolean onlyThis = onlyThisFloders.contains(path); + a.checkBoxPrompt( + m.getResources().getString(R.string.sort_only_this), + onlyThis, + (buttonView, isChecked) -> { + if (isChecked) { + if (!onlyThisFloders.contains(path)) { + onlyThisFloders.add(path); + } + } else { + if (onlyThisFloders.contains(path)) { + onlyThisFloders.remove(path); + } + } + }); + a.negativeText(R.string.ascending).positiveColor(accentColor); + a.positiveText(R.string.descending).negativeColor(accentColor); + a.onNegative( + (dialog, which) -> { + onSortTypeSelected(m, sharedPref, onlyThisFloders, dialog, SortOrder.ASC); + }); + a.onPositive( + (dialog, which) -> { + onSortTypeSelected(m, sharedPref, onlyThisFloders, dialog, SortOrder.DESC); + }); + a.title(R.string.sort_by); + a.build().show(); + } + + private static void onSortTypeSelected( + MainFragment m, + SharedPreferences sharedPref, + Set onlyThisFloders, + MaterialDialog dialog, + SortOrder sortOrder) { + final SortType sortType = + new SortType(SortBy.getDirectorySortBy(dialog.getSelectedIndex()), sortOrder); + SortHandler sortHandler = SortHandler.getInstance(); + if (onlyThisFloders.contains(m.getCurrentPath())) { + Sort oldSort = sortHandler.findEntry(m.getCurrentPath()); + if (oldSort == null) { + sortHandler.addEntry(m.getCurrentPath(), sortType); + } else { + sortHandler.updateEntry(oldSort, m.getCurrentPath(), sortType); + } + } else { + sortHandler.clear(m.getCurrentPath()); + sharedPref.edit().putString("sortby", String.valueOf(sortType.toDirectorySortInt())).apply(); + } + sharedPref.edit().putStringSet(PREFERENCE_SORTBY_ONLY_THIS, onlyThisFloders).apply(); + m.updateList(false); + dialog.dismiss(); + } + + public static void setPermissionsDialog( + final View v, + View but, + final HybridFile file, + final String f, + final Context context, + final MainFragment mainFrag) { + final AppCompatCheckBox readown = v.findViewById(R.id.creadown); + final AppCompatCheckBox readgroup = v.findViewById(R.id.creadgroup); + final AppCompatCheckBox readother = v.findViewById(R.id.creadother); + final AppCompatCheckBox writeown = v.findViewById(R.id.cwriteown); + final AppCompatCheckBox writegroup = v.findViewById(R.id.cwritegroup); + final AppCompatCheckBox writeother = v.findViewById(R.id.cwriteother); + final AppCompatCheckBox exeown = v.findViewById(R.id.cexeown); + final AppCompatCheckBox exegroup = v.findViewById(R.id.cexegroup); + final AppCompatCheckBox exeother = v.findViewById(R.id.cexeother); + String perm = f; + if (perm.length() < 6) { + v.setVisibility(View.GONE); + but.setVisibility(View.GONE); + Toast.makeText(context, R.string.not_allowed, Toast.LENGTH_SHORT).show(); + return; + } + ArrayList arrayList = FileUtils.parse(perm); + Boolean[] read = arrayList.get(0); + Boolean[] write = arrayList.get(1); + final Boolean[] exe = arrayList.get(2); + readown.setChecked(read[0]); + readgroup.setChecked(read[1]); + readother.setChecked(read[2]); + writeown.setChecked(write[0]); + writegroup.setChecked(write[1]); + writeother.setChecked(write[2]); + exeown.setChecked(exe[0]); + exegroup.setChecked(exe[1]); + exeother.setChecked(exe[2]); + but.setOnClickListener( + v1 -> { + int perms = + RootHelper.permissionsToOctalString( + readown.isChecked(), + writeown.isChecked(), + exeown.isChecked(), + readgroup.isChecked(), + writegroup.isChecked(), + exegroup.isChecked(), + readother.isChecked(), + writeother.isChecked(), + exeother.isChecked()); + + try { + ChangeFilePermissionsCommand.INSTANCE.changeFilePermissions( + file.getPath(), + perms, + file.isDirectory(context), + isSuccess -> { + if (isSuccess) { + Toast.makeText(context, mainFrag.getString(R.string.done), Toast.LENGTH_LONG) + .show(); + } else { + Toast.makeText( + context, + mainFrag.getString(R.string.operation_unsuccesful), + Toast.LENGTH_LONG) + .show(); + } + return null; + }); + } catch (ShellNotRunningException e) { + Toast.makeText(context, mainFrag.getString(R.string.root_failure), Toast.LENGTH_LONG) + .show(); + LOG.warn("failed to set permission dialog", e); + } + }); + } + + public static void showChangePathsDialog( + final MainActivity mainActivity, final SharedPreferences prefs) { + final MainFragment mainFragment = mainActivity.getCurrentMainFragment(); + Objects.requireNonNull(mainActivity); + final MaterialDialog.Builder a = new MaterialDialog.Builder(mainActivity); + a.input( + null, + mainFragment.getCurrentPath(), + false, + (dialog, charSequence) -> { + boolean isAccessible = FileUtils.isPathAccessible(charSequence.toString(), prefs); + dialog.getActionButton(DialogAction.POSITIVE).setEnabled(isAccessible); + }); + + a.alwaysCallInputCallback(); + + int accentColor = mainActivity.getAccent(); + + a.widgetColor(accentColor); + + a.theme(mainActivity.getAppTheme().getMaterialDialogTheme()); + a.title(R.string.enterpath); + + a.positiveText(R.string.go); + a.positiveColor(accentColor); + + a.negativeText(R.string.cancel); + a.negativeColor(accentColor); + + a.onPositive( + (dialog, which) -> { + mainFragment.loadlist( + dialog.getInputEditText().getText().toString(), false, OpenMode.UNKNOWN, false); + }); + + a.show(); + } + + public static MaterialDialog showOtgSafExplanationDialog(ThemedActivity themedActivity) { + return GeneralDialogCreation.showBasicDialog( + themedActivity, + R.string.saf_otg_explanation, + R.string.otg_access, + R.string.ok, + R.string.cancel); + } + + public static void showSignInWithGoogleDialog(@NonNull MainActivity mainActivity) { + View customView = + DialogSigninWithGoogleBinding.inflate(LayoutInflater.from(mainActivity)).getRoot(); + int accentColor = mainActivity.getAccent(); + + MaterialDialog dialog = + new MaterialDialog.Builder(mainActivity) + .customView(customView, false) + .title(R.string.signin_with_google_title) + .negativeText(android.R.string.cancel) + .negativeColor(accentColor) + .onNegative((dlg, which) -> dlg.dismiss()) + .build(); + + customView + .findViewById(R.id.signin_with_google) + .setOnClickListener( + v -> { + mainActivity.addConnection(OpenMode.GDRIVE); + dialog.dismiss(); + }); + + dialog.show(); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/dialogs/HiddenFilesDialog.kt b/app/src/main/java/com/amaze/filemanager/ui/dialogs/HiddenFilesDialog.kt new file mode 100644 index 0000000..269bd43 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/dialogs/HiddenFilesDialog.kt @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.dialogs + +import android.graphics.Color +import com.afollestad.materialdialogs.MaterialDialog +import com.amaze.filemanager.R +import com.amaze.filemanager.adapters.HiddenAdapter +import com.amaze.filemanager.fileoperations.filesystem.OpenMode +import com.amaze.filemanager.filesystem.files.FileUtils +import com.amaze.filemanager.ui.activities.MainActivity +import com.amaze.filemanager.ui.fragments.MainFragment +import com.amaze.filemanager.utils.DataUtils + +object HiddenFilesDialog { + /** + * Create hidden files dialog, it shows the files hidden from the context view when selecting + */ + @JvmStatic + fun showHiddenDialog( + mainActivity: MainActivity, + mainFragment: MainFragment, + ) { + val sharedPrefs = mainActivity.prefs + val appTheme = mainActivity.appTheme + + val adapter = + HiddenAdapter( + mainActivity, + mainFragment, + sharedPrefs, + FileUtils.toHybridFileConcurrentRadixTree(DataUtils.getInstance().hiddenFiles), + null, + false, + ) + + val materialDialog = + MaterialDialog.Builder(mainActivity).also { builder -> + builder.positiveText(R.string.close) + builder.positiveColor(mainActivity.accent) + builder.title(R.string.hiddenfiles) + builder.theme(appTheme.getMaterialDialogTheme()) + builder.autoDismiss(true) + builder.adapter(adapter, null) + builder.dividerColor(Color.GRAY) + }.build() + + adapter.materialDialog = materialDialog + materialDialog.setOnDismissListener { + mainFragment.loadlist(mainFragment.currentPath, false, OpenMode.UNKNOWN, false) + } + materialDialog.show() + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/dialogs/HistoryDialog.kt b/app/src/main/java/com/amaze/filemanager/ui/dialogs/HistoryDialog.kt new file mode 100644 index 0000000..cb1e6f7 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/dialogs/HistoryDialog.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.dialogs + +import com.afollestad.materialdialogs.DialogAction +import com.afollestad.materialdialogs.MaterialDialog +import com.amaze.filemanager.R +import com.amaze.filemanager.adapters.HiddenAdapter +import com.amaze.filemanager.filesystem.files.FileUtils +import com.amaze.filemanager.ui.activities.MainActivity +import com.amaze.filemanager.ui.fragments.MainFragment +import com.amaze.filemanager.utils.DataUtils + +object HistoryDialog { + /** + * Create history dialog, it shows the accessed folders from last accessed to first accessed + */ + @JvmStatic + fun showHistoryDialog( + mainActivity: MainActivity, + mainFragment: MainFragment, + ) { + val sharedPrefs = mainActivity.prefs + val appTheme = mainActivity.appTheme + + val adapter = + HiddenAdapter( + mainActivity, + mainFragment, + sharedPrefs, + FileUtils.toHybridFileArrayList(DataUtils.getInstance().history), + null, + true, + ) + + val materialDialog = + MaterialDialog.Builder(mainActivity).also { builder -> + builder.positiveText(R.string.cancel) + builder.positiveColor(mainActivity.accent) + builder.negativeText(R.string.clear) + builder.negativeColor(mainActivity.accent) + builder.title(R.string.history) + builder.onNegative { _: MaterialDialog?, _: DialogAction? -> + DataUtils.getInstance().clearHistory() + } + builder.theme(appTheme.getMaterialDialogTheme()) + builder.adapter(adapter, null) + }.build() + adapter.materialDialog = materialDialog + materialDialog.show() + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/dialogs/OpenFileDialogFragment.kt b/app/src/main/java/com/amaze/filemanager/ui/dialogs/OpenFileDialogFragment.kt new file mode 100644 index 0000000..72a063d --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/dialogs/OpenFileDialogFragment.kt @@ -0,0 +1,484 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.dialogs + +import android.content.ActivityNotFoundException +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.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.LinearLayoutManager +import com.amaze.filemanager.R +import com.amaze.filemanager.adapters.AppsRecyclerAdapter +import com.amaze.filemanager.adapters.data.AppDataParcelable +import com.amaze.filemanager.adapters.data.OpenFileParcelable +import com.amaze.filemanager.adapters.glide.AppsAdapterPreloadModel +import com.amaze.filemanager.adapters.holders.AppHolder +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.databinding.FragmentOpenFileDialogBinding +import com.amaze.filemanager.filesystem.files.FileUtils +import com.amaze.filemanager.ui.activities.MainActivity +import com.amaze.filemanager.ui.activities.superclasses.BasicActivity +import com.amaze.filemanager.ui.activities.superclasses.PreferenceActivity +import com.amaze.filemanager.ui.activities.superclasses.ThemedActivity +import com.amaze.filemanager.ui.base.BaseBottomSheetFragment +import com.amaze.filemanager.ui.fragments.AdjustListViewForTv +import com.amaze.filemanager.ui.icons.MimeTypes +import com.amaze.filemanager.ui.provider.UtilitiesProvider +import com.amaze.filemanager.ui.startActivityCatchingSecurityException +import com.amaze.filemanager.ui.views.ThemedTextView +import com.amaze.filemanager.utils.GlideConstants +import com.bumptech.glide.Glide +import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader +import com.bumptech.glide.util.ViewPreloadSizeProvider +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +class OpenFileDialogFragment : BaseBottomSheetFragment(), AdjustListViewForTv { + private var uri: Uri? = null + private var mimeType: String? = null + private var useNewStack: Boolean? = null + private var fragmentOpenFileDialogBinding: FragmentOpenFileDialogBinding? = null + private val viewBinding get() = fragmentOpenFileDialogBinding!! + + private lateinit var adapter: AppsRecyclerAdapter + private lateinit var utilsProvider: UtilitiesProvider + private lateinit var sharedPreferences: SharedPreferences + + companion object { + private val log: Logger = LoggerFactory.getLogger(OpenFileDialogFragment::class.java) + + private const val KEY_URI = "uri" + private const val KEY_MIME_TYPE = "mime_type" + private const val KEY_USE_NEW_STACK = "use_new_stack" + private const val KEY_PREFERENCES_DEFAULT = "_DEFAULT" + const val KEY_PREFERENCES_LAST = "_LAST" + + /** + * Opens the file using previously set default app or shows a bottom sheet dialog + */ + fun openFileOrShow( + uri: Uri, + mimeType: String, + useNewStack: Boolean, + activity: PreferenceActivity, + forceChooser: Boolean, + ) { + if (mimeType == MimeTypes.ALL_MIME_TYPES || + forceChooser || + !getPreferenceAndStartActivity( + uri, + mimeType, + useNewStack, + activity, + ) + ) { + if (forceChooser) { + clearMimeTypePreference( + MimeTypes.getMimeType(uri.toString(), false), + activity.prefs, + ) + } + val openFileDialogFragment = newInstance(uri, mimeType, useNewStack) + openFileDialogFragment.show( + activity.supportFragmentManager, + javaClass.simpleName, + ) + } + } + + private fun newInstance( + uri: Uri, + mimeType: String, + useNewStack: Boolean, + ): OpenFileDialogFragment { + val args = Bundle() + + val fragment = OpenFileDialogFragment() + args.putParcelable(KEY_URI, uri) + args.putString(KEY_MIME_TYPE, mimeType) + args.putBoolean(KEY_USE_NEW_STACK, useNewStack) + fragment.arguments = args + return fragment + } + + private fun startActivity( + context: Context, + intent: Intent, + ) { + try { + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + log.error(e.message, e) + Toast.makeText(context, R.string.no_app_found, Toast.LENGTH_SHORT).show() + throw e + } + } + + /** + * Builds an intent which necessary permission flags for external apps to open uri file + */ + fun buildIntent( + context: Context, + uri: Uri, + mimeType: String, + useNewStack: Boolean, + className: String?, + packageName: String?, + ): Intent { + val chooserIntent = Intent() + chooserIntent.action = Intent.ACTION_VIEW + chooserIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + chooserIntent.setDataAndType(uri, mimeType) + + for ( + resolveInfo in context.packageManager + .queryIntentActivities( + chooserIntent, + PackageManager.MATCH_DEFAULT_ONLY, + ) + ) context.grantUriPermission( + resolveInfo.activityInfo.packageName, + uri, + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + + if (useNewStack) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT) + } else { + chooserIntent.addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + or Intent.FLAG_ACTIVITY_TASK_ON_HOME, + ) + } + } + className?.run { + packageName?.run { + chooserIntent.setClassName(packageName, className) + } + } + return chooserIntent + } + + private fun getPreferenceAndStartActivity( + uri: Uri, + mimeType: String, + useNewStack: Boolean, + activity: PreferenceActivity, + ): Boolean { + val classAndPackageRaw = + activity.prefs.getString( + mimeType.plus( + KEY_PREFERENCES_DEFAULT, + ), + null, + ) + var result = false + if (!classAndPackageRaw.isNullOrEmpty()) { + try { + val classNameAndPackageName = classAndPackageRaw.split(" ") + val intent = + buildIntent( + activity, + uri, + mimeType, + useNewStack, + classNameAndPackageName[0], + classNameAndPackageName[1], + ) + startActivity(activity, intent) + result = true + } catch (e: ActivityNotFoundException) { + activity.prefs.edit().putString( + mimeType.plus(KEY_PREFERENCES_DEFAULT), + null, + ).apply() + } + } + return result + } + + /** + * Sets last open app preference for bottom sheet file chooser. + * Next time same mime type comes, this app will be shown on top of the list if present + */ + fun setLastOpenedApp( + appDataParcelable: AppDataParcelable, + preferenceActivity: PreferenceActivity, + ) { + preferenceActivity.prefs.edit().putString( + appDataParcelable.openFileParcelable?.mimeType.plus(KEY_PREFERENCES_LAST), + String.format( + "%s %s", + appDataParcelable.openFileParcelable?.className, + appDataParcelable.openFileParcelable?.packageName, + ), + ).apply() + } + + /** + * Sets default app for mime type selected using 'Always' button from bottom sheet + */ + private fun setDefaultOpenedApp( + appDataParcelable: AppDataParcelable, + preferenceActivity: PreferenceActivity, + ) { + preferenceActivity.prefs.edit().putString( + appDataParcelable.openFileParcelable?.mimeType.plus(KEY_PREFERENCES_DEFAULT), + String.format( + "%s %s", + appDataParcelable.openFileParcelable?.className, + appDataParcelable.openFileParcelable?.packageName, + ), + ).apply() + } + + /** + * Clears all default apps set preferences for mime types + */ + fun clearPreferences(sharedPreferences: SharedPreferences) { + AppConfig.getInstance().runInBackground { + val keys = HashSet() + sharedPreferences.all.keys.forEach { + if (it.endsWith(KEY_PREFERENCES_DEFAULT) || + it.endsWith(KEY_PREFERENCES_LAST) + ) { + keys.add(it) + } + } + keys.forEach { + sharedPreferences.edit().remove(it).apply() + } + } + } + + private fun clearMimeTypePreference( + mimeType: String, + sharedPreferences: SharedPreferences, + ) { + sharedPreferences.edit().remove(mimeType.plus(KEY_PREFERENCES_DEFAULT)).apply() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + uri = arguments?.getParcelable(KEY_URI) + mimeType = arguments?.getString(KEY_MIME_TYPE) + useNewStack = arguments?.getBoolean(KEY_USE_NEW_STACK) + utilsProvider = (activity as BasicActivity?)!!.utilsProvider + setStyle(STYLE_NORMAL, R.style.appBottomSheetDialogTheme) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? { + fragmentOpenFileDialogBinding = FragmentOpenFileDialogBinding.inflate(inflater) + initDialogResources(viewBinding.parent) + return viewBinding.root + } + + override fun onDestroyView() { + super.onDestroyView() + fragmentOpenFileDialogBinding = null + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + val modelProvider = AppsAdapterPreloadModel(this, true) + val sizeProvider = ViewPreloadSizeProvider() + var preloader = + RecyclerViewPreloader( + Glide.with(this), + modelProvider, + sizeProvider, + GlideConstants.MAX_PRELOAD_FILES, + ) + sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) + + val intent = + buildIntent( + requireContext(), + uri!!, + mimeType!!, + useNewStack!!, + null, + null, + ) + val appDataParcelableList = initAppDataParcelableList(intent) + val lastClassAndPackageRaw = + sharedPreferences + .getString(mimeType.plus(KEY_PREFERENCES_LAST), null) + val lastClassAndPackage = lastClassAndPackageRaw?.split(" ") + val lastAppData: AppDataParcelable = + initLastAppData( + lastClassAndPackage, + appDataParcelableList, + ) ?: return + + adapter = + AppsRecyclerAdapter( + this, + modelProvider, + true, + this, + appDataParcelableList, + ) + loadViews(lastAppData) + + viewBinding.appsRecyclerView.addOnScrollListener(preloader) + } + + override fun onPause() { + super.onPause() + dismiss() + } + + private fun loadViews(lastAppData: AppDataParcelable) { + lastAppData.let { + val lastAppIntent = + buildIntent( + requireContext(), + it.openFileParcelable?.uri!!, + it.openFileParcelable?.mimeType!!, + it.openFileParcelable?.useNewStack!!, + it.openFileParcelable?.className, + it.openFileParcelable?.packageName, + ) + + viewBinding.run { + appsRecyclerView.layoutManager = LinearLayoutManager(requireContext()) + appsRecyclerView.adapter = adapter + lastAppTitle.text = it.label + lastAppImage.setImageDrawable( + requireActivity().packageManager.getApplicationIcon(it.packageName), + ) + justOnceButton.setTextColor((activity as ThemedActivity).accent) + justOnceButton.setOnClickListener { _ -> + setLastOpenedApp(it, activity as PreferenceActivity) + requireContext().startActivityCatchingSecurityException(lastAppIntent) + } + alwaysButton.setTextColor((activity as ThemedActivity).accent) + alwaysButton.setOnClickListener { _ -> + setDefaultOpenedApp(it, activity as PreferenceActivity) + requireContext().startActivityCatchingSecurityException(lastAppIntent) + } + openAsButton.setOnClickListener { + FileUtils.openWith(uri, activity as PreferenceActivity, useNewStack!!) + dismiss() + } + ThemedTextView.setTextViewColor(lastAppTitle, requireContext()) + ThemedTextView.setTextViewColor(chooseDifferentAppTextView, requireContext()) + } + } + } + + private fun initAppDataParcelableList(intent: Intent): MutableList { + val packageManager = requireContext().packageManager + val appDataParcelableList: MutableList = ArrayList() + packageManager.queryIntentActivities(intent, PackageManager.MATCH_ALL).forEach { + val openFileParcelable = + OpenFileParcelable( + uri, + mimeType, + useNewStack, + it.activityInfo.name, + it.activityInfo.packageName, + ) + val label = it.loadLabel(packageManager).toString() + val appDataParcelable = + AppDataParcelable( + if (label.isNotEmpty()) label else it.activityInfo.packageName, + "", + null, + it.activityInfo.packageName, + "", + "", + 0, + 0, false, + openFileParcelable, + ) + appDataParcelableList.add(appDataParcelable) + } + return appDataParcelableList + } + + private fun initLastAppData( + lastClassAndPackage: List?, + appDataParcelableList: MutableList, + ): AppDataParcelable? { + if (appDataParcelableList.size == 0) { + AppConfig.toast(requireContext(), requireContext().getString(R.string.no_app_found)) + FileUtils.openWith(uri, activity as PreferenceActivity, useNewStack!!) + dismiss() + return null + } + + if (appDataParcelableList.size == 1) { + requireContext().startActivityCatchingSecurityException( + buildIntent( + requireContext(), + appDataParcelableList[0].openFileParcelable?.uri!!, + appDataParcelableList[0].openFileParcelable?.mimeType!!, + appDataParcelableList[0].openFileParcelable?.useNewStack!!, + appDataParcelableList[0].openFileParcelable?.className, + appDataParcelableList[0].openFileParcelable?.packageName, + ), + ) + + dismiss() + return null + } + + var lastAppData: AppDataParcelable? = + if (!lastClassAndPackage.isNullOrEmpty()) { + appDataParcelableList.find { + it.openFileParcelable?.className == lastClassAndPackage[0] + } + } else { + null + } + lastAppData = lastAppData ?: appDataParcelableList[0] + appDataParcelableList.remove(lastAppData) + return lastAppData + } + + override fun adjustListViewForTv( + viewHolder: AppHolder, + mainActivity: MainActivity, + ) { + // do nothing + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/dialogs/RenameBookmark.java b/app/src/main/java/com/amaze/filemanager/ui/dialogs/RenameBookmark.java new file mode 100644 index 0000000..850e119 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/dialogs/RenameBookmark.java @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.dialogs; + +import com.afollestad.materialdialogs.DialogAction; +import com.afollestad.materialdialogs.MaterialDialog; +import com.amaze.filemanager.R; +import com.amaze.filemanager.ui.activities.superclasses.BasicActivity; +import com.amaze.filemanager.utils.DataUtils; +import com.amaze.filemanager.utils.SimpleTextWatcher; +import com.google.android.material.textfield.TextInputLayout; + +import android.app.Dialog; +import android.app.DialogFragment; +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.text.Editable; +import android.view.View; + +import androidx.appcompat.widget.AppCompatEditText; +import androidx.preference.PreferenceManager; + +public class RenameBookmark extends DialogFragment { + + private String title; + private String path; + private BookmarkCallback bookmarkCallback; + private final DataUtils dataUtils = DataUtils.getInstance(); + + public static RenameBookmark getInstance(String name, String path, int accentColor) { + RenameBookmark renameBookmark = new RenameBookmark(); + Bundle bundle = new Bundle(); + bundle.putString("title", name); + bundle.putString("path", path); + bundle.putInt("accentColor", accentColor); + + renameBookmark.setArguments(bundle); + return renameBookmark; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + } + + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + Context c = getActivity(); + if (getActivity() instanceof BookmarkCallback) + bookmarkCallback = (BookmarkCallback) getActivity(); + title = getArguments().getString("title"); + path = getArguments().getString("path"); + int accentColor = getArguments().getInt("accentColor"); + SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(c); + + if (dataUtils.containsBooks(new String[] {title, path}) != -1) { + final MaterialDialog materialDialog; + String pa = path; + MaterialDialog.Builder builder = new MaterialDialog.Builder(c); + builder.title(R.string.rename_bookmark); + builder.positiveColor(accentColor); + builder.negativeColor(accentColor); + builder.neutralColor(accentColor); + builder.positiveText(R.string.save); + builder.neutralText(R.string.cancel); + builder.negativeText(R.string.delete); + builder.theme(((BasicActivity) getActivity()).getAppTheme().getMaterialDialogTheme()); + builder.autoDismiss(false); + View v2 = getActivity().getLayoutInflater().inflate(R.layout.rename, null); + builder.customView(v2, true); + final TextInputLayout t1 = v2.findViewById(R.id.t1); + final TextInputLayout t2 = v2.findViewById(R.id.t2); + final AppCompatEditText conName = v2.findViewById(R.id.editText4); + conName.setText(title); + final String s1 = getString(R.string.cant_be_empty, c.getString(R.string.name)); + final String s2 = getString(R.string.cant_be_empty, c.getString(R.string.path)); + conName.addTextChangedListener( + new SimpleTextWatcher() { + @Override + public void afterTextChanged(Editable s) { + if (conName.getText().toString().length() == 0) t1.setError(s2); + else t1.setError(""); + } + }); + final AppCompatEditText ip = v2.findViewById(R.id.editText); + t2.setVisibility(View.GONE); + ip.setText(pa); + builder.onNeutral((dialog, which) -> dialog.dismiss()); + + materialDialog = builder.build(); + materialDialog + .getActionButton(DialogAction.POSITIVE) + .setOnClickListener( + v -> { + String t = ip.getText().toString(); + String name = conName.getText().toString(); + int i = -1; + if ((i = dataUtils.containsBooks(new String[] {title, path})) != -1) { + if (!t.equals(title) && t.length() >= 1) { + dataUtils.removeBook(i); + dataUtils.addBook(new String[] {name, t}); + dataUtils.sortBook(); + if (bookmarkCallback != null) { + bookmarkCallback.modify(path, title, t, name); + } + } + } + materialDialog.dismiss(); + }); + materialDialog + .getActionButton(DialogAction.NEGATIVE) + .setOnClickListener( + v -> { + int i; + if ((i = dataUtils.containsBooks(new String[] {title, path})) != -1) { + dataUtils.removeBook(i); + if (bookmarkCallback != null) { + bookmarkCallback.delete(title, path); + } + } + materialDialog.dismiss(); + }); + return materialDialog; + } + return null; + } + + public interface BookmarkCallback { + void delete(String title, String path); + + void modify(String oldpath, String oldname, String newpath, String newname); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/dialogs/SftpConnectDialog.kt b/app/src/main/java/com/amaze/filemanager/ui/dialogs/SftpConnectDialog.kt new file mode 100644 index 0000000..bf3c836 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/dialogs/SftpConnectDialog.kt @@ -0,0 +1,868 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.dialogs + +import android.app.Activity +import android.app.AlertDialog +import android.app.Dialog +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.text.Editable +import android.text.InputFilter +import android.text.TextUtils +import android.text.TextWatcher +import android.view.View +import android.widget.AdapterView +import android.widget.ArrayAdapter +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.text.isDigitsOnly +import androidx.core.view.isVisible +import androidx.fragment.app.DialogFragment +import com.afollestad.materialdialogs.DialogAction +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.internal.MDButton +import com.amaze.filemanager.R +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.asynchronous.asynctasks.ftp.AbstractGetHostInfoTask +import com.amaze.filemanager.asynchronous.asynctasks.ftp.hostcert.FtpsGetHostCertificateTask +import com.amaze.filemanager.asynchronous.asynctasks.ssh.GetSshHostFingerprintTask +import com.amaze.filemanager.asynchronous.asynctasks.ssh.PemToKeyPairObservable +import com.amaze.filemanager.database.UtilsHandler +import com.amaze.filemanager.database.models.OperationData +import com.amaze.filemanager.databinding.SftpDialogBinding +import com.amaze.filemanager.fileoperations.filesystem.OpenMode +import com.amaze.filemanager.filesystem.ftp.FTPClientImpl.Companion.ARG_TLS +import com.amaze.filemanager.filesystem.ftp.FTPClientImpl.Companion.TLS_EXPLICIT +import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool +import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.FTPS_DEFAULT_PORT +import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.FTPS_URI_PREFIX +import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.FTP_DEFAULT_PORT +import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.FTP_URI_PREFIX +import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.SSH_DEFAULT_PORT +import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.SSH_URI_PREFIX +import com.amaze.filemanager.filesystem.ftp.NetCopyClientUtils +import com.amaze.filemanager.filesystem.ftp.NetCopyConnectionInfo.Companion.COLON +import com.amaze.filemanager.ui.activities.MainActivity +import com.amaze.filemanager.ui.activities.superclasses.ThemedActivity +import com.amaze.filemanager.ui.icons.MimeTypes +import com.amaze.filemanager.ui.provider.UtilitiesProvider +import com.amaze.filemanager.utils.BookSorter +import com.amaze.filemanager.utils.DataUtils +import com.amaze.filemanager.utils.MinMaxInputFilter +import com.amaze.filemanager.utils.PasswordUtil +import com.amaze.filemanager.utils.SimpleTextWatcher +import com.amaze.filemanager.utils.X509CertificateUtil.FINGERPRINT +import com.amaze.filemanager.utils.urlEncoded +import com.google.android.material.snackbar.Snackbar +import io.reactivex.Observable.create +import io.reactivex.Single +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import net.schmizz.sshj.common.SecurityUtils +import org.json.JSONObject +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.BufferedReader +import java.lang.ref.WeakReference +import java.security.KeyPair +import java.security.PublicKey +import java.util.concurrent.Callable + +/** SSH/SFTP connection setup dialog. */ +class SftpConnectDialog : DialogFragment() { + companion object { + @JvmStatic + private val log: Logger = LoggerFactory.getLogger(SftpConnectDialog::class.java) + + const val TAG = "sftpdialog" + + const val ARG_NAME = "name" + const val ARG_EDIT = "edit" + const val ARG_ADDRESS = "address" + const val ARG_PORT = "port" + const val ARG_PROTOCOL = "protocol" + const val ARG_USERNAME = "username" + const val ARG_PASSWORD = "password" + const val ARG_DEFAULT_PATH = "defaultPath" + const val ARG_HAS_PASSWORD = "hasPassword" + const val ARG_KEYPAIR_NAME = "keypairName" + + private val VALID_PORT_RANGE = IntRange(1, 65535) + + // Loosely referenced from https://dwheeler.com/essays/fixing-unix-linux-filenames.html + private const val pathBlockedChars = "*?<>|\\" + + private val defaultPathCharFilter = + InputFilter { source, _, _, _, _, _ -> + if (source.isNotEmpty() && source.isNotBlank() && + pathBlockedChars.contains(source) + ) { + "" + } else { + null + } + } + } + + lateinit var ctx: WeakReference + private var selectedPem: Uri? = null + private var selectedParsedKeyPair: KeyPair? = null + private var selectedParsedKeyPairName: String? = null + private var oldPath: String? = null + + lateinit var binding: SftpDialogBinding + + @Suppress("ComplexMethod") + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + ctx = WeakReference(activity) + binding = SftpDialogBinding.inflate(layoutInflater) + val utilsProvider: UtilitiesProvider = AppConfig.getInstance().utilsProvider + val edit = requireArguments().getBoolean(ARG_EDIT, false) + + initForm(edit) + + val accentColor = (activity as ThemedActivity).accent + + // Use system provided action to get Uri to PEM. + binding.selectPemBTN.setOnClickListener { + val intent = + Intent() + .setType(MimeTypes.ALL_MIME_TYPES) + .setAction(Intent.ACTION_GET_CONTENT) + activityResultHandlerForPemSelection.launch(intent) + } + + // Define action for buttons + val dialogBuilder = + MaterialDialog.Builder(ctx.get()!!) + .title(R.string.scp_connection) + .autoDismiss(false) + .customView(binding.root, true) + .theme(utilsProvider.appTheme.materialDialogTheme) + .negativeText(R.string.cancel) + .positiveText(if (edit) R.string.update else R.string.create) + .positiveColor(accentColor) + .negativeColor(accentColor) + .neutralColor(accentColor) + .onPositive(handleOnPositiveButton(edit)) + .onNegative { dialog: MaterialDialog, _: DialogAction? -> + dialog.dismiss() + } + + // If we are editing connection settings, give new actions for neutral and negative buttons + if (edit) { + appendButtonListenersForEdit(dialogBuilder) + } + val dialog = dialogBuilder.build() + + // Some validations to make sure the Create/Update button is clickable only when required + // setting values are given + val okBTN: MDButton = dialog.getActionButton(DialogAction.POSITIVE) + if (!edit) okBTN.isEnabled = false + val validator: TextWatcher = createValidator(edit, okBTN) + binding.ipET.addTextChangedListener(validator) + binding.portET.addTextChangedListener(validator) + binding.usernameET.addTextChangedListener(validator) + binding.passwordET.addTextChangedListener(validator) + return dialog + } + + private fun initForm(edit: Boolean) = + binding.run { + portET.apply { + filters = arrayOf(MinMaxInputFilter(VALID_PORT_RANGE)) + // For convenience, so I don't need to press backspace all the time + onFocusChangeListener = + View.OnFocusChangeListener { _: View?, hasFocus: Boolean -> + if (hasFocus) { + selectAll() + } + } + } + protocolDropDown.adapter = + ArrayAdapter( + requireContext(), + android.R.layout.simple_spinner_dropdown_item, + requireContext().resources.getStringArray(R.array.ftpProtocols), + ) + chkFtpAnonymous.setOnCheckedChangeListener { _, isChecked -> + usernameET.isEnabled = !isChecked + passwordET.isEnabled = !isChecked + if (isChecked) { + usernameET.setText("") + passwordET.setText("") + } + } + defaultPathET.filters = arrayOf(defaultPathCharFilter) + // If it's new connection setup, set some default values + // Otherwise, use given Bundle instance for filling in the blanks + if (!edit) { + connectionET.setText(R.string.scp_connection) + portET.setText(SSH_DEFAULT_PORT.toString()) + protocolDropDown.onItemSelectedListener = + object : AdapterView.OnItemSelectedListener { + override fun onItemSelected( + parent: AdapterView<*>?, + view: View?, + position: Int, + id: Long, + ) { + portET.setText( + when (position) { + 1 -> FTP_DEFAULT_PORT.toString() + 2 -> FTPS_DEFAULT_PORT.toString() + else -> SSH_DEFAULT_PORT.toString() + }, + ) + chkFtpAnonymous.visibility = + when (position) { + 0 -> View.GONE + else -> View.VISIBLE + } + chkFtpExplicitTls.visibility = + when (position) { + 0 -> View.GONE + else -> View.VISIBLE + } + if (position == 0) { + chkFtpAnonymous.isChecked = false + chkFtpExplicitTls.isChecked = false + } + selectPemBTN.visibility = + when (position) { + 0 -> View.VISIBLE + else -> View.GONE + } + } + + override fun onNothingSelected(parent: AdapterView<*>?) = Unit + } + } else { + protocolDropDown.setSelection( + when (requireArguments().getString(ARG_PROTOCOL)) { + FTP_URI_PREFIX -> 1 + FTPS_URI_PREFIX -> 2 + else -> 0 + }, + ) + connectionET.setText(requireArguments().getString(ARG_NAME)) + ipET.setText(requireArguments().getString(ARG_ADDRESS)) + portET.setText(requireArguments().getInt(ARG_PORT).toString()) + defaultPathET.setText(requireArguments().getString(ARG_DEFAULT_PATH)) + usernameET.setText(requireArguments().getString(ARG_USERNAME) ?: "") + if ("" == (requireArguments().getString(ARG_USERNAME) ?: "")) { + chkFtpAnonymous.isChecked = true + } + if (requireArguments().getBoolean(ARG_HAS_PASSWORD)) { + passwordET.setHint(R.string.password_unchanged) + } else { + selectedParsedKeyPairName = requireArguments().getString(ARG_KEYPAIR_NAME) + selectPemBTN.text = selectedParsedKeyPairName + } + if (TLS_EXPLICIT == requireArguments().getString(ARG_TLS)) { + chkFtpExplicitTls.isChecked = true + } + oldPath = + NetCopyClientUtils.deriveUriFrom( + requireArguments().getString(ARG_PROTOCOL)!!, + requireArguments().getString(ARG_ADDRESS)!!, + requireArguments().getInt(ARG_PORT), + requireArguments().getString(ARG_DEFAULT_PATH, ""), + requireArguments().getString(ARG_USERNAME) ?: "", + requireArguments().getString(ARG_PASSWORD), + TLS_EXPLICIT == requireArguments().getString(ARG_TLS), + edit, + ) + } + } + + private fun appendButtonListenersForEdit(dialogBuilder: MaterialDialog.Builder) { + createConnectionSettings(edit = true).run { + dialogBuilder + .negativeText(R.string.delete) + .onNegative { dialog: MaterialDialog, _: DialogAction? -> + val path = + NetCopyClientUtils.deriveUriFrom( + getProtocolPrefixFromDropdownSelection(), + hostname, + port, + defaultPath, + username, + requireArguments().getString(ARG_PASSWORD, null), + edit = true, + ) + val i = + DataUtils.getInstance().containsServer( + arrayOf(connectionName, path), + ) + if (i > -1) { + DataUtils.getInstance().removeServer(i) + AppConfig.getInstance() + .runInBackground { + AppConfig.getInstance().utilsHandler.removeFromDatabase( + OperationData( + UtilsHandler.Operation.SFTP, + path, + connectionName, + null, + null, + null, + ), + ) + } + (activity as MainActivity).drawer.refreshDrawer() + } + dialog.dismiss() + }.neutralText(android.R.string.cancel) + .onNeutral { dialog: MaterialDialog, _: DialogAction? -> dialog.dismiss() } + } + } + + private fun createValidator( + edit: Boolean, + okBTN: MDButton, + ): SimpleTextWatcher { + return object : SimpleTextWatcher() { + override fun afterTextChanged(s: Editable) { + val portETValue = binding.portET.text.toString() + val port = + if (portETValue.isDigitsOnly() && (portETValue.length in 1..5)) { + portETValue.toInt() + } else { + -1 + } + val hasCredential: Boolean = + if (edit) { + if (true == binding.passwordET.text?.isNotEmpty() || + !TextUtils.isEmpty(requireArguments().getString(ARG_PASSWORD)) + ) { + true + } else { + true == selectedParsedKeyPairName?.isNotEmpty() + } + } else { + true == binding.passwordET.text?.isNotEmpty() || selectedParsedKeyPair != null + } + okBTN.isEnabled = ( + true == binding.connectionET.text?.isNotEmpty() && + true == binding.ipET.text?.isNotEmpty() && + port in VALID_PORT_RANGE && + true == binding.usernameET.text?.isNotEmpty() && + hasCredential + ) || ( + binding.chkFtpAnonymous.isChecked && + binding.protocolDropDown.selectedItemPosition > 0 + ) + } + } + } + + private fun handleOnPositiveButton(edit: Boolean): MaterialDialog.SingleButtonCallback = + MaterialDialog.SingleButtonCallback { _, _ -> + createConnectionSettings(edit).run { + when (prefix) { + FTP_URI_PREFIX -> positiveButtonForFtp(this, edit) + else -> positiveButtonForSftp(this, edit) + } + } + } + + private fun positiveButtonForFtp( + connectionSettings: ConnectionSettings, + edit: Boolean, + ) { + connectionSettings.run { + authenticateAndSaveSetup(connectionSettings = connectionSettings, isEdit = edit) + } + } + + /* + * for SSH and FTPS, get host's cert/public key fingerprint. + */ + private fun positiveButtonForSftp( + connectionSettings: ConnectionSettings, + edit: Boolean, + ) { + connectionSettings.run { + // Get original SSH host key + AppConfig.getInstance().utilsHandler.getRemoteHostKey( + NetCopyClientUtils.deriveUriFrom( + prefix, + hostname, + port, + defaultPath, + username, + requireArguments().getString(ARG_PASSWORD, null), + edit, + ), + )?.let { sshHostKey -> + NetCopyClientConnectionPool.removeConnection( + this.toUriString(), + ) { + if (prefix == FTPS_URI_PREFIX) { + reconnectToFtpsServerToVerifyHostFingerprint( + this, + JSONObject(sshHostKey), + edit, + ) + } else { + reconnectToSshServerToVerifyHostFingerprint(this, sshHostKey, edit) + } + } + } ?: run { + if (prefix == FTPS_URI_PREFIX) { + firstConnectToFtpsServer(this, edit) + } else { + firstConnectToSftpServer(this, edit) + } + } + } + } + + /* + * Used by firstConnectToFtpsServer() and firstConnectToSftpServer(). + */ + private val createFirstConnectCallback: + (Boolean, ConnectionSettings, String, String, String, JSONObject?) -> Unit = { + edit, + connectionSettings, + hostAndPort, + hostKeyAlgorithm, + hostKeyFingerprint, + hostInfo, + -> + AlertDialog.Builder(ctx.get()) + .setTitle(R.string.ssh_host_key_verification_prompt_title) + .setMessage( + getString( + R.string.ssh_host_key_verification_prompt, + hostAndPort, + hostKeyAlgorithm, + hostKeyFingerprint, + ), + ).setCancelable(true) + .setPositiveButton(R.string.yes) { + dialog1: DialogInterface, _: Int -> + // This closes the host fingerprint verification dialog + dialog1.dismiss() + if (authenticateAndSaveSetup( + connectionSettings, + hostInfo?.toString() ?: hostKeyFingerprint, + edit, + ) + ) { + dialog1.dismiss() + log.debug("Saved setup") + dismiss() + } + }.setNegativeButton(R.string.no) { + dialog1: DialogInterface, _: Int -> + dialog1.dismiss() + }.show() + } + + private fun firstConnectToFtpsServer( + connectionSettings: ConnectionSettings, + edit: Boolean, + ) = connectionSettings.run { + connectToSecureServerInternal( + FtpsGetHostCertificateTask( + hostname, + port, + explicitTls, + requireContext(), + ) { hostInfo -> + createFirstConnectCallback.invoke( + edit, + this, + StringBuilder(hostname).also { + if (port != FTPS_DEFAULT_PORT && port > 0) { + it.append(':').append(port) + } + }.toString(), + "SHA-256", + hostInfo.getString(FINGERPRINT), + hostInfo, + ) + }, + ) + } + + private fun firstConnectToSftpServer( + connectionSettings: ConnectionSettings, + edit: Boolean, + ) = connectionSettings.run { + connectToSecureServerInternal( + GetSshHostFingerprintTask( + hostname, + port, + true, + ) { hostKey: PublicKey -> + createFirstConnectCallback.invoke( + edit, + this, + StringBuilder(hostname).also { + if (port != NetCopyClientConnectionPool.SSH_DEFAULT_PORT && port > 0) { + it.append(COLON).append(port) + } + }.toString(), + hostKey.algorithm, + SecurityUtils.getFingerprint(hostKey), + null, + ) + }, + ) + } + + private val createReconnectSecureServerCallback: + (ConnectionSettings, String, String, () -> Boolean, Boolean) -> Unit = { + connectionSettings, oldHostIdentity, newHostIdentity, hostIdentityIsValid, edit -> + if (hostIdentityIsValid.invoke()) { + authenticateAndSaveSetup( + connectionSettings, + oldHostIdentity, + edit, + ) + } else { + AlertDialog.Builder(ctx.get()) + .setTitle( + R.string.ssh_connect_failed_host_key_changed_title, + ).setMessage( + R.string.ssh_connect_failed_host_key_changed_prompt, + ).setPositiveButton( + R.string.update_host_key, + ) { _: DialogInterface?, _: Int -> + authenticateAndSaveSetup( + connectionSettings, + newHostIdentity, + edit, + ) + }.setNegativeButton(R.string.cancel_recommended) { + dialog1: DialogInterface, _: Int -> + dialog1.dismiss() + }.show() + } + } + + private fun reconnectToSshServerToVerifyHostFingerprint( + connectionSettings: ConnectionSettings, + sshHostKey: String, + edit: Boolean, + ) { + connectionSettings.run { + connectToSecureServerInternal( + GetSshHostFingerprintTask(hostname, port, false) { + currentHostKey: PublicKey -> + SecurityUtils.getFingerprint(currentHostKey).let { + currentHostKeyFingerprint -> + createReconnectSecureServerCallback( + connectionSettings, + sshHostKey, + currentHostKeyFingerprint, + { currentHostKeyFingerprint == sshHostKey }, + edit, + ) + } + }, + ) + } + } + + private fun reconnectToFtpsServerToVerifyHostFingerprint( + connectionSettings: ConnectionSettings, + ftpsHostInfo: JSONObject, + edit: Boolean, + ) { + connectionSettings.run { + connectToSecureServerInternal( + FtpsGetHostCertificateTask( + hostname, + port, + explicitTls, + requireContext(), + ) { hostInfo: JSONObject -> + createReconnectSecureServerCallback( + connectionSettings, + ftpsHostInfo.toString(), + hostInfo.toString(), + { ftpsHostInfo.getString(FINGERPRINT) == hostInfo.getString(FINGERPRINT) }, + edit, + ) + }, + ) + } + } + + private fun > connectToSecureServerInternal(task: AbstractGetHostInfoTask) { + Single.fromCallable(task.getTask()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnSubscribe { task.onPreExecute() } + .subscribe(task::onFinish, task::onError) + } + + @Suppress("LabeledExpression") + private val activityResultHandlerForPemSelection = + registerForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { + if (Activity.RESULT_OK == it.resultCode) { + it.data?.data?.run { + selectedPem = this + runCatching { + requireContext().contentResolver.openInputStream(this)?.let { + selectedKeyContent -> + val observable = PemToKeyPairObservable(selectedKeyContent) + create(observable).subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .retryWhen { exceptions -> + exceptions.flatMap { exception -> + create { subscriber -> + observable.displayPassphraseDialog(exception, { + subscriber.onNext(Unit) + }, { + subscriber.onError(exception) + }) + } + } + } + .subscribe({ result -> + selectedParsedKeyPair = result + selectedParsedKeyPairName = + this + .lastPathSegment!! + .substring( + this.lastPathSegment!! + .indexOf('/') + 1, + ) + val okBTN = + (dialog as MaterialDialog) + .getActionButton(DialogAction.POSITIVE) + okBTN.isEnabled = okBTN.isEnabled || true + binding.selectPemBTN.text = selectedParsedKeyPairName + }, {}) + } + }.onFailure { + log.error("Error reading PEM key", it) + } + } + } + } + + private fun authenticateAndSaveSetup( + connectionSettings: ConnectionSettings, + hostKeyFingerprint: String? = null, + isEdit: Boolean, + ): Boolean = + connectionSettings.run { + val path = this.toUriString() + val encryptedPath = NetCopyClientUtils.encryptFtpPathAsNecessary(path) + return if (!isEdit) { + saveFtpConnectionAndLoadlist( + connectionSettings, + hostKeyFingerprint, + encryptedPath, + selectedParsedKeyPairName, + selectedParsedKeyPair, + ) + } else { + updateFtpConnection( + connectionName, + hostKeyFingerprint, + encryptedPath, + ) + } + } + + @Suppress("LongParameterList") + private fun saveFtpConnectionAndLoadlist( + connectionSettings: ConnectionSettings, + hostKeyFingerprint: String?, + encryptedPath: String, + selectedParsedKeyPairName: String?, + selectedParsedKeyPair: KeyPair?, + ): Boolean { + connectionSettings.run { + return runCatching { + NetCopyClientConnectionPool.getConnection( + prefix, + hostname, + port, + hostKeyFingerprint, + username, + if (false == password?.isBlank()) { + PasswordUtil.encryptPassword(requireContext(), password)?.replace("\n", "") + } else { + password + }, + selectedParsedKeyPair, + explicitTls, + )?.run { + if (DataUtils.getInstance().containsServer(encryptedPath) == -1) { + DataUtils.getInstance().addServer(arrayOf(connectionName, encryptedPath)) + (activity as MainActivity).drawer.refreshDrawer() + AppConfig.getInstance().utilsHandler.saveToDatabase( + OperationData( + UtilsHandler.Operation.SFTP, + encryptedPath, + connectionName, + hostKeyFingerprint, + selectedParsedKeyPairName, + getPemContents(), + ), + ) + val ma = (activity as MainActivity).currentMainFragment + ma?.loadlist( + encryptedPath, + false, + if (prefix == SSH_URI_PREFIX) { + OpenMode.SFTP + } else { + OpenMode.FTP + }, + false, + ) + dismiss() + } else { + Snackbar.make( + requireActivity().findViewById(R.id.content_frame), + getString(R.string.connection_exists), + Snackbar.LENGTH_SHORT, + ).show() + dismiss() + } + true + } ?: false + }.getOrElse { + log.warn("Problem getting connection and load file list", it) + false + } + } + } + + private fun updateFtpConnection( + connectionName: String, + hostKeyFingerprint: String?, + encryptedPath: String, + ): Boolean { + val i = DataUtils.getInstance().containsServer(oldPath) + + if (i != -1) { + DataUtils.getInstance().removeServer(i) + } + + DataUtils.getInstance().addServer(arrayOf(connectionName, encryptedPath)) + DataUtils.getInstance().servers.sortWith(BookSorter()) + (activity as MainActivity).drawer.refreshDrawer() + AppConfig.getInstance().runInBackground { + AppConfig.getInstance().utilsHandler.updateSsh( + connectionName, + requireArguments().getString(ARG_NAME)!!, + encryptedPath, + hostKeyFingerprint, + selectedParsedKeyPairName, + getPemContents(), + ) + } + dismiss() + return true + } + + // Read the PEM content from InputStream to String. + private fun getPemContents(): String? = + selectedPem?.run { + runCatching { + requireContext().contentResolver.openInputStream(this) + ?.bufferedReader() + ?.use(BufferedReader::readText) + }.getOrNull() + } + + private fun getProtocolPrefixFromDropdownSelection(): String { + return when (binding.protocolDropDown.selectedItem.toString()) { + requireContext().getString(R.string.protocol_ftp) -> FTP_URI_PREFIX + requireContext().getString(R.string.protocol_ftps) -> FTPS_URI_PREFIX + else -> SSH_URI_PREFIX + } + } + + internal data class ConnectionSettings( + val prefix: String, + val connectionName: String, + val hostname: String, + val port: Int, + val defaultPath: String? = null, + val username: String, + val password: String? = null, + val selectedParsedKeyPairName: String? = null, + val selectedParsedKeyPair: KeyPair? = null, + val explicitTls: Boolean = false, + ) { + fun toUriString() = + NetCopyClientUtils.deriveUriFrom( + prefix, + hostname, + port, + defaultPath, + username, + password, + explicitTls, + ) + } + + // FIXME: username/password may not need urlEncoded during edit mode + private fun createConnectionSettings(edit: Boolean = false) = + ConnectionSettings( + prefix = getProtocolPrefixFromDropdownSelection(), + connectionName = binding.connectionET.text.toString(), + hostname = binding.ipET.text.toString(), + port = + binding.portET.text.toString().let { + if (it.isEmpty() || it.isBlank()) { + SSH_DEFAULT_PORT + } else { + it.toInt() + } + }, + defaultPath = binding.defaultPathET.text.toString(), + username = binding.usernameET.text.toString().urlEncoded(), + password = + if (true == binding.passwordET.text?.isEmpty()) { + if (edit) { + requireArguments().getString(ARG_PASSWORD, null)?.run { + PasswordUtil.decryptPassword(AppConfig.getInstance(), this) + } + } else { + requireArguments().getString(ARG_PASSWORD, null) + } + } else { + binding.passwordET.text.toString().urlEncoded() + }, + selectedParsedKeyPairName = this.selectedParsedKeyPairName, + selectedParsedKeyPair = selectedParsedKeyPair, + explicitTls = + binding.chkFtpExplicitTls.isVisible && + binding.chkFtpExplicitTls.isChecked, + ) +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/dialogs/SmbConnectDialog.java b/app/src/main/java/com/amaze/filemanager/ui/dialogs/SmbConnectDialog.java new file mode 100644 index 0000000..a1f4dca --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/dialogs/SmbConnectDialog.java @@ -0,0 +1,426 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.dialogs; + +import static android.util.Base64.URL_SAFE; +import static com.amaze.filemanager.filesystem.ftp.NetCopyConnectionInfo.AT; +import static com.amaze.filemanager.filesystem.ftp.NetCopyConnectionInfo.COLON; +import static com.amaze.filemanager.filesystem.ftp.NetCopyConnectionInfo.SLASH; +import static com.amaze.filemanager.filesystem.smb.CifsContexts.SMB_URI_PREFIX; +import static com.amaze.filemanager.utils.smb.SmbUtil.PARAM_DISABLE_IPC_SIGNING_CHECK; +import static java.net.URLDecoder.decode; +import static java.net.URLEncoder.encode; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.MalformedURLException; +import java.net.URL; +import java.security.GeneralSecurityException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.afollestad.materialdialogs.MaterialDialog; +import com.amaze.filemanager.R; +import com.amaze.filemanager.databinding.SmbDialogBinding; +import com.amaze.filemanager.filesystem.smb.CifsContexts; +import com.amaze.filemanager.ui.ExtensionsKt; +import com.amaze.filemanager.ui.activities.superclasses.BasicActivity; +import com.amaze.filemanager.ui.activities.superclasses.ThemedActivity; +import com.amaze.filemanager.ui.provider.UtilitiesProvider; +import com.amaze.filemanager.utils.EditTextColorStateUtil; +import com.amaze.filemanager.utils.PasswordUtil; +import com.amaze.filemanager.utils.SimpleTextWatcher; +import com.amaze.filemanager.utils.Utils; +import com.amaze.filemanager.utils.smb.SmbUtil; +import com.google.android.material.textfield.TextInputLayout; + +import android.app.Dialog; +import android.content.Context; +import android.net.UrlQuerySanitizer; +import android.os.Bundle; +import android.text.Editable; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.appcompat.widget.AppCompatCheckBox; +import androidx.appcompat.widget.AppCompatEditText; +import androidx.appcompat.widget.AppCompatTextView; +import androidx.fragment.app.DialogFragment; + +import jcifs.smb.SmbFile; +import kotlin.text.Charsets; + +public class SmbConnectDialog extends DialogFragment { + + // Dialog tag. + public static final String TAG = "smbdialog"; + + public static final String ARG_NAME = "name"; + + public static final String ARG_PATH = "path"; + + public static final String ARG_EDIT = "edit"; + + private static final Logger LOG = LoggerFactory.getLogger(SmbConnectDialog.class); + + private UtilitiesProvider utilsProvider; + + private SmbConnectionListener smbConnectionListener; + + private SmbDialogBinding binding; + private String emptyAddress; + private String emptyName; + private String invalidDomain; + private String invalidUsername; + + public interface SmbConnectionListener { + + /** + * Callback denoting a new connection been added from dialog + * + * @param edit whether we edit existing connection or not + * @param name name of connection as appears in navigation drawer + * @param encryptedPath the full path to the server. Includes encrypted password to save in + * database. Later be decrypted at every boot when we read from db entry. + * @param oldname the old name of connection if we're here to edit + * @param oldPath the old full path (un-encrypted as we read from existing entry in db, which we + * decrypted beforehand). + */ + void addConnection( + boolean edit, + @NonNull String name, + @NonNull String encryptedPath, + @Nullable String oldname, + @Nullable String oldPath); + + /** + * Callback denoting a connection been deleted from dialog + * + * @param name name of connection as in navigation drawer and in database entry + * @param path the full path to server. Includes an un-encrypted password as we decrypted it + * beforehand while reading from database before coming here to delete. We'll later have to + * encrypt the password back again in order to match entry from db and to successfully + * delete it. If we don't want this behaviour, then we'll have to not allow duplicate + * connection name, and delete entry based on the name only. But that is not supported as of + * now. See {@link com.amaze.filemanager.database.UtilsHandler#removeSmbPath(String, + * String)} + */ + void deleteConnection(String name, String path); + } + + @VisibleForTesting + public void setSmbConnectionListener(SmbConnectionListener smbConnectionListener) { + this.smbConnectionListener = smbConnectionListener; + } + + @VisibleForTesting + public SmbConnectionListener getSmbConnectionListener() { + return smbConnectionListener; + } + + @VisibleForTesting + public SmbDialogBinding getBinding() { + return binding; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + utilsProvider = ((BasicActivity) getActivity()).getUtilsProvider(); + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + + final boolean edit = getArguments().getBoolean(ARG_EDIT, false); + final String path = getArguments().getString(ARG_PATH); + final String name = getArguments().getString(ARG_NAME); + Context context = requireActivity(); + emptyAddress = getString(R.string.cant_be_empty, getString(R.string.ip)); + emptyName = getString(R.string.cant_be_empty, getString(R.string.connection_name)); + invalidDomain = getString(R.string.invalid, getString(R.string.domain)); + invalidUsername = getString(R.string.invalid, getString(R.string.username).toLowerCase()); + if (requireActivity() instanceof SmbConnectionListener && smbConnectionListener == null) { + smbConnectionListener = (SmbConnectionListener) getActivity(); + } + final MaterialDialog.Builder ba3 = new MaterialDialog.Builder(context); + ba3.title((R.string.smb_connection)); + ba3.autoDismiss(false); + binding = SmbDialogBinding.inflate(LayoutInflater.from(context)); + final TextInputLayout connectionTIL = binding.connectionTIL; + final TextInputLayout ipTIL = binding.ipTIL; + final TextInputLayout domainTIL = binding.domainTIL; + final TextInputLayout usernameTIL = binding.usernameTIL; + final TextInputLayout passwordTIL = binding.passwordTIL; + final AppCompatEditText conName = binding.connectionET; + + ExtensionsKt.makeRequired(connectionTIL); + ExtensionsKt.makeRequired(ipTIL); + ExtensionsKt.makeRequired(usernameTIL); + ExtensionsKt.makeRequired(passwordTIL); + + conName.addTextChangedListener( + new SimpleTextWatcher() { + @Override + public void afterTextChanged(@NonNull Editable s) { + if (conName.getText().toString().length() == 0) connectionTIL.setError(emptyName); + else connectionTIL.setError(""); + } + }); + final AppCompatEditText ip = binding.ipET; + ip.addTextChangedListener( + new SimpleTextWatcher() { + @Override + public void afterTextChanged(@NonNull Editable s) { + if (ip.getText().toString().length() == 0) ipTIL.setError(emptyAddress); + else ipTIL.setError(""); + } + }); + final AppCompatEditText share = binding.shareET; + final AppCompatEditText domain = binding.domainET; + domain.addTextChangedListener( + new SimpleTextWatcher() { + @Override + public void afterTextChanged(@NonNull Editable s) { + if (domain.getText().toString().contains(";")) domainTIL.setError(invalidDomain); + else domainTIL.setError(""); + } + }); + final AppCompatEditText user = binding.usernameET; + user.addTextChangedListener( + new SimpleTextWatcher() { + @Override + public void afterTextChanged(@NonNull Editable s) { + if (user.getText().toString().contains(String.valueOf(COLON))) + usernameTIL.setError(invalidUsername); + else usernameTIL.setError(""); + } + }); + + int accentColor = ((ThemedActivity) getActivity()).getAccent(); + final AppCompatEditText pass = binding.passwordET; + final AppCompatCheckBox chkSmbAnonymous = binding.chkSmbAnonymous; + final AppCompatCheckBox chkSmbDisableIpcSignature = binding.chkSmbDisableIpcSignature; + AppCompatTextView help = binding.wanthelp; + + EditTextColorStateUtil.setTint(getActivity(), conName, accentColor); + EditTextColorStateUtil.setTint(getActivity(), user, accentColor); + EditTextColorStateUtil.setTint(getActivity(), pass, accentColor); + + Utils.setTint(getActivity(), chkSmbAnonymous, accentColor); + help.setOnClickListener( + v -> { + int accentColor1 = ((ThemedActivity) getActivity()).getAccent(); + GeneralDialogCreation.showSMBHelpDialog(getActivity(), accentColor1); + }); + + chkSmbAnonymous.setOnClickListener( + v -> { + if (chkSmbAnonymous.isChecked()) { + user.setEnabled(false); + pass.setEnabled(false); + } else { + user.setEnabled(true); + pass.setEnabled(true); + } + }); + + if (edit) { + String userp = ""; + String passp = ""; + String ipp = ""; + String domainp = ""; + String sharep = ""; + + conName.setText(name); + try { + URL a = new URL(path); + String userinfo = a.getUserInfo(); + if (userinfo != null) { + String inf = decode(userinfo, Charsets.UTF_8.name()); + int domainDelim = !inf.contains(";") ? 0 : inf.indexOf(';'); + domainp = inf.substring(0, domainDelim); + if (domainp != null && domainp.length() > 0) inf = inf.substring(domainDelim + 1); + if (!inf.contains(":")) userp = inf; + else { + userp = inf.substring(0, inf.indexOf(COLON)); + try { + passp = + PasswordUtil.INSTANCE.decryptPassword( + context, inf.substring(inf.indexOf(COLON) + 1), URL_SAFE); + passp = decode(passp, Charsets.UTF_8.name()); + } catch (GeneralSecurityException | IOException e) { + LOG.warn("Error decrypting password", e); + passp = ""; + } + } + domain.setText(domainp); + user.setText(userp); + pass.setText(passp); + } else { + chkSmbAnonymous.setChecked(true); + } + ipp = a.getHost(); + sharep = a.getPath().replaceFirst("/", "").replaceAll("/$", ""); + ip.setText(ipp); + share.setText(sharep); + + UrlQuerySanitizer sanitizer = new UrlQuerySanitizer(path); + if (sanitizer.hasParameter(PARAM_DISABLE_IPC_SIGNING_CHECK)) { + chkSmbDisableIpcSignature.setChecked( + Boolean.parseBoolean(sanitizer.getValue(PARAM_DISABLE_IPC_SIGNING_CHECK))); + } + } catch (UnsupportedEncodingException | IllegalArgumentException e) { + LOG.warn("failed to load smb dialog info for path {}", path, e); + } catch (MalformedURLException e) { + LOG.warn("failed to load smb dialog info", e); + } + + } else if (path != null && path.length() > 0) { + conName.setText(name); + ip.setText(path); + user.requestFocus(); + } else { + conName.setText(R.string.smb_connection); + conName.requestFocus(); + } + + ba3.customView(binding.getRoot(), true); + ba3.theme(utilsProvider.getAppTheme().getMaterialDialogTheme()); + ba3.neutralText(android.R.string.cancel); + ba3.positiveText(edit ? R.string.update : R.string.create); + if (edit) ba3.negativeText(R.string.delete); + ba3.positiveColor(accentColor).negativeColor(accentColor).neutralColor(accentColor); + ba3.onPositive( + (dialog, which) -> { + String[] s; + String ipa = ip.getText().toString(); + String con_nam = conName.getText().toString(); + String sDomain = domain.getText().toString(); + String sShare = share.getText().toString(); + String username = user.getText().toString(); + TextInputLayout firstInvalidField = null; + if (con_nam == null || con_nam.length() == 0) { + connectionTIL.setError(emptyName); + firstInvalidField = connectionTIL; + } + if (ipa == null || ipa.length() == 0) { + ipTIL.setError(emptyAddress); + if (firstInvalidField == null) firstInvalidField = ipTIL; + } + if (sDomain.contains(";")) { + domainTIL.setError(invalidDomain); + if (firstInvalidField == null) firstInvalidField = domainTIL; + } + if (username.contains(":")) { + usernameTIL.setError(invalidUsername); + if (firstInvalidField == null) firstInvalidField = usernameTIL; + } + if (firstInvalidField != null) { + firstInvalidField.requestFocus(); + return; + } + SmbFile smbFile; + String domaind = domain.getText().toString(); + if (chkSmbAnonymous.isChecked() + || (TextUtils.isEmpty(user.getText()) && TextUtils.isEmpty(pass.getText()))) + smbFile = createSMBPath(new String[] {ipa, "", "", domaind, sShare}, true, false); + else { + String useraw = user.getText().toString(); + String useru = useraw.replaceAll(" ", "\\ "); + String passp = pass.getText().toString(); + smbFile = + createSMBPath(new String[] {ipa, useru, passp, domaind, sShare}, false, false); + } + + if (smbFile == null) return; + + StringBuilder extraParams = new StringBuilder(); + if (chkSmbDisableIpcSignature.isChecked()) + extraParams.append(PARAM_DISABLE_IPC_SIGNING_CHECK).append('=').append(true); + + try { + s = + new String[] { + conName.getText().toString(), + SmbUtil.getSmbEncryptedPath(getActivity(), smbFile.getPath()) + }; + } catch (Exception e) { + LOG.warn("failed to load smb dialog info", e); + Toast.makeText(getActivity(), getString(R.string.error), Toast.LENGTH_LONG).show(); + return; + } + + if (smbConnectionListener != null) { + // encrypted path means path with encrypted pass + String qs = extraParams.length() > 0 ? extraParams.insert(0, '?').toString() : ""; + smbConnectionListener.addConnection(edit, s[0], s[1] + qs, name, path); + } + dismiss(); + }); + ba3.onNegative( + (dialog, which) -> { + if (smbConnectionListener != null) { + smbConnectionListener.deleteConnection(name, path); + } + + dismiss(); + }); + ba3.onNeutral((dialog, which) -> dismiss()); + + return ba3.build(); + } + + // Begin URL building, hence will need to URL encode credentials here, to begin with. + private SmbFile createSMBPath(String[] auth, boolean anonymous, boolean disableIpcSignCheck) { + try { + String yourPeerIP = auth[0]; + String domain = auth[3]; + String share = auth[4]; + + StringBuilder sb = new StringBuilder(SMB_URI_PREFIX); + if (!TextUtils.isEmpty(domain)) sb.append(encode(domain + ";", Charsets.UTF_8.name())); + if (!anonymous) + sb.append(encode(auth[1], Charsets.UTF_8.name())) + .append(COLON) + .append(encode(auth[2], Charsets.UTF_8.name())) + .append(AT); + sb.append(yourPeerIP).append(SLASH); + if (!TextUtils.isEmpty(share)) { + sb.append(share).append(SLASH); + } + return new SmbFile( + sb.toString(), + CifsContexts.createWithDisableIpcSigningCheck(sb.toString(), disableIpcSignCheck)); + } catch (MalformedURLException e) { + LOG.warn("failed to load smb path", e); + } catch (UnsupportedEncodingException | IllegalArgumentException e) { + LOG.warn("Failed to load smb path", e); + } + return null; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/dialogs/SmbSearchDialog.kt b/app/src/main/java/com/amaze/filemanager/ui/dialogs/SmbSearchDialog.kt new file mode 100644 index 0000000..0cd1a20 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/dialogs/SmbSearchDialog.kt @@ -0,0 +1,270 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.dialogs + +import android.app.Activity +import android.app.Dialog +import android.content.Context +import android.graphics.Color +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.appcompat.widget.AppCompatImageView +import androidx.appcompat.widget.AppCompatTextView +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.recyclerview.widget.RecyclerView +import com.afollestad.materialdialogs.DialogAction +import com.afollestad.materialdialogs.MaterialDialog +import com.amaze.filemanager.R +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.ui.activities.MainActivity +import com.amaze.filemanager.ui.activities.superclasses.ThemedActivity +import com.amaze.filemanager.ui.provider.UtilitiesProvider +import com.amaze.filemanager.ui.theme.AppTheme +import com.amaze.filemanager.utils.ComputerParcelable +import com.amaze.filemanager.utils.smb.SmbDeviceScannerObservable +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.Disposable +import io.reactivex.schedulers.Schedulers +import org.slf4j.LoggerFactory + +/** Created by arpitkh996 on 16-01-2016 edited by Emmanuel Messulam @gmail.com> */ +class SmbSearchDialog : DialogFragment() { + private lateinit var utilsProvider: UtilitiesProvider + private lateinit var listViewAdapter: ListViewAdapter + private val viewModel = ComputerParcelableViewModel() + private var accentColor = 0 + private lateinit var subnetScannerObserver: Disposable + + override fun onCreate(bundle: Bundle?) { + super.onCreate(bundle) + utilsProvider = AppConfig.getInstance().utilsProvider + accentColor = (activity as ThemedActivity).accent + } + + override fun dismiss() { + super.dismiss() + if (!subnetScannerObserver.isDisposed) { + subnetScannerObserver.dispose() + } + } + + @Suppress("LongMethod", "LabeledExpression") + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val builder = MaterialDialog.Builder(requireActivity()) + builder.title(R.string.searching_devices) + builder.negativeColor(accentColor) + builder.negativeText(R.string.cancel) + builder.onNegative { _: MaterialDialog?, _: DialogAction? -> + if (!subnetScannerObserver.isDisposed) { + subnetScannerObserver.dispose() + } + dismiss() + } + builder.onPositive { _: MaterialDialog?, _: DialogAction? -> + if (!subnetScannerObserver.isDisposed) { + subnetScannerObserver.dispose() + } + if (activity != null && activity is MainActivity) { + dismiss() + val mainActivity = activity as MainActivity + mainActivity.showSMBDialog("", "", false) + } + } + builder.positiveText(R.string.use_custom_ip) + builder.positiveColor(accentColor) + viewModel.valHolder.postValue(ComputerParcelable("-1", "-1")) + listViewAdapter = ListViewAdapter(requireActivity()) + val observable = SmbDeviceScannerObservable() + subnetScannerObserver = + observable + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnDispose { + observable.stop() + } + .subscribe( + { computer: ComputerParcelable -> + if (!listViewAdapter.contains(computer)) { + viewModel.valHolder.postValue(computer) + } + }, + { err: Throwable -> + LOG.error("Error searching for devices", err) + }, + ) { + subnetScannerObserver.dispose() + activity?.runOnUiThread { + if (listViewAdapter.dummyOnly()) { + dismiss() + Toast.makeText( + activity, + getString(R.string.no_device_found), + Toast.LENGTH_SHORT, + ).show() + val mainActivity = activity as MainActivity + mainActivity.showSMBDialog("", "", false) + return@runOnUiThread + } + listViewAdapter.removeDummy() + } + } + builder.adapter(listViewAdapter, null) + viewModel.valHolder.observe(this) { + listViewAdapter.add(it) + } + return builder.build() + } + + private inner class ListViewAdapter( + context: Context, + ) : RecyclerView.Adapter() { + private val items: MutableList = ArrayList() + private val mInflater: LayoutInflater + + init { + mInflater = context.getSystemService(Activity.LAYOUT_INFLATER_SERVICE) as LayoutInflater + } + + /** + * Called by [ComputerParcelableViewModel], add found computer to list view + */ + fun add(computer: ComputerParcelable) { + if (computer.addr == "-1" && computer.name == "-1") { + items.add(computer) + } else { + items.add(items.size - 1, computer) + } + notifyDataSetChanged() + } + + /** + * Called by Observable when finish probing. If no other computers found, remove first + * (dummy) host + */ + fun removeDummy() { + items.remove( + items.find { + it.addr == "-1" && it.name == "-1" + }, + ) + notifyDataSetChanged() + } + + /** + * Answers if the computer list contains given instance. + */ + fun contains(computer: ComputerParcelable): Boolean { + return items.contains(computer) + } + + /** + * Answers if the list is empty = only has the dummy [ComputerParcelable] instance + */ + fun dummyOnly(): Boolean { + return items.size == 1 && items.last().addr == "-1" + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): ViewHolder { + val view: View + return when (viewType) { + VIEW_PROGRESSBAR -> { + view = mInflater.inflate(R.layout.smb_progress_row, parent, false) + ViewHolder(view) + } + else -> { + view = + mInflater.inflate(R.layout.smb_computers_row, parent, false) + ElementViewHolder(view) + } + } + } + + override fun onBindViewHolder( + holder: ViewHolder, + position: Int, + ) { + val viewType = getItemViewType(position) + if (viewType == Companion.VIEW_PROGRESSBAR) { + return + } + val (addr, name) = items[position] + holder.rootView.setOnClickListener { + if (activity != null && activity is MainActivity) { + dismiss() + val mainActivity = activity as MainActivity + mainActivity.showSMBDialog( + listViewAdapter.items[position].name, + listViewAdapter.items[position].addr, + false, + ) + } + } + if (holder is ElementViewHolder) { + holder.txtTitle.text = name + holder.image.setImageResource(R.drawable.ic_settings_remote_white_48dp) + if (utilsProvider.appTheme == AppTheme.LIGHT) { + holder.image.setColorFilter(Color.parseColor("#666666")) + } + holder.txtDesc.text = addr + } + } + + override fun getItemViewType(position: Int): Int { + val (addr) = items[position] + return if (addr == "-1") { + VIEW_PROGRESSBAR + } else { + VIEW_ELEMENT + } + } + + override fun getItemId(position: Int): Long = position.toLong() + + override fun getItemCount(): Int = items.size + } + + private open class ViewHolder(val rootView: View) : RecyclerView.ViewHolder(rootView) + + private class ElementViewHolder(rootView: View) : + ViewHolder(rootView) { + val image: AppCompatImageView = rootView.findViewById(R.id.icon) + val txtTitle: AppCompatTextView = rootView.findViewById(R.id.firstline) + val txtDesc: AppCompatTextView = rootView.findViewById(R.id.secondLine) + } + + private class ComputerParcelableViewModel : ViewModel() { + val valHolder = MutableLiveData() + } + + companion object { + private const val VIEW_PROGRESSBAR = 1 + private const val VIEW_ELEMENT = 2 + private val LOG = LoggerFactory.getLogger(SmbSearchDialog::class.java) + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/dialogs/share/ShareAdapter.java b/app/src/main/java/com/amaze/filemanager/ui/dialogs/share/ShareAdapter.java new file mode 100644 index 0000000..d6b7f02 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/dialogs/share/ShareAdapter.java @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.dialogs.share; + +import java.util.ArrayList; + +import com.afollestad.materialdialogs.MaterialDialog; +import com.amaze.filemanager.R; + +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.graphics.drawable.Drawable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.appcompat.widget.AppCompatImageView; +import androidx.appcompat.widget.AppCompatTextView; +import androidx.recyclerview.widget.RecyclerView; + +/** Created by Arpit on 01-07-2015 edited by Emmanuel Messulam */ +class ShareAdapter extends RecyclerView.Adapter { + + private ArrayList items; + private MaterialDialog dialog; + private ArrayList labels; + private ArrayList drawables; + private Context context; + + void updateMatDialog(MaterialDialog b) { + this.dialog = b; + } + + ShareAdapter( + Context context, + ArrayList intents, + ArrayList labels, + ArrayList arrayList1) { + items = new ArrayList<>(intents); + this.context = context; + this.labels = labels; + this.drawables = arrayList1; + } + + @Override + public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.simplerow, parent, false); + + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(ViewHolder holder, int position) { + holder.render(position); + } + + class ViewHolder extends RecyclerView.ViewHolder { + private View rootView; + + private AppCompatTextView textView; + private AppCompatImageView imageView; + + ViewHolder(View view) { + super(view); + + rootView = view; + + textView = view.findViewById(R.id.firstline); + imageView = view.findViewById(R.id.icon); + } + + void render(final int position) { + if (drawables.get(position) != null) imageView.setImageDrawable(drawables.get(position)); + textView.setVisibility(View.VISIBLE); + textView.setText(labels.get(position)); + rootView.setOnClickListener( + v -> { + if (dialog != null && dialog.isShowing()) dialog.dismiss(); + try { + context.startActivity(items.get(position)); + } catch (ActivityNotFoundException e) { + Toast.makeText(context, R.string.no_app_found, Toast.LENGTH_SHORT).show(); + } + }); + } + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public int getItemCount() { + return items.size(); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/dialogs/share/ShareTask.java b/app/src/main/java/com/amaze/filemanager/ui/dialogs/share/ShareTask.java new file mode 100644 index 0000000..8f7c441 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/dialogs/share/ShareTask.java @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.dialogs.share; + +import java.util.ArrayList; +import java.util.List; + +import com.afollestad.materialdialogs.MaterialDialog; +import com.amaze.filemanager.R; +import com.amaze.filemanager.ui.theme.AppTheme; +import com.amaze.filemanager.utils.PackageUtils; + +import android.app.Activity; +import android.content.ComponentName; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.AsyncTask; +import android.widget.Toast; + +/** Created by Arpit on 01-07-2015. */ +public class ShareTask extends AsyncTask { + private AppTheme appTheme; + + private Activity contextc; + private int fab_skin; + private ArrayList sharingUris; + private ArrayList targetShareIntents = new ArrayList<>(); + private ArrayList labels = new ArrayList<>(); + private ArrayList drawables = new ArrayList<>(); + + public ShareTask(Activity context, ArrayList sharingUris, AppTheme appTheme, int fab_skin) { + this.contextc = context; + this.sharingUris = sharingUris; + this.appTheme = appTheme; + this.fab_skin = fab_skin; + } + + @Override + protected Void doInBackground(String... strings) { + if (sharingUris.size() > 0) { + String mime = strings[0]; + boolean bluetooth_present = false; + Intent shareIntent = new Intent().setAction(getShareIntentAction()).setType(mime); + PackageManager packageManager = contextc.getPackageManager(); + List resInfos = packageManager.queryIntentActivities(shareIntent, 0); + if (!resInfos.isEmpty()) { + for (ResolveInfo resInfo : resInfos) { + String packageName = resInfo.activityInfo.packageName; + drawables.add(resInfo.loadIcon(packageManager)); + labels.add(resInfo.loadLabel(packageManager).toString()); + if (packageName.contains("android.bluetooth")) { + bluetooth_present = true; + } + Intent intent = new Intent(); + intent.setComponent(new ComponentName(packageName, resInfo.activityInfo.name)); + intent.setAction(getShareIntentAction()); + intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + intent.setType(mime); + if (sharingUris.size() == 1) { + intent.putExtra(Intent.EXTRA_STREAM, sharingUris.get(0)); + } else { + intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, sharingUris); + } + intent.setPackage(packageName); + targetShareIntents.add(intent); + } + } + if (!bluetooth_present + && PackageUtils.Companion.appInstalledOrNot("com.android.bluetooth", packageManager)) { + Intent intent = new Intent(); + intent.setComponent( + new ComponentName( + "com.android.bluetooth", "com.android.bluetooth.opp.BluetoothOppLauncherActivity")); + intent.setAction(getShareIntentAction()); + intent.setType(mime); + if (sharingUris.size() == 1) { + intent.putExtra(Intent.EXTRA_STREAM, sharingUris.get(0)); + } else { + intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, sharingUris); + } + intent.setPackage("com.android.bluetooth"); + targetShareIntents.add(intent); + labels.add(contextc.getString(R.string.bluetooth)); + drawables.add( + contextc + .getResources() + .getDrawable( + appTheme.equals(AppTheme.LIGHT) + ? R.drawable.ic_settings_bluetooth_black_24dp + : R.drawable.ic_settings_bluetooth_white_36dp)); + } + } + return null; + } + + private String getShareIntentAction() { + return this.sharingUris.size() == 1 ? Intent.ACTION_SEND : Intent.ACTION_SEND_MULTIPLE; + } + + @Override + public void onPostExecute(Void v) { + if (!targetShareIntents.isEmpty()) { + MaterialDialog.Builder builder = new MaterialDialog.Builder(contextc); + builder.title(R.string.share); + builder.theme(appTheme.getMaterialDialogTheme()); + ShareAdapter shareAdapter = new ShareAdapter(contextc, targetShareIntents, labels, drawables); + builder.adapter(shareAdapter, null); + builder.negativeText(R.string.cancel); + builder.negativeColor(fab_skin); + MaterialDialog b = builder.build(); + shareAdapter.updateMatDialog(b); + b.show(); + } else { + Toast.makeText(contextc, R.string.no_app_found, Toast.LENGTH_SHORT).show(); + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/drag/DragToTrashListener.kt b/app/src/main/java/com/amaze/filemanager/ui/drag/DragToTrashListener.kt new file mode 100644 index 0000000..d588b01 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/drag/DragToTrashListener.kt @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.drag + +import android.view.DragEvent +import android.view.View + +class DragToTrashListener( + private val dragEventCallback: () -> Unit, + private val dragEnteredCallback: () -> Unit, +) : View.OnDragListener { + override fun onDrag( + p0: View?, + p1: DragEvent?, + ): Boolean { + return when (p1?.action) { + DragEvent.ACTION_DRAG_ENDED -> { + true + } + DragEvent.ACTION_DRAG_ENTERED -> { + dragEnteredCallback.invoke() + true + } + DragEvent.ACTION_DRAG_EXITED -> { + true + } + DragEvent.ACTION_DRAG_STARTED -> { + true + } + DragEvent.ACTION_DRAG_LOCATION -> { + true + } + DragEvent.ACTION_DROP -> { + dragEventCallback() + true + } + else -> false + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/drag/RecyclerAdapterDragListener.kt b/app/src/main/java/com/amaze/filemanager/ui/drag/RecyclerAdapterDragListener.kt new file mode 100644 index 0000000..f2f5094 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/drag/RecyclerAdapterDragListener.kt @@ -0,0 +1,261 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.drag + +import android.util.Log +import android.view.DragEvent +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import com.amaze.filemanager.adapters.RecyclerAdapter +import com.amaze.filemanager.adapters.data.LayoutElementParcelable +import com.amaze.filemanager.adapters.holders.ItemViewHolder +import com.amaze.filemanager.filesystem.HybridFile +import com.amaze.filemanager.filesystem.HybridFileParcelable +import com.amaze.filemanager.ui.dialogs.DragAndDropDialog +import com.amaze.filemanager.ui.fragments.MainFragment +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants +import com.amaze.filemanager.utils.DataUtils +import com.amaze.filemanager.utils.safeLet + +class RecyclerAdapterDragListener( + private val adapter: RecyclerAdapter, + private val holder: ItemViewHolder?, + private val dragAndDropPref: Int, + private val mainFragment: MainFragment, +) : View.OnDragListener { + private val TAG = javaClass.simpleName + + override fun onDrag( + p0: View?, + p1: DragEvent?, + ): Boolean { + return when (p1?.action) { + DragEvent.ACTION_DRAG_ENDED -> { + Log.d(TAG, "ENDING DRAG, DISABLE CORNERS") + mainFragment.requireMainActivity().initCornersDragListener( + true, + dragAndDropPref + != PreferencesConstants.PREFERENCE_DRAG_TO_SELECT, + ) + if (dragAndDropPref + != PreferencesConstants.PREFERENCE_DRAG_TO_SELECT + ) { + val dataUtils = DataUtils.getInstance() + dataUtils.checkedItemsList = null + mainFragment.requireMainActivity() + .tabFragment.dragPlaceholder?.visibility = View.INVISIBLE + } + true + } + DragEvent.ACTION_DRAG_ENTERED -> { + safeLet(holder, adapter.itemsDigested) { + holder, itemsDigested -> + if (itemsDigested.size != 0 && + holder.adapterPosition < itemsDigested.size + ) { + val listItem = (itemsDigested[holder.adapterPosition]) + if (dragAndDropPref == PreferencesConstants.PREFERENCE_DRAG_TO_SELECT) { + if (listItem.specialType != RecyclerAdapter.TYPE_BACK && + listItem.shouldToggleDragChecked + ) { + listItem.toggleShouldToggleDragChecked() + adapter.toggleChecked( + holder.adapterPosition, + if (mainFragment.mainFragmentViewModel?.isList == true) { + holder.checkImageView + } else { + holder.checkImageViewGrid + }, + ) + } + } else { + val currentElement = listItem.layoutElementParcelable + if (currentElement != null && + currentElement.isDirectory && + listItem.specialType != RecyclerAdapter.TYPE_BACK + ) { + holder.baseItemView.isSelected = true + } + } + } + } + true + } + DragEvent.ACTION_DRAG_EXITED -> { + safeLet(holder, adapter.itemsDigested) { + holder, itemsDigested -> + if (itemsDigested.size != 0 && + holder.adapterPosition < itemsDigested.size + ) { + if (dragAndDropPref != PreferencesConstants.PREFERENCE_DRAG_TO_SELECT) { + val listItem = itemsDigested[holder.adapterPosition] + if (listItem.specialTypeHasFile() && + listItem.specialType != RecyclerAdapter.TYPE_BACK + ) { + val currentElement = listItem.requireLayoutElementParcelable() + + if (currentElement.isDirectory && + !adapter.checkedItems.contains(currentElement) + ) { + holder.baseItemView.run { + isSelected = false + isFocusable = false + isFocusableInTouchMode = false + clearFocus() + } + } + } + } + } + } + true + } + DragEvent.ACTION_DRAG_STARTED -> { + return true + } + DragEvent.ACTION_DRAG_LOCATION -> { + holder?.run { + if (dragAndDropPref != PreferencesConstants.PREFERENCE_DRAG_TO_SELECT) { + holder.baseItemView.run { + isFocusable = true + isFocusableInTouchMode = true + requestFocus() + } + } + } + true + } + DragEvent.ACTION_DROP -> { + if (dragAndDropPref != PreferencesConstants.PREFERENCE_DRAG_TO_SELECT) { + var checkedItems: ArrayList? = adapter.checkedItems + var currentFileParcelable: HybridFileParcelable? = null + var isCurrentElementDirectory: Boolean? = null + var isEmptyArea: Boolean? = null + var pasteLocation: String? = + if (adapter.itemsDigested?.size == 0) { + mainFragment.currentPath + } else { + if (holder == null || holder.adapterPosition == RecyclerView.NO_POSITION) { + Log.d(TAG, "Trying to drop into empty area") + isEmptyArea = true + mainFragment.currentPath + } else { + adapter.itemsDigested?.let { + itemsDigested -> + if (itemsDigested[holder.adapterPosition].specialType + == RecyclerAdapter.TYPE_BACK + ) { + // dropping in goback button + // hack to get the parent path + val hybridFileParcelable = + mainFragment + .elementsList!![1].generateBaseFile() + val hybridFile = + HybridFile( + hybridFileParcelable.mode, + hybridFileParcelable.getParent(mainFragment.context), + ) + hybridFile.getParent(mainFragment.context) + } else { + val currentElement = + itemsDigested[holder.adapterPosition] + .layoutElementParcelable + currentFileParcelable = currentElement?.generateBaseFile() + isCurrentElementDirectory = currentElement?.isDirectory + currentElement?.desc + } + } + } + } + if (checkedItems?.size == 0) { + // probably because we switched tabs and + // this adapter doesn't have any checked items, get from data utils + val dataUtils = DataUtils.getInstance() + Log.d( + TAG, + "Didn't find checked items in adapter, " + + "checking dataUtils size ${ + dataUtils.checkedItemsList?.size ?: "null"}", + ) + checkedItems = dataUtils.checkedItemsList + } + val arrayList = ArrayList() + checkedItems?.forEach { + val file = it.generateBaseFile() + if (it.desc.equals(pasteLocation) || + ( + ( + isCurrentElementDirectory == false && + currentFileParcelable?.getParent(mainFragment.context) + .equals(file.getParent(mainFragment.context)) + ) || + ( + isEmptyArea == true && + mainFragment.currentPath + .equals(file.getParent(mainFragment.context)) + ) + ) + ) { + Log.d( + TAG, + ( + "Trying to drop into one of checked items or current " + + "location, not allowed ${it.desc}" + ), + ) + holder?.baseItemView?.run { + isFocusable = false + isFocusableInTouchMode = false + clearFocus() + } + return false + } + arrayList.add(it.generateBaseFile()) + } + if (isCurrentElementDirectory == false || isEmptyArea == true) { + pasteLocation = mainFragment.currentPath + } + Log.d( + TAG, + ( + "Trying to drop into one of checked items " + + "%s" + ).format(pasteLocation), + ) + DragAndDropDialog.showDialogOrPerformOperation( + pasteLocation!!, + arrayList, + mainFragment.requireMainActivity(), + ) + adapter.toggleChecked(false) + holder?.baseItemView?.run { + isSelected = false + isFocusable = false + isFocusableInTouchMode = false + clearFocus() + } + } + true + } + else -> false + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/drag/TabFragmentBottomDragListener.kt b/app/src/main/java/com/amaze/filemanager/ui/drag/TabFragmentBottomDragListener.kt new file mode 100644 index 0000000..4df6291 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/drag/TabFragmentBottomDragListener.kt @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.drag + +import android.view.DragEvent +import android.view.View + +class TabFragmentBottomDragListener( + private val dragEnterCallBack: () -> Unit, + private val dragExitCallBack: () -> Unit, +) : View.OnDragListener { + override fun onDrag( + p0: View?, + p1: DragEvent?, + ): Boolean { + return when (p1?.action) { + DragEvent.ACTION_DRAG_ENDED -> { + true + } + DragEvent.ACTION_DRAG_ENTERED -> { + dragEnterCallBack() + true + } + DragEvent.ACTION_DRAG_EXITED -> { + dragExitCallBack() + true + } + DragEvent.ACTION_DRAG_STARTED -> { + true + } + DragEvent.ACTION_DRAG_LOCATION -> { + true + } + DragEvent.ACTION_DROP -> { + true + } + else -> false + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/drag/TabFragmentSideDragListener.kt b/app/src/main/java/com/amaze/filemanager/ui/drag/TabFragmentSideDragListener.kt new file mode 100644 index 0000000..c673730 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/drag/TabFragmentSideDragListener.kt @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.drag + +import android.view.DragEvent +import android.view.View + +class TabFragmentSideDragListener(private val dragEventCallback: () -> Unit) : View.OnDragListener { + override fun onDrag( + p0: View?, + p1: DragEvent?, + ): Boolean { + return when (p1?.action) { + DragEvent.ACTION_DRAG_ENDED -> { + true + } + DragEvent.ACTION_DRAG_ENTERED -> { + dragEventCallback() + true + } + DragEvent.ACTION_DRAG_EXITED -> { + true + } + DragEvent.ACTION_DRAG_STARTED -> { + true + } + DragEvent.ACTION_DRAG_LOCATION -> { + true + } + DragEvent.ACTION_DROP -> { + true + } + else -> false + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/AdjustListViewForTv.kt b/app/src/main/java/com/amaze/filemanager/ui/fragments/AdjustListViewForTv.kt new file mode 100644 index 0000000..fba387d --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/AdjustListViewForTv.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.fragments + +import androidx.recyclerview.widget.RecyclerView +import com.amaze.filemanager.ui.activities.MainActivity + +interface AdjustListViewForTv { + /** + * Adjust list view focus scroll when using dpad. + * Scroll few more elements up / down so that it's easier for user to see list + */ + fun adjustListViewForTv( + viewHolder: T, + mainActivity: MainActivity, + ) +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/AppsListFragment.java b/app/src/main/java/com/amaze/filemanager/ui/fragments/AppsListFragment.java new file mode 100644 index 0000000..fd8355f --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/AppsListFragment.java @@ -0,0 +1,304 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.fragments; + +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_APPLIST_ISASCENDING; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_APPLIST_SORTBY; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import com.afollestad.materialdialogs.MaterialDialog; +import com.amaze.filemanager.R; +import com.amaze.filemanager.adapters.AppsRecyclerAdapter; +import com.amaze.filemanager.adapters.data.AppDataParcelable; +import com.amaze.filemanager.adapters.glide.AppsAdapterPreloadModel; +import com.amaze.filemanager.adapters.holders.AppHolder; +import com.amaze.filemanager.asynchronous.loaders.AppListLoader; +import com.amaze.filemanager.ui.activities.MainActivity; +import com.amaze.filemanager.ui.provider.UtilitiesProvider; +import com.amaze.filemanager.ui.theme.AppTheme; +import com.amaze.filemanager.ui.views.FastScroller; +import com.amaze.filemanager.utils.GlideConstants; +import com.amaze.filemanager.utils.Utils; +import com.bumptech.glide.Glide; +import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader; +import com.bumptech.glide.util.ViewPreloadSizeProvider; + +import android.content.SharedPreferences; +import android.graphics.drawable.ColorDrawable; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.loader.app.LoaderManager; +import androidx.loader.content.Loader; +import androidx.preference.PreferenceManager; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import me.zhanghai.android.materialprogressbar.MaterialProgressBar; + +public class AppsListFragment extends Fragment + implements LoaderManager.LoaderCallbacks>, + AdjustListViewForTv { + + public static final int ID_LOADER_APP_LIST = 0; + + private AppsRecyclerAdapter adapter; + private SharedPreferences sharedPreferences; + private boolean isAscending; + private int sortby; + private View rootView; + private AppsAdapterPreloadModel modelProvider; + private LinearLayoutManager linearLayoutManager; + private RecyclerViewPreloader preloader; + private List appDataParcelableList; + private FastScroller fastScroller; + private boolean showSystemApps = false; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + } + + @Nullable + @Override + public View onCreateView( + @NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + rootView = inflater.inflate(R.layout.fragment_app_list, container, false); + return rootView; + } + + @Override + public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + final MainActivity mainActivity = (MainActivity) getActivity(); + Objects.requireNonNull(mainActivity); + + UtilitiesProvider utilsProvider = mainActivity.getUtilsProvider(); + modelProvider = new AppsAdapterPreloadModel(this, false); + ViewPreloadSizeProvider sizeProvider = new ViewPreloadSizeProvider<>(); + preloader = + new RecyclerViewPreloader<>( + Glide.with(this), modelProvider, sizeProvider, GlideConstants.MAX_PRELOAD_APPSADAPTER); + linearLayoutManager = new LinearLayoutManager(getContext()); + updateViews(mainActivity, utilsProvider); + + sharedPreferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); + isAscending = sharedPreferences.getBoolean(PREFERENCE_APPLIST_ISASCENDING, true); + sortby = sharedPreferences.getInt(PREFERENCE_APPLIST_SORTBY, 0); + fastScroller = rootView.findViewById(R.id.fastscroll); + fastScroller.setPressedHandleColor(mainActivity.getAccent()); + fastScroller.setRecyclerView(getRecyclerView(), 1); + mainActivity + .getAppbar() + .getAppbarLayout() + .addOnOffsetChangedListener( + (appBarLayout, verticalOffset) -> { + fastScroller.updateHandlePosition(verticalOffset, 112); + }); + LoaderManager.getInstance(this).initLoader(ID_LOADER_APP_LIST, null, this); + } + + @Override + public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) { + requireActivity().getMenuInflater().inflate(R.menu.app_menu, menu); + menu.findItem(R.id.checkbox_system_apps).setChecked(false); + super.onCreateOptionsMenu(menu, inflater); + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + switch (item.getItemId()) { + case R.id.sort: + showSortDialog(((MainActivity) requireActivity()).getAppTheme()); + return true; + case R.id.exit: + requireActivity().finish(); + return true; + case R.id.checkbox_system_apps: + item.setChecked(!item.isChecked()); + adapter.setData(appDataParcelableList, item.isChecked()); + showSystemApps = item.isChecked(); + return true; + default: + return super.onOptionsItemSelected(item); + } + } + + private void updateViews(MainActivity mainActivity, UtilitiesProvider utilsProvider) { + mainActivity.getAppbar().setTitle(R.string.apps); + mainActivity.hideFab(); + mainActivity.getAppbar().getBottomBar().setVisibility(View.GONE); + mainActivity.supportInvalidateOptionsMenu(); + + if (utilsProvider.getAppTheme().equals(AppTheme.DARK)) { + getActivity() + .getWindow() + .getDecorView() + .setBackgroundColor(Utils.getColor(getContext(), R.color.holo_dark_background)); + } else if (utilsProvider.getAppTheme().equals(AppTheme.BLACK)) { + getActivity() + .getWindow() + .getDecorView() + .setBackgroundColor(Utils.getColor(getContext(), android.R.color.black)); + } + int skin_color = mainActivity.getCurrentColorPreference().getPrimaryFirstTab(); + int skinTwoColor = mainActivity.getCurrentColorPreference().getPrimarySecondTab(); + mainActivity.updateViews( + new ColorDrawable(MainActivity.currentTab == 1 ? skinTwoColor : skin_color)); + + getRecyclerView().addOnScrollListener(preloader); + getRecyclerView().setLayoutManager(linearLayoutManager); + } + + public void showSortDialog(AppTheme appTheme) { + final MainActivity mainActivity = (MainActivity) getActivity(); + if (mainActivity == null) { + return; + } + + WeakReference appsListFragment = new WeakReference<>(this); + + int accentColor = mainActivity.getAccent(); + String[] sort = getResources().getStringArray(R.array.sortbyApps); + MaterialDialog.Builder builder = + new MaterialDialog.Builder(mainActivity) + .theme(appTheme.getMaterialDialogTheme()) + .items(sort) + .itemsCallbackSingleChoice(sortby, (dialog, view, which, text) -> true) + .negativeText(R.string.ascending) + .positiveColor(accentColor) + .positiveText(R.string.descending) + .negativeColor(accentColor) + .onNegative( + (dialog, which) -> { + final AppsListFragment $this = appsListFragment.get(); + if ($this == null) { + return; + } + $this.saveAndReload(dialog.getSelectedIndex(), true); + dialog.dismiss(); + }) + .onPositive( + (dialog, which) -> { + final AppsListFragment $this = appsListFragment.get(); + if ($this == null) { + return; + } + + $this.saveAndReload(dialog.getSelectedIndex(), false); + dialog.dismiss(); + }) + .title(R.string.sort_by); + + builder.build().show(); + } + + private void saveAndReload(int newSortby, boolean newIsAscending) { + sortby = newSortby; + isAscending = newIsAscending; + + sharedPreferences + .edit() + .putBoolean(PREFERENCE_APPLIST_ISASCENDING, newIsAscending) + .putInt(PREFERENCE_APPLIST_SORTBY, newSortby) + .apply(); + + LoaderManager.getInstance(this).restartLoader(AppsListFragment.ID_LOADER_APP_LIST, null, this); + } + + @NonNull + @Override + public Loader> onCreateLoader(int id, Bundle args) { + return new AppListLoader(getContext(), sortby, isAscending); + } + + @Override + public void onLoadFinished( + @NonNull Loader> loader, List data) { + getSpinner().setVisibility(View.GONE); + if (data.isEmpty()) { + getRecyclerView().setVisibility(View.GONE); + rootView.findViewById(R.id.empty_text_view).setVisibility(View.VISIBLE); + } else { + appDataParcelableList = new ArrayList<>(data); + List adapterList = new ArrayList<>(); + for (AppDataParcelable appDataParcelable : data) { + if (!showSystemApps && appDataParcelable.isSystemApp()) { + continue; + } + adapterList.add(appDataParcelable); + } + adapter = new AppsRecyclerAdapter(this, modelProvider, false, this, adapterList); + getRecyclerView().setVisibility(View.VISIBLE); + getRecyclerView().setAdapter(adapter); + } + } + + @Override + public void onLoaderReset(@NonNull Loader> loader) { + adapter.setData(Collections.emptyList(), true); + } + + @Override + public void adjustListViewForTv( + @NonNull AppHolder viewHolder, @NonNull MainActivity mainActivity) { + try { + int[] location = new int[2]; + viewHolder.rl.getLocationOnScreen(location); + Log.i(getClass().getSimpleName(), "Current x and y " + location[0] + " " + location[1]); + if (location[1] < mainActivity.getAppbar().getAppbarLayout().getHeight()) { + getRecyclerView().scrollToPosition(Math.max(viewHolder.getAdapterPosition() - 5, 0)); + } else if (location[1] + viewHolder.rl.getHeight() + >= getContext().getResources().getDisplayMetrics().heightPixels) { + getRecyclerView() + .scrollToPosition( + Math.min(viewHolder.getAdapterPosition() + 5, adapter.getItemCount() - 1)); + } + } catch (IndexOutOfBoundsException e) { + Log.w(getClass().getSimpleName(), "Failed to adjust scrollview for tv", e); + } + } + + private RecyclerView getRecyclerView() { + return rootView.findViewById(R.id.list_view); + } + + private MaterialProgressBar getSpinner() { + return rootView.findViewById(R.id.loading_spinner); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/CloudSheetFragment.java b/app/src/main/java/com/amaze/filemanager/ui/fragments/CloudSheetFragment.java new file mode 100644 index 0000000..3a976e0 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/CloudSheetFragment.java @@ -0,0 +1,206 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.fragments; + +import com.amaze.filemanager.BuildConfig; +import com.amaze.filemanager.R; +import com.amaze.filemanager.database.CloudContract; +import com.amaze.filemanager.databinding.FragmentSheetCloudBinding; +import com.amaze.filemanager.fileoperations.filesystem.OpenMode; +import com.amaze.filemanager.ui.activities.MainActivity; +import com.amaze.filemanager.ui.dialogs.GeneralDialogCreation; +import com.amaze.filemanager.ui.dialogs.SftpConnectDialog; +import com.amaze.filemanager.ui.dialogs.SmbSearchDialog; +import com.amaze.filemanager.ui.theme.AppTheme; +import com.amaze.filemanager.utils.Utils; +import com.google.android.material.bottomsheet.BottomSheetBehavior; +import com.google.android.material.bottomsheet.BottomSheetDialog; +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; + +import android.app.Dialog; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * Created by vishal on 18/2/17. + * + *

Class represents implementation of a new cloud connection sheet dialog + */ +public class CloudSheetFragment extends BottomSheetDialogFragment implements View.OnClickListener { + + private View rootView; + private LinearLayout mSmbLayout, + mScpLayout, + mDropboxLayout, + mBoxLayout, + mGoogleDriveLayout, + mOnedriveLayout, + mGetCloudLayout; + + public static final String TAG_FRAGMENT = "cloud_fragment"; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + BottomSheetDialog dialog = (BottomSheetDialog) super.onCreateDialog(savedInstanceState); + + dialog.setOnShowListener( + dialog1 -> { + BottomSheetDialog d = (BottomSheetDialog) dialog1; + + FrameLayout bottomSheet = + (FrameLayout) d.findViewById(com.google.android.material.R.id.design_bottom_sheet); + BottomSheetBehavior.from(bottomSheet).setState(BottomSheetBehavior.STATE_EXPANDED); + }); + return dialog; + } + + @Override + public void setupDialog(Dialog dialog, int style) { + super.setupDialog(dialog, style); + + rootView = FragmentSheetCloudBinding.inflate(LayoutInflater.from(requireActivity())).getRoot(); + + MainActivity activity = (MainActivity) getActivity(); + + if (activity.getAppTheme().equals(AppTheme.DARK)) { + rootView.setBackgroundColor(Utils.getColor(getContext(), R.color.holo_dark_background)); + } else if (activity.getAppTheme().equals(AppTheme.BLACK)) { + rootView.setBackgroundColor(Utils.getColor(getContext(), android.R.color.black)); + } else { + rootView.setBackgroundColor(Utils.getColor(getContext(), android.R.color.white)); + } + + mSmbLayout = rootView.findViewById(R.id.linear_layout_smb); + mScpLayout = rootView.findViewById(R.id.linear_layout_scp); + mBoxLayout = rootView.findViewById(R.id.linear_layout_box); + mDropboxLayout = rootView.findViewById(R.id.linear_layout_dropbox); + mGoogleDriveLayout = rootView.findViewById(R.id.linear_layout_google_drive); + mOnedriveLayout = rootView.findViewById(R.id.linear_layout_onedrive); + mGetCloudLayout = rootView.findViewById(R.id.linear_layout_get_cloud); + + if (isCloudProviderAvailable(getContext())) { + + mBoxLayout.setVisibility(View.VISIBLE); + mDropboxLayout.setVisibility(View.VISIBLE); + mGoogleDriveLayout.setVisibility(View.VISIBLE); + mOnedriveLayout.setVisibility(View.VISIBLE); + mGetCloudLayout.setVisibility(View.GONE); + } + + if (BuildConfig.IS_VERSION_FDROID) { + mBoxLayout.setVisibility(View.GONE); + mDropboxLayout.setVisibility(View.GONE); + mGoogleDriveLayout.setVisibility(View.GONE); + mOnedriveLayout.setVisibility(View.GONE); + mGetCloudLayout.setVisibility(View.GONE); + } + + mSmbLayout.setOnClickListener(this); + mScpLayout.setOnClickListener(this); + mBoxLayout.setOnClickListener(this); + mDropboxLayout.setOnClickListener(this); + mGoogleDriveLayout.setOnClickListener(this); + mOnedriveLayout.setOnClickListener(this); + mGetCloudLayout.setOnClickListener(this); + + dialog.setContentView(rootView); + } + + /** Determines whether cloud provider is installed or not */ + public static final boolean isCloudProviderAvailable(Context context) { + + PackageManager pm = context.getPackageManager(); + try { + pm.getPackageInfo(CloudContract.APP_PACKAGE_NAME, PackageManager.GET_ACTIVITIES); + return true; + } catch (PackageManager.NameNotFoundException e) { + return false; + } + } + + @Override + public void onClick(View v) { + switch (v.getId()) { + case R.id.linear_layout_smb: + dismiss(); + SmbSearchDialog smbDialog = new SmbSearchDialog(); + smbDialog.show(getActivity().getSupportFragmentManager(), "tab"); + return; + case R.id.linear_layout_scp: + dismiss(); + SftpConnectDialog sftpConnectDialog = new SftpConnectDialog(); + Bundle args = new Bundle(); + args.putBoolean("edit", false); + sftpConnectDialog.setArguments(args); + sftpConnectDialog.show(getFragmentManager(), "tab"); + return; + case R.id.linear_layout_box: + ((MainActivity) getActivity()).addConnection(OpenMode.BOX); + break; + case R.id.linear_layout_dropbox: + ((MainActivity) getActivity()).addConnection(OpenMode.DROPBOX); + break; + case R.id.linear_layout_google_drive: + GeneralDialogCreation.showSignInWithGoogleDialog((MainActivity) getActivity()); + break; + case R.id.linear_layout_onedrive: + ((MainActivity) getActivity()).addConnection(OpenMode.ONEDRIVE); + break; + case R.id.linear_layout_get_cloud: + Intent cloudPluginIntent = new Intent(Intent.ACTION_VIEW); + cloudPluginIntent.setData(Uri.parse(getString(R.string.cloud_plugin_google_play_uri))); + try { + startActivity(cloudPluginIntent); + } catch (ActivityNotFoundException ifGooglePlayIsNotInstalled) { + cloudPluginIntent.setData( + Uri.parse(getString(R.string.cloud_plugin_google_play_web_uri))); + startActivity(cloudPluginIntent); + } + break; + } + + // dismiss this sheet dialog + dismiss(); + } + + public interface CloudConnectionCallbacks { + void addConnection(OpenMode service); + + void deleteConnection(OpenMode service); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/CompressedExplorerFragment.kt b/app/src/main/java/com/amaze/filemanager/ui/fragments/CompressedExplorerFragment.kt new file mode 100644 index 0000000..dacea24 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/CompressedExplorerFragment.kt @@ -0,0 +1,795 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.fragments + +import android.content.ComponentName +import android.content.ContentResolver +import android.content.Intent +import android.content.ServiceConnection +import android.graphics.drawable.ColorDrawable +import android.net.Uri +import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES.KITKAT +import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Bundle +import android.os.IBinder +import android.provider.MediaStore +import android.util.Log +import android.view.ActionMode +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.annotation.ColorInt +import androidx.annotation.StringRes +import androidx.appcompat.widget.AppCompatEditText +import androidx.appcompat.widget.AppCompatTextView +import androidx.core.view.children +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.afollestad.materialdialogs.DialogAction +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.MaterialDialog.SingleButtonCallback +import com.amaze.filemanager.R +import com.amaze.filemanager.adapters.CompressedExplorerAdapter +import com.amaze.filemanager.adapters.data.CompressedObjectParcelable +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.asynchronous.asynctasks.DeleteTask +import com.amaze.filemanager.asynchronous.services.ExtractService +import com.amaze.filemanager.databinding.ActionmodeBinding +import com.amaze.filemanager.databinding.MainFragBinding +import com.amaze.filemanager.fileoperations.filesystem.OpenMode +import com.amaze.filemanager.fileoperations.filesystem.compressed.ArchivePasswordCache +import com.amaze.filemanager.filesystem.HybridFileParcelable +import com.amaze.filemanager.filesystem.compressed.CompressedHelper +import com.amaze.filemanager.filesystem.compressed.showcontents.Decompressor +import com.amaze.filemanager.filesystem.files.FileUtils +import com.amaze.filemanager.ui.activities.MainActivity +import com.amaze.filemanager.ui.colors.ColorPreferenceHelper +import com.amaze.filemanager.ui.dialogs.GeneralDialogCreation +import com.amaze.filemanager.ui.fragments.data.CompressedExplorerFragmentViewModel +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants +import com.amaze.filemanager.ui.theme.AppTheme +import com.amaze.filemanager.ui.views.DividerItemDecoration +import com.amaze.filemanager.ui.views.FastScroller +import com.amaze.filemanager.utils.BottomBarButtonPath +import com.amaze.filemanager.utils.Utils +import com.github.junrar.exception.UnsupportedRarV5Exception +import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.appbar.AppBarLayout.OnOffsetChangedListener +import io.reactivex.Flowable +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import org.apache.commons.compress.PasswordRequiredException +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +@Suppress("TooManyFunctions") +class CompressedExplorerFragment : Fragment(), BottomBarButtonPath { + lateinit var compressedFile: File + + private val viewModel: CompressedExplorerFragmentViewModel by viewModels() + + /** + * files to be deleted from cache with a Map maintaining key - the root of directory created (for + * deletion purposes after we exit out of here and value - the path of file to open + */ + @JvmField + var files: ArrayList? = null + + @JvmField + var selection = false + + /** Normally this would be "/" but for pathing issues it isn't */ + var relativeDirectory = "" + + @JvmField + @ColorInt + var accentColor = 0 + + @JvmField + @ColorInt + var iconskin = 0 + var compressedExplorerAdapter: CompressedExplorerAdapter? = null + + @JvmField + var mActionMode: ActionMode? = null + + @JvmField + var coloriseIcons = false + + @JvmField + var showSize = false + + @JvmField + var showLastModified = false + var gobackitem = false + var listView: RecyclerView? = null + lateinit var swipeRefreshLayout: SwipeRefreshLayout + + /** flag states whether to open file after service extracts it */ + @JvmField + var isOpen = false + private lateinit var fastScroller: FastScroller + private lateinit var decompressor: Decompressor + private var addheader = true + private var dividerItemDecoration: DividerItemDecoration? = null + private var showDividers = false + private var mToolbarContainer: View? = null + private var stopAnims = true + private var file = 0 + private var folder = 0 + private var isCachedCompressedFile = false + private val offsetListenerForToolbar = + OnOffsetChangedListener { appBarLayout: AppBarLayout?, verticalOffset: Int -> + fastScroller.updateHandlePosition(verticalOffset, 112) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val rootView: View = MainFragBinding.inflate(inflater).root + listView = + rootView.findViewById(R.id.listView).also { + it.setOnTouchListener { _: View?, _: MotionEvent? -> + compressedExplorerAdapter?.apply { + if (stopAnims && !this.stoppedAnimation) { + stopAnim() + } + this.stoppedAnimation = true + stopAnims = false + } + false + } + } + + swipeRefreshLayout = + rootView + .findViewById(R.id.activity_main_swipe_refresh_layout).also { + it.setOnRefreshListener { refresh() } + it.isRefreshing = true + } + viewModel.elements.observe( + viewLifecycleOwner, + { elements -> + viewModel.folder?.run { + createViews(elements, this) + swipeRefreshLayout.isRefreshing = false + updateBottomBar() + } + }, + ) + return rootView + } + + /** + * Stop animation at archive file list view. + */ + fun stopAnim() { + listView?.children?.forEach { v -> + v.clearAnimation() + } + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + val sp = PreferenceManager.getDefaultSharedPreferences(requireActivity()) + val fileName = prepareCompressedFile(requireArguments().getString(KEY_PATH, "/")) + mToolbarContainer = + requireMainActivity().appbar.appbarLayout.also { + it.setOnTouchListener { _: View?, _: MotionEvent? -> + if (stopAnims) { + if (false == compressedExplorerAdapter?.stoppedAnimation) { + stopAnim() + } + compressedExplorerAdapter?.stoppedAnimation = true + } + stopAnims = false + false + } + } + listView?.visibility = View.VISIBLE + listView?.layoutManager = LinearLayoutManager(activity) + val utilsProvider = AppConfig.getInstance().utilsProvider + when (utilsProvider.appTheme) { + AppTheme.DARK -> + requireView() + .setBackgroundColor(Utils.getColor(context, R.color.holo_dark_background)) + AppTheme.BLACK -> + listView?.setBackgroundColor(Utils.getColor(context, android.R.color.black)) + else -> + listView?.setBackgroundColor( + Utils.getColor( + context, + android.R.color.background_light, + ), + ) + } + gobackitem = sp.getBoolean(PreferencesConstants.PREFERENCE_SHOW_GOBACK_BUTTON, false) + coloriseIcons = sp.getBoolean(PreferencesConstants.PREFERENCE_COLORIZE_ICONS, true) + showSize = sp.getBoolean(PreferencesConstants.PREFERENCE_SHOW_FILE_SIZE, false) + showLastModified = sp.getBoolean(PreferencesConstants.PREFERENCE_SHOW_LAST_MODIFIED, true) + showDividers = sp.getBoolean(PreferencesConstants.PREFERENCE_SHOW_DIVIDERS, true) + accentColor = requireMainActivity().accent + iconskin = requireMainActivity().currentColorPreference.iconSkin + + // mainActivity.findViewById(R.id.buttonbarframe).setBackgroundColor(Color.parseColor(skin)); + if (savedInstanceState == null) { + compressedFile.run { + files = ArrayList() + // adding a cache file to delete where any user interaction elements will be cached + val path = + if (isCachedCompressedFile) { + this.absolutePath + } else { + requireActivity().externalCacheDir!! + .path + CompressedHelper.SEPARATOR + fileName + } + files?.add(HybridFileParcelable(path)) + val decompressor = CompressedHelper.getCompressorInstance(requireContext(), this) + if (decompressor == null) { + Toast.makeText( + requireContext(), + R.string.error_cant_decompress_that_file, + Toast.LENGTH_LONG, + ).show() + parentFragmentManager.beginTransaction() + .remove(this@CompressedExplorerFragment).commit() + return + } + this@CompressedExplorerFragment.decompressor = decompressor + changePath("") + } + } else { + onRestoreInstanceState(savedInstanceState) + } + requireMainActivity().supportInvalidateOptionsMenu() + } + + private fun prepareCompressedFile(pathArg: String): String { + val pathUri = Uri.parse(pathArg) + var fileName: String = pathUri.path ?: "filename" + if (ContentResolver.SCHEME_CONTENT == pathUri.scheme) { + requireContext() + .contentResolver + .query( + pathUri, + arrayOf(MediaStore.MediaColumns.DISPLAY_NAME), + null, + null, + null, + )?.run { + try { + if (moveToFirst()) { + fileName = + getString(0).let { + /* + * Strip any slashes to prevent possibility to access files outside + * cache dir if malicious ContentProvider gives malicious value + * of MediaStore.MediaColumns.DISPLAY_NAME when querying + */ + if (it.contains(File.pathSeparator)) { + it.substringAfterLast(File.pathSeparatorChar) + } else { + it + } + } + compressedFile = File(requireContext().cacheDir, fileName) + } else { + // At this point, we know nothing the file the URI represents, we are doing everything + // wild guess. + compressedFile = + File.createTempFile( + "compressed", + null, + requireContext().cacheDir, + ) + .also { + fileName = it.name + } + } + compressedFile.deleteOnExit() + FileOutputStream(compressedFile).use { outputStream -> + requireContext().contentResolver.openInputStream(pathUri) + ?.use { it.copyTo(outputStream, DEFAULT_BUFFER_SIZE) } + } + isCachedCompressedFile = true + } catch (e: IOException) { + Log.e(TAG, "Error opening URI $pathUri for reading", e) + AppConfig.toast( + requireContext(), + requireContext() + .getString( + R.string.compressed_explorer_fragment_error_open_uri, + pathUri.toString(), + ), + ) + requireActivity().onBackPressed() + } finally { + close() + } + } + } else { + pathUri.path?.let { path -> + compressedFile = + File(path).also { + fileName = it.name.substring(0, it.name.lastIndexOf(".")) + } + } + } + + return fileName + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putParcelableArrayList(KEY_ELEMENTS, viewModel.elements.value) + outState.putString(KEY_PATH, relativeDirectory) + outState.putString(KEY_URI, compressedFile.path) + outState.putParcelableArrayList(KEY_CACHE_FILES, files) + outState.putBoolean(KEY_OPEN, isOpen) + } + + private fun onRestoreInstanceState(savedInstanceState: Bundle?) { + savedInstanceState?.let { bundle -> + prepareCompressedFile(bundle.getString(KEY_URI)!!) + files = bundle.getParcelableArrayList(KEY_CACHE_FILES) + isOpen = bundle.getBoolean(KEY_OPEN) + relativeDirectory = bundle.getString(KEY_PATH, "") + compressedFile.let { + val decompressor = CompressedHelper.getCompressorInstance(requireContext(), it) + if (decompressor == null) { + parentFragmentManager.beginTransaction() + .remove(this@CompressedExplorerFragment).commit() + Toast.makeText( + requireContext(), + R.string.error_cant_decompress_that_file, + Toast.LENGTH_LONG, + ).show() + return + } + this@CompressedExplorerFragment.decompressor = decompressor + } + viewModel.elements.value = bundle.getParcelableArrayList(KEY_ELEMENTS) + } + } + + @JvmField + var mActionModeCallback: ActionMode.Callback = + object : ActionMode.Callback { + private fun hideOption( + id: Int, + menu: Menu, + ) { + val item = menu.findItem(id) + item.isVisible = false + } + + private fun showOption( + id: Int, + menu: Menu, + ) { + val item = menu.findItem(id) + item.isVisible = true + } + + // called when the action mode is created; startActionMode() was called + override fun onCreateActionMode( + mode: ActionMode, + menu: Menu, + ): Boolean { + // Inflate a menu resource providing context menu items + val v = ActionmodeBinding.inflate(LayoutInflater.from(requireContext())).root + mode.customView = v + // assumes that you have "contexual.xml" menu resources + mode.menuInflater.inflate(R.menu.contextual, menu) + hideOption(R.id.cpy, menu) + hideOption(R.id.cut, menu) + hideOption(R.id.delete, menu) + hideOption(R.id.addshortcut, menu) + hideOption(R.id.share, menu) + hideOption(R.id.openwith, menu) + showOption(R.id.all, menu) + hideOption(R.id.compress, menu) + hideOption(R.id.hide, menu) + showOption(R.id.ex, menu) + mode.title = getString(R.string.select) + requireMainActivity().updateViews( + ColorDrawable( + Utils.getColor( + context, + R.color.holo_dark_action_mode, + ), + ), + ) + if (SDK_INT >= LOLLIPOP) { + val window = requireActivity().window + if (requireMainActivity() + .getBoolean(PreferencesConstants.PREFERENCE_COLORED_NAVIGATION) + ) { + window.navigationBarColor = + Utils.getColor(context, android.R.color.black) + } + } + if (SDK_INT < KITKAT) { + requireMainActivity().appbar.toolbar.visibility = View.GONE + } + return true + } + + // the following method is called each time + // the action mode is shown. Always called after + // onCreateActionMode, but + // may be called multiple times if the mode is invalidated. + override fun onPrepareActionMode( + mode: ActionMode, + menu: Menu, + ): Boolean { + compressedExplorerAdapter?.checkedItemPositions?.let { positions -> + (mode.customView.findViewById(R.id.item_count) as AppCompatTextView).text = + positions.size.toString() + menu.findItem(R.id.all) + .setTitle( + if (positions.size == folder + file) { + R.string.deselect_all + } else { + R.string.select_all + }, + ) + } + return false // Return false if nothing is done + } + + // called when the user selects a contextual menu item + override fun onActionItemClicked( + mode: ActionMode, + item: MenuItem, + ): Boolean { + return compressedExplorerAdapter?.let { + when (item.itemId) { + R.id.all -> { + val positions = it.checkedItemPositions + val shouldDeselectAll = positions.size != folder + file + it.toggleChecked(shouldDeselectAll) + mode.invalidate() + item.setTitle( + if (shouldDeselectAll) { + R.string.deselect_all + } else { + R.string.select_all + }, + ) + if (!shouldDeselectAll) { + selection = false + mActionMode?.finish() + mActionMode = null + } + return true + } + R.id.ex -> { + Toast.makeText(activity, getString(R.string.extracting), Toast.LENGTH_SHORT) + .show() + val dirs = + arrayOfNulls( + it.checkedItemPositions.size, + ) + var i = 0 + while (i < dirs.size) { + dirs[i] = + viewModel + .elements + .value!![it.checkedItemPositions[i]].path + i++ + } + decompressor.decompress(compressedFile.path, dirs) + mode.finish() + return true + } + else -> false + } + } ?: false + } + + override fun onDestroyActionMode(actionMode: ActionMode) { + compressedExplorerAdapter?.toggleChecked(false) + @ColorInt val primaryColor = + ColorPreferenceHelper.getPrimary( + requireMainActivity().currentColorPreference, + MainActivity.currentTab, + ) + selection = false + requireMainActivity().updateViews(ColorDrawable(primaryColor)) + if (SDK_INT >= LOLLIPOP) { + val window = requireActivity().window + if (requireMainActivity() + .getBoolean(PreferencesConstants.PREFERENCE_COLORED_NAVIGATION) + ) { + window.navigationBarColor = + requireMainActivity().skinStatusBar + } + } + mActionMode = null + } + } + + override fun onDestroyView() { + super.onDestroyView() + + // Clearing the touch listeners allows the fragment to + // be cleaned after it is destroyed, preventing leaks + mToolbarContainer?.setOnTouchListener(null) + (mToolbarContainer as AppBarLayout?)?.removeOnOffsetChangedListener( + offsetListenerForToolbar, + ) + requireMainActivity().supportInvalidateOptionsMenu() + + // needed to remove any extracted file from cache, when onResume was not called + // in case of opening any unknown file inside the zip + if (true == files?.isNotEmpty() && true == files?.get(0)?.exists()) { + DeleteTask(requireActivity(), this).execute(files) + } + if (isCachedCompressedFile) { + compressedFile.delete() + } + } + + override fun onResume() { + super.onResume() + requireMainActivity().hideFab() + val intent = Intent(activity, ExtractService::class.java) + requireActivity().bindService(intent, mServiceConnection, 0) + } + + override fun onPause() { + super.onPause() + requireActivity().unbindService(mServiceConnection) + } + + private val mServiceConnection: ServiceConnection = + object : ServiceConnection { + override fun onServiceConnected( + name: ComponentName, + service: IBinder, + ) = Unit + + override fun onServiceDisconnected(name: ComponentName) { + // open file if pending + if (isOpen) { + files?.let { cachedFiles -> + // open most recent entry added to files to be deleted from cache + val cacheFile = File(cachedFiles[cachedFiles.size - 1].path) + if (cacheFile.exists()) { + FileUtils.openFile( + cacheFile, + requireMainActivity(), + requireMainActivity().prefs, + ) + } + // reset the flag and cache file, as it's root is already in the list for deletion + isOpen = false + cachedFiles.removeAt(cachedFiles.size - 1) + } + } + } + } + + override fun changePath(path: String) { + var folder = path + if (folder.startsWith("/")) folder = folder.substring(1) + val addGoBackItem = gobackitem && !isRoot(folder) + decompressor.let { + Flowable.fromCallable(it.changePath(folder, addGoBackItem)) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { result -> + viewModel.elements.postValue(result) + viewModel.folder = folder + }, + { error -> + if (error is PasswordRequiredException) { + dialogGetPasswordFromUser(folder) + } else { + archiveCorruptOrUnsupportedToast(error) + } + }, + ) + swipeRefreshLayout.isRefreshing = true + updateBottomBar() + } + } + + private fun dialogGetPasswordFromUser(filePath: String) { + val positiveCallback = + SingleButtonCallback { dialog: MaterialDialog, _: DialogAction? -> + val editText = + dialog.view.findViewById( + R.id.singleedittext_input, + ) + val password: String = editText.text.toString() + ArchivePasswordCache.getInstance()[filePath] = password + dialog.dismiss() + changePath(filePath) + } + ArchivePasswordCache.getInstance().remove(filePath) + GeneralDialogCreation.showPasswordDialog( + requireContext(), + (requireActivity() as MainActivity), + AppConfig.getInstance().utilsProvider.appTheme, + R.string.archive_password_prompt, + R.string.authenticate_password, + positiveCallback, + null, + ) + } + + private fun archiveCorruptOrUnsupportedToast(e: Throwable?) { + @StringRes val msg: Int = + if (e?.cause?.javaClass is UnsupportedRarV5Exception) { + R.string.error_unsupported_v5_rar + } else { + R.string.archive_unsupported_or_corrupt + } + Toast.makeText( + activity, + requireContext().getString(msg, compressedFile.absolutePath), + Toast.LENGTH_LONG, + ).show() + requireActivity().supportFragmentManager.beginTransaction().remove(this).commit() + } + + override val path: String + get() = + if (!isRootRelativePath) { + CompressedHelper.SEPARATOR + relativeDirectory + } else { + "" + } + + override val rootDrawable: Int + get() = R.drawable.ic_compressed_white_24dp + + private fun refresh() { + changePath(relativeDirectory) + } + + private fun updateBottomBar() { + compressedFile.let { + val path = + if (!isRootRelativePath) { + it.name + CompressedHelper.SEPARATOR + relativeDirectory + } else { + it.name + } + requireMainActivity() + .getAppbar() + .bottomBar + .updatePath(path, OpenMode.FILE, folder, file, this) + } + } + + private fun createViews( + items: List?, + dir: String, + ) { + if (compressedExplorerAdapter == null) { + compressedExplorerAdapter = + CompressedExplorerAdapter( + activity, + AppConfig.getInstance().utilsProvider, + items, + this, + decompressor, + PreferenceManager.getDefaultSharedPreferences(requireMainActivity()), + ) + listView?.adapter = compressedExplorerAdapter + } else { + compressedExplorerAdapter?.generateZip(items) + } + folder = 0 + file = 0 + items?.forEach { item -> + if (item.type == CompressedObjectParcelable.TYPE_GOBACK) Unit // do nothing + if (item.directory) folder++ else file++ + } + stopAnims = true + if (!addheader) { + dividerItemDecoration?.run { + listView?.removeItemDecoration(this) + } + // listView.removeItemDecoration(headersDecor); + addheader = true + } else { + dividerItemDecoration = + DividerItemDecoration( + activity, + true, + showDividers, + ).also { + listView?.addItemDecoration(it) + } + // headersDecor = new StickyRecyclerHeadersDecoration(compressedExplorerAdapter); + // listView.addItemDecoration(headersDecor); + addheader = false + } + fastScroller = + requireView().findViewById(R.id.fastscroll).also { + it.setRecyclerView(listView!!, 1) + it.setPressedHandleColor(requireMainActivity().accent) + } + (mToolbarContainer as AppBarLayout?)?.addOnOffsetChangedListener(offsetListenerForToolbar) + listView?.stopScroll() + relativeDirectory = dir + updateBottomBar() + swipeRefreshLayout.isRefreshing = false + } + + /** + * Indicator whether navigation through back button is possible. + */ + fun canGoBack(): Boolean { + return !isRootRelativePath + } + + /** + * Go one level up in the archive hierarchy. + */ + fun goBack() { + val parent: String = File(relativeDirectory).parent ?: "" + changePath(parent) + } + + private val isRootRelativePath: Boolean + get() = isRoot(relativeDirectory) + + private fun isRoot(folder: String?): Boolean { + return folder == null || folder.isEmpty() + } + + /** + * Wrapper of requireActivity() to return [MainActivity]. + * + * @return [MainActivity] + */ + fun requireMainActivity(): MainActivity = requireActivity() as MainActivity + + companion object { + const val KEY_PATH = "path" + private const val KEY_CACHE_FILES = "cache_files" + private const val KEY_URI = "uri" + private const val KEY_ELEMENTS = "elements" + private const val KEY_OPEN = "is_open" + private val TAG = CompressedExplorerFragment::class.java.simpleName + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/DbViewerFragment.java b/app/src/main/java/com/amaze/filemanager/ui/fragments/DbViewerFragment.java new file mode 100644 index 0000000..b0f44fa --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/DbViewerFragment.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.fragments; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.asynchronous.asynctasks.DbViewerTask; +import com.amaze.filemanager.ui.activities.DatabaseViewerActivity; +import com.amaze.filemanager.ui.theme.AppTheme; +import com.amaze.filemanager.utils.Utils; + +import android.database.Cursor; +import android.graphics.Color; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.webkit.WebView; +import android.widget.RelativeLayout; + +import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatTextView; +import androidx.fragment.app.Fragment; + +/** Created by Vishal on 06-02-2015. */ +public class DbViewerFragment extends Fragment { + public DatabaseViewerActivity databaseViewerActivity; + private String tableName; + private View rootView; + private Cursor schemaCursor, contentCursor; + private RelativeLayout relativeLayout; + public AppCompatTextView loadingText; + private WebView webView; + + @Override + public View onCreateView( + LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + databaseViewerActivity = (DatabaseViewerActivity) getActivity(); + + rootView = inflater.inflate(R.layout.fragment_db_viewer, null); + webView = rootView.findViewById(R.id.webView1); + loadingText = rootView.findViewById(R.id.loadingText); + relativeLayout = rootView.findViewById(R.id.tableLayout); + tableName = getArguments().getString("table"); + databaseViewerActivity.setTitle(tableName); + + schemaCursor = + databaseViewerActivity.sqLiteDatabase.rawQuery( + "PRAGMA table_info(" + tableName + ");", null); + contentCursor = + databaseViewerActivity.sqLiteDatabase.rawQuery("SELECT * FROM " + tableName, null); + + new DbViewerTask(schemaCursor, contentCursor, webView, this).execute(); + + return rootView; + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + if (databaseViewerActivity.getAppTheme().equals(AppTheme.DARK)) { + relativeLayout.setBackgroundColor(Utils.getColor(getContext(), R.color.holo_dark_background)); + webView.setBackgroundColor(Utils.getColor(getContext(), R.color.holo_dark_background)); + } else if (databaseViewerActivity.getAppTheme().equals(AppTheme.BLACK)) { + relativeLayout.setBackgroundColor(Utils.getColor(getContext(), android.R.color.black)); + webView.setBackgroundColor(Utils.getColor(getContext(), android.R.color.black)); + } else { + relativeLayout.setBackgroundColor(Color.parseColor("#ffffff")); + webView.setBackgroundColor(Color.parseColor("#ffffff")); + } + } + + @Override + public void onDetach() { + super.onDetach(); + schemaCursor.close(); + contentCursor.close(); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/FtpServerFragment.kt b/app/src/main/java/com/amaze/filemanager/ui/fragments/FtpServerFragment.kt new file mode 100644 index 0000000..4cba8f6 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/FtpServerFragment.kt @@ -0,0 +1,978 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.fragments + +import android.app.Activity.RESULT_OK +import android.content.BroadcastReceiver +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.graphics.drawable.ColorDrawable +import android.net.ConnectivityManager +import android.net.Uri +import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M +import android.os.Build.VERSION_CODES.O +import android.os.Bundle +import android.os.Process +import android.provider.DocumentsContract +import android.provider.DocumentsContract.EXTRA_INITIAL_URI +import android.provider.Settings +import android.text.InputType +import android.text.Spanned +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.CompoundButton +import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.widget.AppCompatButton +import androidx.appcompat.widget.AppCompatImageButton +import androidx.appcompat.widget.AppCompatTextView +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.text.HtmlCompat +import androidx.core.text.HtmlCompat.FROM_HTML_MODE_COMPACT +import androidx.fragment.app.Fragment +import androidx.preference.PreferenceManager +import com.afollestad.materialdialogs.DialogAction +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.folderselector.FolderChooserDialog +import com.amaze.filemanager.R +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.asynchronous.services.ftp.FtpService +import com.amaze.filemanager.asynchronous.services.ftp.FtpService.Companion.KEY_PREFERENCE_PATH +import com.amaze.filemanager.asynchronous.services.ftp.FtpService.Companion.KEY_PREFERENCE_ROOT_FILESYSTEM +import com.amaze.filemanager.asynchronous.services.ftp.FtpService.Companion.isRunning +import com.amaze.filemanager.asynchronous.services.ftp.FtpService.FtpReceiverActions +import com.amaze.filemanager.databinding.DialogFtpLoginBinding +import com.amaze.filemanager.databinding.FragmentFtpBinding +import com.amaze.filemanager.filesystem.files.FileUtils +import com.amaze.filemanager.ui.activities.MainActivity +import com.amaze.filemanager.ui.notifications.FtpNotification +import com.amaze.filemanager.ui.runIfDocumentsUIExists +import com.amaze.filemanager.ui.theme.AppTheme +import com.amaze.filemanager.utils.NetworkUtil.getLocalInetAddress +import com.amaze.filemanager.utils.NetworkUtil.isConnectedToLocalNetwork +import com.amaze.filemanager.utils.NetworkUtil.isConnectedToWifi +import com.amaze.filemanager.utils.OneCharacterCharSequence +import com.amaze.filemanager.utils.PasswordUtil +import com.amaze.filemanager.utils.Utils +import com.google.android.material.snackbar.BaseTransientBottomBar +import com.google.android.material.snackbar.Snackbar +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.IOException +import java.security.GeneralSecurityException + +/** + * Created by yashwanthreddyg on 10-06-2016. Edited by Luca D'Amico (Luca91) on 25 Jul 2017 (Fixed + * FTP Server while usi + */ +@Suppress("TooManyFunctions") +class FtpServerFragment : Fragment(R.layout.fragment_ftp) { + private val log: Logger = LoggerFactory.getLogger(FtpServerFragment::class.java) + + private val statusText: AppCompatTextView get() = binding.textViewFtpStatus + private val url: AppCompatTextView get() = binding.textViewFtpUrl + private val username: AppCompatTextView get() = binding.textViewFtpUsername + private val password: AppCompatTextView get() = binding.textViewFtpPassword + private val port: AppCompatTextView get() = binding.textViewFtpPort + private val sharedPath: AppCompatTextView get() = binding.textViewFtpPath + private val ftpBtn: AppCompatButton get() = binding.startStopButton + private val ftpPasswordVisibleButton: AppCompatImageButton get() = binding.ftpPasswordVisible + private var accentColor = 0 + private var spannedStatusNoConnection: Spanned? = null + private var spannedStatusConnected: Spanned? = null + private var spannedStatusUrl: Spanned? = null + private var spannedStatusSecure: Spanned? = null + private var spannedStatusNotRunning: Spanned? = null + private var snackbar: Snackbar? = null + + private var _binding: FragmentFtpBinding? = null + private val binding get() = _binding!! + + private val mainActivity: MainActivity get() = requireActivity() as MainActivity + + private val activityResultHandlerOnFtpServerPathUpdate = + createOpenDocumentTreeIntentCallback { + directoryUri -> + changeFTPServerPath(directoryUri.toString()) + updatePathText() + } + + private val activityResultHandlerOnFtpServerPathGrantedSafAccess = + createOpenDocumentTreeIntentCallback { + directoryUri -> + changeFTPServerPath(directoryUri.toString()) + updatePathText() + doStartServer() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = FragmentFtpBinding.inflate(inflater) + accentColor = mainActivity.accent + mainActivity.findViewById(R.id.main_parent) + .nextFocusDownId = R.id.startStopButton + updateSpans() + updateStatus() + updateViews(mainActivity, binding) + ftpBtn.setOnClickListener { + ftpBtnOnClick() + } + return binding.root + } + + private fun ftpBtnOnClick() { + if (!isRunning()) { + if (isConnectedToWifi(requireContext()) || + isConnectedToLocalNetwork(requireContext()) + ) { + startServer() + } else { + // no Wi-Fi and no eth, we shouldn't be here in the first place, + // because of broadcast receiver, but just to be sure + statusText.text = spannedStatusNoConnection + } + } else { + stopServer() + } + } + + // Pending upgrading material-dialogs to simplify the logic here. + @Suppress("ComplexMethod", "LongMethod") + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.choose_ftp_port -> { + val currentFtpPort = defaultPortFromPreferences + MaterialDialog.Builder(requireContext()) + .input( + getString(R.string.ftp_port_edit_menu_title), + currentFtpPort.toString(), + true, + ) { _: MaterialDialog?, _: CharSequence? -> } + .inputType(InputType.TYPE_CLASS_NUMBER) + .onPositive { dialog: MaterialDialog, _: DialogAction? -> + val editText = dialog.inputEditText + if (editText != null) { + val name = editText.text.toString() + val portNumber = name.toIntOrNull() + if (portNumber == null || portNumber < 1024) { + Toast.makeText( + activity, + R.string.ftp_port_change_error_invalid, + Toast.LENGTH_SHORT, + ) + .show() + } else { + changeFTPServerPort(portNumber) + Toast.makeText( + activity, + R.string.ftp_port_change_success, + Toast.LENGTH_SHORT, + ) + .show() + } + } + } + .positiveText(getString(R.string.change).uppercase()) + .negativeText(R.string.cancel) + .build() + .show() + return true + } + R.id.ftp_path -> { + if (shouldUseSafFileSystem()) { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + + intent.runIfDocumentsUIExists(mainActivity) { + activityResultHandlerOnFtpServerPathUpdate.launch( + intent, + ) + } + } else { + val dialogBuilder = FolderChooserDialog.Builder(requireActivity()) + dialogBuilder + .chooseButton(R.string.choose_folder) + .initialPath(defaultPathFromPreferences) + .goUpLabel(getString(R.string.folder_go_up_one_level)) + .cancelButton(R.string.cancel) + .tag(TAG) + .build() + .show(activity) + } + + return true + } + R.id.ftp_login -> { + val loginDialogBuilder = MaterialDialog.Builder(requireContext()) + val loginDialogView = + DialogFtpLoginBinding.inflate(LayoutInflater.from(requireContext())).apply { + initLoginDialogViews(this) + loginDialogBuilder.onPositive { _: MaterialDialog, _: DialogAction -> + if (checkboxFtpAnonymous.isChecked) { + // remove preferences + setFTPUsername("") + setFTPPassword("") + } else { + // password and username field not empty, let's set them to preferences + setFTPUsername(editTextDialogFtpUsername.text.toString()) + setFTPPassword(editTextDialogFtpPassword.text.toString()) + } + } + } + val dialog = + loginDialogBuilder.customView(loginDialogView.root, true) + .title(getString(R.string.ftp_login)) + .positiveText(getString(R.string.set).uppercase()) + .negativeText(getString(R.string.cancel)) + .build() + + // TextWatcher for port number was deliberately removed. It didn't work anyway, so + // no reason to keep here. Pending reimplementation when material-dialogs lib is + // upgraded. + + dialog.show() + return true + } + R.id.checkbox_ftp_readonly -> { + val shouldReadonly = !item.isChecked + item.isChecked = shouldReadonly + readonlyPreference = shouldReadonly + updatePathText() + promptUserToRestartServer() + return true + } + R.id.checkbox_ftp_secure -> { + val shouldSecure = !item.isChecked + item.isChecked = shouldSecure + securePreference = shouldSecure + promptUserToRestartServer() + return true + } + R.id.checkbox_ftp_legacy_filesystem -> { + val shouldUseSafFileSystem = !item.isChecked + item.isChecked = shouldUseSafFileSystem + legacyFileSystemPreference = shouldUseSafFileSystem + promptUserToRestartServer() + return true + } + R.id.ftp_timeout -> { + val timeoutBuilder = MaterialDialog.Builder(requireActivity()) + timeoutBuilder.title( + getString(R.string.ftp_timeout) + + " (" + + resources.getString(R.string.ftp_seconds) + + ")", + ) + timeoutBuilder.input( + ( + FtpService.DEFAULT_TIMEOUT.toString() + + " " + + resources.getString(R.string.ftp_seconds) + ), + ftpTimeout.toString(), + true, + ) { _: MaterialDialog?, input: CharSequence -> + val isInputInteger: Boolean = + try { + // try parsing for integer check + input.toString().toInt() + true + } catch (e: NumberFormatException) { + false + } + ftpTimeout = + if (input.isEmpty() || !isInputInteger) { + FtpService.DEFAULT_TIMEOUT + } else { + Integer.valueOf(input.toString()) + } + } + timeoutBuilder + .positiveText(resources.getString(R.string.set).uppercase()) + .negativeText(resources.getString(R.string.cancel)) + .build() + .show() + return true + } + R.id.exit -> { + requireActivity().finish() + return true + } + } + return false + } + + override fun onCreateOptionsMenu( + menu: Menu, + inflater: MenuInflater, + ) { + mainActivity.menuInflater.inflate(R.menu.ftp_server_menu, menu) + menu.findItem(R.id.checkbox_ftp_readonly).isChecked = readonlyPreference + menu.findItem(R.id.checkbox_ftp_secure).isChecked = securePreference + menu.findItem(R.id.checkbox_ftp_legacy_filesystem).isChecked = legacyFileSystemPreference + super.onCreateOptionsMenu(menu, inflater) + } + + private fun shouldUseSafFileSystem(): Boolean { + return mainActivity.prefs.getBoolean( + FtpService.KEY_PREFERENCE_SAF_FILESYSTEM, + false, + ) && + SDK_INT >= M + } + + private val mWifiReceiver: BroadcastReceiver = + object : BroadcastReceiver() { + override fun onReceive( + context: Context, + intent: Intent, + ) { + // connected to Wi-Fi or eth + if (isConnectedToLocalNetwork(context)) { + ftpBtn.isEnabled = true + dismissSnackbar() + } else { + // Wi-Fi or eth connection lost + stopServer() + statusText.text = spannedStatusNoConnection + ftpBtn.isEnabled = false + ftpBtn.text = resources.getString(R.string.start_ftp).uppercase() + promptUserToEnableWireless() + } + } + } + + /** + * Handles messages sent from [EventBus]. + * + * @param signal as [FtpReceiverActions] + */ + @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) + @Suppress("StringLiteralDuplication") + fun onFtpReceiveActions(signal: FtpReceiverActions) { + updateSpans() + when (signal) { + FtpReceiverActions.STARTED, FtpReceiverActions.STARTED_FROM_TILE -> { + statusText.text = + if (securePreference) { + spannedStatusSecure + } else { + spannedStatusConnected + } + + url.text = spannedStatusUrl + ftpBtn.text = resources.getString(R.string.stop_ftp).uppercase() + FtpNotification.updateNotification( + context, + FtpReceiverActions.STARTED_FROM_TILE == signal, + ) + } + FtpReceiverActions.FAILED_TO_START -> { + statusText.text = spannedStatusNotRunning + Toast.makeText(context, R.string.unknown_error, Toast.LENGTH_LONG).show() + ftpBtn.text = resources.getString(R.string.start_ftp).uppercase() + url.text = "URL: " + } + FtpReceiverActions.STOPPED -> { + statusText.text = spannedStatusNotRunning + url.text = "URL: " + ftpBtn.text = resources.getString(R.string.start_ftp).uppercase() + } + } + updateStatus() + } + + @Suppress("LabeledExpression") + private fun createOpenDocumentTreeIntentCallback(callback: (directoryUri: Uri) -> Unit): ActivityResultLauncher { + return registerForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { + if (it.resultCode == RESULT_OK && SDK_INT >= LOLLIPOP) { + val directoryUri = it.data?.data ?: return@registerForActivityResult + requireContext().contentResolver.takePersistableUriPermission( + directoryUri, + GRANT_URI_RW_PERMISSION, + ) + callback.invoke(directoryUri) + } + } + } + + /** Check URI access. Prompt user to DocumentsUI if necessary */ + private fun checkUriAccessIfNecessary(callback: () -> Unit) { + val directoryUri: String = + mainActivity.prefs + .getString(KEY_PREFERENCE_PATH, defaultPathFromPreferences)!! + if (shouldUseSafFileSystem()) { + Uri.parse(directoryUri).run { + if (requireContext().checkUriPermission( + this, + Process.myPid(), + Process.myUid(), + GRANT_URI_RW_PERMISSION, + ) == PackageManager.PERMISSION_DENIED + ) { + mainActivity.accent.run { + val c = mainActivity.applicationContext + + MaterialDialog.Builder(mainActivity) + .content(R.string.ftp_prompt_accept_first_start_saf_access) + .widgetColor(accentColor) + .theme(mainActivity.appTheme.getMaterialDialogTheme()) + .title(R.string.ftp_prompt_accept_first_start_saf_access_title) + .positiveText(R.string.ok) + .positiveColor(accentColor) + .negativeText(R.string.cancel) + .negativeColor(accentColor) + .onPositive( + fun( + dialog: MaterialDialog, + _: DialogAction, + ) { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + + intent.runIfDocumentsUIExists(mainActivity) { + activityResultHandlerOnFtpServerPathGrantedSafAccess.launch( + intent.also { + if (SDK_INT >= O && + directoryUri.startsWith(defaultPathFromPreferences) + ) { + it.putExtra( + EXTRA_INITIAL_URI, + DocumentsContract.buildDocumentUri( + "com.android.externalstorage.documents", + "primary:" + + directoryUri + .substringAfter( + defaultPathFromPreferences, + ), + ), + ) + } + }, + ) + } + + dialog.dismiss() + }, + ).build().show() + } + } else { + callback.invoke() + } + } + } else { + if (directoryUri.startsWith(ContentResolver.SCHEME_CONTENT)) { + AppConfig.toast( + mainActivity, + getString(R.string.ftp_server_fallback_path_reset_prompt), + ) + resetFTPPath() + } + callback.invoke() + } + } + + /** Sends a broadcast to start ftp server */ + private fun startServer() { + checkUriAccessIfNecessary { + doStartServer() + } + } + + /** Sends a broadcast to stop ftp server */ + private fun stopServer() { + requireContext().sendBroadcast( + Intent(FtpService.ACTION_STOP_FTPSERVER) + .setPackage(requireContext().packageName), + ) + } + + private fun doStartServer() = + requireContext().sendBroadcast( + Intent(FtpService.ACTION_START_FTPSERVER) + .setPackage(requireContext().packageName), + ) + + override fun onResume() { + super.onResume() + val wifiFilter = IntentFilter() + wifiFilter.addAction(ConnectivityManager.CONNECTIVITY_ACTION) + requireContext().registerReceiver(mWifiReceiver, wifiFilter) + EventBus.getDefault().register(this) + updateStatus() + } + + override fun onPause() { + super.onPause() + requireContext().unregisterReceiver(mWifiReceiver) + EventBus.getDefault().unregister(this) + dismissSnackbar() + } + + /** Update UI widgets after change in shared preferences */ + private fun updateStatus() { + if (!isRunning()) { + if (!isConnectedToWifi(requireContext()) && + !isConnectedToLocalNetwork(requireContext()) + ) { + statusText.text = spannedStatusNoConnection + ftpBtn.isEnabled = false + } else { + statusText.text = spannedStatusNotRunning + ftpBtn.isEnabled = true + } + url.text = "URL: " + ftpBtn.text = resources.getString(R.string.start_ftp).uppercase() + } else { + accentColor = mainActivity.accent + url.text = spannedStatusUrl + statusText.text = spannedStatusConnected + ftpBtn.isEnabled = true + ftpBtn.text = resources.getString(R.string.stop_ftp).uppercase() + } + val passwordDecrypted = passwordFromPreferences + val passwordBulleted: CharSequence = + OneCharacterCharSequence( + '\u25CF', + passwordDecrypted?.length ?: 0, + ) + username.text = "${resources.getString(R.string.username)}: $usernameFromPreferences" + password.text = "${resources.getString(R.string.password)}: $passwordBulleted" + ftpPasswordVisibleButton.setImageDrawable( + resources.getDrawable(R.drawable.ic_eye_grey600_24dp), + ) + ftpPasswordVisibleButton.visibility = + if (passwordDecrypted?.isEmpty() == true) { + View.GONE + } else { + View.VISIBLE + } + ftpPasswordVisibleButton.setOnClickListener { + if (password.text.toString().contains("\u25CF")) { + // password was not visible, let's make it visible + password.text = resources.getString(R.string.password) + ": " + passwordDecrypted + ftpPasswordVisibleButton.setImageDrawable( + resources.getDrawable(R.drawable.ic_eye_off_grey600_24dp), + ) + } else { + // password was visible, let's hide it + password.text = resources.getString(R.string.password) + ": " + passwordBulleted + ftpPasswordVisibleButton.setImageDrawable( + resources.getDrawable(R.drawable.ic_eye_grey600_24dp), + ) + } + } + port.text = "${resources.getString(R.string.ftp_port)}: $defaultPortFromPreferences" + updatePathText() + + if (defaultPathFromPreferences == FtpService.defaultPath(requireContext())) { + sharedPath.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0) + } else { + sharedPath.setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.ic_clear_all, 0) + } + } + + private fun updatePathText() { + val sb = + StringBuilder(resources.getString(R.string.ftp_path)) + .append(": ") + .append(pathToDisplayString(defaultPathFromPreferences)) + if (readonlyPreference) sb.append(" \uD83D\uDD12") + sharedPath.text = sb.toString() + setListener() + } + + private fun setListener() { + sharedPath.setOnTouchListener { _, event -> + + if (sharedPath.compoundDrawables[2] != null && event.action == KeyEvent.ACTION_DOWN) { + if (event.x >= sharedPath.right - sharedPath.compoundDrawables[2].bounds.width()) { + resetFTPPath() + updateStatus() + + AppConfig.toast( + mainActivity, + getString(R.string.ftp_server_reset_notify), + ) + } + } + + false + } + } + + private fun resetFTPPath() { + mainActivity.prefs + .edit() + .putString(KEY_PREFERENCE_PATH, FtpService.defaultPath(requireContext())) + .apply() + } + + /** Updates the status spans */ + private fun updateSpans() { + var ftpAddress = ftpAddressString + if (ftpAddress == null) { + ftpAddress = "" + Toast.makeText( + context, + resources.getString(R.string.local_inet_addr_error), + Toast.LENGTH_SHORT, + ) + .show() + } + val statusHead = "${resources.getString(R.string.ftp_status_title)}: " + spannedStatusConnected = + HtmlCompat.fromHtml( + "$statusHead  " + + "${resources.getString(R.string.ftp_status_running)}", + FROM_HTML_MODE_COMPACT, + ) + spannedStatusUrl = + HtmlCompat.fromHtml( + "URL: $ftpAddress", + FROM_HTML_MODE_COMPACT, + ) + spannedStatusNoConnection = + HtmlCompat.fromHtml( + "$statusHead    " + + "" + + "${resources.getString(R.string.ftp_status_no_connection)}", + FROM_HTML_MODE_COMPACT, + ) + spannedStatusNotRunning = + HtmlCompat.fromHtml( + "$statusHead    " + + "${resources.getString(R.string.ftp_status_not_running)}", + FROM_HTML_MODE_COMPACT, + ) + spannedStatusSecure = + HtmlCompat.fromHtml( + "$statusHead    " + + "${resources.getString(R.string.ftp_status_secure_connection)}", + FROM_HTML_MODE_COMPACT, + ) + spannedStatusUrl = + HtmlCompat.fromHtml( + "URL: $ftpAddress", + FROM_HTML_MODE_COMPACT, + ) + } + + private fun initLoginDialogViews(loginDialogView: DialogFtpLoginBinding) { + val usernameEditText = loginDialogView.editTextDialogFtpUsername + val passwordEditText = loginDialogView.editTextDialogFtpPassword + val anonymousCheckBox = loginDialogView.checkboxFtpAnonymous + anonymousCheckBox.setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean -> + usernameEditText.isEnabled = !isChecked + passwordEditText.isEnabled = !isChecked + } + + // init dialog views as per preferences + if (usernameFromPreferences == FtpService.DEFAULT_USERNAME) { + anonymousCheckBox.isChecked = true + } else { + usernameEditText.setText(usernameFromPreferences) + passwordEditText.setText(passwordFromPreferences) + } + } + + private fun updateViews( + mainActivity: MainActivity, + binding: FragmentFtpBinding, + ) { + mainActivity.appbar.setTitle(R.string.ftp) + mainActivity.hideFab() + mainActivity.appbar.bottomBar.setVisibility(View.GONE) + mainActivity.invalidateOptionsMenu() + + val startDividerView = binding.dividerFtpStart + val statusDividerView = binding.dividerFtpStatus + + when (mainActivity.appTheme) { + AppTheme.LIGHT -> { + startDividerView.setBackgroundColor(Utils.getColor(context, R.color.divider)) + statusDividerView.setBackgroundColor(Utils.getColor(context, R.color.divider)) + } + AppTheme.DARK, AppTheme.BLACK -> { + startDividerView.setBackgroundColor( + Utils.getColor(context, R.color.divider_dark_card), + ) + statusDividerView.setBackgroundColor( + Utils.getColor(context, R.color.divider_dark_card), + ) + } + else -> { + } + } + val skin_color = mainActivity.currentColorPreference.primaryFirstTab + val skinTwoColor = mainActivity.currentColorPreference.primarySecondTab + mainActivity.updateViews( + ColorDrawable(if (MainActivity.currentTab == 1) skinTwoColor else skin_color), + ) + + ftpBtn.setOnKeyListener( + View.OnKeyListener { _, _, event -> + if (event.action == KeyEvent.ACTION_DOWN) { + when (event.keyCode) { + KeyEvent.KEYCODE_DPAD_UP -> { + mainActivity.appbar.appbarLayout.requestFocus() + mainActivity.appbar.toolbar.requestFocus() + } + KeyEvent.KEYCODE_DPAD_CENTER -> { + ftpBtnOnClick() + } + KeyEvent.KEYCODE_BACK -> { + mainActivity.onBackPressed() + } + else -> { + return@OnKeyListener false + } + } + } + true + }, + ) + } + + // return address the FTP server is running + private val ftpAddressString: String? + get() { + val ia = getLocalInetAddress(requireContext()) ?: return null + return ( + ( + if (securePreference) { + FtpService.INITIALS_HOST_SFTP + } else { + FtpService.INITIALS_HOST_FTP + } + ) + + ia.hostAddress + + ":" + + defaultPortFromPreferences + ) + } + + private val defaultPortFromPreferences: Int + get() = + mainActivity.prefs + .getInt(FtpService.PORT_PREFERENCE_KEY, FtpService.DEFAULT_PORT) + private val usernameFromPreferences: String + get() = + mainActivity.prefs + .getString(FtpService.KEY_PREFERENCE_USERNAME, FtpService.DEFAULT_USERNAME)!! + + // can't decrypt the password saved in preferences, remove the preference altogether + private val passwordFromPreferences: String? + get() = + runCatching { + val encryptedPassword: String = + mainActivity.prefs.getString( + FtpService.KEY_PREFERENCE_PASSWORD, + "", + )!! + if (encryptedPassword == "") { + "" + } else { + PasswordUtil.decryptPassword(requireContext(), encryptedPassword) + } + }.onFailure { + log.warn("failed to decrypt ftp server password", it) + Toast.makeText(requireContext(), R.string.error, Toast.LENGTH_SHORT).show() + mainActivity.prefs.edit().putString(FtpService.KEY_PREFERENCE_PASSWORD, "").apply() + }.getOrNull() + + private val defaultPathFromPreferences: String + get() { + return PreferenceManager.getDefaultSharedPreferences(mainActivity) + .getString(KEY_PREFERENCE_PATH, FtpService.defaultPath(requireContext()))!! + } + + private fun pathToDisplayString(path: String): String { + return when { + path.startsWith("file:///") -> { + path.substringAfter("file://") + } + path.startsWith("content://") -> { + return Uri.parse(path).let { + "/storage${it.path?.substringAfter("/tree")?.replace(':', '/')}" + } + } + else -> { + path + } + } + } + + private fun changeFTPServerPort(port: Int) { + mainActivity.prefs.edit().putInt(FtpService.PORT_PREFERENCE_KEY, port).apply() + + // first update spans which will point to an updated status + updateSpans() + updateStatus() + } + + /** + * Update FTP server shared path in [android.content.SharedPreferences]. + * + * @param path new shared path. Can be either absolute path (pre 4.4) or URI, which can be + * file:/// or content:// as prefix + */ + fun changeFTPServerPath(path: String) { + val preferences = PreferenceManager.getDefaultSharedPreferences(mainActivity).edit() + if (FileUtils.isRunningAboveStorage(path)) { + preferences.putBoolean(KEY_PREFERENCE_ROOT_FILESYSTEM, true) + } + preferences.putString(KEY_PREFERENCE_PATH, path) + preferences.apply() + updateStatus() + } + + private fun setFTPUsername(username: String) { + mainActivity + .prefs + .edit() + .putString(FtpService.KEY_PREFERENCE_USERNAME, username) + .apply() + updateStatus() + } + + private fun setFTPPassword(password: String) { + try { + context?.run { + mainActivity + .prefs + .edit() + .putString( + FtpService.KEY_PREFERENCE_PASSWORD, + PasswordUtil.encryptPassword(this, password), + ) + .apply() + } + } catch (e: GeneralSecurityException) { + log.warn("failed to set ftp password", e) + Toast.makeText(context, resources.getString(R.string.error), Toast.LENGTH_LONG) + .show() + } catch (e: IOException) { + log.warn("failed to set ftp password", e) + Toast.makeText(context, resources.getString(R.string.error), Toast.LENGTH_LONG) + .show() + } + updateStatus() + } + + // Returns timeout from preferences, in seconds + private var ftpTimeout: Int + get() = + mainActivity + .prefs + .getInt(FtpService.KEY_PREFERENCE_TIMEOUT, FtpService.DEFAULT_TIMEOUT) + private set(seconds) { + mainActivity.prefs.edit().putInt(FtpService.KEY_PREFERENCE_TIMEOUT, seconds).apply() + } + + private var securePreference: Boolean + get() = + mainActivity + .prefs + .getBoolean(FtpService.KEY_PREFERENCE_SECURE, FtpService.DEFAULT_SECURE) + private set(isSecureEnabled) { + mainActivity + .prefs + .edit() + .putBoolean(FtpService.KEY_PREFERENCE_SECURE, isSecureEnabled) + .apply() + } + + private var readonlyPreference: Boolean + get() = mainActivity.prefs.getBoolean(FtpService.KEY_PREFERENCE_READONLY, false) + private set(isReadonly) { + mainActivity + .prefs + .edit() + .putBoolean(FtpService.KEY_PREFERENCE_READONLY, isReadonly) + .apply() + } + + private var legacyFileSystemPreference: Boolean + get() = mainActivity.prefs.getBoolean(FtpService.KEY_PREFERENCE_SAF_FILESYSTEM, false) + private set(useSafFileSystem) { + mainActivity + .prefs + .edit() + .putBoolean(FtpService.KEY_PREFERENCE_SAF_FILESYSTEM, useSafFileSystem) + .apply() + } + + private fun promptUserToRestartServer() { + if (isRunning()) AppConfig.toast(context, R.string.ftp_prompt_restart_server) + } + + private fun promptUserToEnableWireless() { + // No wifi, no data, no connection at all + snackbar = + Utils.showThemedSnackbar( + activity as MainActivity?, + getString(R.string.ftp_server_prompt_connect_to_network), + BaseTransientBottomBar.LENGTH_INDEFINITE, + R.string.ftp_server_open_settings, + ) { startActivity(Intent(Settings.ACTION_WIFI_SETTINGS)) } + snackbar!!.show() + } + + private fun dismissSnackbar() = snackbar?.dismiss() + + companion object { + const val TAG = "FtpServerFragment" + const val REQUEST_CODE_SAF_FTP = 225 + const val GRANT_URI_RW_PERMISSION = + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/MainFragment.java b/app/src/main/java/com/amaze/filemanager/ui/fragments/MainFragment.java new file mode 100644 index 0000000..408f77b --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/MainFragment.java @@ -0,0 +1,1555 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.fragments; + +import static android.os.Build.VERSION.SDK_INT; +import static android.os.Build.VERSION_CODES.JELLY_BEAN; +import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2; +import static android.os.Build.VERSION_CODES.Q; +import static com.amaze.filemanager.filesystem.FileProperties.ANDROID_DATA_DIRS; +import static com.amaze.filemanager.filesystem.FileProperties.ANDROID_DEVICE_DATA_DIRS; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_DIVIDERS; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_GOBACK_BUTTON; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_HIDDENFILES; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_THUMB; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.afollestad.materialdialogs.DialogAction; +import com.afollestad.materialdialogs.MaterialDialog; +import com.amaze.filemanager.R; +import com.amaze.filemanager.adapters.RecyclerAdapter; +import com.amaze.filemanager.adapters.data.LayoutElementParcelable; +import com.amaze.filemanager.adapters.holders.ItemViewHolder; +import com.amaze.filemanager.application.AppConfig; +import com.amaze.filemanager.asynchronous.asynctasks.DeleteTask; +import com.amaze.filemanager.asynchronous.asynctasks.LoadFilesListTask; +import com.amaze.filemanager.asynchronous.handlers.FileHandler; +import com.amaze.filemanager.database.SortHandler; +import com.amaze.filemanager.database.models.explorer.Tab; +import com.amaze.filemanager.fileoperations.filesystem.OpenMode; +import com.amaze.filemanager.filesystem.CustomFileObserver; +import com.amaze.filemanager.filesystem.FileProperties; +import com.amaze.filemanager.filesystem.HybridFile; +import com.amaze.filemanager.filesystem.HybridFileParcelable; +import com.amaze.filemanager.filesystem.SafRootHolder; +import com.amaze.filemanager.filesystem.files.CryptUtil; +import com.amaze.filemanager.filesystem.files.EncryptDecryptUtils; +import com.amaze.filemanager.filesystem.files.FileUtils; +import com.amaze.filemanager.filesystem.files.MediaConnectionUtils; +import com.amaze.filemanager.ui.ExtensionsKt; +import com.amaze.filemanager.ui.activities.MainActivity; +import com.amaze.filemanager.ui.activities.MainActivityViewModel; +import com.amaze.filemanager.ui.dialogs.GeneralDialogCreation; +import com.amaze.filemanager.ui.drag.RecyclerAdapterDragListener; +import com.amaze.filemanager.ui.drag.TabFragmentBottomDragListener; +import com.amaze.filemanager.ui.fragments.data.MainFragmentViewModel; +import com.amaze.filemanager.ui.icons.MimeTypes; +import com.amaze.filemanager.ui.provider.UtilitiesProvider; +import com.amaze.filemanager.ui.theme.AppTheme; +import com.amaze.filemanager.ui.views.CustomScrollGridLayoutManager; +import com.amaze.filemanager.ui.views.CustomScrollLinearLayoutManager; +import com.amaze.filemanager.ui.views.DividerItemDecoration; +import com.amaze.filemanager.ui.views.FastScroller; +import com.amaze.filemanager.ui.views.WarnableTextInputValidator; +import com.amaze.filemanager.utils.BottomBarButtonPath; +import com.amaze.filemanager.utils.DataUtils; +import com.amaze.filemanager.utils.GenericExtKt; +import com.amaze.filemanager.utils.OTGUtil; +import com.amaze.filemanager.utils.Utils; +import com.google.android.material.appbar.AppBarLayout; + +import android.content.BroadcastReceiver; +import android.content.ClipData; +import android.content.ClipDescription; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.UriPermission; +import android.graphics.Color; +import android.media.RingtoneManager; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.provider.DocumentsContract; +import android.text.TextUtils; +import android.text.format.Formatter; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.widget.Toast; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.appcompat.widget.AppCompatEditText; +import androidx.appcompat.widget.AppCompatImageView; +import androidx.appcompat.widget.AppCompatTextView; +import androidx.core.content.pm.ShortcutInfoCompat; +import androidx.core.content.pm.ShortcutManagerCompat; +import androidx.core.graphics.drawable.IconCompat; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.ViewModelProvider; +import androidx.preference.PreferenceManager; +import androidx.recyclerview.widget.DefaultItemAnimator; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import jcifs.smb.SmbException; +import jcifs.smb.SmbFile; +import kotlin.collections.ArraysKt; +import kotlin.collections.CollectionsKt; + +public class MainFragment extends Fragment + implements BottomBarButtonPath, + ViewTreeObserver.OnGlobalLayoutListener, + AdjustListViewForTv { + + private static final Logger LOG = LoggerFactory.getLogger(MainFragment.class); + private static final String KEY_FRAGMENT_MAIN = "main"; + + /** Key for boolean in arguments whether to hide the FAB if this {@link MainFragment} is shown */ + public static final String BUNDLE_HIDE_FAB = "hideFab"; + + public SwipeRefreshLayout mSwipeRefreshLayout; + + public RecyclerAdapter adapter; + private SharedPreferences sharedPref; + + // ATTRIBUTES FOR APPEARANCE AND COLORS + private LinearLayoutManager mLayoutManager; + private GridLayoutManager mLayoutManagerGrid; + private DividerItemDecoration dividerItemDecoration; + private AppBarLayout mToolbarContainer; + private SwipeRefreshLayout nofilesview; + + private RecyclerView listView; + private UtilitiesProvider utilsProvider; + private HashMap scrolls = new HashMap<>(); + private View rootView; + private FastScroller fastScroller; + private CustomFileObserver customFileObserver; + + // defines the current visible tab, default either 0 or 1 + // private int mCurrentTab; + + private MainFragmentViewModel mainFragmentViewModel; + private MainActivityViewModel mainActivityViewModel; + + private boolean hideFab = false; + + private final ActivityResultLauncher handleDocumentUriForRestrictedDirectories = + registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (SDK_INT >= Q) { + if (result.getData() != null && getContext() != null) { + getContext() + .getContentResolver() + .takePersistableUriPermission( + result.getData().getData(), + Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + SafRootHolder.setUriRoot(result.getData().getData()); + loadlist(result.getData().getDataString(), false, OpenMode.DOCUMENT_FILE, true); + } else if (getContext() != null) { + AppConfig.toast(requireContext(), getString(R.string.operation_unsuccesful)); + } + } + }); + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mainFragmentViewModel = new ViewModelProvider(this).get(MainFragmentViewModel.class); + mainActivityViewModel = + new ViewModelProvider(requireMainActivity()).get(MainActivityViewModel.class); + + utilsProvider = requireMainActivity().getUtilsProvider(); + sharedPref = PreferenceManager.getDefaultSharedPreferences(requireActivity()); + mainFragmentViewModel.initBundleArguments(getArguments()); + mainFragmentViewModel.initIsList(); + mainFragmentViewModel.initColumns(sharedPref); + mainFragmentViewModel.initSortModes( + SortHandler.getSortType(getContext(), getCurrentPath()), sharedPref); + mainFragmentViewModel.setAccentColor(requireMainActivity().getAccent()); + mainFragmentViewModel.setPrimaryColor( + requireMainActivity().getCurrentColorPreference().getPrimaryFirstTab()); + mainFragmentViewModel.setPrimaryTwoColor( + requireMainActivity().getCurrentColorPreference().getPrimarySecondTab()); + if (getArguments() != null) { + hideFab = getArguments().getBoolean(BUNDLE_HIDE_FAB, false); + } + } + + @Override + public View onCreateView( + LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + rootView = inflater.inflate(R.layout.main_frag, container, false); + return rootView; + } + + @Override + @SuppressWarnings("PMD.NPathComplexity") + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + mainFragmentViewModel = new ViewModelProvider(this).get(MainFragmentViewModel.class); + listView = rootView.findViewById(R.id.listView); + mToolbarContainer = requireMainActivity().getAppbar().getAppbarLayout(); + fastScroller = rootView.findViewById(R.id.fastscroll); + fastScroller.setPressedHandleColor(mainFragmentViewModel.getAccentColor()); + View.OnTouchListener onTouchListener = + (view1, motionEvent) -> { + if (adapter != null && mainFragmentViewModel.getStopAnims()) { + stopAnimation(); + mainFragmentViewModel.setStopAnims(false); + } + return false; + }; + listView.setOnTouchListener(onTouchListener); + // listView.setOnDragListener(new MainFragmentDragListener()); + mToolbarContainer.setOnTouchListener(onTouchListener); + + mSwipeRefreshLayout = rootView.findViewById(R.id.activity_main_swipe_refresh_layout); + + mSwipeRefreshLayout.setOnRefreshListener(() -> updateList(true)); + + // String itemsstring = res.getString(R.string.items);// TODO: 23/5/2017 use or delete + mToolbarContainer.setBackgroundColor( + MainActivity.currentTab == 1 + ? mainFragmentViewModel.getPrimaryTwoColor() + : mainFragmentViewModel.getPrimaryColor()); + + // listView.setPadding(listView.getPaddingLeft(), paddingTop, listView.getPaddingRight(), + // listView.getPaddingBottom()); + + setHasOptionsMenu(false); + initNoFileLayout(); + HybridFile f = new HybridFile(OpenMode.UNKNOWN, mainFragmentViewModel.getCurrentPath()); + f.generateMode(getActivity()); + getMainActivity().getAppbar().getBottomBar().setClickListener(); + + if (utilsProvider.getAppTheme().equals(AppTheme.LIGHT) && !mainFragmentViewModel.isList()) { + listView.setBackgroundColor(Utils.getColor(getContext(), R.color.grid_background_light)); + } else { + listView.setBackgroundDrawable(null); + } + listView.setHasFixedSize(true); + if (mainFragmentViewModel.isList()) { + mLayoutManager = new CustomScrollLinearLayoutManager(getContext()); + listView.setLayoutManager(mLayoutManager); + } else { + if (mainFragmentViewModel.getColumns() == null) + mLayoutManagerGrid = new CustomScrollGridLayoutManager(getActivity(), 3); + else + mLayoutManagerGrid = + new CustomScrollGridLayoutManager(getActivity(), mainFragmentViewModel.getColumns()); + setGridLayoutSpanSizeLookup(mLayoutManagerGrid); + listView.setLayoutManager(mLayoutManagerGrid); + } + // use a linear layout manager + // View footerView = getActivity().getLayoutInflater().inflate(R.layout.divider, null);// TODO: + // 23/5/2017 use or delete + dividerItemDecoration = + new DividerItemDecoration(requireActivity(), false, getBoolean(PREFERENCE_SHOW_DIVIDERS)); + listView.addItemDecoration(dividerItemDecoration); + mSwipeRefreshLayout.setColorSchemeColors(mainFragmentViewModel.getAccentColor()); + DefaultItemAnimator animator = new DefaultItemAnimator(); + listView.setItemAnimator(animator); + mToolbarContainer.getViewTreeObserver().addOnGlobalLayoutListener(this); + loadViews(); + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + + FragmentManager fragmentManager = requireActivity().getSupportFragmentManager(); + fragmentManager.executePendingTransactions(); + fragmentManager.putFragment(outState, KEY_FRAGMENT_MAIN, this); + } + + public void stopAnimation() { + if ((!adapter.stoppedAnimation)) { + for (int j = 0; j < listView.getChildCount(); j++) { + View v = listView.getChildAt(j); + if (v != null) v.clearAnimation(); + } + } + adapter.stoppedAnimation = true; + } + + void setGridLayoutSpanSizeLookup(GridLayoutManager mLayoutManagerGrid) { + + mLayoutManagerGrid.setSpanSizeLookup( + new CustomScrollGridLayoutManager.SpanSizeLookup() { + + @Override + public int getSpanSize(int position) { + switch (adapter.getItemViewType(position)) { + case RecyclerAdapter.TYPE_HEADER_FILES: + case RecyclerAdapter.TYPE_HEADER_FOLDERS: + return (mainFragmentViewModel.getColumns() == 0 + || mainFragmentViewModel.getColumns() == -1) + ? 3 + : mainFragmentViewModel.getColumns(); + default: + return 1; + } + } + }); + } + + void switchToGrid() { + mainFragmentViewModel.setList(false); + + if (utilsProvider.getAppTheme().equals(AppTheme.LIGHT)) { + + // will always be grid, set alternate white background + listView.setBackgroundColor(Utils.getColor(getContext(), R.color.grid_background_light)); + } + + if (mLayoutManagerGrid == null) + if (mainFragmentViewModel.getColumns() == -1 || mainFragmentViewModel.getColumns() == 0) + mLayoutManagerGrid = new CustomScrollGridLayoutManager(getActivity(), 3); + else + mLayoutManagerGrid = + new CustomScrollGridLayoutManager(getActivity(), mainFragmentViewModel.getColumns()); + setGridLayoutSpanSizeLookup(mLayoutManagerGrid); + listView.setLayoutManager(mLayoutManagerGrid); + listView.clearOnScrollListeners(); + mainFragmentViewModel.setAdapterListItems(null); + mainFragmentViewModel.setIconList(null); + adapter = null; + } + + void switchToList() { + mainFragmentViewModel.setList(true); + + if (utilsProvider.getAppTheme().equals(AppTheme.LIGHT)) { + + listView.setBackgroundDrawable(null); + } + + if (mLayoutManager == null) mLayoutManager = new CustomScrollLinearLayoutManager(getActivity()); + listView.setLayoutManager(mLayoutManager); + listView.clearOnScrollListeners(); + mainFragmentViewModel.setAdapterListItems(null); + mainFragmentViewModel.setIconList(null); + adapter = null; + } + + public void switchView() { + boolean isPathLayoutGrid = + DataUtils.getInstance() + .getListOrGridForPath(mainFragmentViewModel.getCurrentPath(), DataUtils.LIST) + == DataUtils.GRID; + reloadListElements(false, isPathLayoutGrid); + } + + private void loadViews() { + if (mainFragmentViewModel.getCurrentPath() != null) { + if (mainFragmentViewModel.getListElements().size() == 0) { + loadlist( + mainFragmentViewModel.getCurrentPath(), + true, + mainFragmentViewModel.getOpenMode(), + false); + } else { + reloadListElements(true, !mainFragmentViewModel.isList()); + } + } else { + loadlist(mainFragmentViewModel.getHome(), true, mainFragmentViewModel.getOpenMode(), false); + } + } + + private BroadcastReceiver receiver2 = + new BroadcastReceiver() { + + @Override + public void onReceive(Context context, Intent intent) { + // load the list on a load broadcast + // local file system don't need an explicit load, we've set an observer to + // take actions on creation/moving/deletion/modification of file on current path + if (getCurrentPath() != null) { + mainActivityViewModel.evictPathFromListCache(getCurrentPath()); + } + updateList(false); + } + }; + + private BroadcastReceiver decryptReceiver = + new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + + if (mainFragmentViewModel.isEncryptOpen() + && mainFragmentViewModel.getEncryptBaseFile() != null) { + FileUtils.openFile( + mainFragmentViewModel.getEncryptBaseFile().getFile(), + requireMainActivity(), + sharedPref); + mainFragmentViewModel.setEncryptOpen(false); + } + } + }; + + public void home() { + loadlist((mainFragmentViewModel.getHome()), false, OpenMode.FILE, false); + } + + /** + * method called when list item is clicked in the adapter + * + * @param isBackButton is it the back button aka '..' + * @param position the position + * @param layoutElementParcelable the list item + * @param imageView the check icon that is to be animated + */ + public void onListItemClicked( + boolean isBackButton, + int position, + LayoutElementParcelable layoutElementParcelable, + AppCompatImageView imageView) { + + if (requireMainActivity().getListItemSelected()) { + if (isBackButton) { + requireMainActivity().setListItemSelected(false); + if (requireMainActivity().getActionModeHelper().getActionMode() != null) { + requireMainActivity().getActionModeHelper().getActionMode().finish(); + } + requireMainActivity().getActionModeHelper().setActionMode(null); + } else { + // the first {goback} item if back navigation is enabled + registerListItemChecked(position, imageView); + } + } else { + if (isBackButton) { + goBackItemClick(); + } else { + // hiding search view if visible + if (requireMainActivity().getAppbar().getSearchView().isEnabled()) { + requireMainActivity().getAppbar().getSearchView().hideSearchView(); + } + + String path = + !layoutElementParcelable.hasSymlink() + ? layoutElementParcelable.desc + : layoutElementParcelable.symlink; + + if (layoutElementParcelable.isDirectory) { + if (layoutElementParcelable.getMode() == OpenMode.TRASH_BIN) { + // don't open file hierarchy for trash bin + adapter.toggleChecked(position, imageView); + } else { + computeScroll(); + loadlist(path, false, mainFragmentViewModel.getOpenMode(), false); + } + } else if (layoutElementParcelable.desc.endsWith(CryptUtil.CRYPT_EXTENSION) + || layoutElementParcelable.desc.endsWith(CryptUtil.AESCRYPT_EXTENSION)) { + // decrypt the file + mainFragmentViewModel.setEncryptOpen(true); + mainFragmentViewModel.initEncryptBaseFile( + getActivity().getExternalCacheDir().getPath() + + "/" + + layoutElementParcelable + .generateBaseFile() + .getName(getMainActivity()) + .replace(CryptUtil.CRYPT_EXTENSION, "") + .replace(CryptUtil.AESCRYPT_EXTENSION, "")); + + EncryptDecryptUtils.decryptFile( + requireContext(), + requireMainActivity(), + this, + mainFragmentViewModel.getOpenMode(), + layoutElementParcelable.generateBaseFile(), + getActivity().getExternalCacheDir().getPath(), + utilsProvider, + true); + } else { + if (getMainActivity().mReturnIntent) { + // are we here to return an intent to another app + returnIntentResults( + new HybridFileParcelable[] {layoutElementParcelable.generateBaseFile()}); + } else { + layoutElementParcelable.generateBaseFile().openFile(getMainActivity(), false); + DataUtils.getInstance().addHistoryFile(layoutElementParcelable.desc); + } + } + } + } + } + + public void registerListItemChecked(int position, AppCompatImageView imageView) { + MainActivity mainActivity = requireMainActivity(); + if (mainActivity.mReturnIntent + && !mainActivity.getIntent().getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false)) { + // Only one item should be checked + ArrayList checkedItemsIndex = adapter.getCheckedItemsIndex(); + if (checkedItemsIndex.contains(position)) { + // The clicked item was the only item checked so it can be unchecked + adapter.toggleChecked(position, imageView); + } else { + // The clicked item was not checked so we have to uncheck all currently checked items + for (Integer index : checkedItemsIndex) { + adapter.toggleChecked(index, imageView); + } + // Now we check the clicked item + adapter.toggleChecked(position, imageView); + } + } else adapter.toggleChecked(position, imageView); + } + + public void updateTabWithDb(Tab tab) { + mainFragmentViewModel.setCurrentPath(tab.path); + mainFragmentViewModel.setHome(tab.home); + loadlist(mainFragmentViewModel.getCurrentPath(), false, OpenMode.UNKNOWN, false); + } + + /** + * Returns the intent with uri corresponding to specific {@link HybridFileParcelable} back to + * external app + */ + public void returnIntentResults(HybridFileParcelable[] baseFiles) { + requireMainActivity().mReturnIntent = false; + HashMap resultUris = new HashMap<>(); + ArrayList failedPaths = new ArrayList<>(); + + for (HybridFileParcelable baseFile : baseFiles) { + @Nullable Uri resultUri = Utils.getUriForBaseFile(requireActivity(), baseFile); + if (resultUri != null) { + resultUris.put(baseFile, resultUri); + LOG.debug( + resultUri + "\t" + MimeTypes.getMimeType(baseFile.getPath(), baseFile.isDirectory())); + } else { + failedPaths.add(baseFile.getPath()); + } + } + + if (!resultUris.isEmpty()) { + Intent intent = new Intent(); + intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + if (resultUris.size() == 1) { + intent.setAction(Intent.ACTION_SEND); + Map.Entry result = resultUris.entrySet().iterator().next(); + Uri resultUri = result.getValue(); + HybridFileParcelable resultBaseFile = result.getKey(); + + if (requireMainActivity().mRingtonePickerIntent) { + intent.setDataAndType( + resultUri, + MimeTypes.getMimeType(resultBaseFile.getPath(), resultBaseFile.isDirectory())); + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, resultUri); + } else { + LOG.debug("pickup file"); + intent.setDataAndType(resultUri, MimeTypes.getExtension(resultBaseFile.getPath())); + } + + } else { + LOG.debug("pickup multiple files"); + // Build ClipData + ArrayList uriDataClipItems = new ArrayList<>(); + HashSet mimeTypes = new HashSet<>(); + for (Map.Entry result : resultUris.entrySet()) { + HybridFileParcelable baseFile = result.getKey(); + Uri uri = result.getValue(); + mimeTypes.add(MimeTypes.getMimeType(baseFile.getPath(), baseFile.isDirectory())); + uriDataClipItems.add(new ClipData.Item(uri)); + } + ClipData clipData = + new ClipData( + ClipDescription.MIMETYPE_TEXT_URILIST, + mimeTypes.toArray(new String[0]), + uriDataClipItems.remove(0)); + for (ClipData.Item item : uriDataClipItems) { + clipData.addItem(item); + } + + intent.setClipData(clipData); + intent.setAction(Intent.ACTION_SEND_MULTIPLE); + intent.putParcelableArrayListExtra( + Intent.EXTRA_STREAM, new ArrayList<>(resultUris.values())); + } + + requireActivity().setResult(FragmentActivity.RESULT_OK, intent); + } + if (!failedPaths.isEmpty()) { + LOG.warn("Unable to get URIs from baseFiles {}", failedPaths); + } + requireActivity().finish(); + } + + LoadFilesListTask loadFilesListTask; + + /** + * This loads a path into the MainFragment. + * + * @param providedPath the path to be loaded + * @param back if we're coming back from any directory and want the scroll to be restored + * @param providedOpenMode the mode in which the directory should be opened + * @param forceReload whether use cached list or force reload the list items + */ + public void loadlist( + final String providedPath, + final boolean back, + final OpenMode providedOpenMode, + boolean forceReload) { + if (mainFragmentViewModel == null) { + LOG.warn("Viewmodel not available to load the data"); + return; + } + + if (getMainActivity() != null + && getMainActivity().getActionModeHelper() != null + && getMainActivity().getActionModeHelper().getActionMode() != null) { + getMainActivity().getActionModeHelper().getActionMode().finish(); + } + + mSwipeRefreshLayout.setRefreshing(true); + + if (loadFilesListTask != null && loadFilesListTask.getStatus() == AsyncTask.Status.RUNNING) { + LOG.warn("Existing load list task running, cancel current"); + loadFilesListTask.cancel(true); + } + + OpenMode openMode = providedOpenMode; + String actualPath = FileProperties.remapPathForApi30OrAbove(providedPath, false); + + if (SDK_INT >= Q && ArraysKt.any(ANDROID_DATA_DIRS, providedPath::contains)) { + openMode = loadPathInQ(actualPath, providedPath, providedOpenMode); + } + // Monkeypatch :( to fix problems with unexpected non content URI path while openMode is still + // OpenMode.DOCUMENT_FILE + else if (actualPath.startsWith("/") + && (OpenMode.DOCUMENT_FILE.equals(openMode) || OpenMode.ANDROID_DATA.equals(openMode))) { + openMode = OpenMode.FILE; + } + + loadFilesListTask = + new LoadFilesListTask( + getActivity(), + actualPath, + this, + openMode, + getBoolean(PREFERENCE_SHOW_THUMB), + getBoolean(PREFERENCE_SHOW_HIDDENFILES), + forceReload, + (data) -> { + mSwipeRefreshLayout.setRefreshing(false); + if (data != null && data.second != null) { + boolean isPathLayoutGrid = + DataUtils.getInstance().getListOrGridForPath(providedPath, DataUtils.LIST) + == DataUtils.GRID; + setListElements(data.second, back, providedPath, data.first, isPathLayoutGrid); + } else { + LOG.warn("Load list operation cancelled"); + } + }); + loadFilesListTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + @RequiresApi(api = Q) + private OpenMode loadPathInQ(String actualPath, String providedPath, OpenMode providedMode) { + + if (GenericExtKt.containsPath(ANDROID_DEVICE_DATA_DIRS, providedPath) + && !OpenMode.ANDROID_DATA.equals(providedMode)) { + return OpenMode.ANDROID_DATA; + } else if (actualPath.startsWith("/")) { + return OpenMode.FILE; + } else if (actualPath.equals(providedPath)) { + return providedMode; + } else { + boolean hasAccessToSpecialFolder = false; + List uriPermissions = + requireContext().getContentResolver().getPersistedUriPermissions(); + + if (uriPermissions != null && uriPermissions.size() > 0) { + for (UriPermission p : uriPermissions) { + if (p.isReadPermission() && actualPath.startsWith(p.getUri().toString())) { + hasAccessToSpecialFolder = true; + SafRootHolder.setUriRoot(p.getUri()); + break; + } + } + } + + if (!hasAccessToSpecialFolder) { + Intent intent = + new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + .putExtra( + DocumentsContract.EXTRA_INITIAL_URI, + Uri.parse(FileProperties.remapPathForApi30OrAbove(providedPath, true))); + MaterialDialog d = + GeneralDialogCreation.showBasicDialog( + requireMainActivity(), + R.string.android_data_prompt_saf_access, + R.string.android_data_prompt_saf_access_title, + android.R.string.ok, + android.R.string.cancel); + d.getActionButton(DialogAction.POSITIVE) + .setOnClickListener( + v -> { + ExtensionsKt.runIfDocumentsUIExists( + intent, + requireMainActivity(), + () -> handleDocumentUriForRestrictedDirectories.launch(intent)); + + d.dismiss(); + }); + d.show(); + // At this point LoadFilesListTask will be triggered. + // No harm even give OpenMode.FILE here, it loads blank when it doesn't; and after the + // UriPermission is granted loadlist will be called again + return OpenMode.FILE; + } else { + return OpenMode.DOCUMENT_FILE; + } + } + } + + void initNoFileLayout() { + nofilesview = rootView.findViewById(R.id.nofilelayout); + nofilesview.setColorSchemeColors(mainFragmentViewModel.getAccentColor()); + nofilesview.setOnRefreshListener( + () -> { + loadlist( + (mainFragmentViewModel.getCurrentPath()), + false, + mainFragmentViewModel.getOpenMode(), + false); + nofilesview.setRefreshing(false); + }); + nofilesview + .findViewById(R.id.no_files_relative) + .setOnKeyListener( + (v, keyCode, event) -> { + if (event.getAction() == KeyEvent.ACTION_DOWN) { + if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_RIGHT) { + requireMainActivity().getFAB().requestFocus(); + } else if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) { + requireMainActivity().onBackPressed(); + } else { + return false; + } + } + return true; + }); + if (utilsProvider.getAppTheme().equals(AppTheme.LIGHT)) { + ((AppCompatImageView) nofilesview.findViewById(R.id.image)) + .setColorFilter(Color.parseColor("#666666")); + } else if (utilsProvider.getAppTheme().equals(AppTheme.BLACK)) { + nofilesview.setBackgroundColor(Utils.getColor(getContext(), android.R.color.black)); + ((AppCompatTextView) nofilesview.findViewById(R.id.nofiletext)).setTextColor(Color.WHITE); + } else { + nofilesview.setBackgroundColor(Utils.getColor(getContext(), R.color.holo_dark_background)); + ((AppCompatTextView) nofilesview.findViewById(R.id.nofiletext)).setTextColor(Color.WHITE); + } + } + + /** + * Loading adapter after getting a list of elements + * + * @param bitmap the list of objects for the adapter + * @param back if we're coming back from any directory and want the scroll to be restored + * @param path the path for the adapter + * @param openMode the type of file being created + * @param results is the list of elements a result from search + * @param grid whether to set grid view or list view + */ + public void setListElements( + List bitmap, + boolean back, + String path, + final OpenMode openMode, + boolean grid) { + if (bitmap != null) { + mainFragmentViewModel.setListElements(bitmap); + mainFragmentViewModel.setCurrentPath(path); + mainFragmentViewModel.setOpenMode(openMode); + reloadListElements(back, grid); + } else { + // list loading cancelled + // TODO: Add support for cancelling list loading + loadlist(mainFragmentViewModel.getHome(), true, OpenMode.FILE, false); + } + } + + public void reloadListElements(boolean back, boolean grid) { + if (isAdded()) { + boolean isOtg = (OTGUtil.PREFIX_OTG + "/").equals(mainFragmentViewModel.getCurrentPath()); + + if (getBoolean(PREFERENCE_SHOW_GOBACK_BUTTON) + && !"/".equals(mainFragmentViewModel.getCurrentPath()) + && (mainFragmentViewModel.getOpenMode() == OpenMode.FILE + || mainFragmentViewModel.getOpenMode() == OpenMode.ROOT + || (mainFragmentViewModel.getIsCloudOpenMode() + && !mainFragmentViewModel.getIsOnCloudRoot())) + && !isOtg + && (mainFragmentViewModel.getListElements().size() == 0 + || !mainFragmentViewModel + .getListElements() + .get(0) + .size + .equals(getString(R.string.goback)))) { + mainFragmentViewModel.getListElements().add(0, getBackElement()); + } + + if (mainFragmentViewModel.getListElements().size() == 0) { + nofilesview.setVisibility(View.VISIBLE); + listView.setVisibility(View.GONE); + mSwipeRefreshLayout.setEnabled(false); + } else { + mSwipeRefreshLayout.setEnabled(true); + nofilesview.setVisibility(View.GONE); + listView.setVisibility(View.VISIBLE); + } + + if (grid && mainFragmentViewModel.isList()) { + switchToGrid(); + } else if (!grid && !mainFragmentViewModel.isList()) { + switchToList(); + } + + if (adapter == null) { + final List listElements = mainFragmentViewModel.getListElements(); + + adapter = + new RecyclerAdapter( + requireMainActivity(), + this, + utilsProvider, + sharedPref, + listView, + listElements, + requireContext(), + grid); + } else { + adapter.setItems(listView, mainFragmentViewModel.getListElements()); + } + + mainFragmentViewModel.setStopAnims(true); + + if (mainFragmentViewModel.getOpenMode() != OpenMode.CUSTOM + && mainFragmentViewModel.getOpenMode() != OpenMode.TRASH_BIN) { + DataUtils.getInstance().addHistoryFile(mainFragmentViewModel.getCurrentPath()); + } + + listView.setAdapter(adapter); + + if (!mainFragmentViewModel.getAddHeader()) { + listView.removeItemDecoration(dividerItemDecoration); + mainFragmentViewModel.setAddHeader(true); + } + + if (mainFragmentViewModel.getAddHeader() && mainFragmentViewModel.isList()) { + dividerItemDecoration = + new DividerItemDecoration( + requireMainActivity(), true, getBoolean(PREFERENCE_SHOW_DIVIDERS)); + listView.addItemDecoration(dividerItemDecoration); + mainFragmentViewModel.setAddHeader(false); + } + + if (back && scrolls.containsKey(mainFragmentViewModel.getCurrentPath())) { + Bundle b = scrolls.get(mainFragmentViewModel.getCurrentPath()); + int index = b.getInt("index"), top = b.getInt("top"); + if (mainFragmentViewModel.isList()) { + mLayoutManager.scrollToPositionWithOffset(index, top); + } else { + mLayoutManagerGrid.scrollToPositionWithOffset(index, top); + } + } + + requireMainActivity().updatePaths(mainFragmentViewModel.getNo()); + requireMainActivity().showFab(); + requireMainActivity().getAppbar().getAppbarLayout().setExpanded(true); + listView.stopScroll(); + fastScroller.setRecyclerView( + listView, + mainFragmentViewModel.isList() + ? 1 + : (mainFragmentViewModel.getColumns() == 0 + || mainFragmentViewModel.getColumns() == -1) + ? 3 + : mainFragmentViewModel.getColumns()); + mToolbarContainer.addOnOffsetChangedListener( + (appBarLayout, verticalOffset) -> { + fastScroller.updateHandlePosition(verticalOffset, 112); + }); + fastScroller.registerOnTouchListener( + () -> { + if (mainFragmentViewModel.getStopAnims() && adapter != null) { + stopAnimation(); + mainFragmentViewModel.setStopAnims(false); + } + }); + + startFileObserver(); + + listView.post( + () -> { + String fileName = requireMainActivity().getScrollToFileName(); + + if (fileName != null) + mainFragmentViewModel + .getScrollPosition(fileName) + .observe( + getViewLifecycleOwner(), + scrollPosition -> { + if (scrollPosition != -1) + listView.scrollToPosition( + Math.min(scrollPosition + 4, adapter.getItemCount() - 1)); + adapter.notifyItemChanged(scrollPosition); + }); + }); + + } else { + // fragment not added + initNoFileLayout(); + } + } + + private LayoutElementParcelable getBackElement() { + if (mainFragmentViewModel.getBack() == null) { + mainFragmentViewModel.setBack( + new LayoutElementParcelable( + requireContext(), + true, + getString(R.string.goback), + getBoolean(PREFERENCE_SHOW_THUMB))); + } + return mainFragmentViewModel.getBack(); + } + + /** + * Method will resume any decryption tasks like registering decryption receiver or deleting any + * pending opened files in application cache + */ + private void resumeDecryptOperations() { + if (SDK_INT >= JELLY_BEAN_MR2) { + (requireMainActivity()) + .registerReceiver( + decryptReceiver, new IntentFilter(EncryptDecryptUtils.DECRYPT_BROADCAST)); + if (!mainFragmentViewModel.isEncryptOpen() + && !Utils.isNullOrEmpty(mainFragmentViewModel.getEncryptBaseFiles())) { + // we've opened the file and are ready to delete it + new DeleteTask(requireMainActivity(), true) + .execute(mainFragmentViewModel.getEncryptBaseFiles()); + mainFragmentViewModel.setEncryptBaseFiles(new ArrayList<>()); + } + } + } + + private void startFileObserver() { + switch (mainFragmentViewModel.getOpenMode()) { + case ROOT: + case FILE: + if (customFileObserver != null + && !customFileObserver.wasStopped() + && customFileObserver.getPath().equals(getCurrentPath())) { + return; + } + + File file = null; + if (mainFragmentViewModel.getCurrentPath() != null) { + file = new File(mainFragmentViewModel.getCurrentPath()); + } + if (file != null && file.isDirectory() && file.canRead()) { + if (customFileObserver != null) { + // already a watcher instantiated, first it should be stopped + customFileObserver.stopWatching(); + } + + customFileObserver = + new CustomFileObserver( + mainFragmentViewModel.getCurrentPath(), + new FileHandler(this, listView, getBoolean(PREFERENCE_SHOW_THUMB))); + customFileObserver.startWatching(); + } + break; + default: + break; + } + } + + /** + * Show dialog to rename a file + * + * @param f the file to rename + */ + public void rename(final HybridFileParcelable f) { + MaterialDialog renameDialog = + GeneralDialogCreation.showNameDialog( + getMainActivity(), + "", + f.getName(getMainActivity()), + getResources().getString(R.string.rename), + getResources().getString(R.string.save), + null, + getResources().getString(R.string.cancel), + (dialog, which) -> { + AppCompatEditText textfield = + dialog.getCustomView().findViewById(R.id.singleedittext_input); + String name1 = textfield.getText().toString().trim(); + + getMainActivity() + .mainActivityHelper + .rename( + mainFragmentViewModel.getOpenMode(), + f.getPath(), + mainFragmentViewModel.getCurrentPath(), + name1, + f.isDirectory(), + getActivity(), + getMainActivity().isRootExplorer()); + }, + (text) -> { + boolean isValidFilename = FileProperties.isValidFilename(text); + + if (!isValidFilename || text.startsWith(" ")) { + return new WarnableTextInputValidator.ReturnState( + WarnableTextInputValidator.ReturnState.STATE_ERROR, R.string.invalid_name); + } else if (text.length() < 1) { + return new WarnableTextInputValidator.ReturnState( + WarnableTextInputValidator.ReturnState.STATE_ERROR, R.string.field_empty); + } + + return new WarnableTextInputValidator.ReturnState(); + }); + + // place cursor at the starting of edit text by posting a runnable to edit text + // this is done because in case android has not populated the edit text layouts yet, it'll + // reset calls to selection if not posted in message queue + AppCompatEditText textfield = + renameDialog.getCustomView().findViewById(R.id.singleedittext_input); + textfield.post( + () -> { + if (!f.isDirectory()) { + textfield.setSelection(f.getNameString(getContext()).length()); + } + }); + } + + public void computeScroll() { + View vi = null; + + if (listView != null) { + vi = listView.getChildAt(0); + } + + int top = (vi == null) ? 0 : vi.getTop(); + int index; + if (mainFragmentViewModel.isList()) index = mLayoutManager.findFirstVisibleItemPosition(); + else index = mLayoutManagerGrid.findFirstVisibleItemPosition(); + Bundle b = new Bundle(); + b.putInt("index", index); + b.putInt("top", top); + scrolls.put(mainFragmentViewModel.getCurrentPath(), b); + } + + public void goBack() { + if (mainFragmentViewModel.getOpenMode() == OpenMode.CUSTOM + || mainFragmentViewModel.getOpenMode() == OpenMode.TRASH_BIN) { + loadlist(mainFragmentViewModel.getHome(), false, OpenMode.FILE, false); + setHideFab(false); + return; + } + + HybridFile currentFile = + new HybridFile(mainFragmentViewModel.getOpenMode(), mainFragmentViewModel.getCurrentPath()); + if (requireMainActivity().getListItemSelected()) { + adapter.toggleChecked(false); + } else { + setHideFab(false); + if (OpenMode.SMB.equals(mainFragmentViewModel.getOpenMode())) { + if (mainFragmentViewModel.getSmbPath() != null + && !mainFragmentViewModel.getSmbPath().equals(mainFragmentViewModel.getCurrentPath())) { + StringBuilder path = new StringBuilder(currentFile.getSmbFile().getParent()); + if (mainFragmentViewModel.getCurrentPath() != null + && mainFragmentViewModel.getCurrentPath().indexOf('?') > 0) + path.append( + mainFragmentViewModel + .getCurrentPath() + .substring(mainFragmentViewModel.getCurrentPath().indexOf('?'))); + loadlist( + path.toString().replace("%3D", "="), + true, + mainFragmentViewModel.getOpenMode(), + false); + } else loadlist(mainFragmentViewModel.getHome(), false, OpenMode.FILE, false); + } else if (OpenMode.SFTP.equals(mainFragmentViewModel.getOpenMode())) { + if (currentFile.getParent(requireContext()) == null) { + loadlist(mainFragmentViewModel.getHome(), false, OpenMode.FILE, false); + } else if (OpenMode.DOCUMENT_FILE.equals(mainFragmentViewModel.getOpenMode())) { + loadlist(currentFile.getParent(getContext()), true, currentFile.getMode(), false); + } else { + + String parent = currentFile.getParent(getContext()); + + if (parent == null) + parent = mainFragmentViewModel.getHome(); // fall back by traversing back to home folder + + loadlist(parent, true, mainFragmentViewModel.getOpenMode(), false); + } + } else if (OpenMode.FTP.equals(mainFragmentViewModel.getOpenMode())) { + if (mainFragmentViewModel.getCurrentPath() != null) { + String parent = currentFile.getParent(getContext()); + // Hack. + if (parent != null && parent.contains("://")) { + loadlist(parent, true, mainFragmentViewModel.getOpenMode(), false); + } else { + loadlist(mainFragmentViewModel.getHome(), false, OpenMode.FILE, false); + } + } else { + loadlist(mainFragmentViewModel.getHome(), false, OpenMode.FILE, false); + } + } else if (("/").equals(mainFragmentViewModel.getCurrentPath()) + || (mainFragmentViewModel.getHome() != null + && mainFragmentViewModel.getHome().equals(mainFragmentViewModel.getCurrentPath())) + || mainFragmentViewModel.getIsOnCloudRoot()) { + getMainActivity().exit(); + } else if (OpenMode.DOCUMENT_FILE.equals(mainFragmentViewModel.getOpenMode()) + && !currentFile.getPath().startsWith("content://")) { + if (CollectionsKt.contains(ANDROID_DEVICE_DATA_DIRS, currentFile.getParent(getContext()))) { + loadlist(currentFile.getParent(getContext()), false, OpenMode.ANDROID_DATA, false); + } else { + loadlist( + currentFile.getParent(getContext()), + true, + mainFragmentViewModel.getOpenMode(), + false); + } + } else if (FileUtils.canGoBack(getContext(), currentFile)) { + loadlist( + currentFile.getParent(getContext()), true, mainFragmentViewModel.getOpenMode(), false); + } else { + requireMainActivity().exit(); + } + } + } + + public void reauthenticateSmb() { + if (mainFragmentViewModel.getSmbPath() != null) { + try { + requireMainActivity() + .runOnUiThread( + () -> { + int i; + AppConfig.toast(requireContext(), getString(R.string.unknown_error)); + if ((i = + DataUtils.getInstance() + .containsServer(mainFragmentViewModel.getSmbPath())) + != -1) { + requireMainActivity() + .showSMBDialog( + DataUtils.getInstance().getServers().get(i)[0], + mainFragmentViewModel.getSmbPath(), + true); + } + }); + } catch (Exception e) { + LOG.warn("failure when reauthenticating smb connection", e); + } + } + } + + public void goBackItemClick() { + if (mainFragmentViewModel.getOpenMode() == OpenMode.CUSTOM + || mainFragmentViewModel.getOpenMode() == OpenMode.TRASH_BIN) { + loadlist(mainFragmentViewModel.getHome(), false, OpenMode.FILE, false); + return; + } + HybridFile currentFile = + new HybridFile(mainFragmentViewModel.getOpenMode(), mainFragmentViewModel.getCurrentPath()); + if (requireMainActivity().getListItemSelected()) { + adapter.toggleChecked(false); + } else { + if (mainFragmentViewModel.getOpenMode() == OpenMode.SMB) { + if (mainFragmentViewModel.getCurrentPath() != null + && !mainFragmentViewModel.getCurrentPath().equals(mainFragmentViewModel.getSmbPath())) { + StringBuilder path = new StringBuilder(currentFile.getSmbFile().getParent()); + if (mainFragmentViewModel.getCurrentPath().indexOf('?') > 0) + path.append( + mainFragmentViewModel + .getCurrentPath() + .substring(mainFragmentViewModel.getCurrentPath().indexOf('?'))); + loadlist(path.toString(), true, OpenMode.SMB, false); + } else loadlist(mainFragmentViewModel.getHome(), false, OpenMode.FILE, false); + } else if (("/").equals(mainFragmentViewModel.getCurrentPath()) + || mainFragmentViewModel.getIsOnCloudRoot()) { + requireMainActivity().exit(); + } else if (FileUtils.canGoBack(getContext(), currentFile)) { + loadlist( + currentFile.getParent(getContext()), true, mainFragmentViewModel.getOpenMode(), false); + } else requireMainActivity().exit(); + } + } + + public void updateList(boolean forceReload) { + computeScroll(); + loadlist( + mainFragmentViewModel.getCurrentPath(), + true, + mainFragmentViewModel.getOpenMode(), + forceReload); + } + + @Override + public void onResume() { + super.onResume(); + (requireActivity()) + .registerReceiver(receiver2, new IntentFilter(MainActivity.KEY_INTENT_LOAD_LIST)); + + resumeDecryptOperations(); + startFileObserver(); + } + + @Override + public void onPause() { + super.onPause(); + (requireActivity()).unregisterReceiver(receiver2); + if (customFileObserver != null) { + customFileObserver.stopWatching(); + } + + if (SDK_INT >= JELLY_BEAN_MR2) { + (requireActivity()).unregisterReceiver(decryptReceiver); + } + } + + public ArrayList addToSmb( + @NonNull SmbFile[] mFile, @NonNull String path, boolean showHiddenFiles) throws SmbException { + ArrayList smbFileList = new ArrayList<>(); + String extraParams = Uri.parse(path).getQuery(); + + if (mainFragmentViewModel.getSearchHelper().size() > 500) { + mainFragmentViewModel.getSearchHelper().clear(); + } + for (SmbFile aMFile : mFile) { + if ((DataUtils.getInstance().isFileHidden(aMFile.getPath()) || aMFile.isHidden()) + && !showHiddenFiles) { + continue; + } + String name = aMFile.getName(); + name = + (aMFile.isDirectory() && name.endsWith("/")) + ? name.substring(0, name.length() - 1) + : name; + if (path.equals(mainFragmentViewModel.getSmbPath())) { + if (name.endsWith("$")) continue; + } + if (aMFile.isDirectory()) { + mainFragmentViewModel.setFolderCount(mainFragmentViewModel.getFolderCount() + 1); + + Uri.Builder aMFilePathBuilder = Uri.parse(aMFile.getPath()).buildUpon(); + if (!TextUtils.isEmpty(extraParams)) aMFilePathBuilder.query(extraParams); + + LayoutElementParcelable layoutElement = + new LayoutElementParcelable( + requireContext(), + name, + aMFilePathBuilder.build().toString(), + "", + "", + "", + 0, + false, + aMFile.lastModified() + "", + true, + getBoolean(PREFERENCE_SHOW_THUMB), + OpenMode.SMB); + + mainFragmentViewModel.getSearchHelper().add(layoutElement.generateBaseFile()); + smbFileList.add(layoutElement); + + } else { + mainFragmentViewModel.setFileCount(mainFragmentViewModel.getFileCount() + 1); + LayoutElementParcelable layoutElement = + new LayoutElementParcelable( + requireContext(), + name, + aMFile.getPath(), + "", + "", + Formatter.formatFileSize(getContext(), aMFile.length()), + aMFile.length(), + false, + aMFile.lastModified() + "", + false, + getBoolean(PREFERENCE_SHOW_THUMB), + OpenMode.SMB); + layoutElement.setMode(OpenMode.SMB); + mainFragmentViewModel.getSearchHelper().add(layoutElement.generateBaseFile()); + smbFileList.add(layoutElement); + } + } + return smbFileList; + } + + @Override + public void onDestroy() { + super.onDestroy(); + + // not guaranteed to be called unless we call #finish(); + // please move code to onStop + } + + public void hide(String path) { + DataUtils.getInstance().addHiddenFile(path); + File file = new File(path); + if (file.isDirectory()) { + File f1 = new File(path + "/" + ".nomedia"); + if (!f1.exists()) { + try { + requireMainActivity() + .mainActivityHelper + .mkFile( + new HybridFile(OpenMode.FILE, path), + new HybridFile(OpenMode.FILE, f1.getPath()), + this); + } catch (Exception e) { + LOG.warn("failure when hiding file", e); + } + } + MediaConnectionUtils.scanFile( + requireMainActivity(), new HybridFile[] {new HybridFile(OpenMode.FILE, path)}); + } + } + + public void addShortcut(LayoutElementParcelable path) { + // Adding shortcut for MainActivity + // on Home screen + final Context ctx = requireContext(); + + if (!ShortcutManagerCompat.isRequestPinShortcutSupported(requireContext())) { + Toast.makeText( + getActivity(), + getString(R.string.add_shortcut_not_supported_by_launcher), + Toast.LENGTH_SHORT) + .show(); + return; + } + + Intent shortcutIntent = new Intent(ctx, MainActivity.class); + shortcutIntent.putExtra("path", path.desc); + shortcutIntent.setAction(Intent.ACTION_MAIN); + shortcutIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); + + // Using file path as shortcut id. + ShortcutInfoCompat info = + new ShortcutInfoCompat.Builder(ctx, path.desc) + .setActivity(requireMainActivity().getComponentName()) + .setIcon(IconCompat.createWithResource(ctx, R.mipmap.ic_launcher)) + .setIntent(shortcutIntent) + .setLongLabel(path.desc) + .setShortLabel(new File(path.desc).getName()) + .build(); + + ShortcutManagerCompat.requestPinShortcut(ctx, info, null); + } + + @Override + public void onDetach() { + super.onDetach(); + } + + @Nullable + public MainActivity getMainActivity() { + return (MainActivity) getActivity(); + } + + @NonNull + public MainActivity requireMainActivity() { + return (MainActivity) requireActivity(); + } + + @Nullable + public List getElementsList() { + return mainFragmentViewModel.getListElements(); + } + + public void initTopAndEmptyAreaDragListeners(boolean destroy) { + if (destroy) { + mToolbarContainer.setOnDragListener(null); + listView.stopScroll(); + listView.setOnDragListener(null); + nofilesview.setOnDragListener(null); + } else { + mToolbarContainer.setOnDragListener( + new TabFragmentBottomDragListener( + () -> { + smoothScrollListView(true); + return null; + }, + () -> { + stopSmoothScrollListView(); + return null; + })); + listView.setOnDragListener( + new RecyclerAdapterDragListener( + adapter, null, mainFragmentViewModel.getDragAndDropPreference(), this)); + nofilesview.setOnDragListener( + new RecyclerAdapterDragListener( + adapter, null, mainFragmentViewModel.getDragAndDropPreference(), this)); + } + } + + public void smoothScrollListView(boolean upDirection) { + if (listView != null) { + if (upDirection) { + listView.smoothScrollToPosition(0); + } else { + listView.smoothScrollToPosition(mainFragmentViewModel.getAdapterListItems().size()); + } + } + } + + public void stopSmoothScrollListView() { + if (listView != null) { + listView.stopScroll(); + } + } + + @Nullable + public String getCurrentPath() { + if (mainFragmentViewModel == null) { + LOG.warn("Viewmodel not available to get current path"); + return null; + } + return mainFragmentViewModel.getCurrentPath(); + } + + @Override + public void changePath(@NonNull String path) { + loadlist(path, false, mainFragmentViewModel.getOpenMode(), false); + } + + @Override + public String getPath() { + return getCurrentPath(); + } + + @Override + public int getRootDrawable() { + return R.drawable.ic_root_white_24px; + } + + private boolean getBoolean(String key) { + return requireMainActivity().getBoolean(key); + } + + @Override + public void onGlobalLayout() { + if (mainFragmentViewModel.getColumns() == null) { + int screenWidth = listView.getWidth(); + int dpToPx = Utils.dpToPx(requireContext(), 115); + if (dpToPx == 0) { + // HACK to fix a crash see #3249 + dpToPx = 1; + } + mainFragmentViewModel.setColumns(screenWidth / dpToPx); + if (!mainFragmentViewModel.isList()) { + mLayoutManagerGrid.setSpanCount(mainFragmentViewModel.getColumns()); + } + } + // TODO: This trigger causes to lose selected items in case of grid view, + // but is necessary to adjust columns for grid view when screen is rotated + /*if (!mainFragmentViewModel.isList()) { + loadViews(); + }*/ + if (android.os.Build.VERSION.SDK_INT >= JELLY_BEAN) { + mToolbarContainer.getViewTreeObserver().removeOnGlobalLayoutListener(this); + } else { + mToolbarContainer.getViewTreeObserver().removeGlobalOnLayoutListener(this); + } + } + + public @Nullable MainFragmentViewModel getMainFragmentViewModel() { + if (isAdded()) { + if (mainFragmentViewModel == null) { + mainFragmentViewModel = new ViewModelProvider(this).get(MainFragmentViewModel.class); + } + return mainFragmentViewModel; + } else { + LOG.error("Failed to get viewmodel, fragment not yet added"); + return null; + } + } + + public @Nullable MainActivityViewModel getMainActivityViewModel() { + if (isAdded()) { + if (mainActivityViewModel == null) { + mainActivityViewModel = + new ViewModelProvider(requireMainActivity()).get(MainActivityViewModel.class); + } + return mainActivityViewModel; + } else { + LOG.error("Failed to get viewmodel, fragment not yet added"); + return null; + } + } + + @Override + public void adjustListViewForTv( + @NonNull ItemViewHolder viewHolder, @NonNull MainActivity mainActivity) { + try { + int[] location = new int[2]; + viewHolder.baseItemView.getLocationOnScreen(location); + LOG.info("Current x and y " + location[0] + " " + location[1]); + if (location[1] < requireMainActivity().getAppbar().getAppbarLayout().getHeight()) { + listView.scrollToPosition(Math.max(viewHolder.getAdapterPosition() - 5, 0)); + } else if (location[1] + viewHolder.baseItemView.getHeight() + > requireContext().getResources().getDisplayMetrics().heightPixels) { + listView.scrollToPosition( + Math.min(viewHolder.getAdapterPosition() + 5, adapter.getItemCount() - 1)); + } + } catch (IndexOutOfBoundsException e) { + LOG.warn("Failed to adjust scrollview for tv", e); + } + } + + /** Whether the FAB should be hidden when this MainFragment is shown */ + public boolean getHideFab() { + return this.hideFab; + } + + /** Set whether the FAB should be hidden when this MainFragment is shown */ + public void setHideFab(boolean hideFab) { + this.hideFab = hideFab; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/ProcessViewerFragment.java b/app/src/main/java/com/amaze/filemanager/ui/fragments/ProcessViewerFragment.java new file mode 100644 index 0000000..1507198 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/ProcessViewerFragment.java @@ -0,0 +1,498 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.fragments; + +import static androidx.core.text.HtmlCompat.FROM_HTML_MODE_COMPACT; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.asynchronous.services.AbstractProgressiveService; +import com.amaze.filemanager.asynchronous.services.CopyService; +import com.amaze.filemanager.asynchronous.services.DecryptService; +import com.amaze.filemanager.asynchronous.services.EncryptService; +import com.amaze.filemanager.asynchronous.services.ExtractService; +import com.amaze.filemanager.asynchronous.services.ZipService; +import com.amaze.filemanager.databinding.ProcessparentBinding; +import com.amaze.filemanager.filesystem.files.FileUtils; +import com.amaze.filemanager.ui.activities.MainActivity; +import com.amaze.filemanager.ui.theme.AppTheme; +import com.amaze.filemanager.utils.DatapointParcelable; +import com.amaze.filemanager.utils.ObtainableServiceBinder; +import com.amaze.filemanager.utils.Utils; +import com.github.mikephil.charting.charts.LineChart; +import com.github.mikephil.charting.components.XAxis; +import com.github.mikephil.charting.components.YAxis; +import com.github.mikephil.charting.data.Entry; +import com.github.mikephil.charting.data.LineData; +import com.github.mikephil.charting.data.LineDataSet; +import com.github.mikephil.charting.interfaces.datasets.ILineDataSet; + +import android.content.ComponentName; +import android.content.Intent; +import android.content.ServiceConnection; +import android.graphics.Color; +import android.graphics.Typeface; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.os.IBinder; +import android.text.Spanned; +import android.text.format.Formatter; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.core.text.HtmlCompat; +import androidx.fragment.app.Fragment; + +public class ProcessViewerFragment extends Fragment { + + /** + * Helps defining the result type for {@link #processResults(DatapointParcelable, int)} to process + */ + private static final int SERVICE_COPY = 0, + SERVICE_EXTRACT = 1, + SERVICE_COMPRESS = 2, + SERVICE_ENCRYPT = 3, + SERVICE_DECRYPT = 4; + + private boolean isInitialized = false; + private MainActivity mainActivity; + private int accentColor; + private final LineData lineData = new LineData(); + private ProcessparentBinding binding = null; + + /** Time in seconds just for showing to the user. No guarantees. */ + private long looseTimeInSeconds = 0L; + + private ServiceConnection mCopyConnection, + mExtractConnection, + mCompressConnection, + mEncryptConnection, + mDecryptConnection; + + @Override + public View onCreateView( + @NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + binding = ProcessparentBinding.inflate(inflater); + View rootView = binding.getRoot(); + + mainActivity = (MainActivity) getActivity(); + + accentColor = mainActivity.getAccent(); + if (mainActivity.getAppTheme().equals(AppTheme.DARK) + || mainActivity.getAppTheme().equals(AppTheme.BLACK)) + rootView.setBackgroundResource((R.color.cardView_background)); + + if (mainActivity.getAppTheme().equals(AppTheme.DARK) + || mainActivity.getAppTheme().equals(AppTheme.BLACK)) { + + binding.deleteButton.setImageResource(R.drawable.ic_action_cancel); + binding.cardView.setCardBackgroundColor( + Utils.getColor(getContext(), R.color.cardView_foreground)); + binding.cardView.setCardElevation(0f); + } + + return rootView; + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + mCopyConnection = new CustomServiceConnection(this, binding.progressChart, SERVICE_COPY); + mExtractConnection = new CustomServiceConnection(this, binding.progressChart, SERVICE_EXTRACT); + mCompressConnection = + new CustomServiceConnection(this, binding.progressChart, SERVICE_COMPRESS); + mEncryptConnection = new CustomServiceConnection(this, binding.progressChart, SERVICE_ENCRYPT); + mDecryptConnection = new CustomServiceConnection(this, binding.progressChart, SERVICE_DECRYPT); + + mainActivity.getAppbar().setTitle(R.string.process_viewer); + mainActivity.hideFab(); + mainActivity.getAppbar().getBottomBar().setVisibility(View.GONE); + mainActivity.supportInvalidateOptionsMenu(); + + int skin_color = mainActivity.getCurrentColorPreference().getPrimaryFirstTab(); + int skinTwoColor = mainActivity.getCurrentColorPreference().getPrimarySecondTab(); + accentColor = mainActivity.getAccent(); + + mainActivity.updateViews( + new ColorDrawable(MainActivity.currentTab == 1 ? skinTwoColor : skin_color)); + } + + @Override + public void onResume() { + super.onResume(); + + Intent intent = new Intent(getActivity(), CopyService.class); + getActivity().bindService(intent, mCopyConnection, 0); + + Intent intent1 = new Intent(getActivity(), ExtractService.class); + getActivity().bindService(intent1, mExtractConnection, 0); + + Intent intent2 = new Intent(getActivity(), ZipService.class); + getActivity().bindService(intent2, mCompressConnection, 0); + + Intent intent3 = new Intent(getActivity(), EncryptService.class); + getActivity().bindService(intent3, mEncryptConnection, 0); + + Intent intent4 = new Intent(getActivity(), DecryptService.class); + getActivity().bindService(intent4, mDecryptConnection, 0); + } + + @Override + public void onPause() { + super.onPause(); + getActivity().unbindService(mCopyConnection); + getActivity().unbindService(mExtractConnection); + getActivity().unbindService(mCompressConnection); + getActivity().unbindService(mEncryptConnection); + getActivity().unbindService(mDecryptConnection); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + mainActivity = null; + } + + public void processResults(final DatapointParcelable dataPackage, int serviceType) { + if (binding == null) return; + + if (dataPackage != null) { + String name = dataPackage.getName(); + long total = dataPackage.getTotalSize(); + long doneBytes = dataPackage.getByteProgress(); + boolean move = dataPackage.getMove(); + + if (!isInitialized) { + + // initializing views for the first time + chartInit(total); + + // setting progress image + setupDrawables(serviceType, move); + isInitialized = true; + } + + addEntry( + FileUtils.readableFileSizeFloat(doneBytes), + FileUtils.readableFileSizeFloat(dataPackage.getSpeedRaw())); + + binding.textViewProgressFileName.setText(name); + + Spanned bytesText = + HtmlCompat.fromHtml( + getResources().getString(R.string.written) + + " " + + Formatter.formatFileSize(getContext(), doneBytes) + + " " + + getResources().getString(R.string.out_of) + + " " + + Formatter.formatFileSize(getContext(), total) + + "", + FROM_HTML_MODE_COMPACT); + binding.textViewProgressBytes.setText(bytesText); + + Spanned fileProcessedSpan = + HtmlCompat.fromHtml( + getResources().getString(R.string.processing_file) + + " " + + dataPackage.getSourceProgress() + + " " + + getResources().getString(R.string.of) + + " " + + dataPackage.getAmountOfSourceFiles() + + "", + FROM_HTML_MODE_COMPACT); + binding.textViewProgressFile.setText(fileProcessedSpan); + + Spanned speedSpan = + HtmlCompat.fromHtml( + getResources().getString(R.string.current_speed) + + ": " + + Formatter.formatFileSize(getContext(), dataPackage.getSpeedRaw()) + + "/s", + FROM_HTML_MODE_COMPACT); + binding.textViewProgressSpeed.setText(speedSpan); + + Spanned timerSpan = + HtmlCompat.fromHtml( + getResources().getString(R.string.service_timer) + + ": " + + Utils.formatTimer(++looseTimeInSeconds) + + "", + FROM_HTML_MODE_COMPACT); + + binding.textViewProgressTimer.setText(timerSpan); + + if (dataPackage.getCompleted()) binding.deleteButton.setVisibility(View.GONE); + } + } + + /** Setup drawables and click listeners based on the SERVICE_* constants */ + private void setupDrawables(int serviceType, boolean isMove) { + switch (serviceType) { + case SERVICE_COPY: + if (mainActivity.getAppTheme().equals(AppTheme.DARK) + || mainActivity.getAppTheme().equals(AppTheme.BLACK)) { + + Drawable copyIcon = + ContextCompat.getDrawable(requireContext(), R.drawable.ic_content_copy_white_36dp); + binding.progressImage.setImageDrawable(copyIcon); + } else { + Drawable greyCopyIcon = + ContextCompat.getDrawable(requireContext(), R.drawable.ic_content_copy_grey600_36dp); + binding.progressImage.setImageDrawable(greyCopyIcon); + } + binding.textViewProgressType.setText( + isMove + ? getResources().getString(R.string.moving) + : getResources().getString(R.string.copying)); + cancelBroadcast(new Intent(CopyService.TAG_BROADCAST_COPY_CANCEL)); + break; + case SERVICE_EXTRACT: + if (mainActivity.getAppTheme().equals(AppTheme.DARK) + || mainActivity.getAppTheme().equals(AppTheme.BLACK)) { + + Drawable zipBoxIcon = + ContextCompat.getDrawable(requireContext(), R.drawable.ic_zip_box_white); + binding.progressImage.setImageDrawable(zipBoxIcon); + } else { + Drawable greyZipBoxIcon = + ContextCompat.getDrawable(requireContext(), R.drawable.ic_zip_box_grey); + binding.progressImage.setImageDrawable(greyZipBoxIcon); + } + binding.textViewProgressType.setText(getResources().getString(R.string.extracting)); + cancelBroadcast(new Intent(ExtractService.TAG_BROADCAST_EXTRACT_CANCEL)); + break; + case SERVICE_COMPRESS: + if (mainActivity.getAppTheme().equals(AppTheme.DARK) + || mainActivity.getAppTheme().equals(AppTheme.BLACK)) { + + Drawable zipBoxIcon = + ContextCompat.getDrawable(requireContext(), R.drawable.ic_zip_box_white); + binding.progressImage.setImageDrawable(zipBoxIcon); + } else { + Drawable greyZipBoxIcon = + ContextCompat.getDrawable(requireContext(), R.drawable.ic_zip_box_grey); + binding.progressImage.setImageDrawable(greyZipBoxIcon); + } + binding.textViewProgressType.setText(getResources().getString(R.string.compressing)); + cancelBroadcast(new Intent(ZipService.KEY_COMPRESS_BROADCAST_CANCEL)); + break; + case SERVICE_ENCRYPT: + if (mainActivity.getAppTheme().equals(AppTheme.DARK) + || mainActivity.getAppTheme().equals(AppTheme.BLACK)) { + + Drawable folderIcon = + ContextCompat.getDrawable(requireContext(), R.drawable.ic_folder_lock_white_36dp); + binding.progressImage.setImageDrawable(folderIcon); + } else { + Drawable greyFolderIcon = + ContextCompat.getDrawable(requireContext(), R.drawable.ic_folder_lock_grey600_36dp); + binding.progressImage.setImageDrawable(greyFolderIcon); + } + binding.textViewProgressType.setText(getResources().getString(R.string.crypt_encrypting)); + cancelBroadcast(new Intent(EncryptService.TAG_BROADCAST_CRYPT_CANCEL)); + break; + case SERVICE_DECRYPT: + if (mainActivity.getAppTheme().equals(AppTheme.DARK) + || mainActivity.getAppTheme().equals(AppTheme.BLACK)) { + + Drawable folderUnlockedIcon = + ContextCompat.getDrawable( + requireContext(), R.drawable.ic_folder_lock_open_white_36dp); + binding.progressImage.setImageDrawable(folderUnlockedIcon); + } else { + Drawable greyFolderUnlockedIcon = + ContextCompat.getDrawable( + requireContext(), R.drawable.ic_folder_lock_open_grey600_36dp); + binding.progressImage.setImageDrawable(greyFolderUnlockedIcon); + } + binding.textViewProgressType.setText(getResources().getString(R.string.crypt_decrypting)); + cancelBroadcast(new Intent(EncryptService.TAG_BROADCAST_CRYPT_CANCEL)); + break; + } + } + + /** Setup click listener to cancel button click for various intent types */ + private void cancelBroadcast(final Intent intent) { + if (binding == null) return; + + binding.deleteButton.setOnClickListener( + v -> { + Toast.makeText( + getActivity(), getResources().getString(R.string.stopping), Toast.LENGTH_LONG) + .show(); + getActivity().sendBroadcast(intent); + binding.textViewProgressType.setText(getResources().getString(R.string.cancelled)); + binding.textViewProgressSpeed.setText(""); + binding.textViewProgressFile.setText(""); + binding.textViewProgressBytes.setText(""); + binding.textViewProgressFileName.setText(""); + + binding.textViewProgressType.setTextColor( + Utils.getColor(getContext(), android.R.color.holo_red_light)); + }); + } + + /** + * Add a new entry dynamically to the chart, initializes a {@link LineDataSet} if not done so + * + * @param xValue the x-axis value, the number of bytes processed till now + * @param yValue the y-axis value, bytes processed per sec + */ + private void addEntry(float xValue, float yValue) { + ILineDataSet dataSet = lineData.getDataSetByIndex(0); + + if (dataSet == null) { // adding set for first time + dataSet = createDataSet(); + lineData.addDataSet(dataSet); + } + + dataSet.addEntry(new Entry(xValue, yValue)); + + lineData.notifyDataChanged(); + binding.progressChart.notifyDataSetChanged(); + binding.progressChart.invalidate(); + } + + /** Creates an instance for {@link LineDataSet} which will store the entries */ + private LineDataSet createDataSet() { + LineDataSet lineDataset = new LineDataSet(new ArrayList(), null); + + lineDataset.setLineWidth(1.75f); + lineDataset.setCircleRadius(5f); + lineDataset.setCircleHoleRadius(2.5f); + lineDataset.setColor(Color.WHITE); + lineDataset.setCircleColor(Color.WHITE); + lineDataset.setHighLightColor(Color.WHITE); + lineDataset.setDrawValues(false); + lineDataset.setCircleColorHole(accentColor); + + return lineDataset; + } + + /** + * Initialize chart for the first time + * + * @param totalBytes maximum value for x-axis + */ + private void chartInit(long totalBytes) { + binding.progressChart.setBackgroundColor(accentColor); + binding.progressChart.getLegend().setEnabled(false); + + // no description text + binding.progressChart.getDescription().setEnabled(false); + + XAxis xAxis = binding.progressChart.getXAxis(); + YAxis yAxisLeft = binding.progressChart.getAxisLeft(); + binding.progressChart.getAxisRight().setEnabled(false); + yAxisLeft.setTextColor(Color.WHITE); + yAxisLeft.setAxisLineColor(Color.TRANSPARENT); + yAxisLeft.setTypeface(Typeface.DEFAULT_BOLD); + yAxisLeft.setGridColor(Utils.getColor(getContext(), R.color.white_translucent)); + + xAxis.setAxisMaximum(FileUtils.readableFileSizeFloat(totalBytes)); + xAxis.setAxisMinimum(0.0f); + xAxis.setAxisLineColor(Color.TRANSPARENT); + xAxis.setGridColor(Color.TRANSPARENT); + xAxis.setTextColor(Color.WHITE); + xAxis.setTypeface(Typeface.DEFAULT_BOLD); + binding.progressChart.setData(lineData); + binding.progressChart.invalidate(); + } + + private static class CustomServiceConnection implements ServiceConnection { + + private final WeakReference fragment; + private final WeakReference lineChart; + private final int serviceType; + + public CustomServiceConnection( + ProcessViewerFragment frag, LineChart lineChart, int serviceType) { + fragment = new WeakReference<>(frag); + this.lineChart = new WeakReference<>(lineChart); + this.serviceType = serviceType; + } + + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + ObtainableServiceBinder binder = + (ObtainableServiceBinder) service; + AbstractProgressiveService specificService = binder.getService(); + + for (int i = 0; i < specificService.getDataPackageSize(); i++) { + DatapointParcelable dataPackage = specificService.getDataPackage(i); + ProcessViewerFragment processViewerFragment = fragment.get(); + if (processViewerFragment != null) { + processViewerFragment.processResults(dataPackage, serviceType); + } + } + + // animate the chart a little after initial values have been applied + LineChart chart = lineChart.get(); + if (chart != null) { + chart.animateXY(500, 500); + } + + specificService.setProgressListener( + new AbstractProgressiveService.ProgressListener() { + @Override + public void onUpdate(final DatapointParcelable dataPackage) { + ProcessViewerFragment processViewerFragment = fragment.get(); + if (processViewerFragment != null) { + if (processViewerFragment.getActivity() == null) { + // callback called when we're not inside the app + return; + } + processViewerFragment + .getActivity() + .runOnUiThread( + () -> processViewerFragment.processResults(dataPackage, serviceType)); + } + } + + @Override + public void refresh() {} + }); + } + + @Override + public void onServiceDisconnected(ComponentName name) {} + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/TabFragment.java b/app/src/main/java/com/amaze/filemanager/ui/fragments/TabFragment.java new file mode 100644 index 0000000..9cfe880 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/TabFragment.java @@ -0,0 +1,532 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.fragments; + +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_CURRENT_TAB; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SAVED_PATHS; +import static com.amaze.filemanager.utils.PreferenceUtils.DEFAULT_CURRENT_TAB; +import static com.amaze.filemanager.utils.PreferenceUtils.DEFAULT_SAVED_PATHS; + +import java.util.ArrayList; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.application.AppConfig; +import com.amaze.filemanager.database.TabHandler; +import com.amaze.filemanager.database.models.explorer.Tab; +import com.amaze.filemanager.fileoperations.filesystem.OpenMode; +import com.amaze.filemanager.ui.ColorCircleDrawable; +import com.amaze.filemanager.ui.ExtensionsKt; +import com.amaze.filemanager.ui.activities.MainActivity; +import com.amaze.filemanager.ui.colors.UserColorPreferences; +import com.amaze.filemanager.ui.dialogs.GeneralDialogCreation; +import com.amaze.filemanager.ui.drag.DragToTrashListener; +import com.amaze.filemanager.ui.drag.TabFragmentSideDragListener; +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants; +import com.amaze.filemanager.ui.views.Indicator; +import com.amaze.filemanager.utils.DataUtils; +import com.amaze.filemanager.utils.Utils; + +import android.animation.ArgbEvaluator; +import android.content.SharedPreferences; +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; +import android.os.Build; +import android.os.Bundle; +import android.view.HapticFeedbackConstants; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.DecelerateInterpolator; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.appcompat.widget.AppCompatImageView; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.fragment.app.FragmentManager; +import androidx.preference.PreferenceManager; +import androidx.viewpager2.adapter.FragmentStateAdapter; +import androidx.viewpager2.widget.ViewPager2; + +public class TabFragment extends Fragment { + private final Logger LOG = LoggerFactory.getLogger(TabFragment.class); + + private static final String KEY_PATH = "path"; + private static final String KEY_POSITION = "pos"; + + private static final String KEY_FRAGMENT_0 = "tab0"; + private static final String KEY_FRAGMENT_1 = "tab1"; + + private boolean savePaths; + private FragmentManager fragmentManager; + + private final List fragments = new ArrayList<>(); + private ScreenSlidePagerAdapter sectionsPagerAdapter; + private ViewPager2 viewPager; + private SharedPreferences sharedPrefs; + private String path; + + /** ink indicators for viewpager only for Lollipop+ */ + private Indicator indicator; + + /** views for circlular drawables below android lollipop */ + private AppCompatImageView circleDrawable1, circleDrawable2; + + /** color drawable for action bar background */ + private final ColorDrawable colorDrawable = new ColorDrawable(); + + /** colors relative to current visible tab */ + private @ColorInt int startColor, endColor; + + private ViewGroup rootView; + + private final ArgbEvaluator evaluator = new ArgbEvaluator(); + private ConstraintLayout dragPlaceholder; + + @Override + public View onCreateView( + LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + rootView = (ViewGroup) inflater.inflate(R.layout.tabfragment, container, false); + + fragmentManager = requireActivity().getSupportFragmentManager(); + dragPlaceholder = rootView.findViewById(R.id.drag_placeholder); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + indicator = requireActivity().findViewById(R.id.indicator); + } else { + circleDrawable1 = requireActivity().findViewById(R.id.tab_indicator1); + circleDrawable2 = requireActivity().findViewById(R.id.tab_indicator2); + } + + sharedPrefs = PreferenceManager.getDefaultSharedPreferences(requireActivity()); + savePaths = sharedPrefs.getBoolean(PREFERENCE_SAVED_PATHS, DEFAULT_SAVED_PATHS); + + viewPager = rootView.findViewById(R.id.pager); + + boolean hideFab = false; + if (getArguments() != null) { + path = getArguments().getString(KEY_PATH); + hideFab = getArguments().getBoolean(MainFragment.BUNDLE_HIDE_FAB); + } + + requireMainActivity().supportInvalidateOptionsMenu(); + viewPager.registerOnPageChangeCallback(new OnPageChangeCallbackImpl()); + + sectionsPagerAdapter = new ScreenSlidePagerAdapter(requireActivity()); + if (savedInstanceState == null) { + int lastOpenTab = sharedPrefs.getInt(PREFERENCE_CURRENT_TAB, DEFAULT_CURRENT_TAB); + MainActivity.currentTab = lastOpenTab; + + refactorDrawerStorages(true, hideFab); + + viewPager.setAdapter(sectionsPagerAdapter); + + try { + viewPager.setCurrentItem(lastOpenTab, true); + if (circleDrawable1 != null && circleDrawable2 != null) { + updateIndicator(viewPager.getCurrentItem()); + } + } catch (Exception e) { + LOG.warn("failed to set current viewpager item", e); + } + } else { + fragments.clear(); + try { + fragments.add(0, fragmentManager.getFragment(savedInstanceState, KEY_FRAGMENT_0)); + fragments.add(1, fragmentManager.getFragment(savedInstanceState, KEY_FRAGMENT_1)); + } catch (Exception e) { + LOG.warn("failed to clear fragments", e); + } + + sectionsPagerAdapter = new ScreenSlidePagerAdapter(requireActivity()); + + viewPager.setAdapter(sectionsPagerAdapter); + int pos1 = savedInstanceState.getInt(KEY_POSITION, 0); + MainActivity.currentTab = pos1; + viewPager.setCurrentItem(pos1); + sectionsPagerAdapter.notifyDataSetChanged(); + } + + if (indicator != null) indicator.setViewPager(viewPager); + + UserColorPreferences userColorPreferences = requireMainActivity().getCurrentColorPreference(); + + // color of viewpager when current tab is 0 + startColor = userColorPreferences.getPrimaryFirstTab(); + // color of viewpager when current tab is 1 + endColor = userColorPreferences.getPrimarySecondTab(); + + /* + TODO + //update the views as there is any change in {@link MainActivity#currentTab} + //probably due to config change + colorDrawable.setColor(Color.parseColor(MainActivity.currentTab==1 ? + ThemedActivity.skinTwo : ThemedActivity.skin)); + mainActivity.updateViews(colorDrawable); + */ + + return rootView; + } + + @Override + public void onDestroyView() { + indicator = null; // Free the strong reference + sharedPrefs.edit().putInt(PREFERENCE_CURRENT_TAB, MainActivity.currentTab).apply(); + super.onDestroyView(); + } + + public void updatePaths(int pos) { + // Getting old path from database before clearing + TabHandler tabHandler = TabHandler.getInstance(); + + int i = 1; + for (Fragment fragment : fragments) { + if (fragment instanceof MainFragment) { + MainFragment mainFragment = (MainFragment) fragment; + if (mainFragment.getMainFragmentViewModel() != null + && i - 1 == MainActivity.currentTab + && i == pos) { + updateBottomBar(mainFragment); + requireMainActivity() + .getDrawer() + .selectCorrectDrawerItemForPath(mainFragment.getCurrentPath()); + if (mainFragment.getMainFragmentViewModel().getOpenMode() == OpenMode.FILE) { + tabHandler.update( + new Tab( + i, + mainFragment.getCurrentPath(), + mainFragment.getMainFragmentViewModel().getHome())); + } else { + tabHandler.update( + new Tab( + i, + mainFragment.getMainFragmentViewModel().getHome(), + mainFragment.getMainFragmentViewModel().getHome())); + } + } + i++; + } + } + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + + if (sharedPrefs != null) { + sharedPrefs.edit().putInt(PREFERENCE_CURRENT_TAB, MainActivity.currentTab).apply(); + } + + if (fragments.size() != 0) { + if (fragmentManager == null) { + return; + } + + fragmentManager.executePendingTransactions(); + fragmentManager.putFragment(outState, KEY_FRAGMENT_0, fragments.get(0)); + fragmentManager.putFragment(outState, KEY_FRAGMENT_1, fragments.get(1)); + outState.putInt(KEY_POSITION, viewPager.getCurrentItem()); + } + } + + public void setPagingEnabled(boolean isPaging) { + viewPager.setUserInputEnabled(isPaging); + } + + public void setCurrentItem(int index) { + viewPager.setCurrentItem(index); + } + + private class OnPageChangeCallbackImpl extends ViewPager2.OnPageChangeCallback { + + @Override + public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { + final MainFragment mainFragment = requireMainActivity().getCurrentMainFragment(); + if (mainFragment == null + || mainFragment.getMainFragmentViewModel() == null + || mainFragment.getMainActivity().getListItemSelected()) { + return; // we do not want to update toolbar colors when ActionMode is activated + } + + // during the config change + @ColorInt + int color = (int) evaluator.evaluate(position + positionOffset, startColor, endColor); + + colorDrawable.setColor(color); + requireMainActivity().updateViews(colorDrawable); + } + + @Override + public void onPageSelected(int p1) { + requireMainActivity() + .getAppbar() + .getAppbarLayout() + .animate() + .translationY(0) + .setInterpolator(new DecelerateInterpolator(2)) + .start(); + + MainActivity.currentTab = p1; + + if (sharedPrefs != null) { + sharedPrefs.edit().putInt(PREFERENCE_CURRENT_TAB, MainActivity.currentTab).apply(); + } + + Fragment fragment = fragments.get(p1); + if (fragment instanceof MainFragment) { + MainFragment ma = (MainFragment) fragment; + if (ma.getCurrentPath() != null) { + requireMainActivity().getDrawer().selectCorrectDrawerItemForPath(ma.getCurrentPath()); + updateBottomBar(ma); + // FAB might be hidden in the previous tab + // so we check if it should be shown for the new tab + requireMainActivity().showFab(); + } + } + + if (circleDrawable1 != null && circleDrawable2 != null) updateIndicator(p1); + } + + @Override + public void onPageScrollStateChanged(int state) { + // nothing to do + } + } + + private class ScreenSlidePagerAdapter extends FragmentStateAdapter { + public ScreenSlidePagerAdapter(FragmentActivity fragmentActivity) { + super(fragmentActivity); + } + + @Override + public int getItemCount() { + return fragments.size(); + } + + @NonNull + @Override + public Fragment createFragment(int position) { + Fragment f; + f = fragments.get(position); + return f; + } + } + + private void addNewTab(int num, String path) { + addTab(new Tab(num, path, path), "", false); + } + + /** + * Fetches new storage paths from drawer and apply to tabs This method will just create tabs in UI + * change paths in database. Calls should implement updating each tab's list for new paths. + * + * @param addTab whether new tabs should be added to ui or just change values in database + * @param hideFabInCurrentMainFragment whether the FAB should be hidden in the current {@link + * MainFragment} + */ + public void refactorDrawerStorages(boolean addTab, boolean hideFabInCurrentMainFragment) { + TabHandler tabHandler = TabHandler.getInstance(); + Tab tab1 = tabHandler.findTab(1); + Tab tab2 = tabHandler.findTab(2); + Tab[] tabs = tabHandler.getAllTabs(); + String firstTabPath = requireMainActivity().getDrawer().getFirstPath(); + String secondTabPath = requireMainActivity().getDrawer().getSecondPath(); + + if (tabs == null || tabs.length < 1 || tab1 == null || tab2 == null) { + // creating tabs in db for the first time, probably the first launch of + // app, or something got corrupted + String currentFirstTab = Utils.isNullOrEmpty(firstTabPath) ? "/" : firstTabPath; + String currentSecondTab = Utils.isNullOrEmpty(secondTabPath) ? firstTabPath : secondTabPath; + if (addTab) { + addNewTab(1, currentSecondTab); + addNewTab(2, currentFirstTab); + } + tabHandler.addTab(new Tab(1, currentSecondTab, currentSecondTab)).blockingAwait(); + tabHandler.addTab(new Tab(2, currentFirstTab, currentFirstTab)).blockingAwait(); + + if (currentFirstTab.equalsIgnoreCase("/")) { + sharedPrefs.edit().putBoolean(PreferencesConstants.PREFERENCE_ROOTMODE, true).apply(); + } + } else { + if (path != null && path.length() != 0) { + if (MainActivity.currentTab == 0) { + addTab(tab1, path, hideFabInCurrentMainFragment); + addTab(tab2, "", false); + } + + if (MainActivity.currentTab == 1) { + addTab(tab1, "", false); + addTab(tab2, path, hideFabInCurrentMainFragment); + } + } else { + addTab(tab1, "", false); + addTab(tab2, "", false); + } + } + } + + private void addTab(@NonNull Tab tab, String path, boolean hideFabInTab) { + MainFragment main = new MainFragment(); + Bundle b = new Bundle(); + + if (path != null && path.length() != 0) { + b.putString("lastpath", path); + b.putInt("openmode", OpenMode.UNKNOWN.ordinal()); + } else { + b.putString("lastpath", tab.getOriginalPath(savePaths, requireMainActivity().getPrefs())); + } + + b.putString("home", tab.home); + b.putInt("no", tab.tabNumber); + // specifies if the constructed MainFragment hides the FAB when it is shown + b.putBoolean(MainFragment.BUNDLE_HIDE_FAB, hideFabInTab); + main.setArguments(b); + fragments.add(main); + sectionsPagerAdapter.notifyDataSetChanged(); + viewPager.setOffscreenPageLimit(4); + } + + public Fragment getCurrentTabFragment() { + if (fragments.size() == 2) return fragments.get(viewPager.getCurrentItem()); + else return null; + } + + public Fragment getFragmentAtIndex(int pos) { + if (fragments.size() == 2 && pos < 2) return fragments.get(pos); + else return null; + } + + // updating indicator color as per the current viewpager tab + void updateIndicator(int index) { + if (index != 0 && index != 1) return; + + int accentColor = requireMainActivity().getAccent(); + + circleDrawable1.setImageDrawable(new ColorCircleDrawable(accentColor)); + circleDrawable2.setImageDrawable(new ColorCircleDrawable(Color.GRAY)); + } + + public ConstraintLayout getDragPlaceholder() { + return this.dragPlaceholder; + } + + public void updateBottomBar(MainFragment mainFragment) { + if (mainFragment == null || mainFragment.getMainFragmentViewModel() == null) { + LOG.warn("Failed to update bottom bar: main fragment not available"); + return; + } + requireMainActivity() + .getAppbar() + .getBottomBar() + .updatePath( + mainFragment.getCurrentPath(), + mainFragment.getMainFragmentViewModel().getOpenMode(), + mainFragment.getMainFragmentViewModel().getFolderCount(), + mainFragment.getMainFragmentViewModel().getFileCount(), + mainFragment); + } + + public void initLeftRightAndTopDragListeners(boolean destroy, boolean shouldInvokeLeftAndRight) { + if (shouldInvokeLeftAndRight) { + initLeftAndRightDragListeners(destroy); + } + for (Fragment fragment : fragments) { + if (fragment instanceof MainFragment) { + MainFragment m = (MainFragment) fragment; + m.initTopAndEmptyAreaDragListeners(destroy); + } + } + } + + private void initLeftAndRightDragListeners(boolean destroy) { + final MainFragment mainFragment = requireMainActivity().getCurrentMainFragment(); + View leftPlaceholder = rootView.findViewById(R.id.placeholder_drag_left); + View rightPlaceholder = rootView.findViewById(R.id.placeholder_drag_right); + AppCompatImageView dragToTrash = rootView.findViewById(R.id.placeholder_trash_bottom); + DataUtils dataUtils = DataUtils.getInstance(); + if (destroy) { + leftPlaceholder.setOnDragListener(null); + rightPlaceholder.setOnDragListener(null); + dragToTrash.setOnDragListener(null); + leftPlaceholder.setVisibility(View.GONE); + rightPlaceholder.setVisibility(View.GONE); + ExtensionsKt.hideFade(dragToTrash, 150); + } else { + leftPlaceholder.setVisibility(View.VISIBLE); + rightPlaceholder.setVisibility(View.VISIBLE); + ExtensionsKt.showFade(dragToTrash, 150); + leftPlaceholder.setOnDragListener( + new TabFragmentSideDragListener( + () -> { + if (viewPager.getCurrentItem() == 1) { + if (mainFragment != null) { + dataUtils.setCheckedItemsList(mainFragment.adapter.getCheckedItems()); + requireMainActivity().getActionModeHelper().disableActionMode(); + } + viewPager.setCurrentItem(0, true); + } + return null; + })); + rightPlaceholder.setOnDragListener( + new TabFragmentSideDragListener( + () -> { + if (viewPager.getCurrentItem() == 0) { + if (mainFragment != null) { + dataUtils.setCheckedItemsList(mainFragment.adapter.getCheckedItems()); + requireMainActivity().getActionModeHelper().disableActionMode(); + } + viewPager.setCurrentItem(1, true); + } + return null; + })); + dragToTrash.setOnDragListener( + new DragToTrashListener( + () -> { + if (mainFragment != null) { + GeneralDialogCreation.deleteFilesDialog( + requireContext(), + requireMainActivity(), + mainFragment.adapter.getCheckedItems(), + requireMainActivity().getAppTheme()); + } else { + AppConfig.toast(requireContext(), getString(R.string.operation_unsuccesful)); + } + return null; + }, + () -> { + dragToTrash.performHapticFeedback( + HapticFeedbackConstants.LONG_PRESS, + HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING); + return null; + })); + } + } + + @NonNull + private MainActivity requireMainActivity() { + return (MainActivity) requireActivity(); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/data/AppListViewModel.kt b/app/src/main/java/com/amaze/filemanager/ui/fragments/data/AppListViewModel.kt new file mode 100644 index 0000000..103ce85 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/data/AppListViewModel.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.fragments.data + +import android.os.Parcelable +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.amaze.filemanager.adapters.data.AppDataParcelable + +class AppListViewModel : ViewModel() { + var recyclerViewState: Parcelable? = null + val isAscending = false + val sortby = 0 + val showSystemApps = true + + val appDataParcelable: MutableLiveData> by lazy { + MutableLiveData() + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/data/CompressedExplorerFragmentViewModel.kt b/app/src/main/java/com/amaze/filemanager/ui/fragments/data/CompressedExplorerFragmentViewModel.kt new file mode 100644 index 0000000..8f208a0 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/data/CompressedExplorerFragmentViewModel.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.fragments.data + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.amaze.filemanager.adapters.data.CompressedObjectParcelable + +class CompressedExplorerFragmentViewModel : ViewModel() { + val elements: MutableLiveData> by lazy { + MutableLiveData() + } + + var folder: String? = null +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/data/MainFragmentViewModel.kt b/app/src/main/java/com/amaze/filemanager/ui/fragments/data/MainFragmentViewModel.kt new file mode 100644 index 0000000..69b61e4 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/data/MainFragmentViewModel.kt @@ -0,0 +1,260 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.fragments.data + +import android.content.SharedPreferences +import android.os.Bundle +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.amaze.filemanager.adapters.RecyclerAdapter +import com.amaze.filemanager.adapters.data.IconDataParcelable +import com.amaze.filemanager.adapters.data.LayoutElementParcelable +import com.amaze.filemanager.database.CloudHandler +import com.amaze.filemanager.fileoperations.filesystem.OpenMode +import com.amaze.filemanager.filesystem.HybridFileParcelable +import com.amaze.filemanager.filesystem.files.sort.DirSortBy +import com.amaze.filemanager.filesystem.files.sort.SortBy +import com.amaze.filemanager.filesystem.files.sort.SortOrder +import com.amaze.filemanager.filesystem.files.sort.SortType +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_GRID_COLUMNS +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_GRID_COLUMNS_DEFAULT +import com.amaze.filemanager.utils.DataUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.Objects + +class MainFragmentViewModel : ViewModel() { + var currentPath: String? = null + + /** This is not an exact copy of the elements in the adapter */ + var listElements: List = ArrayList() + + var adapterListItems: ArrayList? = null + var iconList: ArrayList? = null + + var fileCount = 0 + var folderCount: Int = 0 + var columns: Int? = null + var smbPath: String? = null + var searchHelper = ArrayList() + var no = 0 + + var sortType: SortType = SortType(SortBy.NAME, SortOrder.ASC) + + var dsort: DirSortBy = DirSortBy.DIR_ON_TOP + + var home: String? = null + + lateinit var openMode: OpenMode + + // defines the current visible tab, default either 0 or 1 + // private int mCurrentTab; + /** For caching the back button */ + var back: LayoutElementParcelable? = null + + var dragAndDropPreference = 0 + + var isEncryptOpen = false // do we have to open a file when service is begin destroyed + + // the cached base file which we're to open, delete it later + var encryptBaseFile: HybridFileParcelable? = null + + /** a list of encrypted base files which are supposed to be deleted */ + var encryptBaseFiles = ArrayList() + + // defines the current visible tab, default either 0 or 1 + // private int mCurrentTab; + + /** boolean to identify if the view is a list or grid */ + var isList = true + var addHeader = false + var accentColor = 0 + var primaryColor = 0 + var primaryTwoColor = 0 + var stopAnims = true + + /** + * Initialize arguemnts from bundle in MainFragment + */ + fun initBundleArguments(bundle: Bundle?) { + bundle?.run { + if (no == 0) { + no = getInt("no", 1) + } + + if (home.isNullOrBlank()) { + getString("home")?.run { + home = this + } + } + + if (currentPath.isNullOrBlank()) { + getString("lastpath")?.run { + currentPath = this + } + } + if (!::openMode.isInitialized) { + openMode = + if (getInt("openmode", -1) !== -1) { + OpenMode.getOpenMode(getInt("openmode", -1)) + } else { + OpenMode.FILE + } + } + } + } + + /** + * Initialize drag drop preference + */ + fun initDragAndDropPreference(sharedPreferences: SharedPreferences) { + dragAndDropPreference = + sharedPreferences.getInt( + PreferencesConstants.PREFERENCE_DRAG_AND_DROP_PREFERENCE, + PreferencesConstants.PREFERENCE_DRAG_DEFAULT, + ) + } + + /** + * Initialize isList from dataUtils + */ + fun initIsList() { + isList = DataUtils.getInstance().getListOrGridForPath( + currentPath, + DataUtils.LIST, + ) == DataUtils.LIST + } + + /** + * Initialize column number from preference + */ + fun initColumns(sharedPreferences: SharedPreferences) { + val columnPreference = + sharedPreferences.getString( + PREFERENCE_GRID_COLUMNS, + PREFERENCE_GRID_COLUMNS_DEFAULT, + ) + Objects.requireNonNull(columnPreference) + columns = columnPreference?.toInt() + } + + /** + * Assigns sort modes A value from 0 to 3 defines sort mode as name/last modified/size/type in + * ascending order Values from 4 to 7 defines sort mode as name/last modified/size/type in + * descending order + * + * + * Final value of [.sortby] varies from 0 to 3 + */ + fun initSortModes( + sortType: SortType, + sharedPref: SharedPreferences, + ) { + this.sortType = sortType + sharedPref.getString( + PreferencesConstants.PREFERENCE_DIRECTORY_SORT_MODE, + "0", + )?.run { + dsort = DirSortBy.getDirSortBy(Integer.parseInt(this)) + } + } + + /** + * Initialize encrypted file + */ + fun initEncryptBaseFile(path: String) { + encryptBaseFile = HybridFileParcelable(path) + encryptBaseFile?.run { + encryptBaseFiles.add(this) + } + } + + /** + * Check if current path is cloud root path + */ + fun getIsOnCloudRoot(): Boolean { + return CloudHandler.CLOUD_PREFIX_GOOGLE_DRIVE + "/" == currentPath || + CloudHandler.CLOUD_PREFIX_ONE_DRIVE + "/" == currentPath || + CloudHandler.CLOUD_PREFIX_BOX + "/" == currentPath || + CloudHandler.CLOUD_PREFIX_DROPBOX + "/" == currentPath + } + + /** + * Check if current openMode is cloud + */ + fun getIsCloudOpenMode(): Boolean { + return openMode == OpenMode.GDRIVE || + openMode == OpenMode.DROPBOX || + openMode == OpenMode.BOX || + openMode == OpenMode.ONEDRIVE + } + + /** + * Get checked items in adapter + */ + fun getCheckedItems(): ArrayList { + val selected = ArrayList() + adapterListItems?.forEach { item -> + val layoutElementParcelable = item.layoutElementParcelable + if (layoutElementParcelable != null && + item.checked == RecyclerAdapter.ListItem.CHECKED + ) { + selected.add(layoutElementParcelable) + } + } + return selected + } + + /** + * Get the position of an item + */ + fun getScrollPosition(title: String): MutableLiveData { + val mutableLiveData: MutableLiveData = MutableLiveData(-1) + + viewModelScope.launch(Dispatchers.IO) { + adapterListItems?.forEachIndexed { index, item -> + if (item.layoutElementParcelable != null && + item.layoutElementParcelable?.title.equals(title) + ) { + item.setChecked(true) + mutableLiveData.postValue(index) + } + } + } + return mutableLiveData + } + + /** + * increments `fileCount` + */ + fun incrementFileCount() { + fileCount++ + } + + /** + * increments `folderCount` + */ + fun incrementFolderCount() { + folderCount++ + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/AppearancePrefsFragment.kt b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/AppearancePrefsFragment.kt new file mode 100644 index 0000000..6626ef8 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/AppearancePrefsFragment.kt @@ -0,0 +1,181 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.fragments.preferencefragments + +import android.os.Bundle +import androidx.preference.Preference +import com.afollestad.materialdialogs.MaterialDialog +import com.amaze.filemanager.R +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_GRID_COLUMNS +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_GRID_COLUMNS_DEFAULT +import com.amaze.filemanager.ui.theme.AppThemePreference +import java.util.Objects + +class AppearancePrefsFragment : BasePrefsFragment() { + override val title = R.string.appearance + + /** + * The actual value saved for the preference, to see the localized strings see [R.array.columns] + */ + private val savedPreferenceValues = + listOf( + PREFERENCE_GRID_COLUMNS_DEFAULT, + "2", + "3", + "4", + "5", + "6", + ) + private var currentTheme = 0 + private var gridColumnPref: Preference? = null + + private val onClickTheme = + Preference.OnPreferenceClickListener { + val builder = MaterialDialog.Builder(activity) + builder.items(R.array.theme) + .itemsCallbackSingleChoice(currentTheme) { dialog, _, which, _ -> + val editor = activity.prefs.edit() + editor.putString(PreferencesConstants.FRAGMENT_THEME, which.toString()) + editor.apply() + + activity.utilsProvider.themeManager.setAppThemePreference( + AppThemePreference.getTheme(which), + ) + activity.recreate() + + dialog.dismiss() + true + } + .title(R.string.theme) + .build() + .show() + + true + } + + private val onClickGridColumn = + Preference.OnPreferenceClickListener { + val dialog = + MaterialDialog.Builder(activity).also { builder -> + builder.theme(activity.utilsProvider.appTheme.getMaterialDialogTheme()) + builder.title(R.string.gridcolumnno) + val columnsPreference = + activity + .prefs + .getString(PREFERENCE_GRID_COLUMNS, PREFERENCE_GRID_COLUMNS_DEFAULT) + + Objects.requireNonNull(columnsPreference) + val current = + when (columnsPreference) { + null -> { + PREFERENCE_GRID_COLUMNS_DEFAULT.toInt() + } + else -> { + columnsPreference.toInt() - 1 + } + } + + builder + .items(R.array.columns) + .itemsCallbackSingleChoice(current) { dialog, _, which, _ -> + val editor = activity.prefs.edit() + editor.putString( + PREFERENCE_GRID_COLUMNS, + savedPreferenceValues[which], + ) + editor.apply() + dialog.dismiss() + updateGridColumnSummary() + true + } + }.build() + dialog.show() + + true + } + + private val onClickFollowBatterySaver = + Preference.OnPreferenceClickListener { + // recreate the activity since the theme could have changed with this preference change + activity.recreate() + true + } + + override fun onCreatePreferences( + savedInstanceState: Bundle?, + rootKey: String?, + ) { + setPreferencesFromResource(R.xml.appearance_prefs, rootKey) + + val themePref = findPreference(PreferencesConstants.FRAGMENT_THEME) + val themes = resources.getStringArray(R.array.theme) + currentTheme = + activity + .prefs + .getString(PreferencesConstants.FRAGMENT_THEME, "4")!! + .toInt() + + themePref?.summary = themes[currentTheme] + themePref?.onPreferenceClickListener = onClickTheme + + val batterySaverPref = + findPreference( + PreferencesConstants.FRAGMENT_FOLLOW_BATTERY_SAVER, + ) + + val currentThemeEnum = AppThemePreference.getTheme(currentTheme) + batterySaverPref?.isVisible = currentThemeEnum.canBeLight + batterySaverPref?.onPreferenceClickListener = onClickFollowBatterySaver + + findPreference(PreferencesConstants.PREFERENCE_COLORED_NAVIGATION) + ?.let { + it.isEnabled = true + it.onPreferenceClickListener = + Preference.OnPreferenceClickListener { + activity.invalidateNavBar() + + true + } + } + + findPreference( + PreferencesConstants.PREFERENCE_SELECT_COLOR_CONFIG, + )?.onPreferenceClickListener = + Preference.OnPreferenceClickListener { + activity.pushFragment(ColorPrefsFragment()) + + true + } + + gridColumnPref = findPreference(PREFERENCE_GRID_COLUMNS) + updateGridColumnSummary() + gridColumnPref?.onPreferenceClickListener = onClickGridColumn + } + + private fun updateGridColumnSummary() { + val preferenceColumns = + activity.prefs.getString( + PREFERENCE_GRID_COLUMNS, + PREFERENCE_GRID_COLUMNS_DEFAULT, + ) + gridColumnPref?.summary = preferenceColumns + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/BackupPrefsFragment.kt b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/BackupPrefsFragment.kt new file mode 100644 index 0000000..10fedb4 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/BackupPrefsFragment.kt @@ -0,0 +1,240 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.fragments.preferencefragments + +import android.app.Activity +import android.content.Intent +import android.content.SharedPreferences +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.preference.Preference +import androidx.preference.Preference.OnPreferenceClickListener +import androidx.preference.PreferenceManager +import com.amaze.filemanager.R +import com.amaze.filemanager.TagsHelper +import com.amaze.filemanager.ui.activities.MainActivity +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.BufferedReader +import java.io.File +import java.io.FileWriter +import java.io.IOException +import java.io.InputStreamReader + +class BackupPrefsFragment : BasePrefsFragment() { + private val TAG: String = TagsHelper.getTag(BasePrefsFragment::class.java) + private val log: Logger = LoggerFactory.getLogger(BackupPrefsFragment::class.java) + + companion object { + val IMPORT_BACKUP_FILE: Int = 2 + } + + override val title = R.string.backup + + /** Export app settings to a JSON file */ + fun exportPrefs() { + val map: Map = + PreferenceManager + .getDefaultSharedPreferences(requireActivity()).all + + val gsonString: String = Gson().toJson(map) + + try { + val file = File(context?.cacheDir?.absolutePath + File.separator + "amaze_backup.json") + + val fileWriter = FileWriter(file) + + fileWriter.append(gsonString) + + Log.i(TAG, "wrote export to: ${file.absolutePath}") + + fileWriter.flush() + fileWriter.close() + + Toast.makeText( + context, + getString(R.string.select_save_location), + Toast.LENGTH_SHORT, + ).show() + + val intent = Intent(context, MainActivity::class.java) + + intent.action = Intent.ACTION_SEND + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + + intent.putExtra(Intent.EXTRA_STREAM, Uri.fromFile(file)) + + startActivity(intent) + } catch (e: IOException) { + Toast.makeText(context, getString(R.string.exporting_failed), Toast.LENGTH_SHORT).show() + log.error(getString(R.string.exporting_failed), e) + } + } + + /** Import app settings from a JSON file */ + private fun importPrefs() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + startActivityForResult( + Intent(Intent.ACTION_OPEN_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType("*/*") + .putExtra( + Intent.EXTRA_MIME_TYPES, + arrayOf("application/json"), + ), + IMPORT_BACKUP_FILE, + ) + } else { + startActivityForResult( + Intent.createChooser( + Intent(Intent.ACTION_GET_CONTENT) + .setType("application/json"), + "Choose backup file", + ), + IMPORT_BACKUP_FILE, + ) + } + } + + override fun onActivityResult( + requestCode: Int, + resultCode: Int, + data: Intent?, + ) { + super.onActivityResult(requestCode, resultCode, data) + + val nonNull = data != null && data.data != null + + if (requestCode == IMPORT_BACKUP_FILE && + resultCode == Activity.RESULT_OK && + nonNull + ) { + val uri = data!!.data + + Log.i(TAG, "read import file: $uri") + + try { + val inputStream = + uri?.let { + context?.contentResolver?.openInputStream(it) + } + + val bufferedReader = BufferedReader(InputStreamReader(inputStream)) + val stringBuilder = StringBuilder() + + var line: String? + while (bufferedReader.readLine().also { line = it } != null) + stringBuilder.append(line).append('\n') + + val type = object : TypeToken>() {}.type + + val map: Map = + Gson().fromJson( + stringBuilder.toString(), + type, + ) + + val editor: SharedPreferences.Editor? = + PreferenceManager.getDefaultSharedPreferences(requireActivity()).edit() + + for ((key, value) in map) + storePreference(editor, key, value) + + editor?.apply() + + Toast.makeText( + context, + getString(R.string.importing_completed), + Toast.LENGTH_SHORT, + ).show() + + startActivity( + Intent( + context, + MainActivity::class.java, + ), + ) // restart Amaze for changes to take effect + } catch (e: IOException) { + Toast.makeText( + context, + getString(R.string.importing_failed), + Toast.LENGTH_SHORT, + ).show() + log.error(getString(R.string.importing_failed), e) + } + } else { + Toast.makeText(context, getString(R.string.unknown_error), Toast.LENGTH_SHORT).show() + } + } + + @Suppress("UNCHECKED_CAST") + private fun storePreference( + editor: SharedPreferences.Editor?, + key: String?, + value: Any, + ) { + try { + when (value::class.simpleName) { + "Boolean" -> editor?.putBoolean(key, value as Boolean) + "Float" -> editor?.putFloat(key, value as Float) + "Int" -> editor?.putInt(key, value as Int) + "Long" -> editor?.putLong(key, value as Long) + "String" -> editor?.putString(key, value.toString()) + "Set<*>" -> editor?.putStringSet(key, value as Set) + } + } catch (e: java.lang.ClassCastException) { + Toast.makeText( + context, + "${getString(R.string.import_failed_for)} $key", + Toast.LENGTH_SHORT, + ).show() + log.error("${getString(R.string.import_failed_for)} $key", e) + } + } + + override fun onCreatePreferences( + savedInstanceState: Bundle?, + rootKey: String?, + ) { + setPreferencesFromResource(R.xml.backup_prefs, rootKey) + + findPreference( + PreferencesConstants.PREFERENCE_EXPORT_SETTINGS, + )?.onPreferenceClickListener = + OnPreferenceClickListener { + exportPrefs() + true + } + + findPreference( + PreferencesConstants.PREFERENCE_IMPORT_SETTINGS, + )?.onPreferenceClickListener = + OnPreferenceClickListener { + importPrefs() + true + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/BasePrefsFragment.kt b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/BasePrefsFragment.kt new file mode 100644 index 0000000..24afba6 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/BasePrefsFragment.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.fragments.preferencefragments + +import androidx.preference.PreferenceFragmentCompat +import com.afollestad.materialdialogs.folderselector.FolderChooserDialog +import com.amaze.filemanager.ui.activities.PreferencesActivity +import java.io.File + +abstract class BasePrefsFragment : PreferenceFragmentCompat(), FolderChooserDialog.FolderCallback { + protected val activity: PreferencesActivity + get() = requireActivity() as PreferencesActivity + + abstract val title: Int + + override fun onResume() { + super.onResume() + + activity.supportActionBar?.title = getString(title) + } + + override fun onFolderSelection( + dialog: FolderChooserDialog, + folder: File, + ) { + dialog.dismiss() + } + + override fun onFolderChooserDismissed(dialog: FolderChooserDialog) { + dialog.dismiss() + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/BehaviorPrefsFragment.kt b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/BehaviorPrefsFragment.kt new file mode 100644 index 0000000..189b77b --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/BehaviorPrefsFragment.kt @@ -0,0 +1,281 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.fragments.preferencefragments + +import android.os.Bundle +import android.os.Environment +import android.text.InputType +import androidx.preference.Preference +import androidx.preference.PreferenceManager +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.folderselector.FolderChooserDialog +import com.amaze.filemanager.R +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.ui.dialogs.OpenFileDialogFragment.Companion.clearPreferences +import com.amaze.trashbin.TrashBinConfig +import java.io.File + +class BehaviorPrefsFragment : BasePrefsFragment(), FolderChooserDialog.FolderCallback { + override val title = R.string.behavior + + override fun onCreatePreferences( + savedInstanceState: Bundle?, + rootKey: String?, + ) { + setPreferencesFromResource(R.xml.behavior_prefs, rootKey) + + findPreference("clear_open_file")?.onPreferenceClickListener = + Preference.OnPreferenceClickListener { + clearPreferences(activity.prefs) + AppConfig.toast(getActivity(), activity.getString(R.string.done)) + true + } + + findPreference(PreferencesConstants.PREFERENCE_ZIP_EXTRACT_PATH) + ?.onPreferenceClickListener = + Preference.OnPreferenceClickListener { + FolderChooserDialog.Builder(activity) + .tag(PreferencesConstants.PREFERENCE_ZIP_EXTRACT_PATH) + .goUpLabel(getString(R.string.folder_go_up_one_level)) + .chooseButton(R.string.choose_folder) + .cancelButton(R.string.cancel) + .initialPath( + activity.prefs.getString( + PreferencesConstants.PREFERENCE_ZIP_EXTRACT_PATH, + Environment.getExternalStorageDirectory().path, + ), + ) + .build() + .show(activity) + true + } + findPreference(PreferencesConstants.PREFERENCE_TRASH_BIN_RETENTION_NUM_OF_FILES) + ?.onPreferenceClickListener = + Preference.OnPreferenceClickListener { + trashBinRetentionNumOfFiles() + true + } + findPreference(PreferencesConstants.PREFERENCE_TRASH_BIN_RETENTION_DAYS) + ?.onPreferenceClickListener = + Preference.OnPreferenceClickListener { + trashBinRetentionDays() + true + } + findPreference(PreferencesConstants.PREFERENCE_TRASH_BIN_RETENTION_BYTES) + ?.onPreferenceClickListener = + Preference.OnPreferenceClickListener { + trashBinRetentionBytes() + true + } + findPreference(PreferencesConstants.PREFERENCE_TRASH_BIN_CLEANUP_INTERVAL) + ?.onPreferenceClickListener = + Preference.OnPreferenceClickListener { + trashBinCleanupInterval() + true + } + } + + override fun onFolderSelection( + dialog: FolderChooserDialog, + folder: File, + ) { + // Just to be safe + if (folder.exists() && folder.isDirectory) { + // Write settings to preferences + val e = activity.prefs.edit() + e.putString(dialog.tag, folder.absolutePath) + e.apply() + } + dialog.dismiss() + } + + private fun trashBinRetentionNumOfFiles() { + val dialogBuilder = MaterialDialog.Builder(activity) + dialogBuilder.title( + resources.getString(R.string.trash_bin_retention_num_of_files_title), + ) + val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) + val numOfFiles = + sharedPrefs.getInt( + PreferencesConstants.KEY_TRASH_BIN_RETENTION_NUM_OF_FILES, + TrashBinConfig.RETENTION_NUM_OF_FILES, + ) + dialogBuilder.inputType(InputType.TYPE_CLASS_NUMBER) + dialogBuilder.input( + "", + "$numOfFiles", + true, + ) { _, _ -> } + dialogBuilder.theme( + activity.utilsProvider.appTheme.materialDialogTheme, + ) + dialogBuilder.positiveText(resources.getString(R.string.ok)) + dialogBuilder.negativeText(resources.getString(R.string.cancel)) + dialogBuilder.neutralText(resources.getString(R.string.default_string)) + dialogBuilder.positiveColor(activity.accent) + dialogBuilder.negativeColor(activity.accent) + dialogBuilder.neutralColor(activity.accent) + dialogBuilder.onPositive { dialog, _ -> + val inputText = dialog.inputEditText?.text.toString() + sharedPrefs.edit().putInt( + PreferencesConstants.KEY_TRASH_BIN_RETENTION_NUM_OF_FILES, + inputText.toInt(), + ).apply() + } + dialogBuilder.onNeutral { _, _ -> + sharedPrefs.edit().putInt( + PreferencesConstants.KEY_TRASH_BIN_RETENTION_NUM_OF_FILES, + TrashBinConfig.RETENTION_NUM_OF_FILES, + ).apply() + } + dialogBuilder.onNegative { dialog, _ -> dialog.cancel() } + dialogBuilder.build().show() + } + + private fun trashBinRetentionDays() { + val dialogBuilder = MaterialDialog.Builder(activity) + dialogBuilder.title( + resources.getString(R.string.trash_bin_retention_days_title), + ) + val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) + val days = + sharedPrefs.getInt( + PreferencesConstants.KEY_TRASH_BIN_RETENTION_DAYS, + TrashBinConfig.RETENTION_DAYS_INFINITE, + ) + dialogBuilder.inputType(InputType.TYPE_CLASS_NUMBER) + dialogBuilder.input( + "", + "$days", + true, + ) { _, _ -> } + dialogBuilder.theme( + activity.utilsProvider.appTheme.materialDialogTheme, + ) + dialogBuilder.positiveText(resources.getString(R.string.ok)) + dialogBuilder.negativeText(resources.getString(R.string.cancel)) + dialogBuilder.neutralText(resources.getString(R.string.default_string)) + dialogBuilder.positiveColor(activity.accent) + dialogBuilder.negativeColor(activity.accent) + dialogBuilder.neutralColor(activity.accent) + dialogBuilder.onPositive { dialog, _ -> + val inputText = dialog.inputEditText?.text.toString() + sharedPrefs.edit().putInt( + PreferencesConstants.KEY_TRASH_BIN_RETENTION_DAYS, + inputText.toInt(), + ).apply() + } + dialogBuilder.onNeutral { _, _ -> + sharedPrefs.edit().putInt( + PreferencesConstants.KEY_TRASH_BIN_RETENTION_DAYS, + TrashBinConfig.RETENTION_DAYS_INFINITE, + ).apply() + } + dialogBuilder.onNegative { dialog, _ -> dialog.cancel() } + dialogBuilder.build().show() + } + + private fun trashBinRetentionBytes() { + val dialogBuilder = MaterialDialog.Builder(activity) + dialogBuilder.title( + resources.getString(R.string.trash_bin_retention_bytes_title), + ) + val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) + val bytes = + sharedPrefs.getLong( + PreferencesConstants.KEY_TRASH_BIN_RETENTION_BYTES, + TrashBinConfig.RETENTION_BYTES_INFINITE, + ) + dialogBuilder.inputType(InputType.TYPE_CLASS_NUMBER) + dialogBuilder.input( + "", + "$bytes", + true, + ) { _, _ -> } + dialogBuilder.theme( + activity.utilsProvider.appTheme.materialDialogTheme, + ) + dialogBuilder.positiveText(resources.getString(R.string.ok)) + dialogBuilder.negativeText(resources.getString(R.string.cancel)) + dialogBuilder.neutralText(resources.getString(R.string.default_string)) + dialogBuilder.positiveColor(activity.accent) + dialogBuilder.negativeColor(activity.accent) + dialogBuilder.neutralColor(activity.accent) + dialogBuilder.onPositive { dialog, _ -> + val inputText = dialog.inputEditText?.text.toString() + sharedPrefs.edit().putLong( + PreferencesConstants.KEY_TRASH_BIN_RETENTION_BYTES, + inputText.toLong(), + ).apply() + } + dialogBuilder.onNeutral { _, _ -> + sharedPrefs.edit().putLong( + PreferencesConstants.KEY_TRASH_BIN_RETENTION_BYTES, + TrashBinConfig.RETENTION_BYTES_INFINITE, + ).apply() + } + dialogBuilder.onNegative { dialog, _ -> dialog.cancel() } + dialogBuilder.build().show() + } + + private fun trashBinCleanupInterval() { + val dialogBuilder = MaterialDialog.Builder(activity) + dialogBuilder.title( + resources.getString(R.string.trash_bin_cleanup_interval_title), + ) + val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) + val intervalHours = + sharedPrefs.getInt( + PreferencesConstants.KEY_TRASH_BIN_CLEANUP_INTERVAL_HOURS, + TrashBinConfig.INTERVAL_CLEANUP_HOURS, + ) + dialogBuilder.inputType(InputType.TYPE_CLASS_NUMBER) + dialogBuilder.input( + "", + "$intervalHours", + true, + ) { _, _ -> } + dialogBuilder.theme( + activity.utilsProvider.appTheme.materialDialogTheme, + ) + dialogBuilder.positiveText(resources.getString(R.string.ok)) + dialogBuilder.negativeText(resources.getString(R.string.cancel)) + dialogBuilder.neutralText(resources.getString(R.string.default_string)) + dialogBuilder.positiveColor(activity.accent) + dialogBuilder.negativeColor(activity.accent) + dialogBuilder.neutralColor(activity.accent) + dialogBuilder.onPositive { dialog, _ -> + val inputText = dialog.inputEditText?.text.toString() + sharedPrefs.edit().putInt( + PreferencesConstants.KEY_TRASH_BIN_CLEANUP_INTERVAL_HOURS, + inputText.toInt(), + ).apply() + } + dialogBuilder.onNeutral { _, _ -> + sharedPrefs.edit().putInt( + PreferencesConstants.KEY_TRASH_BIN_CLEANUP_INTERVAL_HOURS, + TrashBinConfig.INTERVAL_CLEANUP_HOURS, + ).apply() + } + dialogBuilder.onNegative { dialog, _ -> dialog.cancel() } + dialogBuilder.build().show() + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/BookmarksPrefsFragment.kt b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/BookmarksPrefsFragment.kt new file mode 100644 index 0000000..3ce9e62 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/BookmarksPrefsFragment.kt @@ -0,0 +1,272 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.fragments.preferencefragments + +import android.os.Bundle +import android.text.Editable +import android.view.LayoutInflater +import android.widget.Toast +import androidx.appcompat.widget.AppCompatEditText +import androidx.preference.Preference +import androidx.preference.PreferenceCategory +import com.afollestad.materialdialogs.DialogAction +import com.afollestad.materialdialogs.MaterialDialog +import com.amaze.filemanager.R +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.database.UtilsHandler +import com.amaze.filemanager.database.models.OperationData +import com.amaze.filemanager.databinding.DialogTwoedittextsBinding +import com.amaze.filemanager.filesystem.files.FileUtils +import com.amaze.filemanager.ui.views.preference.PathSwitchPreference +import com.amaze.filemanager.utils.DataUtils +import com.amaze.filemanager.utils.SimpleTextWatcher + +class BookmarksPrefsFragment : BasePrefsFragment() { + override val title = R.string.show_bookmarks_pref + + companion object { + private val dataUtils = DataUtils.getInstance()!! + } + + private val position: MutableMap = HashMap() + private var bookmarksList: PreferenceCategory? = null + + private val itemOnEditListener = { it: PathSwitchPreference -> + showEditDialog(it) + } + + private val itemOnDeleteListener = { it: PathSwitchPreference -> + showDeleteDialog(it) + } + + override fun onCreatePreferences( + savedInstanceState: Bundle?, + rootKey: String?, + ) { + setPreferencesFromResource(R.xml.bookmarks_prefs, rootKey) + + findPreference("add_bookmarks")?.onPreferenceClickListener = + Preference.OnPreferenceClickListener { + showCreateDialog() + + true + } + + bookmarksList = findPreference("bookmarks_list") + reload() + } + + private fun reload() { + for (p in position) { + bookmarksList?.removePreference(p.key) + } + + position.clear() + for (i in dataUtils.books.indices) { + val p = PathSwitchPreference(activity, itemOnEditListener, itemOnDeleteListener) + p.title = dataUtils.books[i][0] + p.summary = dataUtils.books[i][1] + position[p] = i + bookmarksList?.addPreference(p) + } + } + + private fun showCreateDialog() { + val fabSkin = activity.accent + val utilsHandler = AppConfig.getInstance().utilsHandler + val dialogBinding = DialogTwoedittextsBinding.inflate(LayoutInflater.from(requireContext())) + + val v = dialogBinding.root + dialogBinding.textInput1.hint = getString(R.string.name) + dialogBinding.textInput2.hint = getString(R.string.directory) + val txtShortcutName = dialogBinding.text1 + val txtShortcutPath = dialogBinding.text2 + + val dialog = + MaterialDialog.Builder(requireActivity()) + .title(R.string.create_bookmark) + .theme(activity.appTheme.getMaterialDialogTheme()) + .positiveColor(fabSkin) + .positiveText(R.string.create) + .negativeColor(fabSkin) + .negativeText(android.R.string.cancel) + .customView(v, false) + .build() + dialog.getActionButton(DialogAction.POSITIVE).isEnabled = false + disableButtonIfTitleEmpty(txtShortcutName, dialog) + disableButtonIfNotPath(txtShortcutPath, dialog) + dialog.getActionButton(DialogAction.POSITIVE) + .setOnClickListener { + val result = isValidBookmark(txtShortcutName.text.toString(), txtShortcutPath.text.toString()) + if (!result.first) { + Toast.makeText( + requireContext(), + requireContext().getString(result.second), + Toast.LENGTH_SHORT, + ).show() + } else { + val p = PathSwitchPreference(activity, itemOnEditListener, itemOnDeleteListener) + p.title = txtShortcutName.text + p.summary = txtShortcutPath.text + position[p] = dataUtils.books.size + bookmarksList?.addPreference(p) + val values = + arrayOf( + txtShortcutName.text.toString(), + txtShortcutPath.text.toString(), + ) + dataUtils.addBook(values) + utilsHandler.saveToDatabase( + OperationData( + UtilsHandler.Operation.BOOKMARKS, + txtShortcutName.text.toString(), + txtShortcutPath.text.toString(), + ), + ) + dialog.dismiss() + } + } + dialog.show() + } + + private fun isValidBookmark( + name: String, + path: String, + ): Pair { + return when { + name.isEmpty() -> Pair(false, R.string.invalid_name) + dataUtils.containsBooks(arrayOf(name, path)) != -1 -> Pair(false, R.string.bookmark_exists) + !FileUtils.isPathAccessible(path, activity.prefs) -> Pair(false, R.string.ftp_path_change_error_invalid) + else -> Pair(true, 0) + } + } + + private fun showEditDialog(p: PathSwitchPreference) { + val fabSkin = activity.accent + val utilsHandler = AppConfig.getInstance().utilsHandler + val dialogBinding = DialogTwoedittextsBinding.inflate(LayoutInflater.from(requireContext())) + + val v = dialogBinding.root + dialogBinding.textInput1.hint = getString(R.string.name) + dialogBinding.textInput2.hint = getString(R.string.directory) + val editText1 = dialogBinding.text1 + val editText2 = dialogBinding.text2 + editText1.setText(p.title) + editText2.setText(p.summary) + + val dialog = + MaterialDialog.Builder(activity) + .title(R.string.edit_bookmark) + .theme(activity.appTheme.getMaterialDialogTheme()) + .positiveColor(fabSkin) + .positiveText(getString(R.string.edit).uppercase()) // TODO: 29/4/2017 don't use toUpperCase() + .negativeColor(fabSkin) + .negativeText(android.R.string.cancel) + .customView(v, false) + .build() + dialog.getActionButton(DialogAction.POSITIVE).isEnabled = + FileUtils.isPathAccessible(editText2.text.toString(), activity.prefs) + disableButtonIfTitleEmpty(editText1, dialog) + disableButtonIfNotPath(editText2, dialog) + dialog.getActionButton(DialogAction.POSITIVE) + .setOnClickListener { + val oldName = p.title.toString() + val oldPath = p.summary.toString() + dataUtils.removeBook(position[p]!!) + position.remove(p) + bookmarksList?.removePreference(p) + p.title = editText1.text + p.summary = editText2.text + position[p] = position.size + bookmarksList?.addPreference(p) + val values = arrayOf(editText1.text.toString(), editText2.text.toString()) + dataUtils.addBook(values) + AppConfig.getInstance() + .runInBackground { + utilsHandler.renameBookmark( + oldName, + oldPath, + editText1.text.toString(), + editText2.text.toString(), + ) + } + dialog.dismiss() + } + dialog.show() + } + + private fun showDeleteDialog(p: PathSwitchPreference) { + val fabSkin = activity.accent + val utilsHandler = AppConfig.getInstance().utilsHandler + + val dialog = + MaterialDialog.Builder(activity) + .title(R.string.question_delete_bookmark) + .theme(activity.appTheme.getMaterialDialogTheme()) + .positiveColor(fabSkin) + .positiveText(getString(R.string.delete).uppercase()) // TODO: 29/4/2017 don't use toUpperCase(), 20/9,2017 why not? + .negativeColor(fabSkin) + .negativeText(android.R.string.cancel) + .build() + dialog.getActionButton(DialogAction.POSITIVE) + .setOnClickListener { + dataUtils.removeBook(position[p]!!) + utilsHandler.removeFromDatabase( + OperationData( + UtilsHandler.Operation.BOOKMARKS, + p.title.toString(), + p.summary.toString(), + ), + ) + bookmarksList?.removePreference(p) + position.remove(p) + dialog.dismiss() + } + dialog.show() + } + + private fun disableButtonIfNotPath( + path: AppCompatEditText, + dialog: MaterialDialog, + ) { + path.addTextChangedListener( + object : SimpleTextWatcher() { + override fun afterTextChanged(s: Editable) { + dialog.getActionButton(DialogAction.POSITIVE).isEnabled = + FileUtils.isPathAccessible(s.toString(), activity.prefs) + } + }, + ) + } + + private fun disableButtonIfTitleEmpty( + title: AppCompatEditText, + dialog: MaterialDialog, + ) { + title.addTextChangedListener( + object : SimpleTextWatcher() { + override fun afterTextChanged(s: Editable) { + dialog.getActionButton(DialogAction.POSITIVE).isEnabled = title.length() > 0 + } + }, + ) + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/ColorPrefsFragment.kt b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/ColorPrefsFragment.kt new file mode 100644 index 0000000..e9b7119 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/ColorPrefsFragment.kt @@ -0,0 +1,175 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.fragments.preferencefragments + +import android.os.Bundle +import android.view.LayoutInflater +import androidx.annotation.ColorInt +import androidx.preference.Preference +import com.afollestad.materialdialogs.MaterialDialog +import com.amaze.filemanager.R +import com.amaze.filemanager.adapters.ColorAdapter +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.databinding.DialogGridBinding +import com.amaze.filemanager.ui.colors.ColorPreference +import com.amaze.filemanager.ui.colors.UserColorPreferences +import com.amaze.filemanager.ui.dialogs.ColorPickerDialog + +class ColorPrefsFragment : BasePrefsFragment() { + override val title = R.string.color_title + + private var dialog: MaterialDialog? = null + + override fun onCreatePreferences( + savedInstanceState: Bundle?, + rootKey: String?, + ) { + setPreferencesFromResource(R.xml.color_prefs, rootKey) + + val showColorChangeDialogListener = + Preference.OnPreferenceClickListener { + showColorChangeDialog(it.key) + + true + } + + val colorPickerPref = + activity.prefs.getInt( + PreferencesConstants.PREFERENCE_COLOR_CONFIG, + ColorPickerDialog.NO_DATA, + ) + + val skin = findPreference(PreferencesConstants.PREFERENCE_SKIN) + val skinTwo = findPreference(PreferencesConstants.PREFERENCE_SKIN_TWO) + val accent = findPreference(PreferencesConstants.PREFERENCE_ACCENT) + val icon = findPreference(PreferencesConstants.PREFERENCE_ICON_SKIN) + + if (colorPickerPref != ColorPickerDialog.CUSTOM_INDEX) { + skin?.isEnabled = false + skinTwo?.isEnabled = false + accent?.isEnabled = false + icon?.isEnabled = false + } else { + skin?.onPreferenceClickListener = showColorChangeDialogListener + skinTwo?.onPreferenceClickListener = showColorChangeDialogListener + accent?.onPreferenceClickListener = showColorChangeDialogListener + icon?.onPreferenceClickListener = showColorChangeDialogListener + } + } + + override fun onDisplayPreferenceDialog(preference: Preference) { + if (preference.key == PreferencesConstants.PRESELECTED_CONFIGS) { + showPreselectedColorsConfigDialog() + } else { + super.onDisplayPreferenceDialog(preference) + } + } + + private fun showPreselectedColorsConfigDialog() { + val newDialog = + ColorPickerDialog.newInstance( + PreferencesConstants.PRESELECTED_CONFIGS, + activity.currentColorPreference, + activity.appTheme, + ) + newDialog.setListener { + val colorPickerPref = + activity.prefs.getInt( + PreferencesConstants.PREFERENCE_COLOR_CONFIG, + ColorPickerDialog.NO_DATA, + ) + if (colorPickerPref == ColorPickerDialog.RANDOM_INDEX) { + AppConfig.toast(getActivity(), R.string.set_random) + } + + activity.recreate() + } + newDialog.setTargetFragment(this, 0) + newDialog.show(parentFragmentManager, PreferencesConstants.PREFERENCE_SELECT_COLOR_CONFIG) + } + + private fun showColorChangeDialog(colorPrefKey: String) { + val currentColorPreference = activity.currentColorPreference ?: return + + @ColorInt val currentColor = + when (colorPrefKey) { + PreferencesConstants.PREFERENCE_SKIN -> currentColorPreference.primaryFirstTab + PreferencesConstants.PREFERENCE_SKIN_TWO -> currentColorPreference.primarySecondTab + PreferencesConstants.PREFERENCE_ACCENT -> currentColorPreference.accent + PreferencesConstants.PREFERENCE_ICON_SKIN -> currentColorPreference.iconSkin + else -> 0 + } + + val adapter = + ColorAdapter( + activity, + ColorPreference.availableColors, + currentColor, + ) { selectedColor: Int -> + @ColorInt var primaryFirst = currentColorPreference.primaryFirstTab + + @ColorInt var primarySecond = currentColorPreference.primarySecondTab + + @ColorInt var accent = currentColorPreference.accent + + @ColorInt var iconSkin = currentColorPreference.iconSkin + when (colorPrefKey) { + PreferencesConstants.PREFERENCE_SKIN -> primaryFirst = selectedColor + PreferencesConstants.PREFERENCE_SKIN_TWO -> primarySecond = selectedColor + PreferencesConstants.PREFERENCE_ACCENT -> accent = selectedColor + PreferencesConstants.PREFERENCE_ICON_SKIN -> iconSkin = selectedColor + } + activity + .colorPreference + .saveColorPreferences( + activity.prefs, + UserColorPreferences(primaryFirst, primarySecond, accent, iconSkin), + ) + dialog?.dismiss() + activity.recreate() + } + + val v = + DialogGridBinding.inflate(LayoutInflater.from(requireContext())).root.also { + it.adapter = adapter + it.onItemClickListener = adapter + } + + val fabSkin = activity.accent + + dialog = + MaterialDialog.Builder(activity) + .positiveText(com.amaze.filemanager.R.string.cancel) + .title(com.amaze.filemanager.R.string.choose_color) + .theme(activity.appTheme.getMaterialDialogTheme()) + .autoDismiss(true) + .positiveColor(fabSkin) + .neutralColor(fabSkin) + .neutralText(com.amaze.filemanager.R.string.default_string) + .onNeutral { _, _ -> + activity + .colorPreference + .saveColorPreferences(activity.prefs, currentColorPreference) + activity.recreate() + }.customView(v, false) + .show() + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/PreferencesConstants.kt b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/PreferencesConstants.kt new file mode 100644 index 0000000..5a02a26 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/PreferencesConstants.kt @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.fragments.preferencefragments + +object PreferencesConstants { + // appearance_prefs.xml + const val FRAGMENT_THEME = "theme" + const val FRAGMENT_FOLLOW_BATTERY_SAVER = "follow_battery_saver" + const val PREFERENCE_USE_CIRCULAR_IMAGES = "circularimages" + const val PREFERENCE_SHOW_DIVIDERS = "showDividers" + const val PREFERENCE_SHOW_HEADERS = "showHeaders" + const val PREFERENCE_COLORIZE_ICONS = "coloriseIcons" + const val PREFERENCE_COLORED_NAVIGATION = "colorednavigation" + const val PREFERENCE_SELECT_COLOR_CONFIG = "selectcolorconfig" + const val PREFERENCE_INTELLI_HIDE_TOOLBAR = "intelliHideToolbar" + const val PREFERENCE_GRID_COLUMNS = "columnsGrid" + const val PREFERENCE_GRID_COLUMNS_DEFAULT = "3" + const val PREFERENCE_ENABLE_MARQUEE_FILENAME = "enableMarqueeFilename" + + // color_prefs.xml + const val PREFERENCE_SKIN = "skin" + const val PREFERENCE_SKIN_TWO = "skin_two" + const val PREFERENCE_ACCENT = "accent_skin" + const val PREFERENCE_ICON_SKIN = "icon_skin" + const val PRESELECTED_CONFIGS = "preselectedconfigs" + + /** The value is an int with values RANDOM_INDEX, CUSTOM_INDEX, NO_DATA or [0, ...] */ + const val PREFERENCE_COLOR_CONFIG = "color config" + + // ui_prefs.xml + const val PREFERENCE_SHOW_THUMB = "showThumbs" + const val PREFERENCE_SHOW_FILE_SIZE = "showFileSize" + const val PREFERENCE_SHOW_PERMISSIONS = "showPermissions" + const val PREFERENCE_SHOW_GOBACK_BUTTON = "goBack_checkbox" + const val PREFERENCE_SHOW_HIDDENFILES = "showHidden" + const val PREFERENCE_SHOW_LAST_MODIFIED = "showLastModified" + const val PREFERENCE_DRAG_AND_DROP_PREFERENCE = "dragAndDropPreference" + const val PREFERENCE_DRAG_AND_DROP_REMEMBERED = "dragOperationRemembered" + const val PREFERENCE_LANGUAGE = "language" + + // drag and drop + const val PREFERENCE_DRAG_DEFAULT = 0 + const val PREFERENCE_DRAG_TO_SELECT = 1 + const val PREFERENCE_DRAG_TO_MOVE_COPY = 2 + const val PREFERENCE_DRAG_REMEMBER_COPY = "copy" + const val PREFERENCE_DRAG_REMEMBER_MOVE = "move" // END drag and drop preferences + + // bookmarks_prefs.xml + const val PREFERENCE_SHOW_SIDEBAR_FOLDERS = "sidebar_bookmarks_enable" + + // quickaccess_prefs.xml + const val PREFERENCE_SHOW_SIDEBAR_QUICKACCESSES = "sidebar_quickaccess_enable" + + // behavior_prefs.xml + const val PREFERENCE_ROOT_LEGACY_LISTING = "legacyListing" + const val PREFERENCE_ROOTMODE = "rootmode" + const val PREFERENCE_CHANGEPATHS = "typeablepaths" + const val PREFERENCE_SAVED_PATHS = "savepaths" + const val PREFERENCE_ZIP_EXTRACT_PATH = "extractpath" + const val PREFERENCE_TEXTEDITOR_NEWSTACK = "texteditor_newstack" + const val PREFERENCE_DELETE_CONFIRMATION = "delete_confirmation" + const val PREFERENCE_DISABLE_PLAYER_INTENT_FILTERS = "disable_player_intent_filters" + const val PREFERENCE_TRASH_BIN_RETENTION_NUM_OF_FILES = "retention_num_of_files" + const val PREFERENCE_TRASH_BIN_RETENTION_DAYS = "retention_days" + const val PREFERENCE_TRASH_BIN_RETENTION_BYTES = "retention_bytes" + const val PREFERENCE_TRASH_BIN_CLEANUP_INTERVAL = "cleanup_interval" + const val PREFERENCE_REGEX = "regex" + const val PREFERENCE_REGEX_MATCHES = "matches" + + // security_prefs.xml + const val PREFERENCE_CRYPT_FINGERPRINT = "crypt_fingerprint" + const val PREFERENCE_CRYPT_MASTER_PASSWORD = "crypt_password" + const val PREFERENCE_CRYPT_MASTER_PASSWORD_DEFAULT = "" + const val PREFERENCE_CRYPT_FINGERPRINT_DEFAULT = false + + const val PREFERENCE_CRYPT_WARNING_REMEMBER = "crypt_remember" + const val ENCRYPT_PASSWORD_FINGERPRINT = "fingerprint" + const val ENCRYPT_PASSWORD_MASTER = "master" + const val PREFERENCE_CRYPT_WARNING_REMEMBER_DEFAULT = false + + // backup_prefs.xml + const val PREFERENCE_EXPORT_SETTINGS = "export_settings" + const val PREFERENCE_IMPORT_SETTINGS = "import_settings" + + // recent search items + const val PREFERENCE_RECENT_SEARCH_ITEMS = "recent_searches" + + // others + const val PREFERENCE_CURRENT_TAB = "" + const val PREFERENCE_BOOKMARKS_ADDED = "books_added" + const val PREFERENCE_DIRECTORY_SORT_MODE = "dirontop" + const val PREFERENCE_DRAWER_HEADER_PATH = "drawer_header_path" + const val PREFERENCE_URI = "URI" + const val PREFERENCE_VIEW = "view" + const val PREFERENCE_NEED_TO_SET_HOME = "needtosethome" + + const val PREFERENCE_SORTBY_ONLY_THIS = "sortby_only_this" + const val PREFERENCE_APPLIST_SORTBY = "AppsListFragment.sortBy" + const val PREFERENCE_APPLIST_ISASCENDING = "AppsListFragment.isAscending" + + const val KEY_TRASH_BIN_RETENTION_DAYS = "trash_bin_retention_days" + const val KEY_TRASH_BIN_RETENTION_BYTES = "trash_bin_retention_bytes" + const val KEY_TRASH_BIN_RETENTION_NUM_OF_FILES = "trash_bin_retention_num_of_files" + const val KEY_TRASH_BIN_CLEANUP_INTERVAL_HOURS = "trash_bin_cleanup_interval_hours" + + const val DEFAULT_PREFERENCE_DELETE_CONFIRMATION = true +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/PrefsFragment.kt b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/PrefsFragment.kt new file mode 100644 index 0000000..74a7e81 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/PrefsFragment.kt @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.fragments.preferencefragments + +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import android.widget.Toast +import androidx.preference.Preference +import com.amaze.filemanager.R +import com.amaze.filemanager.ui.activities.AboutActivity +import com.amaze.filemanager.utils.Utils + +class PrefsFragment : BasePrefsFragment() { + override val title = R.string.setting + + override fun onCreatePreferences( + savedInstanceState: Bundle?, + rootKey: String?, + ) { + setPreferencesFromResource(R.xml.preferences, rootKey) + + findPreference("appearance")?.onPreferenceClickListener = + Preference.OnPreferenceClickListener { + activity.pushFragment(AppearancePrefsFragment()) + true + } + + findPreference("ui")?.onPreferenceClickListener = + Preference.OnPreferenceClickListener { + activity.pushFragment(UiPrefsFragment()) + true + } + + findPreference("behavior")?.onPreferenceClickListener = + Preference.OnPreferenceClickListener { + activity.pushFragment(BehaviorPrefsFragment()) + true + } + + findPreference("security")?.onPreferenceClickListener = + Preference.OnPreferenceClickListener { + activity.pushFragment(SecurityPrefsFragment()) + true + } + + findPreference("backup")?.onPreferenceClickListener = + Preference.OnPreferenceClickListener { + activity.pushFragment(BackupPrefsFragment()) + true + } + + findPreference("about")?.onPreferenceClickListener = + Preference.OnPreferenceClickListener { + startActivity(Intent(activity, AboutActivity::class.java)) + false + } + + findPreference("feedback") + ?.onPreferenceClickListener = + Preference.OnPreferenceClickListener { + val emailIntent = Utils.buildEmailIntent(requireContext(), null, Utils.EMAIL_SUPPORT) + + val activities = + activity.packageManager.queryIntentActivities( + emailIntent, + PackageManager.MATCH_DEFAULT_ONLY, + ) + + if (activities.isNotEmpty()) { + startActivity( + Intent.createChooser( + emailIntent, + resources.getString(R.string.feedback), + ), + ) + } else { + Toast.makeText( + getActivity(), + resources.getString(R.string.send_email_to) + " " + Utils.EMAIL_SUPPORT, + Toast.LENGTH_LONG, + ) + .show() + } + + false + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/QuickAccessesPrefsFragment.kt b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/QuickAccessesPrefsFragment.kt new file mode 100644 index 0000000..b58d85b --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/QuickAccessesPrefsFragment.kt @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.fragments.preferencefragments + +import android.os.Bundle +import androidx.preference.Preference +import androidx.preference.SwitchPreference +import com.amaze.filemanager.R +import com.amaze.filemanager.utils.TinyDB + +class QuickAccessesPrefsFragment : BasePrefsFragment() { + override val title = R.string.show_quick_access_pref + + companion object { + const val KEY = "quick access array" + val KEYS = + arrayOf( + "fastaccess", + "recent", + "image", + "video", + "audio", + "documents", + "apks", + ) + val DEFAULT = arrayOf(true, true, true, true, true, true, true) + + val prefPos: Map = + KEYS.withIndex().associate { + Pair(it.value, it.index) + } + } + + override fun onCreatePreferences( + savedInstanceState: Bundle?, + rootKey: String?, + ) { + setPreferencesFromResource(R.xml.quickaccess_prefs, rootKey) + + val currentValue = TinyDB.getBooleanArray(activity.prefs, KEY, DEFAULT)!! + + val onChange = + Preference.OnPreferenceClickListener { preference -> + prefPos[preference.key]?.let { + currentValue[it] = (preference as SwitchPreference).isChecked + TinyDB.putBooleanArray(activity.prefs, KEY, currentValue) + } + + true + } + + for (key in KEYS) { + findPreference(key)?.onPreferenceClickListener = onChange + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/SecurityPrefsFragment.kt b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/SecurityPrefsFragment.kt new file mode 100644 index 0000000..74381bf --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/SecurityPrefsFragment.kt @@ -0,0 +1,228 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.fragments.preferencefragments + +import android.Manifest +import android.app.KeyguardManager +import android.content.Context +import android.content.pm.PackageManager +import android.hardware.fingerprint.FingerprintManager +import android.os.Build +import android.os.Bundle +import android.widget.Toast +import androidx.core.app.ActivityCompat +import androidx.preference.Preference +import com.afollestad.materialdialogs.MaterialDialog +import com.amaze.filemanager.R +import com.amaze.filemanager.ui.views.preference.CheckBox +import com.amaze.filemanager.utils.PasswordUtil +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.IOException +import java.security.GeneralSecurityException + +class SecurityPrefsFragment : BasePrefsFragment() { + private val log: Logger = LoggerFactory.getLogger(SecurityPrefsFragment::class.java) + + override val title = R.string.security + + private var masterPasswordPreference: Preference? = null + private var keyguardManager: KeyguardManager? = null + private var fingerprintManager: FingerprintManager? = null + private val onClickFingerprint = + Preference.OnPreferenceChangeListener { _, _ -> + if (ActivityCompat.checkSelfPermission( + activity, + Manifest.permission.USE_FINGERPRINT, + ) + != PackageManager.PERMISSION_GRANTED + ) { + Toast.makeText( + activity, + resources.getString(R.string.crypt_fingerprint_no_permission), + Toast.LENGTH_LONG, + ) + .show() + false + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && + fingerprintManager?.hasEnrolledFingerprints() == false + ) { + Toast.makeText( + activity, + resources.getString(R.string.crypt_fingerprint_not_enrolled), + Toast.LENGTH_LONG, + ) + .show() + false + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && + keyguardManager?.isKeyguardSecure == false + ) { + Toast.makeText( + activity, + resources.getString(R.string.crypt_fingerprint_no_security), + Toast.LENGTH_LONG, + ) + .show() + false + } else { + masterPasswordPreference?.isEnabled = false + true + } + } + private val onClickMasterPassword = + Preference.OnPreferenceClickListener { + val masterPasswordDialogBuilder = MaterialDialog.Builder(activity) + masterPasswordDialogBuilder.title( + resources.getString(R.string.crypt_pref_master_password_title), + ) + + var decryptedPassword: String? = null + try { + val preferencePassword = + activity.prefs.getString( + PreferencesConstants.PREFERENCE_CRYPT_MASTER_PASSWORD, + PreferencesConstants.PREFERENCE_CRYPT_MASTER_PASSWORD_DEFAULT, + )!! + decryptedPassword = + if ( + preferencePassword != + PreferencesConstants.PREFERENCE_CRYPT_MASTER_PASSWORD_DEFAULT + ) { + + // password is set, try to decrypt + PasswordUtil.decryptPassword(activity, preferencePassword) + } else { + // no password set in preferences, just leave the field empty + "" + } + } catch (e: GeneralSecurityException) { + log.warn("failed to decrypt master password", e) + } catch (e: IOException) { + log.warn("failed to decrypt master password", e) + } + + masterPasswordDialogBuilder.input( + resources.getString(R.string.authenticate_password), + decryptedPassword, + true, + ) { _, _ -> } + masterPasswordDialogBuilder.theme( + activity.utilsProvider.appTheme.getMaterialDialogTheme(), + ) + masterPasswordDialogBuilder.positiveText(resources.getString(R.string.ok)) + masterPasswordDialogBuilder.negativeText(resources.getString(R.string.cancel)) + masterPasswordDialogBuilder.positiveColor(activity.accent) + masterPasswordDialogBuilder.negativeColor(activity.accent) + + masterPasswordDialogBuilder.onPositive { dialog, _ -> + try { + val inputText = dialog.inputEditText!!.text.toString() + if (inputText != + PreferencesConstants.PREFERENCE_CRYPT_MASTER_PASSWORD_DEFAULT + ) { + val editor = activity.prefs.edit() + editor.putString( + PreferencesConstants.PREFERENCE_CRYPT_MASTER_PASSWORD, + PasswordUtil.encryptPassword( + activity, + dialog.inputEditText!!.text.toString(), + ), + ) + editor.apply() + } else { + val editor = activity.prefs.edit() + editor.putString(PreferencesConstants.PREFERENCE_CRYPT_MASTER_PASSWORD, "") + editor.apply() + } + } catch (e: GeneralSecurityException) { + log.warn("failed to encrypt master password", e) + val editor = activity.prefs.edit() + editor.putString( + PreferencesConstants.PREFERENCE_CRYPT_MASTER_PASSWORD, + PreferencesConstants.PREFERENCE_CRYPT_MASTER_PASSWORD_DEFAULT, + ) + editor.apply() + } catch (e: IOException) { + log.warn("failed to encrypt master password", e) + val editor = activity.prefs.edit() + editor.putString( + PreferencesConstants.PREFERENCE_CRYPT_MASTER_PASSWORD, + PreferencesConstants.PREFERENCE_CRYPT_MASTER_PASSWORD_DEFAULT, + ) + editor.apply() + } + } + + masterPasswordDialogBuilder.onNegative { dialog, _ -> dialog.cancel() } + + masterPasswordDialogBuilder.build().show() + + true + } + + override fun onCreatePreferences( + savedInstanceState: Bundle?, + rootKey: String?, + ) { + setPreferencesFromResource(R.xml.security_prefs, rootKey) + + masterPasswordPreference = + findPreference( + PreferencesConstants.PREFERENCE_CRYPT_MASTER_PASSWORD, + ) + val checkBoxFingerprint = + findPreference( + PreferencesConstants.PREFERENCE_CRYPT_FINGERPRINT, + ) + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2 || + activity.prefs.getBoolean( + PreferencesConstants.PREFERENCE_CRYPT_FINGERPRINT, + false, + ) + ) { + // encryption feature not available + masterPasswordPreference?.isEnabled = false + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + // finger print sensor + keyguardManager = + activity.getSystemService(Context.KEYGUARD_SERVICE) + as KeyguardManager? + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + fingerprintManager = + activity.getSystemService(Context.FINGERPRINT_SERVICE) + as FingerprintManager? + if (fingerprintManager?.isHardwareDetected == true) { + checkBoxFingerprint?.isEnabled = true + } + } + + checkBoxFingerprint?.onPreferenceChangeListener = onClickFingerprint + } else { + // fingerprint manager class not defined in the framework + checkBoxFingerprint?.isEnabled = false + } + + masterPasswordPreference?.onPreferenceClickListener = onClickMasterPassword + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/UiPrefsFragment.kt b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/UiPrefsFragment.kt new file mode 100644 index 0000000..50d8ffb --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/UiPrefsFragment.kt @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.fragments.preferencefragments + +import android.os.Bundle +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat +import androidx.preference.Preference +import com.afollestad.materialdialogs.MaterialDialog +import com.amaze.filemanager.R +import com.amaze.filemanager.utils.getLangPreferenceDropdownEntries + +class UiPrefsFragment : BasePrefsFragment() { + override val title = R.string.ui + + private var dragAndDropPref: Preference? = null + + override fun onCreatePreferences( + savedInstanceState: Bundle?, + rootKey: String?, + ) { + setPreferencesFromResource(R.xml.ui_prefs, rootKey) + + findPreference("sidebar_bookmarks")?.onPreferenceClickListener = + Preference.OnPreferenceClickListener { + activity.pushFragment(BookmarksPrefsFragment()) + true + } + + findPreference("sidebar_quick_access")?.onPreferenceClickListener = + Preference.OnPreferenceClickListener { + activity.pushFragment(QuickAccessesPrefsFragment()) + true + } + + findPreference(PreferencesConstants.PREFERENCE_LANGUAGE)?.apply { + val availableLocales = requireContext().getLangPreferenceDropdownEntries() + val currentLanguagePreference = + AppCompatDelegate.getApplicationLocales().let { + if (AppCompatDelegate.getApplicationLocales() == + LocaleListCompat.getEmptyLocaleList() + ) { + 0 + } else { + availableLocales.values.indexOf( + AppCompatDelegate.getApplicationLocales()[0], + ) + 1 + } + } + this.summary = + if (currentLanguagePreference == 0) { + getString(R.string.preference_language_system_default) + } else { + availableLocales.entries.find { + it.value == AppCompatDelegate.getApplicationLocales()[0] + }?.key + } + onPreferenceClickListener = + Preference.OnPreferenceClickListener { + MaterialDialog.Builder(activity).apply { + theme(activity.utilsProvider.appTheme.materialDialogTheme) + title(R.string.preference_language_dialog_title) + items( + arrayOf(getString(R.string.preference_language_system_default)) + .plus(availableLocales.keys.toTypedArray()) + .toSet(), + ) + itemsCallbackSingleChoice(currentLanguagePreference) { + dialog, _, _, textLabel -> + if (textLabel == getString(R.string.preference_language_system_default)) { + AppCompatDelegate.setApplicationLocales( + LocaleListCompat.getEmptyLocaleList(), + ) + } else { + AppCompatDelegate.setApplicationLocales( + LocaleListCompat.create(availableLocales[textLabel]), + ) + } + dialog.dismiss() + true + } + }.show() + true + } + } + + val dragToMoveArray = resources.getStringArray(R.array.dragAndDropPreference) + dragAndDropPref = findPreference(PreferencesConstants.PREFERENCE_DRAG_AND_DROP_PREFERENCE) + updateDragAndDropPreferenceSummary() + dragAndDropPref?.onPreferenceClickListener = + Preference.OnPreferenceClickListener { + val dragDialogBuilder = MaterialDialog.Builder(activity) + dragDialogBuilder.theme( + activity.utilsProvider.appTheme.getMaterialDialogTheme(), + ) + dragDialogBuilder.title(R.string.drag_and_drop_preference) + val currentDragPreference: Int = + activity.prefs.getInt( + PreferencesConstants.PREFERENCE_DRAG_AND_DROP_PREFERENCE, + PreferencesConstants.PREFERENCE_DRAG_DEFAULT, + ) + dragDialogBuilder + .items(R.array.dragAndDropPreference) + .itemsCallbackSingleChoice(currentDragPreference) { dialog, _, which, _ -> + val editor = activity.prefs.edit() + editor.putInt( + PreferencesConstants.PREFERENCE_DRAG_AND_DROP_PREFERENCE, + which, + ) + editor.putString( + PreferencesConstants.PREFERENCE_DRAG_AND_DROP_REMEMBERED, + null, + ) + editor.apply() + dialog.dismiss() + updateDragAndDropPreferenceSummary() + true + } + dragDialogBuilder.build().show() + true + } + } + + private fun updateDragAndDropPreferenceSummary() { + val value = + activity.prefs.getInt( + PreferencesConstants.PREFERENCE_DRAG_AND_DROP_PREFERENCE, + PreferencesConstants.PREFERENCE_DRAG_DEFAULT, + ) + val dragToMoveArray = resources.getStringArray(R.array.dragAndDropPreference) + dragAndDropPref?.summary = dragToMoveArray[value] + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/icons/Icons.java b/app/src/main/java/com/amaze/filemanager/ui/icons/Icons.java new file mode 100644 index 0000000..9cf5861 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/icons/Icons.java @@ -0,0 +1,254 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.icons; + +import java.util.HashMap; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.filesystem.compressed.CompressedHelper; + +import androidx.annotation.DrawableRes; + +public class Icons { + public static final int NOT_KNOWN = -1; + public static final int APK = 0, + AUDIO = 1, + CERTIFICATE = 2, + CODE = 3, + COMPRESSED = 4, + CONTACT = 5, + EVENTS = 6, + FONT = 7, + IMAGE = 8, + PDF = 9, + PRESENTATION = 10, + SPREADSHEETS = 11, + DOCUMENTS = 12, + TEXT = 13, + VIDEO = 14, + ENCRYPTED = 15, + GIF = 16; + + // construct a with an approximation of the capacity + private static HashMap sMimeIconIds = new HashMap<>(1 + (int) (114 / 0.75)); + + private static void put(String mimeType, int resId) { + if (sMimeIconIds.put(mimeType, resId) != null) { + throw new RuntimeException(mimeType + " already registered!"); + } + } + + private static void putKeys(int resId, String... mimeTypes) { + for (String type : mimeTypes) { + put(type, resId); + } + } + + static { + putKeys(APK, "application/vnd.android.package-archive"); + putKeys(AUDIO, "application/ogg", "application/x-flac"); + putKeys( + CERTIFICATE, + "application/pgp-keys", + "application/pgp-signature", + "application/x-pkcs12", + "application/x-pkcs7-certreqresp", + "application/x-pkcs7-crl", + "application/x-x509-ca-cert", + "application/x-x509-user-cert", + "application/x-pkcs7-certificates", + "application/x-pkcs7-mime", + "application/x-pkcs7-signature"); + putKeys( + CODE, + "application/rdf+xml", + "application/rss+xml", + "application/x-object", + "application/xhtml+xml", + "text/css", + "text/html", + "text/xml", + "text/x-c++hdr", + "text/x-c++src", + "text/x-chdr", + "text/x-csrc", + "text/x-dsrc", + "text/x-csh", + "text/x-haskell", + "text/x-java", + "text/x-literate-haskell", + "text/x-pascal", + "text/x-tcl", + "text/x-tex", + "application/x-latex", + "application/x-texinfo", + "application/atom+xml", + "application/ecmascript", + "application/json", + "application/javascript", + "application/xml", + "text/javascript", + "application/x-javascript"); + putKeys( + COMPRESSED, + "application/mac-binhex40", + "application/rar", + "application/zip", + "application/gzip", + "application/java-archive", + "application/x-apple-diskimage", + "application/x-debian-package", + "application/x-gtar", + "application/x-iso9660-image", + "application/x-lha", + "application/x-lzh", + "application/x-lzx", + "application/x-stuffit", + "application/x-tar", + "application/x-webarchive", + "application/x-webarchive-xml", + "application/x-gzip", + "application/x-7z-compressed", + "application/x-deb", + "application/x-rar-compressed", + "application/x-lzma", + "application/x-xz", + "application/x-bzip2"); + putKeys(CONTACT, "text/x-vcard", "text/vcard"); + putKeys(EVENTS, "text/calendar", "text/x-vcalendar"); + putKeys( + FONT, + "application/x-font", + "application/font-woff", + "application/x-font-woff", + "application/x-font-ttf"); + putKeys( + IMAGE, + "application/vnd.oasis.opendocument.graphics", + "application/vnd.oasis.opendocument.graphics-template", + "application/vnd.oasis.opendocument.image", + "application/vnd.stardivision.draw", + "application/vnd.sun.xml.draw", + "application/vnd.sun.xml.draw.template", + "image/jpeg", + "image/png"); + putKeys(PDF, "application/pdf"); + putKeys( + PRESENTATION, + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.openxmlformats-officedocument.presentationml.template", + "application/vnd.openxmlformats-officedocument.presentationml.slideshow", + "application/vnd.stardivision.impress", + "application/vnd.sun.xml.impress", + "application/vnd.sun.xml.impress.template", + "application/x-kpresenter", + "application/vnd.oasis.opendocument.presentation"); + putKeys( + SPREADSHEETS, + "application/vnd.oasis.opendocument.spreadsheet", + "application/vnd.oasis.opendocument.spreadsheet-template", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.spreadsheetml.template", + "application/vnd.stardivision.calc", + "application/vnd.sun.xml.calc", + "application/vnd.sun.xml.calc.template", + "application/x-kspread", + "text/comma-separated-values"); + putKeys( + DOCUMENTS, + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.wordprocessingml.template", + "application/vnd.oasis.opendocument.text", + "application/vnd.oasis.opendocument.text-master", + "application/vnd.oasis.opendocument.text-template", + "application/vnd.oasis.opendocument.text-web", + "application/vnd.stardivision.writer", + "application/vnd.stardivision.writer-global", + "application/vnd.sun.xml.writer", + "application/vnd.sun.xml.writer.global", + "application/vnd.sun.xml.writer.template", + "application/x-abiword", + "application/x-kword", + "text/markdown"); + putKeys(TEXT, "text/plain"); + putKeys(VIDEO, "application/x-quicktimeplayer", "application/x-shockwave-flash"); + putKeys(ENCRYPTED, "application/octet-stream"); + } + + public static @DrawableRes int loadMimeIcon(String path, boolean isDirectory) { + if (path.equals("..")) return R.drawable.ic_arrow_left_white_24dp; + if (CompressedHelper.isFileExtractable(path) && !isDirectory) + return R.drawable.ic_compressed_white_24dp; + + int type = getTypeOfFile(path, isDirectory); + + switch (type) { + case APK: + return R.drawable.ic_doc_apk_white; + case AUDIO: + return R.drawable.ic_doc_audio_am; + case IMAGE: + return R.drawable.ic_doc_image; + case TEXT: + return R.drawable.ic_doc_text_am; + case VIDEO: + return R.drawable.ic_doc_video_am; + case PDF: + return R.drawable.ic_doc_pdf; + case CERTIFICATE: + return R.drawable.ic_doc_certificate; + case CODE: + return R.drawable.ic_doc_codes; + case FONT: // + return R.drawable.ic_doc_font; + case ENCRYPTED: + return R.drawable.ic_folder_lock_white_36dp; + default: + if (isDirectory) return R.drawable.ic_grid_folder_new; + else { + return R.drawable.ic_doc_generic_am; + } + } + } + + public static int getTypeOfFile(String path, boolean isDirectory) { + String mimeType = MimeTypes.getMimeType(path, isDirectory); + if (mimeType == null) return NOT_KNOWN; + + Integer type = sMimeIconIds.get(mimeType); + if (type != null) return type; + else { + if (checkType(mimeType, "text")) return TEXT; + else if (checkType(mimeType, "image")) return IMAGE; + else if (checkType(mimeType, "video")) return VIDEO; + else if (checkType(mimeType, "audio")) return AUDIO; + else if (checkType(mimeType, "crypt")) return ENCRYPTED; + else return NOT_KNOWN; + } + } + + private static boolean checkType(String mime, String check) { + return mime != null && mime.contains("/") && check.equals(mime.substring(0, mime.indexOf("/"))); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/icons/MimeTypes.java b/app/src/main/java/com/amaze/filemanager/ui/icons/MimeTypes.java new file mode 100644 index 0000000..1f203d3 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/icons/MimeTypes.java @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.icons; + +import java.util.HashMap; +import java.util.Locale; +import java.util.regex.Pattern; + +import com.amaze.filemanager.filesystem.files.CryptUtil; + +import android.webkit.MimeTypeMap; + +import androidx.annotation.Nullable; + +public final class MimeTypes { + + public static final String ALL_MIME_TYPES = "*/*"; + + // construct a with an approximation of the capacity + private static final HashMap MIME_TYPES = new HashMap<>(1 + (int) (68 / 0.75)); + + static { + + /* + * ================= MIME TYPES ==================== + */ + MIME_TYPES.put("asm", "text/x-asm"); + MIME_TYPES.put("json", "application/json"); + MIME_TYPES.put("js", "application/javascript"); + + MIME_TYPES.put("def", "text/plain"); + MIME_TYPES.put("in", "text/plain"); + MIME_TYPES.put("rc", "text/plain"); + MIME_TYPES.put("list", "text/plain"); + MIME_TYPES.put("log", "text/plain"); + MIME_TYPES.put("pl", "text/plain"); + MIME_TYPES.put("prop", "text/plain"); + MIME_TYPES.put("properties", "text/plain"); + MIME_TYPES.put("ini", "text/plain"); + MIME_TYPES.put("md", "text/markdown"); + + MIME_TYPES.put("epub", "application/epub+zip"); + MIME_TYPES.put("ibooks", "application/x-ibooks+zip"); + + MIME_TYPES.put("ifb", "text/calendar"); + MIME_TYPES.put("eml", "message/rfc822"); + MIME_TYPES.put("msg", "application/vnd.ms-outlook"); + + MIME_TYPES.put("ace", "application/x-ace-compressed"); + MIME_TYPES.put("bz", "application/x-bzip"); + MIME_TYPES.put("bz2", "application/x-bzip2"); + MIME_TYPES.put("cab", "application/vnd.ms-cab-compressed"); + MIME_TYPES.put("gz", "application/x-gzip"); + MIME_TYPES.put("7z", "application/x-7z-compressed"); + MIME_TYPES.put("lrf", "application/octet-stream"); + MIME_TYPES.put("jar", "application/java-archive"); + MIME_TYPES.put("xz", "application/x-xz"); + MIME_TYPES.put("lzma", "application/x-lzma"); + MIME_TYPES.put("Z", "application/x-compress"); + + MIME_TYPES.put("bat", "application/x-msdownload"); + MIME_TYPES.put("ksh", "text/plain"); + MIME_TYPES.put("sh", "application/x-sh"); + + MIME_TYPES.put("db", "application/octet-stream"); + MIME_TYPES.put("db3", "application/octet-stream"); + + MIME_TYPES.put("otf", "application/x-font-otf"); + MIME_TYPES.put("ttf", "application/x-font-ttf"); + MIME_TYPES.put("psf", "application/x-font-linux-psf"); + + MIME_TYPES.put("cgm", "image/cgm"); + MIME_TYPES.put("btif", "image/prs.btif"); + MIME_TYPES.put("dwg", "image/vnd.dwg"); + MIME_TYPES.put("dxf", "image/vnd.dxf"); + MIME_TYPES.put("fbs", "image/vnd.fastbidsheet"); + MIME_TYPES.put("fpx", "image/vnd.fpx"); + MIME_TYPES.put("fst", "image/vnd.fst"); + MIME_TYPES.put("mdi", "image/vnd.ms-mdi"); + MIME_TYPES.put("npx", "image/vnd.net-fpx"); + MIME_TYPES.put("xif", "image/vnd.xiff"); + MIME_TYPES.put("pct", "image/x-pict"); + MIME_TYPES.put("pic", "image/x-pict"); + MIME_TYPES.put("gif", "image/gif"); + + MIME_TYPES.put("adp", "audio/adpcm"); + MIME_TYPES.put("au", "audio/basic"); + MIME_TYPES.put("snd", "audio/basic"); + MIME_TYPES.put("m2a", "audio/mpeg"); + MIME_TYPES.put("m3a", "audio/mpeg"); + MIME_TYPES.put("oga", "audio/ogg"); + MIME_TYPES.put("spx", "audio/ogg"); + MIME_TYPES.put("aac", "audio/x-aac"); + MIME_TYPES.put("mka", "audio/x-matroska"); + MIME_TYPES.put("opus", "audio/ogg"); + + MIME_TYPES.put("jpgv", "video/jpeg"); + MIME_TYPES.put("jpgm", "video/jpm"); + MIME_TYPES.put("jpm", "video/jpm"); + MIME_TYPES.put("mj2", "video/mj2"); + MIME_TYPES.put("mjp2", "video/mj2"); + MIME_TYPES.put("mpa", "video/mpeg"); + MIME_TYPES.put("ogv", "video/ogg"); + MIME_TYPES.put("flv", "video/x-flv"); + MIME_TYPES.put("mkv", "video/x-matroska"); + MIME_TYPES.put("mts", "video/mp2t"); + + MIME_TYPES.put(CryptUtil.CRYPT_EXTENSION.replace(".", ""), "crypt/aze"); + MIME_TYPES.put(CryptUtil.AESCRYPT_EXTENSION.replace(".", ""), "crypt/x-aescrypt"); + } + + /** + * Get Mime Type of a file + * + * @param path the file of which mime type to get + * @return Mime type in form of String + */ + public static String getMimeType(String path, boolean isDirectory) { + if (isDirectory) { + return null; + } + + String type = ALL_MIME_TYPES; + final String extension = getExtension(path); + + // mapping extension to system mime types + if (extension != null && !extension.isEmpty()) { + final String extensionLowerCase = extension.toLowerCase(Locale.getDefault()); + final MimeTypeMap mime = MimeTypeMap.getSingleton(); + type = mime.getMimeTypeFromExtension(extensionLowerCase); + if (type == null) { + type = MIME_TYPES.get(extensionLowerCase); + } + } + if (type == null) type = ALL_MIME_TYPES; + return type; + } + + public static boolean mimeTypeMatch(String mime, String input) { + return Pattern.matches(mime.replace("*", ".*"), input); + } + + /** + * Helper method for {@link #getMimeType(String, boolean)} to calculate the last '.' extension of + * files + * + * @param path the path of file + * @return extension extracted from name in lowercase + */ + public static String getExtension(@Nullable String path) { + if (path != null && path.contains(".")) + return path.substring(path.lastIndexOf(".") + 1).toLowerCase(); + else return ""; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/notifications/FtpNotification.java b/app/src/main/java/com/amaze/filemanager/ui/notifications/FtpNotification.java new file mode 100644 index 0000000..fa51a92 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/notifications/FtpNotification.java @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.notifications; + +import static android.app.PendingIntent.FLAG_ONE_SHOT; +import static com.amaze.filemanager.asynchronous.services.AbstractProgressiveService.getPendingIntentFlag; + +import java.net.InetAddress; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.asynchronous.services.ftp.FtpService; +import com.amaze.filemanager.ui.activities.MainActivity; +import com.amaze.filemanager.utils.NetworkUtil; + +import android.app.Notification; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; + +import androidx.annotation.StringRes; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; +import androidx.preference.PreferenceManager; + +/** + * Created by yashwanthreddyg on 19-06-2016. + * + *

Edited by zent-co on 30-07-2019 + */ +public class FtpNotification { + + private static NotificationCompat.Builder buildNotification( + Context context, @StringRes int contentTitleRes, String contentText, boolean noStopButton) { + Intent notificationIntent = new Intent(context, MainActivity.class); + notificationIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + PendingIntent contentIntent = + PendingIntent.getActivity(context, 0, notificationIntent, getPendingIntentFlag(0)); + + long when = System.currentTimeMillis(); + + NotificationCompat.Builder builder = + new NotificationCompat.Builder(context, NotificationConstants.CHANNEL_FTP_ID) + .setContentTitle(context.getString(contentTitleRes)) + .setContentText(contentText) + .setContentIntent(contentIntent) + .setSmallIcon(R.drawable.ic_ftp_light) + .setTicker(context.getString(R.string.ftp_notif_starting)) + .setWhen(when) + .setOngoing(true) + .setOnlyAlertOnce(true); + + if (!noStopButton) { + int stopIcon = android.R.drawable.ic_menu_close_clear_cancel; + CharSequence stopText = context.getString(R.string.ftp_notif_stop_server); + Intent stopIntent = + new Intent(FtpService.ACTION_STOP_FTPSERVER).setPackage(context.getPackageName()); + PendingIntent stopPendingIntent = + PendingIntent.getBroadcast(context, 0, stopIntent, getPendingIntentFlag(FLAG_ONE_SHOT)); + + builder.addAction(stopIcon, stopText, stopPendingIntent); + } + + NotificationConstants.setMetadata(context, builder, NotificationConstants.TYPE_FTP); + + return builder; + } + + public static Notification startNotification(Context context, boolean noStopButton) { + NotificationCompat.Builder builder = + buildNotification( + context, + R.string.ftp_notif_starting_title, + context.getString(R.string.ftp_notif_starting), + noStopButton); + + return builder.build(); + } + + public static void updateNotification(Context context, boolean noStopButton) { + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + + SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); + int port = sharedPreferences.getInt(FtpService.PORT_PREFERENCE_KEY, FtpService.DEFAULT_PORT); + boolean secureConnection = + sharedPreferences.getBoolean(FtpService.KEY_PREFERENCE_SECURE, FtpService.DEFAULT_SECURE); + + InetAddress address = NetworkUtil.getLocalInetAddress(context); + + String address_text = "Address not found"; + + if (address != null) { + address_text = + (secureConnection ? FtpService.INITIALS_HOST_SFTP : FtpService.INITIALS_HOST_FTP) + + address.getHostAddress() + + ":" + + port + + "/"; + } + + NotificationCompat.Builder builder = + buildNotification( + context, + R.string.ftp_notif_title, + context.getString(R.string.ftp_notif_text, address_text), + noStopButton); + + notificationManager.notify(NotificationConstants.FTP_ID, builder.build()); + } + + private static void removeNotification(Context context) { + NotificationManagerCompat.from(context).cancelAll(); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/notifications/NotificationConstants.java b/app/src/main/java/com/amaze/filemanager/ui/notifications/NotificationConstants.java new file mode 100644 index 0000000..673efc6 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/notifications/NotificationConstants.java @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.notifications; + +import com.amaze.filemanager.R; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Context; +import android.os.Build; + +import androidx.annotation.RequiresApi; +import androidx.core.app.NotificationCompat; + +/** + * @author Emmanuel Messulam on 17/9/2017, at 13:34. + */ +public class NotificationConstants { + + public static final int COPY_ID = 0; + public static final int EXTRACT_ID = 1; + public static final int ZIP_ID = 2; + public static final int DECRYPT_ID = 3; + public static final int ENCRYPT_ID = 4; + public static final int FTP_ID = 5; + public static final int FAILED_ID = 6; + public static final int WAIT_ID = 7; + + public static final int TYPE_NORMAL = 0, TYPE_FTP = 1; + + public static final String CHANNEL_NORMAL_ID = "normalChannel"; + public static final String CHANNEL_FTP_ID = "ftpChannel"; + + /** + * This creates a channel (API >= 26) or applies the correct metadata to a notification (API < 26) + */ + public static void setMetadata( + Context context, NotificationCompat.Builder notification, int type) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + switch (type) { + case TYPE_NORMAL: + createNormalChannel(context); + break; + case TYPE_FTP: + createFtpChannel(context); + break; + default: + throw new IllegalArgumentException("Unrecognized type:" + type); + } + } else { + switch (type) { + case TYPE_NORMAL: + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + notification.setCategory(Notification.CATEGORY_SERVICE); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + notification.setPriority(Notification.PRIORITY_MIN); + } + break; + case TYPE_FTP: + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + notification.setCategory(Notification.CATEGORY_SERVICE); + notification.setVisibility(NotificationCompat.VISIBILITY_PUBLIC); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + notification.setPriority(Notification.PRIORITY_MAX); + } + break; + default: + throw new IllegalArgumentException("Unrecognized type:" + type); + } + } + } + + /** + * You CANNOT call this from android < O. THis channel is set so it doesn't bother the user, but + * it has importance. + */ + @RequiresApi(api = Build.VERSION_CODES.O) + private static void createFtpChannel(Context context) { + NotificationManager mNotificationManager = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + if (mNotificationManager.getNotificationChannel(CHANNEL_FTP_ID) == null) { + NotificationChannel mChannel = + new NotificationChannel( + CHANNEL_FTP_ID, + context.getString(R.string.channel_name_ftp), + NotificationManager.IMPORTANCE_HIGH); + // Configure the notification channel. + mChannel.setDescription(context.getString(R.string.channel_description_ftp)); + mNotificationManager.createNotificationChannel(mChannel); + } + } + + /** + * You CANNOT call this from android < O. THis channel is set so it doesn't bother the user, with + * the lowest importance. + */ + @RequiresApi(api = Build.VERSION_CODES.O) + private static void createNormalChannel(Context context) { + NotificationManager mNotificationManager = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + if (mNotificationManager.getNotificationChannel(CHANNEL_NORMAL_ID) == null) { + NotificationChannel mChannel = + new NotificationChannel( + CHANNEL_NORMAL_ID, + context.getString(R.string.channel_name_normal), + NotificationManager.IMPORTANCE_MIN); + // Configure the notification channel. + mChannel.setDescription(context.getString(R.string.channel_description_normal)); + mNotificationManager.createNotificationChannel(mChannel); + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/provider/UtilitiesProvider.java b/app/src/main/java/com/amaze/filemanager/ui/provider/UtilitiesProvider.java new file mode 100644 index 0000000..7e5ec55 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/provider/UtilitiesProvider.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.provider; + +import com.amaze.filemanager.ui.colors.ColorPreferenceHelper; +import com.amaze.filemanager.ui.theme.AppTheme; +import com.amaze.filemanager.ui.theme.AppThemeManager; + +import android.content.Context; +import android.content.SharedPreferences; + +import androidx.preference.PreferenceManager; + +/** Created by piotaixr on 16/01/17. */ +public class UtilitiesProvider { + private ColorPreferenceHelper colorPreference; + private AppThemeManager appThemeManager; + + public UtilitiesProvider(Context context) { + SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); + + colorPreference = new ColorPreferenceHelper(); + colorPreference.getCurrentUserColorPreferences(context, sharedPreferences); + appThemeManager = new AppThemeManager(sharedPreferences, context); + } + + public ColorPreferenceHelper getColorPreference() { + return colorPreference; + } + + public AppTheme getAppTheme() { + return appThemeManager.getAppTheme(); + } + + public AppThemeManager getThemeManager() { + return appThemeManager; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/selection/SelectionPopupMenu.kt b/app/src/main/java/com/amaze/filemanager/ui/selection/SelectionPopupMenu.kt new file mode 100644 index 0000000..403bf9d --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/selection/SelectionPopupMenu.kt @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.selection + +import android.content.Context +import android.view.MenuItem +import android.view.View +import android.widget.PopupMenu +import androidx.appcompat.view.ContextThemeWrapper +import androidx.appcompat.widget.AppCompatTextView +import com.amaze.filemanager.R +import com.amaze.filemanager.adapters.RecyclerAdapter +import com.amaze.filemanager.ui.activities.MainActivity +import com.amaze.filemanager.ui.theme.AppTheme + +class SelectionPopupMenu( + private val recyclerAdapter: RecyclerAdapter, + private val actionModeView: View, + private val currentPath: String, + context: Context, +) : + PopupMenu(context, actionModeView), PopupMenu.OnMenuItemClickListener { + companion object { + private const val SIMILARITY_THRESHOLD = 500 + const val FUZZYNESS_FACTOR = 4 + + fun invokeSelectionDropdown( + recyclerAdapter: RecyclerAdapter, + actionModeView: View, + currentPath: String, + mainActivity: MainActivity?, + ) { + mainActivity?.also { + var currentContext: Context = mainActivity.applicationContext + if (mainActivity.appTheme == AppTheme.BLACK) { + currentContext = + ContextThemeWrapper( + mainActivity.applicationContext, + R.style.overflow_black, + ) + } + val popupMenu = + SelectionPopupMenu( + recyclerAdapter, + actionModeView, + currentPath, + currentContext, + ) + popupMenu.inflate(R.menu.selection_criteria) + recyclerAdapter.itemsDigested?.let { + itemsDigested -> + if (itemsDigested.size > SIMILARITY_THRESHOLD) { + popupMenu.menu.findItem(R.id.select_similar).isVisible = false + } + } + if (recyclerAdapter.checkedItems.size < 2) { + popupMenu.menu.findItem(R.id.select_fill).isVisible = false + } + popupMenu.setOnMenuItemClickListener(popupMenu) + popupMenu.show() + } + } + } + + override fun onMenuItemClick(item: MenuItem?): Boolean { + when (item?.itemId) { + R.id.select_all -> { + // select_all + recyclerAdapter.toggleChecked( + !recyclerAdapter + .areAllChecked(currentPath), + currentPath, + ) + } + R.id.select_inverse -> { + recyclerAdapter.toggleInverse(currentPath) + } + R.id.select_by_type -> { + recyclerAdapter.toggleSameTypes() + } + R.id.select_by_date -> { + recyclerAdapter.toggleSameDates() + } + R.id.select_similar -> { + recyclerAdapter.toggleSimilarNames() + } + R.id.select_fill -> { + recyclerAdapter.toggleFill() + } + } + actionModeView.invalidate() + actionModeView.findViewById(R.id.item_count).text = + recyclerAdapter + .checkedItems.size.toString() + return true + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/strings/StorageNamingHelper.java b/app/src/main/java/com/amaze/filemanager/ui/strings/StorageNamingHelper.java new file mode 100644 index 0000000..b09a7d0 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/strings/StorageNamingHelper.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.strings; + +import java.io.File; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.fileoperations.filesystem.StorageNaming; + +import android.content.Context; + +import androidx.annotation.NonNull; + +public final class StorageNamingHelper { + private StorageNamingHelper() {} + + @NonNull + public static String getNameForDeviceDescription( + @NonNull Context context, + @NonNull File file, + @StorageNaming.DeviceDescription int deviceDescription) { + switch (deviceDescription) { + case StorageNaming.STORAGE_INTERNAL: + return context.getString(R.string.storage_internal); + case StorageNaming.STORAGE_SD_CARD: + return context.getString(R.string.storage_sd_card); + case StorageNaming.ROOT: + return context.getString(R.string.root_directory); + case StorageNaming.NOT_KNOWN: + default: + return file.getName(); + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/theme/AppTheme.java b/app/src/main/java/com/amaze/filemanager/ui/theme/AppTheme.java new file mode 100644 index 0000000..dd58d95 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/theme/AppTheme.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.theme; + +import com.afollestad.materialdialogs.Theme; + +/** This enum represents the theme of the app (LIGHT or DARK) */ +public enum AppTheme { + LIGHT, + DARK, + BLACK; + + public Theme getMaterialDialogTheme() { + switch (this) { + default: + case LIGHT: + return Theme.LIGHT; + case DARK: + case BLACK: + return Theme.DARK; + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/theme/AppThemeManager.java b/app/src/main/java/com/amaze/filemanager/ui/theme/AppThemeManager.java new file mode 100644 index 0000000..12cfcfc --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/theme/AppThemeManager.java @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.theme; + +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants; + +import android.content.Context; +import android.content.SharedPreferences; + +/** Saves and restores the AppTheme */ +public class AppThemeManager { + private SharedPreferences preferences; + private AppThemePreference appThemePreference; + private final Context context; + + public AppThemeManager(SharedPreferences preferences, Context context) { + this.preferences = preferences; + this.context = context; + String themeId = preferences.getString(PreferencesConstants.FRAGMENT_THEME, "4"); + appThemePreference = AppThemePreference.getTheme(Integer.parseInt(themeId)); + } + + /** + * @return The current Application theme + */ + public AppTheme getAppTheme() { + return appThemePreference.getSimpleTheme(context); + } + + /** + * Change the current theme of the application. The change is saved. + * + * @param appThemePreference The new theme + * @return The theme manager. + */ + public AppThemeManager setAppThemePreference(AppThemePreference appThemePreference) { + this.appThemePreference = appThemePreference; + return this; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/theme/AppThemePreference.kt b/app/src/main/java/com/amaze/filemanager/ui/theme/AppThemePreference.kt new file mode 100644 index 0000000..54c2890 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/theme/AppThemePreference.kt @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2014-2023 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.theme + +import android.content.Context +import android.content.res.Configuration +import android.os.Build +import android.os.PowerManager +import androidx.preference.PreferenceManager +import com.afollestad.materialdialogs.Theme +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants +import java.util.Calendar + +/** + * This enum represents the theme selected by the user in the appearance preferences. + * + * [id] corresponds to the index of the value in the selection dialog in the preferences. + * + * [canBeLight] specifies if the theme can be light in some situations. Used for the "Follow battery saver" + * option. + */ +enum class AppThemePreference(val id: Int, val canBeLight: Boolean) { + LIGHT(0, true), + DARK(1, false), + TIMED(2, true), + BLACK(3, false), + SYSTEM(4, true), + ; + + /** + * Returns the correct [Theme] associated with this [AppThemePreference] based on [context]. + */ + fun getMaterialDialogTheme(context: Context): Theme { + return getSimpleTheme(context).getMaterialDialogTheme() + } + + /** + * Returns the correct [AppTheme]. If this is [AppThemePreference.TIME_INDEX], current time is used to select the theme. + * + * @return The [AppTheme] for the given index + */ + fun getSimpleTheme(context: Context): AppTheme { + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + val followBatterySaver = + preferences.getBoolean( + PreferencesConstants.FRAGMENT_FOLLOW_BATTERY_SAVER, + false, + ) + return getSimpleTheme( + isNightMode(context), + followBatterySaver && isBatterySaverMode(context), + ) + } + + /** + * Returns the correct [AppTheme] based on [isNightMode] and [isBatterySaver]. + */ + fun getSimpleTheme( + isNightMode: Boolean, + isBatterySaver: Boolean, + ): AppTheme { + return if (canBeLight && isBatterySaver) { + AppTheme.DARK + } else { + when (this) { + LIGHT -> AppTheme.LIGHT + DARK -> AppTheme.DARK + TIMED -> { + val hour = Calendar.getInstance()[Calendar.HOUR_OF_DAY] + if (hour <= 6 || hour >= 18) { + AppTheme.DARK + } else { + AppTheme.LIGHT + } + } + + BLACK -> AppTheme.BLACK + SYSTEM -> if (isNightMode) AppTheme.DARK else AppTheme.LIGHT + } + } + } + + /** + * Checks if night mode is on using [context] + */ + private fun isNightMode(context: Context): Boolean { + return ( + context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + == Configuration.UI_MODE_NIGHT_YES + ) + } + + /** + * Checks if battery saver mode is on using [context] + */ + private fun isBatterySaverMode(context: Context): Boolean { + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + powerManager.isPowerSaveMode + } else { + false + } + } + + companion object { + const val LIGHT_INDEX = 0 + const val DARK_INDEX = 1 + const val TIME_INDEX = 2 + const val BLACK_INDEX = 3 + const val SYSTEM_INDEX = 4 + + /** + * Returns the correct AppTheme . If [index] == TIME_INDEX, TIMED is returned. + * + * @param index The theme index + * @return The AppTheme for the given index + */ + @JvmStatic + fun getTheme(index: Int): AppThemePreference { + return when (index) { + LIGHT_INDEX -> LIGHT + DARK_INDEX -> DARK + TIME_INDEX -> TIMED + BLACK_INDEX -> BLACK + SYSTEM_INDEX -> SYSTEM + else -> LIGHT + } + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/views/CheckableCircleView.java b/app/src/main/java/com/amaze/filemanager/ui/views/CheckableCircleView.java new file mode 100644 index 0000000..7584668 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/views/CheckableCircleView.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.views; + +import com.amaze.filemanager.R; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.ColorInt; +import androidx.annotation.Nullable; + +/** This is a circle taht can have a check (√) in the middle */ +public class CheckableCircleView extends View { + + private static final float CHECK_MARGIN_PERCENTUAL = 0.15f; + + private Drawable check; + private Paint paint = new Paint(); + private boolean checked; + + public CheckableCircleView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + check = context.getResources().getDrawable(R.drawable.ic_check_white_24dp); + } + + public void setColor(@ColorInt int color) { + paint.setColor(color); + paint.setAntiAlias(true); + invalidate(); + } + + public void setChecked(boolean checked) { + this.checked = checked; + invalidate(); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + + float min = Math.min(getHeight(), getWidth()); + + canvas.drawCircle(getWidth() / 2f, getHeight() / 2f, min / 2f, paint); + + if (checked) { + float checkMargin = min * CHECK_MARGIN_PERCENTUAL; + check.setBounds( + (int) checkMargin, + (int) checkMargin, + (int) (getWidth() - checkMargin), + (int) (getHeight() - checkMargin)); + check.draw(canvas); + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/views/CircleGradientDrawable.java b/app/src/main/java/com/amaze/filemanager/ui/views/CircleGradientDrawable.java new file mode 100644 index 0000000..ce6b2bd --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/views/CircleGradientDrawable.java @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.views; + +import com.amaze.filemanager.ui.theme.AppTheme; + +import android.graphics.Color; +import android.graphics.drawable.GradientDrawable; +import android.util.DisplayMetrics; + +import androidx.annotation.ColorInt; + +/** + * Created by vishal on 30/5/16. Class used to create background of check icon on selection with a + * Custom {@link Color} and Stroke (boundary) + */ +public class CircleGradientDrawable extends GradientDrawable { + + private static final int STROKE_WIDTH = 2; + private static final String STROKE_COLOR_LIGHT = "#EEEEEE"; + private static final String STROKE_COLOR_DARK = "#424242"; + private DisplayMetrics mDisplayMetrics; + + /** + * Constructor + * + * @param color the hex color of circular icon + * @param appTheme current theme light/dark which will determine the boundary color + * @param metrics to convert the boundary width for {@link #setStroke} method from dp to px + */ + public CircleGradientDrawable(String color, AppTheme appTheme, DisplayMetrics metrics) { + this(appTheme, metrics); + setColor(Color.parseColor(color)); + } + + /** + * Constructor + * + * @param color the color of circular icon + * @param appTheme current theme light/dark which will determine the boundary color + * @param metrics to convert the boundary width for {@link #setStroke} method from dp to px + */ + public CircleGradientDrawable(@ColorInt int color, AppTheme appTheme, DisplayMetrics metrics) { + this(appTheme, metrics); + setColor(color); + } + + public CircleGradientDrawable(AppTheme appTheme, DisplayMetrics metrics) { + this.mDisplayMetrics = metrics; + + setShape(OVAL); + setSize(1, 1); + setStroke( + dpToPx(STROKE_WIDTH), + (appTheme.equals(AppTheme.DARK) || appTheme.equals(AppTheme.BLACK)) + ? Color.parseColor(STROKE_COLOR_DARK) + : Color.parseColor(STROKE_COLOR_LIGHT)); + } + + private int dpToPx(int dp) { + int px = Math.round(mDisplayMetrics.density * dp); + return px; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/views/CircularColorsView.java b/app/src/main/java/com/amaze/filemanager/ui/views/CircularColorsView.java new file mode 100644 index 0000000..928d87f --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/views/CircularColorsView.java @@ -0,0 +1,116 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.views; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.RectF; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.Nullable; + +/** + * @author Emmanuel on 6/10/2017, at 15:41. + */ +public class CircularColorsView extends View { + + private static final float DISTANCE_PERCENTUAL = 0.08f; + private static final float DIAMETER_PERCENTUAL = 0.65f; + private static final int SEMICIRCLE_LINE_WIDTH = 0; + + private boolean paintInitialized = false; + private Paint dividerPaint = new Paint(); + private Paint[] colors = {new Paint(), new Paint(), new Paint(), new Paint()}; + private RectF semicicleRect = new RectF(); + + public CircularColorsView(Context context) { + super(context); + init(); + } + + public CircularColorsView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(); + } + + private void init() { + dividerPaint.setColor(Color.BLACK); + dividerPaint.setStyle(Paint.Style.STROKE); + dividerPaint.setFlags(Paint.ANTI_ALIAS_FLAG); + dividerPaint.setStrokeWidth(SEMICIRCLE_LINE_WIDTH); + } + + public void setDividerColor(int color) { + dividerPaint.setColor(color); + } + + public void setColors(int color, int color1, int color2, int color3) { + colors[0].setColor(color); + colors[1].setColor(color1); + colors[2].setColor(color2); + colors[3].setColor(color3); + + for (Paint p : colors) p.setFlags(Paint.ANTI_ALIAS_FLAG); + + paintInitialized = true; + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + + if (isInEditMode()) setColors(Color.CYAN, Color.RED, Color.GREEN, Color.BLUE); + if (!paintInitialized) throw new IllegalStateException("Paint has not actual color!"); + + float distance = getWidth() * DISTANCE_PERCENTUAL; + + float diameterByHeight = getHeight() * DIAMETER_PERCENTUAL; + float diameterByWidth = (getWidth() - distance * 2) / 3f * DIAMETER_PERCENTUAL; + float diameter = Math.min(diameterByHeight, diameterByWidth); + + float radius = diameter / 2f; + + int centerY = getHeight() / 2; + float[] positionX = { + getWidth() - diameter - distance - diameter - distance - radius, + getWidth() - diameter - distance - radius, + getWidth() - radius + }; + semicicleRect.set( + positionX[0] - radius, centerY - radius, positionX[0] + radius, centerY + radius); + + canvas.drawArc(semicicleRect, 90, 180, true, colors[0]); + canvas.drawArc(semicicleRect, 270, 180, true, colors[1]); + + canvas.drawLine( + semicicleRect.centerX(), + semicicleRect.top, + semicicleRect.centerX(), + semicicleRect.bottom, + dividerPaint); + + canvas.drawCircle(positionX[1], centerY, radius, colors[2]); + canvas.drawCircle(positionX[2], centerY, radius, colors[3]); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/views/CustomLinearSmoothScroller.java b/app/src/main/java/com/amaze/filemanager/ui/views/CustomLinearSmoothScroller.java new file mode 100644 index 0000000..4c34631 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/views/CustomLinearSmoothScroller.java @@ -0,0 +1,370 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.views; + +import android.content.Context; +import android.graphics.PointF; +import android.util.DisplayMetrics; +import android.view.View; +import android.view.animation.DecelerateInterpolator; +import android.view.animation.LinearInterpolator; + +import androidx.recyclerview.widget.RecyclerView; + +/* + * Copyright 2018 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. + */ + +/** + * {@link RecyclerView.SmoothScroller} implementation which uses a {@link LinearInterpolator} until + * the target position becomes a child of the RecyclerView and then uses a {@link + * DecelerateInterpolator} to slowly approach to target position. + * + *

If the {@link RecyclerView.LayoutManager} you are using does not implement the {@link + * RecyclerView.SmoothScroller.ScrollVectorProvider} interface, then you must override the {@link + * #computeScrollVectorForPosition(int)} method. All the LayoutManagers bundled with the support + * library implement this interface. + */ +public class CustomLinearSmoothScroller extends RecyclerView.SmoothScroller { + + private static final boolean DEBUG = false; + + private float MILLISECONDS_PER_INCH = 250f; + + private static final int TARGET_SEEK_SCROLL_DISTANCE_PX = 10000; + + /** + * Align child view's left or top with parent view's left or top + * + * @see #calculateDtToFit(int, int, int, int, int) + * @see #calculateDxToMakeVisible(android.view.View, int) + * @see #calculateDyToMakeVisible(android.view.View, int) + */ + public static final int SNAP_TO_START = -1; + + /** + * Align child view's right or bottom with parent view's right or bottom + * + * @see #calculateDtToFit(int, int, int, int, int) + * @see #calculateDxToMakeVisible(android.view.View, int) + * @see #calculateDyToMakeVisible(android.view.View, int) + */ + public static final int SNAP_TO_END = 1; + + /** + * Decides if the child should be snapped from start or end, depending on where it currently is in + * relation to its parent. + * + *

For instance, if the view is virtually on the left of RecyclerView, using {@code + * SNAP_TO_ANY} is the same as using {@code SNAP_TO_START} + * + * @see #calculateDtToFit(int, int, int, int, int) + * @see #calculateDxToMakeVisible(android.view.View, int) + * @see #calculateDyToMakeVisible(android.view.View, int) + */ + public static final int SNAP_TO_ANY = 0; + + // Trigger a scroll to a further distance than TARGET_SEEK_SCROLL_DISTANCE_PX so that if target + // view is not laid out until interim target position is reached, we can detect the case before + // scrolling slows down and reschedule another interim target scroll + private static final float TARGET_SEEK_EXTRA_SCROLL_RATIO = 1.2f; + + protected final LinearInterpolator mLinearInterpolator = new LinearInterpolator(); + + protected final DecelerateInterpolator mDecelerateInterpolator = new DecelerateInterpolator(); + + protected PointF mTargetVector; + + private final DisplayMetrics mDisplayMetrics; + private boolean mHasCalculatedMillisPerPixel = false; + private float mMillisPerPixel; + + // Temporary variables to keep track of the interim scroll target. These values do not + // point to a real item position, rather point to an estimated location pixels. + protected int mInterimTargetDx = 0; + protected int mInterimTargetDy = 0; + + public CustomLinearSmoothScroller(Context context, boolean isListView) { + mDisplayMetrics = context.getResources().getDisplayMetrics(); + if (!isListView) { + MILLISECONDS_PER_INCH = 500f; + } + } + + /** {@inheritDoc} */ + @Override + protected void onStart() {} + + /** {@inheritDoc} */ + @Override + protected void onTargetFound(View targetView, RecyclerView.State state, Action action) { + final int dx = calculateDxToMakeVisible(targetView, getHorizontalSnapPreference()); + final int dy = calculateDyToMakeVisible(targetView, getVerticalSnapPreference()); + final int distance = (int) Math.sqrt(dx * dx + dy * dy); + final int time = calculateTimeForDeceleration(distance); + if (time > 0) { + action.update(-dx, -dy, time, mDecelerateInterpolator); + } + } + + /** {@inheritDoc} */ + @Override + protected void onSeekTargetStep(int dx, int dy, RecyclerView.State state, Action action) { + // TODO(b/72745539): Is there ever a time when onSeekTargetStep should be called when + // getChildCount returns 0? Should this logic be extracted out of this method such that + // this method is not called if getChildCount() returns 0? + if (getChildCount() == 0) { + stop(); + return; + } + //noinspection PointlessBooleanExpression + if (DEBUG + && mTargetVector != null + && ((mTargetVector.x * dx < 0 || mTargetVector.y * dy < 0))) { + throw new IllegalStateException( + "Scroll happened in the opposite direction" + + " of the target. Some calculations are wrong"); + } + mInterimTargetDx = clampApplyScroll(mInterimTargetDx, dx); + mInterimTargetDy = clampApplyScroll(mInterimTargetDy, dy); + + if (mInterimTargetDx == 0 && mInterimTargetDy == 0) { + updateActionForInterimTarget(action); + } // everything is valid, keep going + } + + /** {@inheritDoc} */ + @Override + protected void onStop() { + mInterimTargetDx = mInterimTargetDy = 0; + mTargetVector = null; + } + + /** + * Calculates the scroll speed. + * + *

By default, LinearSmoothScroller assumes this method always returns the same value and + * caches the result of calling it. + * + * @param displayMetrics DisplayMetrics to be used for real dimension calculations + * @return The time (in ms) it should take for each pixel. For instance, if returned value is 2 + * ms, it means scrolling 1000 pixels with LinearInterpolation should take 2 seconds. + */ + protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) { + return MILLISECONDS_PER_INCH / displayMetrics.densityDpi; + } + + private float getSpeedPerPixel() { + if (!mHasCalculatedMillisPerPixel) { + mMillisPerPixel = calculateSpeedPerPixel(mDisplayMetrics); + mHasCalculatedMillisPerPixel = true; + } + return mMillisPerPixel; + } + + /** + * Calculates the time for deceleration so that transition from LinearInterpolator to + * DecelerateInterpolator looks smooth. + * + * @param dx Distance to scroll + * @return Time for DecelerateInterpolator to smoothly traverse the distance when transitioning + * from LinearInterpolation + */ + protected int calculateTimeForDeceleration(int dx) { + // we want to cover same area with the linear interpolator for the first 10% of the + // interpolation. After that, deceleration will take control. + // area under curve (1-(1-x)^2) can be calculated as (1 - x/3) * x * x + // which gives 0.100028 when x = .3356 + // this is why we divide linear scrolling time with .3356 + return (int) Math.ceil(calculateTimeForScrolling(dx) / .3356); + } + + /** + * Calculates the time it should take to scroll the given distance (in pixels) + * + * @param dx Distance in pixels that we want to scroll + * @return Time in milliseconds + * @see #calculateSpeedPerPixel(android.util.DisplayMetrics) + */ + protected int calculateTimeForScrolling(int dx) { + // In a case where dx is very small, rounding may return 0 although dx > 0. + // To avoid that issue, ceil the result so that if dx > 0, we'll always return positive + // time. + return (int) Math.ceil(Math.abs(dx) * getSpeedPerPixel()); + } + + /** + * When scrolling towards a child view, this method defines whether we should align the left or + * the right edge of the child with the parent RecyclerView. + * + * @return SNAP_TO_START, SNAP_TO_END or SNAP_TO_ANY; depending on the current target vector + * @see #SNAP_TO_START + * @see #SNAP_TO_END + * @see #SNAP_TO_ANY + */ + protected int getHorizontalSnapPreference() { + return mTargetVector == null || mTargetVector.x == 0 + ? SNAP_TO_ANY + : mTargetVector.x > 0 ? SNAP_TO_END : SNAP_TO_START; + } + + /** + * When scrolling towards a child view, this method defines whether we should align the top or the + * bottom edge of the child with the parent RecyclerView. + * + * @return SNAP_TO_START, SNAP_TO_END or SNAP_TO_ANY; depending on the current target vector + * @see #SNAP_TO_START + * @see #SNAP_TO_END + * @see #SNAP_TO_ANY + */ + protected int getVerticalSnapPreference() { + return mTargetVector == null || mTargetVector.y == 0 + ? SNAP_TO_ANY + : mTargetVector.y > 0 ? SNAP_TO_END : SNAP_TO_START; + } + + /** + * When the target scroll position is not a child of the RecyclerView, this method calculates a + * direction vector towards that child and triggers a smooth scroll. + * + * @see #computeScrollVectorForPosition(int) + */ + protected void updateActionForInterimTarget(Action action) { + // find an interim target position + PointF scrollVector = computeScrollVectorForPosition(getTargetPosition()); + if (scrollVector == null || (scrollVector.x == 0 && scrollVector.y == 0)) { + final int target = getTargetPosition(); + action.jumpTo(target); + stop(); + return; + } + normalize(scrollVector); + mTargetVector = scrollVector; + + mInterimTargetDx = (int) (TARGET_SEEK_SCROLL_DISTANCE_PX * scrollVector.x); + mInterimTargetDy = (int) (TARGET_SEEK_SCROLL_DISTANCE_PX * scrollVector.y); + final int time = calculateTimeForScrolling(TARGET_SEEK_SCROLL_DISTANCE_PX); + // To avoid UI hiccups, trigger a smooth scroll to a distance little further than the + // interim target. Since we track the distance travelled in onSeekTargetStep callback, it + // won't actually scroll more than what we need. + action.update( + (int) (mInterimTargetDx * TARGET_SEEK_EXTRA_SCROLL_RATIO), + (int) (mInterimTargetDy * TARGET_SEEK_EXTRA_SCROLL_RATIO), + (int) (time * TARGET_SEEK_EXTRA_SCROLL_RATIO), + mLinearInterpolator); + } + + private int clampApplyScroll(int tmpDt, int dt) { + final int before = tmpDt; + tmpDt -= dt; + if (before * tmpDt <= 0) { // changed sign, reached 0 or was 0, reset + return 0; + } + return tmpDt; + } + + /** + * Helper method for {@link #calculateDxToMakeVisible(android.view.View, int)} and {@link + * #calculateDyToMakeVisible(android.view.View, int)} + */ + public int calculateDtToFit( + int viewStart, int viewEnd, int boxStart, int boxEnd, int snapPreference) { + switch (snapPreference) { + case SNAP_TO_START: + return boxStart - viewStart; + case SNAP_TO_END: + return boxEnd - viewEnd; + case SNAP_TO_ANY: + final int dtStart = boxStart - viewStart; + if (dtStart > 0) { + return dtStart; + } + final int dtEnd = boxEnd - viewEnd; + if (dtEnd < 0) { + return dtEnd; + } + break; + default: + throw new IllegalArgumentException( + "snap preference should be one of the" + + " constants defined in SmoothScroller, starting with SNAP_"); + } + return 0; + } + + /** + * Calculates the vertical scroll amount necessary to make the given view fully visible inside the + * RecyclerView. + * + * @param view The view which we want to make fully visible + * @param snapPreference The edge which the view should snap to when entering the visible area. + * One of {@link #SNAP_TO_START}, {@link #SNAP_TO_END} or {@link #SNAP_TO_ANY}. + * @return The vertical scroll amount necessary to make the view visible with the given snap + * preference. + */ + public int calculateDyToMakeVisible(View view, int snapPreference) { + final RecyclerView.LayoutManager layoutManager = getLayoutManager(); + if (layoutManager == null || !layoutManager.canScrollVertically()) { + return 0; + } + final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) view.getLayoutParams(); + final int top = layoutManager.getDecoratedTop(view) - params.topMargin; + final int bottom = layoutManager.getDecoratedBottom(view) + params.bottomMargin; + final int start = layoutManager.getPaddingTop(); + final int end = layoutManager.getHeight() - layoutManager.getPaddingBottom(); + return calculateDtToFit(top, bottom, start, end, snapPreference); + } + + /** + * Calculates the horizontal scroll amount necessary to make the given view fully visible inside + * the RecyclerView. + * + * @param view The view which we want to make fully visible + * @param snapPreference The edge which the view should snap to when entering the visible area. + * One of {@link #SNAP_TO_START}, {@link #SNAP_TO_END} or {@link #SNAP_TO_END} + * @return The vertical scroll amount necessary to make the view visible with the given snap + * preference. + */ + public int calculateDxToMakeVisible(View view, int snapPreference) { + final RecyclerView.LayoutManager layoutManager = getLayoutManager(); + if (layoutManager == null || !layoutManager.canScrollHorizontally()) { + return 0; + } + final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) view.getLayoutParams(); + final int left = layoutManager.getDecoratedLeft(view) - params.leftMargin; + final int right = layoutManager.getDecoratedRight(view) + params.rightMargin; + final int start = layoutManager.getPaddingLeft(); + final int end = layoutManager.getWidth() - layoutManager.getPaddingRight(); + return calculateDtToFit(left, right, start, end, snapPreference); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/views/CustomScrollGridLayoutManager.kt b/app/src/main/java/com/amaze/filemanager/ui/views/CustomScrollGridLayoutManager.kt new file mode 100644 index 0000000..f7681d3 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/views/CustomScrollGridLayoutManager.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.views + +import android.content.Context +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView + +class CustomScrollGridLayoutManager(context: Context?, spanCount: Int) : + GridLayoutManager(context, spanCount) { + override fun smoothScrollToPosition( + recyclerView: RecyclerView?, + state: RecyclerView.State?, + position: Int, + ) { + val linearSmoothScroller = CustomLinearSmoothScroller(recyclerView!!.context, false) + linearSmoothScroller.targetPosition = position + startSmoothScroll(linearSmoothScroller) + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/views/CustomScrollLinearLayoutManager.kt b/app/src/main/java/com/amaze/filemanager/ui/views/CustomScrollLinearLayoutManager.kt new file mode 100644 index 0000000..b25b8fc --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/views/CustomScrollLinearLayoutManager.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.views + +import android.content.Context +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView + +class CustomScrollLinearLayoutManager(context: Context?) : LinearLayoutManager(context) { + override fun smoothScrollToPosition( + recyclerView: RecyclerView?, + state: RecyclerView.State?, + position: Int, + ) { + val linearSmoothScroller = CustomLinearSmoothScroller(recyclerView!!.context, true) + linearSmoothScroller.targetPosition = position + startSmoothScroll(linearSmoothScroller) + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/views/CustomZoomFocusChange.java b/app/src/main/java/com/amaze/filemanager/ui/views/CustomZoomFocusChange.java new file mode 100644 index 0000000..890878b --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/views/CustomZoomFocusChange.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.views; + +import com.amaze.filemanager.utils.Utils; + +import android.graphics.PointF; +import android.view.View; + +/** Use this with any widget that should be zoomed when it gains focus */ +public class CustomZoomFocusChange implements View.OnFocusChangeListener { + @Override + public void onFocusChange(View v, boolean hasFocus) { + if (!hasFocus) { + Utils.zoom(1f, 1f, new PointF(v.getWidth() / 2, v.getHeight() / 2), v); + } else { + Utils.zoom(1.2f, 1.2f, new PointF(v.getWidth() / 2, v.getHeight() / 2), v); + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/views/DividerItemDecoration.java b/app/src/main/java/com/amaze/filemanager/ui/views/DividerItemDecoration.java new file mode 100644 index 0000000..8c0f2b1 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/views/DividerItemDecoration.java @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.views; + +import com.amaze.filemanager.adapters.RecyclerAdapter; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.view.View; + +import androidx.recyclerview.widget.RecyclerView; + +/** Created by Arpit on 23-04-2015. */ +public class DividerItemDecoration extends RecyclerView.ItemDecoration { + + private static final int[] ATTRS = new int[] {android.R.attr.listDivider}; + + private Drawable mDivider; + + private boolean show; + private int leftPaddingPx = 0, rightPaddingPx = 0; + private boolean showtopbottomdividers; + + public DividerItemDecoration(Context context, boolean showtopbottomdividers, boolean show) { + final TypedArray typedArray = context.obtainStyledAttributes(ATTRS); + mDivider = typedArray.getDrawable(0); + typedArray.recycle(); + this.show = show; + this.showtopbottomdividers = showtopbottomdividers; + leftPaddingPx = (int) (72 * (context.getResources().getDisplayMetrics().densityDpi / 160f)); + rightPaddingPx = (int) (16 * (context.getResources().getDisplayMetrics().densityDpi / 160f)); + } + + @Override + public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) { + super.onDraw(c, parent, state); + + if (!show) return; + if (mDivider != null) drawVertical(c, parent); + } + + /** + * Draws the divider on the canvas provided by RecyclerView Be advised - divider will be drawn + * before the views, hence it'll be below the views of adapter + */ + private void drawVertical(Canvas c, RecyclerView parent) { + final int left = parent.getPaddingLeft() + leftPaddingPx; + final int right = parent.getWidth() - parent.getPaddingRight() - rightPaddingPx; + + final int childCount = parent.getChildCount(); + for (int i = showtopbottomdividers ? 0 : 1; i < childCount - 1; i++) { + + final View child = parent.getChildAt(i); + + int viewType = parent.getChildViewHolder(child).getItemViewType(); + if (viewType == RecyclerAdapter.TYPE_HEADER_FILES + || viewType == RecyclerAdapter.TYPE_HEADER_FOLDERS) { + // no need to decorate header views + continue; + } + + final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child.getLayoutParams(); + final int top = child.getBottom() + params.bottomMargin; + final int bottom = top + mDivider.getIntrinsicHeight(); + mDivider.setBounds(left, top, right, bottom); + mDivider.draw(c); + } + } + + @Override + public void getItemOffsets( + Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { + super.getItemOffsets(outRect, view, parent, state); + + if (parent.getChildAdapterPosition(view) == 0) { + + // not to draw an offset at the top of recycler view + return; + } + + outRect.top = mDivider.getIntrinsicHeight(); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/views/FastScroller.java b/app/src/main/java/com/amaze/filemanager/ui/views/FastScroller.java new file mode 100644 index 0000000..c3bd60b --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/views/FastScroller.java @@ -0,0 +1,255 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.views; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.utils.Utils; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.InsetDrawable; +import android.graphics.drawable.StateListDrawable; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.widget.FrameLayout; + +import androidx.annotation.AttrRes; +import androidx.annotation.NonNull; +import androidx.appcompat.widget.AppCompatImageView; +import androidx.core.content.ContextCompat; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.RecyclerView.OnScrollListener; + +public class FastScroller extends FrameLayout { + private View bar; + private AppCompatImageView handle; + private RecyclerView recyclerView; + private final ScrollListener scrollListener; + boolean manuallyChangingPosition = false; + int columns = 1; + + private class ScrollListener extends OnScrollListener { + public void onScrolled(RecyclerView recyclerView, int i, int i2) { + if (handle != null && !manuallyChangingPosition) { + updateHandlePosition(); + } + } + } + + public FastScroller(@NonNull Context context, AttributeSet attributeSet) { + super(context, attributeSet); + this.scrollListener = new ScrollListener(); + initialise(context); + } + + public FastScroller(@NonNull Context context, AttributeSet attributeSet, int i) { + super(context, attributeSet, i); + this.scrollListener = new ScrollListener(); + initialise(context); + } + + private float computeHandlePosition() { + View firstVisibleView = recyclerView.getChildAt(0); + handle.setVisibility(VISIBLE); + float recyclerViewOversize; // how much is recyclerView bigger than fastScroller + int recyclerViewAbsoluteScroll; + if (firstVisibleView == null || recyclerView == null) return -1; + recyclerViewOversize = + firstVisibleView.getHeight() / columns * recyclerView.getAdapter().getItemCount() + - getHeightMinusPadding(); + recyclerViewAbsoluteScroll = + recyclerView.getChildLayoutPosition(firstVisibleView) + / columns + * firstVisibleView.getHeight() + - firstVisibleView.getTop(); + return recyclerViewAbsoluteScroll / recyclerViewOversize; + } + + private int getHeightMinusPadding() { + return (getHeight() - getPaddingBottom()) - getPaddingTop(); + } + + private void initialise(@NonNull Context context) { + setClipChildren(false); + inflate(context, R.layout.fastscroller, this); + this.handle = findViewById(R.id.scroll_handle); + this.bar = findViewById(R.id.scroll_bar); + this.handle.setEnabled(true); + setPressedHandleColor(Utils.getColor(getContext(), R.color.accent_blue)); + setUpBarBackground(); + setVisibility(VISIBLE); + } + + private void setHandlePosition1(float relativePos) { + handle.setY( + Utils.clamp( + 0, + getHeightMinusPadding() - handle.getHeight(), + relativePos * (getHeightMinusPadding() - handle.getHeight()))); + } + + private void setUpBarBackground() { + InsetDrawable insetDrawable; + int resolveColor = resolveColor(getContext(), R.attr.colorControlNormal); + insetDrawable = + new InsetDrawable( + new ColorDrawable(resolveColor), + getResources().getDimensionPixelSize(R.dimen.fastscroller_track_padding), + 0, + 0, + 0); + this.bar.setBackgroundDrawable(insetDrawable); + } + + int resolveColor(@NonNull Context context, @AttrRes int i) { + TypedArray obtainStyledAttributes = context.obtainStyledAttributes(new int[] {i}); + int color = obtainStyledAttributes.getColor(0, 0); + obtainStyledAttributes.recycle(); + return color; + } + + onTouchListener a; + + public boolean onTouchEvent(@NonNull MotionEvent motionEvent) { + if (motionEvent.getAction() == 0 || motionEvent.getAction() == 2) { + this.handle.setPressed(true); + bar.setVisibility(VISIBLE); + float relativePos = getRelativeTouchPosition(motionEvent); + setHandlePosition1(relativePos); + manuallyChangingPosition = true; + setRecyclerViewPosition(relativePos); + // showIfHidden(); + if (a != null) a.onTouch(); + return true; + } else if (motionEvent.getAction() != 1) { + return super.onTouchEvent(motionEvent); + } else { + bar.setVisibility(INVISIBLE); + manuallyChangingPosition = false; + this.handle.setPressed(false); + // scheduleHide(); + return true; + } + } + + private void invalidateVisibility() { + if (recyclerView.getAdapter() == null + || recyclerView.getAdapter().getItemCount() == 0 + || recyclerView.getChildAt(0) == null + || isRecyclerViewScrollable()) { + setVisibility(INVISIBLE); + } else { + setVisibility(VISIBLE); + } + } + + private boolean isRecyclerViewScrollable() { + return recyclerView.getChildAt(0).getHeight() + * recyclerView.getAdapter().getItemCount() + / columns + <= getHeightMinusPadding() + || recyclerView.getAdapter().getItemCount() / columns < 25; + } + + private void setRecyclerViewPosition(float relativePos) { + if (recyclerView != null) { + int itemCount = recyclerView.getAdapter().getItemCount(); + int targetPos = (int) Utils.clamp(0, itemCount - 1, (int) (relativePos * (float) itemCount)); + recyclerView.scrollToPosition(targetPos); + } + } + + private float getRelativeTouchPosition(MotionEvent event) { + float yInParent = event.getRawY() - Utils.getViewRawY(handle); + return yInParent / (getHeightMinusPadding() - handle.getHeight()); + } + + public interface onTouchListener { + void onTouch(); + } + + public void registerOnTouchListener(onTouchListener onTouchListener) { + a = onTouchListener; + } + + public void setPressedHandleColor(int i) { + handle.setColorFilter(i); + StateListDrawable stateListDrawable = new StateListDrawable(); + Drawable drawable = + ContextCompat.getDrawable(getContext(), R.drawable.fastscroller_handle_normal); + Drawable drawable1 = + ContextCompat.getDrawable(getContext(), R.drawable.fastscroller_handle_pressed); + stateListDrawable.addState( + View.PRESSED_ENABLED_STATE_SET, + new InsetDrawable( + drawable1, + getResources().getDimensionPixelSize(R.dimen.fastscroller_track_padding), + 0, + 0, + 0)); + stateListDrawable.addState( + View.EMPTY_STATE_SET, + new InsetDrawable( + drawable, + getResources().getDimensionPixelSize(R.dimen.fastscroller_track_padding), + 0, + 0, + 0)); + this.handle.setImageDrawable(stateListDrawable); + } + + public void setRecyclerView(@NonNull RecyclerView recyclerView, int columns) { + this.recyclerView = recyclerView; + this.columns = columns; + bar.setVisibility(INVISIBLE); + recyclerView.addOnScrollListener(this.scrollListener); + invalidateVisibility(); + recyclerView.setOnHierarchyChangeListener( + new OnHierarchyChangeListener() { + @Override + public void onChildViewAdded(View parent, View child) { + invalidateVisibility(); + } + + @Override + public void onChildViewRemoved(View parent, View child) { + invalidateVisibility(); + } + }); + } + + void updateHandlePosition() { + setHandlePosition1(computeHandlePosition()); + } + + int vx1 = -1; + + public void updateHandlePosition(int vx, int l) { + if (vx != vx1) { + setPadding(getPaddingLeft(), getPaddingTop(), getPaddingRight(), l + vx); + setHandlePosition1(computeHandlePosition()); + vx1 = vx; + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/views/Indicator.java b/app/src/main/java/com/amaze/filemanager/ui/views/Indicator.java new file mode 100644 index 0000000..8631ae9 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/views/Indicator.java @@ -0,0 +1,822 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.views; + +import java.util.Arrays; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.utils.AnimUtils; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ValueAnimator; +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RectF; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; +import android.view.animation.Interpolator; + +import androidx.recyclerview.widget.RecyclerView; +import androidx.viewpager.widget.ViewPager; +import androidx.viewpager2.widget.ViewPager2; + +/** An ink inspired widget for indicating pages in a {@link ViewPager}. */ +public class Indicator extends View implements View.OnAttachStateChangeListener { + + // defaults + private static final int DEFAULT_DOT_SIZE = 8; // dp + private static final int DEFAULT_GAP = 12; // dp + private static final int DEFAULT_ANIM_DURATION = 400; // ms + private static final int DEFAULT_UNSELECTED_COLOUR = 0x80ffffff; // 50% white + private static final int DEFAULT_SELECTED_COLOUR = 0xffffffff; // 100% white + + // constants + private static final float INVALID_FRACTION = -1f; + private static final float MINIMAL_REVEAL = 0.00001f; + + // configurable attributes + private int dotDiameter; + private int gap; + private long animDuration; + private int unselectedColour; + private int selectedColour; + + // derived from attributes + private float dotRadius; + private float halfDotRadius; + private long animHalfDuration; + private float dotTopY; + private float dotCenterY; + private float dotBottomY; + + // ViewPager + private ViewPager2 viewPager; + + // state + private int pageCount; + private int currentPage; + private int previousPage; + private float selectedDotX; + private boolean selectedDotInPosition; + private float[] dotCenterX; + private float[] joiningFractions; + private float retreatingJoinX1; + private float retreatingJoinX2; + private float[] dotRevealFractions; + private boolean isAttachedToWindow; + private boolean pageChanging; + + // drawing + private final Paint unselectedPaint; + private final Paint selectedPaint; + private final Path combinedUnselectedPath; + private final Path unselectedDotPath; + private final Path unselectedDotLeftPath; + private final Path unselectedDotRightPath; + private final RectF rectF; + + // animation + private ValueAnimator moveAnimation; + private PendingRetreatAnimator retreatAnimation; + private PendingRevealAnimator[] revealAnimations; + private final Interpolator interpolator; + + // working values for beziers + float endX1; + float endY1; + float endX2; + float endY2; + float controlX1; + float controlY1; + float controlX2; + float controlY2; + + public Indicator(Context context) { + this(context, null, 0); + } + + public Indicator(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public Indicator(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + final int density = (int) context.getResources().getDisplayMetrics().density; + + // Load attributes + final TypedArray a = + getContext().obtainStyledAttributes(attrs, R.styleable.Indicator, defStyle, 0); + + dotDiameter = + a.getDimensionPixelSize(R.styleable.Indicator_dotDiameter, DEFAULT_DOT_SIZE * density); + dotRadius = dotDiameter / 2; + halfDotRadius = dotRadius / 2; + gap = a.getDimensionPixelSize(R.styleable.Indicator_dotGap, DEFAULT_GAP * density); + animDuration = + (long) a.getInteger(R.styleable.Indicator_animationDuration, DEFAULT_ANIM_DURATION); + animHalfDuration = animDuration / 2; + selectedColour = + a.getColor(R.styleable.Indicator_currentPageIndicatorColor, DEFAULT_SELECTED_COLOUR); + // half transparent accent color + unselectedColour = + Color.argb( + 80, Color.red(selectedColour), Color.green(selectedColour), Color.blue(selectedColour)); + + a.recycle(); + + unselectedPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + unselectedPaint.setColor(unselectedColour); + selectedPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + selectedPaint.setColor(selectedColour); + interpolator = AnimUtils.getFastOutSlowInInterpolator(context); + + // create paths & rect now – reuse & rewind later + combinedUnselectedPath = new Path(); + unselectedDotPath = new Path(); + unselectedDotLeftPath = new Path(); + unselectedDotRightPath = new Path(); + rectF = new RectF(); + + addOnAttachStateChangeListener(this); + } + + public void setViewPager(ViewPager2 viewPager) { + this.viewPager = viewPager; + viewPager.registerOnPageChangeCallback(new OnPageChangeCallbackImpl()); + setPageCount(viewPager.getAdapter().getItemCount()); + viewPager + .getAdapter() + .registerAdapterDataObserver( + new RecyclerView.AdapterDataObserver() { + @Override + public void onChanged() { + setPageCount(Indicator.this.viewPager.getAdapter().getItemCount()); + } + }); + setCurrentPageImmediate(); + } + + private class OnPageChangeCallbackImpl extends ViewPager2.OnPageChangeCallback { + @Override + public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { + if (isAttachedToWindow) { + float fraction = positionOffset; + int currentPosition = pageChanging ? previousPage : currentPage; + int leftDotPosition = position; + // when swiping from #2 to #1 ViewPager reports position as 1 and a descending offset + // need to convert this into our left-dot-based 'coordinate space' + if (currentPosition != position) { + fraction = 1f - positionOffset; + + // if user scrolls completely to next page then the position param updates to that + // new page but we're not ready to switch our 'current' page yet so adjust for that + if (fraction == 1f) { + leftDotPosition = Math.min(currentPosition, position); + } + } + setJoiningFraction(leftDotPosition, fraction); + } + } + + @Override + public void onPageSelected(int position) { + if (isAttachedToWindow) { + // this is the main event we're interested in! + setSelectedPage(position); + } else { + // when not attached, don't animate the move, just store immediately + setCurrentPageImmediate(); + } + } + + @Override + public void onPageScrollStateChanged(int state) { + // nothing to do + } + } + + private void setPageCount(int pages) { + pageCount = pages; + resetState(); + requestLayout(); + } + + private void calculateDotPositions(int width, int height) { + int left = getPaddingLeft(); + int top = getPaddingTop(); + int right = width - getPaddingRight(); + int bottom = height - getPaddingBottom(); + + int requiredWidth = getRequiredWidth(); + float startLeft = left + ((right - left - requiredWidth) / 2) + dotRadius; + + dotCenterX = new float[pageCount]; + for (int i = 0; i < pageCount; i++) { + dotCenterX[i] = startLeft + i * (dotDiameter + gap); + } + // todo just top aligning for now… should make this smarter + dotTopY = top; + dotCenterY = top + dotRadius; + dotBottomY = top + dotDiameter; + + setCurrentPageImmediate(); + } + + private void setCurrentPageImmediate() { + if (viewPager != null) { + currentPage = viewPager.getCurrentItem(); + } else { + currentPage = 0; + } + if (dotCenterX != null && dotCenterX.length != 0) { + selectedDotX = dotCenterX[currentPage]; + } else { + selectedDotX = 0; + } + } + + private void resetState() { + joiningFractions = new float[pageCount - 1]; + Arrays.fill(joiningFractions, 0f); + dotRevealFractions = new float[pageCount]; + Arrays.fill(dotRevealFractions, 0f); + retreatingJoinX1 = INVALID_FRACTION; + retreatingJoinX2 = INVALID_FRACTION; + selectedDotInPosition = true; + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + + int desiredHeight = getDesiredHeight(); + int height; + switch (MeasureSpec.getMode(heightMeasureSpec)) { + case MeasureSpec.EXACTLY: + height = MeasureSpec.getSize(heightMeasureSpec); + break; + case MeasureSpec.AT_MOST: + height = Math.min(desiredHeight, MeasureSpec.getSize(heightMeasureSpec)); + break; + default: // MeasureSpec.UNSPECIFIED + height = desiredHeight; + break; + } + + int desiredWidth = getDesiredWidth(); + int width; + switch (MeasureSpec.getMode(widthMeasureSpec)) { + case MeasureSpec.EXACTLY: + width = MeasureSpec.getSize(widthMeasureSpec); + break; + case MeasureSpec.AT_MOST: + width = Math.min(desiredWidth, MeasureSpec.getSize(widthMeasureSpec)); + break; + default: // MeasureSpec.UNSPECIFIED + width = desiredWidth; + break; + } + setMeasuredDimension(width, height); + calculateDotPositions(width, height); + } + + private int getDesiredHeight() { + return getPaddingTop() + dotDiameter + getPaddingBottom(); + } + + private int getRequiredWidth() { + return pageCount * dotDiameter + (pageCount - 1) * gap; + } + + private int getDesiredWidth() { + return getPaddingLeft() + getRequiredWidth() + getPaddingRight(); + } + + @Override + public void onViewAttachedToWindow(View view) { + isAttachedToWindow = true; + } + + @Override + public void onViewDetachedFromWindow(View view) { + isAttachedToWindow = false; + } + + @Override + protected void onDraw(Canvas canvas) { + if (viewPager == null || pageCount == 0) return; + drawUnselected(canvas); + drawSelected(canvas); + } + + private void drawUnselected(Canvas canvas) { + + combinedUnselectedPath.rewind(); + + // draw any settled, revealing or joining dots + for (int page = 0; page < pageCount; page++) { + int nextXIndex = page == pageCount - 1 ? page : page + 1; + combinedUnselectedPath.op( + getUnselectedPath( + page, + dotCenterX[page], + dotCenterX[nextXIndex], + page == pageCount - 1 ? INVALID_FRACTION : joiningFractions[page], + dotRevealFractions[page]), + Path.Op.UNION); + } + // draw any retreating joins + if (retreatingJoinX1 != INVALID_FRACTION) { + combinedUnselectedPath.op(getRetreatingJoinPath(), Path.Op.UNION); + } + canvas.drawPath(combinedUnselectedPath, unselectedPaint); + } + + /** + * Unselected dots can be in 6 states: + * + *

#1 At rest #2 Joining neighbour, still separate #3 Joining neighbour, combined curved #4 + * Joining neighbour, combined straight #5 Join retreating #6 Dot re-showing / revealing + * + *

It can also be in a combination of these states e.g. joining one neighbour while retreating + * from another. We therefore create a Path so that we can examine each dot pair separately and + * later take the union for these cases. + * + *

This function returns a path for the given dot **and any action to it's right** e.g. joining + * or retreating from it's neighbour + */ + private Path getUnselectedPath( + int page, float centerX, float nextCenterX, float joiningFraction, float dotRevealFraction) { + + unselectedDotPath.rewind(); + + if ((joiningFraction == 0f || joiningFraction == INVALID_FRACTION) + && dotRevealFraction == 0f + && !(page == currentPage && selectedDotInPosition)) { + + // case #1 – At rest + unselectedDotPath.addCircle(dotCenterX[page], dotCenterY, dotRadius, Path.Direction.CW); + } + + if (joiningFraction > 0f && joiningFraction <= 0.5f && retreatingJoinX1 == INVALID_FRACTION) { + + // case #2 – Joining neighbour, still separate + + // start with the left dot + unselectedDotLeftPath.rewind(); + + // start at the bottom center + unselectedDotLeftPath.moveTo(centerX, dotBottomY); + + // semi circle to the top center + rectF.set(centerX - dotRadius, dotTopY, centerX + dotRadius, dotBottomY); + unselectedDotLeftPath.arcTo(rectF, 90, 180, true); + + // cubic to the right middle + endX1 = centerX + dotRadius + (joiningFraction * gap); + endY1 = dotCenterY; + controlX1 = centerX + halfDotRadius; + controlY1 = dotTopY; + controlX2 = endX1; + controlY2 = endY1 - halfDotRadius; + unselectedDotLeftPath.cubicTo(controlX1, controlY1, controlX2, controlY2, endX1, endY1); + + // cubic back to the bottom center + endX2 = centerX; + endY2 = dotBottomY; + controlX1 = endX1; + controlY1 = endY1 + halfDotRadius; + controlX2 = centerX + halfDotRadius; + controlY2 = dotBottomY; + unselectedDotLeftPath.cubicTo(controlX1, controlY1, controlX2, controlY2, endX2, endY2); + + unselectedDotPath.op(unselectedDotLeftPath, Path.Op.UNION); + + // now do the next dot to the right + unselectedDotRightPath.rewind(); + + // start at the bottom center + unselectedDotRightPath.moveTo(nextCenterX, dotBottomY); + + // semi circle to the top center + rectF.set(nextCenterX - dotRadius, dotTopY, nextCenterX + dotRadius, dotBottomY); + unselectedDotRightPath.arcTo(rectF, 90, -180, true); + + // cubic to the left middle + endX1 = nextCenterX - dotRadius - (joiningFraction * gap); + endY1 = dotCenterY; + controlX1 = nextCenterX - halfDotRadius; + controlY1 = dotTopY; + controlX2 = endX1; + controlY2 = endY1 - halfDotRadius; + unselectedDotRightPath.cubicTo(controlX1, controlY1, controlX2, controlY2, endX1, endY1); + + // cubic back to the bottom center + endX2 = nextCenterX; + endY2 = dotBottomY; + controlX1 = endX1; + controlY1 = endY1 + halfDotRadius; + controlX2 = endX2 - halfDotRadius; + controlY2 = dotBottomY; + unselectedDotRightPath.cubicTo(controlX1, controlY1, controlX2, controlY2, endX2, endY2); + unselectedDotPath.op(unselectedDotRightPath, Path.Op.UNION); + } + + if (joiningFraction > 0.5f && joiningFraction < 1f && retreatingJoinX1 == INVALID_FRACTION) { + + // case #3 – Joining neighbour, combined curved + + // adjust the fraction so that it goes from 0.3 -> 1 to produce a more realistic 'join' + float adjustedFraction = (joiningFraction - 0.2f) * 1.25f; + + // start in the bottom left + unselectedDotPath.moveTo(centerX, dotBottomY); + + // semi-circle to the top left + rectF.set(centerX - dotRadius, dotTopY, centerX + dotRadius, dotBottomY); + unselectedDotPath.arcTo(rectF, 90, 180, true); + + // bezier to the middle top of the join + endX1 = centerX + dotRadius + (gap / 2); + endY1 = dotCenterY - (adjustedFraction * dotRadius); + controlX1 = endX1 - (adjustedFraction * dotRadius); + controlY1 = dotTopY; + controlX2 = endX1 - ((1 - adjustedFraction) * dotRadius); + controlY2 = endY1; + unselectedDotPath.cubicTo(controlX1, controlY1, controlX2, controlY2, endX1, endY1); + + // bezier to the top right of the join + endX2 = nextCenterX; + endY2 = dotTopY; + controlX1 = endX1 + ((1 - adjustedFraction) * dotRadius); + controlY1 = endY1; + controlX2 = endX1 + (adjustedFraction * dotRadius); + controlY2 = dotTopY; + unselectedDotPath.cubicTo(controlX1, controlY1, controlX2, controlY2, endX2, endY2); + + // semi-circle to the bottom right + rectF.set(nextCenterX - dotRadius, dotTopY, nextCenterX + dotRadius, dotBottomY); + unselectedDotPath.arcTo(rectF, 270, 180, true); + + // bezier to the middle bottom of the join + // endX1 stays the same + endY1 = dotCenterY + (adjustedFraction * dotRadius); + controlX1 = endX1 + (adjustedFraction * dotRadius); + controlY1 = dotBottomY; + controlX2 = endX1 + ((1 - adjustedFraction) * dotRadius); + controlY2 = endY1; + unselectedDotPath.cubicTo(controlX1, controlY1, controlX2, controlY2, endX1, endY1); + + // bezier back to the start point in the bottom left + endX2 = centerX; + endY2 = dotBottomY; + controlX1 = endX1 - ((1 - adjustedFraction) * dotRadius); + controlY1 = endY1; + controlX2 = endX1 - (adjustedFraction * dotRadius); + controlY2 = endY2; + unselectedDotPath.cubicTo(controlX1, controlY1, controlX2, controlY2, endX2, endY2); + } + if (joiningFraction == 1 && retreatingJoinX1 == INVALID_FRACTION) { + + // case #4 Joining neighbour, combined straight technically we could use case 3 for this + // situation as well but assume that this is an optimization rather than faffing around + // with beziers just to draw a rounded rect + rectF.set(centerX - dotRadius, dotTopY, nextCenterX + dotRadius, dotBottomY); + unselectedDotPath.addRoundRect(rectF, dotRadius, dotRadius, Path.Direction.CW); + } + + // case #5 is handled by #getRetreatingJoinPath() + // this is done separately so that we can have a single retreating path spanning + // multiple dots and therefore animate it's movement smoothly + + if (dotRevealFraction > MINIMAL_REVEAL) { + + // case #6 – previously hidden dot revealing + unselectedDotPath.addCircle( + centerX, dotCenterY, dotRevealFraction * dotRadius, Path.Direction.CW); + } + + return unselectedDotPath; + } + + private Path getRetreatingJoinPath() { + unselectedDotPath.rewind(); + rectF.set(retreatingJoinX1, dotTopY, retreatingJoinX2, dotBottomY); + unselectedDotPath.addRoundRect(rectF, dotRadius, dotRadius, Path.Direction.CW); + return unselectedDotPath; + } + + private void drawSelected(Canvas canvas) { + canvas.drawCircle(selectedDotX, dotCenterY, dotRadius, selectedPaint); + } + + private void setSelectedPage(int now) { + if (now == currentPage) return; + + pageChanging = true; + previousPage = currentPage; + currentPage = now; + final int steps = Math.abs(now - previousPage); + + if (steps > 1) { + if (now > previousPage) { + for (int i = 0; i < steps; i++) { + setJoiningFraction(previousPage + i, 1f); + } + } else { + for (int i = -1; i > -steps; i--) { + setJoiningFraction(previousPage + i, 1f); + } + } + } + + // create the anim to move the selected dot – this animator will kick off + // retreat animations when it has moved 75% of the way. + // The retreat animation in turn will kick of reveal anims when the + // retreat has passed any dots to be revealed + moveAnimation = createMoveSelectedAnimator(dotCenterX[now], previousPage, now, steps); + moveAnimation.start(); + } + + private ValueAnimator createMoveSelectedAnimator( + final float moveTo, int was, int now, int steps) { + + // create the actual move animator + ValueAnimator moveSelected = ValueAnimator.ofFloat(selectedDotX, moveTo); + + // also set up a pending retreat anim – this starts when the move is 75% complete + retreatAnimation = + new PendingRetreatAnimator( + was, + now, + steps, + now > was + ? new RightwardStartPredicate(moveTo - ((moveTo - selectedDotX) * 0.25f)) + : new LeftwardStartPredicate(moveTo + ((selectedDotX - moveTo) * 0.25f))); + retreatAnimation.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + resetState(); + pageChanging = false; + } + }); + moveSelected.addUpdateListener( + valueAnimator -> { + // todo avoid autoboxing + selectedDotX = (Float) valueAnimator.getAnimatedValue(); + retreatAnimation.startIfNecessary(selectedDotX); + postInvalidateOnAnimation(); + }); + moveSelected.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + // set a flag so that we continue to draw the unselected dot in the target position + // until the selected dot has finished moving into place + selectedDotInPosition = false; + } + + @Override + public void onAnimationEnd(Animator animation) { + // set a flag when anim finishes so that we don't draw both selected & unselected + // page dots + selectedDotInPosition = true; + } + }); + // slightly delay the start to give the joins a chance to run + // unless dot isn't in position yet – then don't delay! + moveSelected.setStartDelay(selectedDotInPosition ? animDuration / 4L : 0L); + moveSelected.setDuration(animDuration * 3L / 4L); + moveSelected.setInterpolator(interpolator); + return moveSelected; + } + + private void setJoiningFraction(int leftDot, float fraction) { + if (leftDot < joiningFractions.length) { + + if (leftDot == 1) { + Log.d("PageIndicator", "dot 1 fraction:\t" + fraction); + } + + joiningFractions[leftDot] = fraction; + postInvalidateOnAnimation(); + } + } + + private void clearJoiningFractions() { + Arrays.fill(joiningFractions, 0f); + postInvalidateOnAnimation(); + } + + private void setDotRevealFraction(int dot, float fraction) { + dotRevealFractions[dot] = fraction; + postInvalidateOnAnimation(); + } + + private void cancelJoiningAnimations() { + // TODO: 20/08/18 ? + } + + /** A {@link ValueAnimator} that starts once a given predicate returns true. */ + abstract class PendingStartAnimator extends ValueAnimator { + + protected boolean hasStarted; + protected StartPredicate predicate; + + public PendingStartAnimator(StartPredicate predicate) { + super(); + this.predicate = predicate; + hasStarted = false; + } + + public void startIfNecessary(float currentValue) { + if (!hasStarted && predicate.shouldStart(currentValue)) { + start(); + hasStarted = true; + } + } + } + + /** + * An Animator that shows and then shrinks a retreating join between the previous and newly + * selected pages. This also sets up some pending dot reveals – to be started when the retreat has + * passed the dot to be revealed. + */ + private class PendingRetreatAnimator extends PendingStartAnimator { + + PendingRetreatAnimator(int was, int now, int steps, StartPredicate predicate) { + super(predicate); + setDuration(animHalfDuration); + setInterpolator(interpolator); + + // work out the start/end values of the retreating join from the direction we're + // travelling in. Also look at the current selected dot position, i.e. we're moving on + // before a prior anim has finished. + final float initialX1 = + now > was + ? Math.min(dotCenterX[was], selectedDotX) - dotRadius + : dotCenterX[now] - dotRadius; + final float finalX1 = now > was ? dotCenterX[now] - dotRadius : dotCenterX[now] - dotRadius; + final float initialX2 = + now > was + ? dotCenterX[now] + dotRadius + : Math.max(dotCenterX[was], selectedDotX) + dotRadius; + final float finalX2 = now > was ? dotCenterX[now] + dotRadius : dotCenterX[now] + dotRadius; + + revealAnimations = new PendingRevealAnimator[steps]; + // hold on to the indexes of the dots that will be hidden by the retreat so that + // we can initialize their revealFraction's i.e. make sure they're hidden while the + // reveal animation runs + final int[] dotsToHide = new int[steps]; + if (initialX1 != finalX1) { // rightward retreat + setFloatValues(initialX1, finalX1); + // create the reveal animations that will run when the retreat passes them + for (int i = 0; i < steps; i++) { + revealAnimations[i] = + new PendingRevealAnimator(was + i, new RightwardStartPredicate(dotCenterX[was + i])); + dotsToHide[i] = was + i; + } + addUpdateListener( + valueAnimator -> { + // todo avoid autoboxing + retreatingJoinX1 = (Float) valueAnimator.getAnimatedValue(); + postInvalidateOnAnimation(); + // start any reveal animations if we've passed them + for (PendingRevealAnimator pendingReveal : revealAnimations) { + pendingReveal.startIfNecessary(retreatingJoinX1); + } + }); + } else { // (initialX2 != finalX2) leftward retreat + setFloatValues(initialX2, finalX2); + // create the reveal animations that will run when the retreat passes them + for (int i = 0; i < steps; i++) { + revealAnimations[i] = + new PendingRevealAnimator(was - i, new LeftwardStartPredicate(dotCenterX[was - i])); + dotsToHide[i] = was - i; + } + addUpdateListener( + valueAnimator -> { + // todo avoid autoboxing + retreatingJoinX2 = (Float) valueAnimator.getAnimatedValue(); + postInvalidateOnAnimation(); + // start any reveal animations if we've passed them + for (PendingRevealAnimator pendingReveal : revealAnimations) { + pendingReveal.startIfNecessary(retreatingJoinX2); + } + }); + } + + addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + cancelJoiningAnimations(); + clearJoiningFractions(); + // we need to set this so that the dots are hidden until the reveal anim runs + for (int dot : dotsToHide) { + setDotRevealFraction(dot, MINIMAL_REVEAL); + } + retreatingJoinX1 = initialX1; + retreatingJoinX2 = initialX2; + postInvalidateOnAnimation(); + } + + @Override + public void onAnimationEnd(Animator animation) { + retreatingJoinX1 = INVALID_FRACTION; + retreatingJoinX2 = INVALID_FRACTION; + postInvalidateOnAnimation(); + } + }); + } + } + + /** An Animator that animates a given dot's revealFraction i.e. scales it up */ + private class PendingRevealAnimator extends PendingStartAnimator { + + private int dot; + + public PendingRevealAnimator(int dot, StartPredicate predicate) { + super(predicate); + setFloatValues(MINIMAL_REVEAL, 1f); + this.dot = dot; + setDuration(animHalfDuration); + setInterpolator(interpolator); + addUpdateListener( + valueAnimator -> { + // todo avoid autoboxing + setDotRevealFraction( + PendingRevealAnimator.this.dot, (Float) valueAnimator.getAnimatedValue()); + }); + addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + setDotRevealFraction(PendingRevealAnimator.this.dot, 0f); + postInvalidateOnAnimation(); + } + }); + } + } + + /** A predicate used to start an animation when a test passes */ + abstract class StartPredicate { + + protected float thresholdValue; + + public StartPredicate(float thresholdValue) { + this.thresholdValue = thresholdValue; + } + + abstract boolean shouldStart(float currentValue); + } + + /** A predicate used to start an animation when a given value is greater than a threshold */ + private class RightwardStartPredicate extends StartPredicate { + + public RightwardStartPredicate(float thresholdValue) { + super(thresholdValue); + } + + boolean shouldStart(float currentValue) { + return currentValue > thresholdValue; + } + } + + /** A predicate used to start an animation then a given value is less than a threshold */ + private class LeftwardStartPredicate extends StartPredicate { + + public LeftwardStartPredicate(float thresholdValue) { + super(thresholdValue); + } + + boolean shouldStart(float currentValue) { + return currentValue < thresholdValue; + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/views/ScrimInsetsRelativeLayout.java b/app/src/main/java/com/amaze/filemanager/ui/views/ScrimInsetsRelativeLayout.java new file mode 100644 index 0000000..9ff3546 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/views/ScrimInsetsRelativeLayout.java @@ -0,0 +1,147 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.views; + +import com.amaze.filemanager.R; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.widget.RelativeLayout; + +import androidx.core.view.ViewCompat; + +/* + * A layout that draws something in the insets passed to {@link #fitSystemWindows(Rect)}, i.e. the area above UI chrome + * (status and navigation bars, overlay action bars). + */ +public class ScrimInsetsRelativeLayout extends RelativeLayout { + private Drawable mInsetForeground; + + private Rect mInsets; + private Rect mTempRect = new Rect(); + private OnInsetsCallback mOnInsetsCallback; + + public ScrimInsetsRelativeLayout(Context context) { + super(context); + init(context, null, 0); + } + + public ScrimInsetsRelativeLayout(Context context, AttributeSet attrs) { + super(context, attrs); + init(context, attrs, 0); + } + + public ScrimInsetsRelativeLayout(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + init(context, attrs, defStyle); + } + + private void init(Context context, AttributeSet attrs, int defStyle) { + final TypedArray a = + context.obtainStyledAttributes(attrs, R.styleable.ScrimInsetsFrameLayout, defStyle, 0); + if (a == null) { + return; + } + mInsetForeground = a.getDrawable(R.styleable.ScrimInsetsFrameLayout_insetForeground); + a.recycle(); + + setWillNotDraw(true); + } + + @Override + protected boolean fitSystemWindows(Rect insets) { + mInsets = new Rect(insets); + setWillNotDraw(mInsetForeground == null); + ViewCompat.postInvalidateOnAnimation(this); + if (mOnInsetsCallback != null) { + mOnInsetsCallback.onInsetsChanged(insets); + } + return true; // consume insets + } + + @Override + public void draw(Canvas canvas) { + super.draw(canvas); + + int width = getWidth(); + int height = getHeight(); + if (mInsets != null && mInsetForeground != null) { + int sc = canvas.save(); + canvas.translate(getScrollX(), getScrollY()); + + // Top + mTempRect.set(0, 0, width, mInsets.top); + mInsetForeground.setBounds(mTempRect); + mInsetForeground.draw(canvas); + + // Bottom + mTempRect.set(0, height - mInsets.bottom, width, height); + mInsetForeground.setBounds(mTempRect); + mInsetForeground.draw(canvas); + + // Left + mTempRect.set(0, mInsets.top, mInsets.left, height - mInsets.bottom); + mInsetForeground.setBounds(mTempRect); + mInsetForeground.draw(canvas); + + // Right + mTempRect.set(width - mInsets.right, mInsets.top, width, height - mInsets.bottom); + mInsetForeground.setBounds(mTempRect); + mInsetForeground.draw(canvas); + + canvas.restoreToCount(sc); + } + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + if (mInsetForeground != null) { + mInsetForeground.setCallback(this); + } + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + if (mInsetForeground != null) { + mInsetForeground.setCallback(null); + } + } + + /** + * Allows the calling container to specify a callback for custom processing when insets change + * (i.e. when {@link #fitSystemWindows(Rect)} is called. This is useful for setting padding on UI + * elements based on UI chrome insets (e.g. a Google Map or a ListView). When using with ListView + * or GridView, remember to set clipToPadding to false. + */ + public void setOnInsetsCallback(OnInsetsCallback onInsetsCallback) { + mOnInsetsCallback = onInsetsCallback; + } + + public interface OnInsetsCallback { + void onInsetsChanged(Rect insets); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/views/ThemedImageButton.java b/app/src/main/java/com/amaze/filemanager/ui/views/ThemedImageButton.java new file mode 100644 index 0000000..f5ac31f --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/views/ThemedImageButton.java @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.views; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.ImageButton; + +/** + * @author Emmanuel on 8/5/2017, at 13:39. + */ +public class ThemedImageButton extends ThemedImageView { + + public ThemedImageButton(Context context) { + super(context); + } + + public ThemedImageButton(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public ThemedImageButton(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + setFocusable(true); + } + + @Override + protected boolean onSetAlpha(int alpha) { + return false; + } + + @Override + public CharSequence getAccessibilityClassName() { + return ImageButton.class.getName(); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/views/ThemedImageView.java b/app/src/main/java/com/amaze/filemanager/ui/views/ThemedImageView.java new file mode 100644 index 0000000..a8dcf91 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/views/ThemedImageView.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.views; + +import com.amaze.filemanager.ui.activities.superclasses.BasicActivity; +import com.amaze.filemanager.ui.theme.AppTheme; + +import android.app.Activity; +import android.content.Context; +import android.content.ContextWrapper; +import android.graphics.Color; +import android.util.AttributeSet; + +/** + * Created by vishal on 18/2/17. + * + *

A custom image view which adds an extra attribute to determine a source image when in material + * dark preference + */ +public class ThemedImageView extends androidx.appcompat.widget.AppCompatImageView { + + public ThemedImageView(Context context) { + this(context, null, 0); + } + + public ThemedImageView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public ThemedImageView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + BasicActivity a = (BasicActivity) getActivity(); + + // dark preference found + if (a != null + && (a.getAppTheme().equals(AppTheme.DARK) || a.getAppTheme().equals(AppTheme.BLACK))) { + setColorFilter(Color.argb(255, 255, 255, 255)); // White Tint + } else if (a == null) { + throw new IllegalStateException("Could not get activity! Can't show correct icon color!"); + } + } + + private Activity getActivity() { + Context context = getContext(); + while (context instanceof ContextWrapper) { + if (context instanceof Activity) { + return (Activity) context; + } + context = ((ContextWrapper) context).getBaseContext(); + } + return null; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/views/ThemedTextView.java b/app/src/main/java/com/amaze/filemanager/ui/views/ThemedTextView.java new file mode 100644 index 0000000..eb72dc4 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/views/ThemedTextView.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.views; + +import org.jetbrains.annotations.NotNull; + +import com.amaze.filemanager.ui.activities.MainActivity; +import com.amaze.filemanager.ui.theme.AppTheme; +import com.amaze.filemanager.utils.Utils; + +import android.content.Context; +import android.util.AttributeSet; + +import androidx.annotation.NonNull; +import androidx.appcompat.widget.AppCompatTextView; + +/** + * Created by vishal on 18/1/17. + * + *

Class sets text color based on current theme, without explicit method call in app lifecycle To + * be used only under themed activity context + */ +public class ThemedTextView extends AppCompatTextView { + + public ThemedTextView(Context context, AttributeSet attrs) { + super(context, attrs); + setTextViewColor(this, context); + } + + public static void setTextViewColor( + @NotNull AppCompatTextView textView, @NonNull Context context) { + if (((MainActivity) context).getAppTheme().equals(AppTheme.LIGHT)) { + textView.setTextColor(Utils.getColor(context, android.R.color.black)); + } else if (((MainActivity) context).getAppTheme().equals(AppTheme.DARK) + || ((MainActivity) context).getAppTheme().equals(AppTheme.BLACK)) { + textView.setTextColor(Utils.getColor(context, android.R.color.white)); + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/views/WarnableTextInputLayout.java b/app/src/main/java/com/amaze/filemanager/ui/views/WarnableTextInputLayout.java new file mode 100644 index 0000000..887e938 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/views/WarnableTextInputLayout.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.views; + +import com.amaze.filemanager.R; +import com.google.android.material.textfield.TextInputLayout; + +import android.content.Context; +import android.util.AttributeSet; + +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; + +/** + * @author Emmanuel Messulam on 31/1/2018, at 14:50. + */ +public class WarnableTextInputLayout extends TextInputLayout { + + private boolean isStyleWarning = false; + + public WarnableTextInputLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + /** Remove error or warning */ + public void removeError() { + super.setError(null); + setErrorEnabled(false); + } + + @Override + public void setError(@Nullable CharSequence error) { + if (isStyleWarning) { + setErrorEnabled(true); + setErrorTextAppearance(R.style.error_inputTextLayout); + isStyleWarning = false; + } + super.setError(error); + } + + public void setWarning(@StringRes int text) { + if (!isStyleWarning) { + removeError(); + setErrorEnabled(true); + setErrorTextAppearance(R.style.warning_inputTextLayout); + isStyleWarning = true; + } + super.setError(getContext().getString(text)); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/views/WarnableTextInputValidator.java b/app/src/main/java/com/amaze/filemanager/ui/views/WarnableTextInputValidator.java new file mode 100644 index 0000000..ac474a2 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/views/WarnableTextInputValidator.java @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.views; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.utils.SimpleTextWatcher; + +import android.content.Context; +import android.text.Editable; +import android.view.MotionEvent; +import android.view.View; + +import androidx.annotation.DrawableRes; +import androidx.annotation.StringRes; +import androidx.appcompat.widget.AppCompatEditText; + +public final class WarnableTextInputValidator extends SimpleTextWatcher + implements View.OnFocusChangeListener, View.OnTouchListener { + private final Context context; + private final AppCompatEditText editText; + private final View button; + private final WarnableTextInputLayout textInputLayout; + private final OnTextValidate validator; + private @DrawableRes int warningDrawable, errorDrawable; + + public WarnableTextInputValidator( + Context context, + AppCompatEditText editText, + WarnableTextInputLayout textInputLayout, + View positiveButton, + OnTextValidate validator) { + this.context = context; + this.editText = editText; + this.editText.setOnFocusChangeListener(this); + this.editText.addTextChangedListener(this); + this.textInputLayout = textInputLayout; + button = positiveButton; + button.setOnTouchListener(this); + button.setEnabled(false); + this.validator = validator; + + warningDrawable = R.drawable.ic_warning_24dp; + errorDrawable = R.drawable.ic_error_24dp; + } + + @Override + public void onFocusChange(View v, boolean hasFocus) { + if (!hasFocus) { + int state = doValidate(false); + button.setEnabled(state != ReturnState.STATE_ERROR); + } + } + + @Override + public boolean onTouch(View v, MotionEvent event) { + return performClick(); + } + + public boolean performClick() { + boolean blockTouchEvent = doValidate(false) == ReturnState.STATE_ERROR; + return blockTouchEvent; + } + + @Override + public void afterTextChanged(Editable s) { + doValidate(false); + } + + /** + * @return ReturnState.state + */ + private int doValidate(boolean onlySetWarning) { + ReturnState state = validator.isTextValid(editText.getText().toString()); + switch (state.state) { + case ReturnState.STATE_NORMAL: + textInputLayout.removeError(); + setEditTextIcon(null); + button.setEnabled(true); + break; + case ReturnState.STATE_ERROR: + if (!onlySetWarning) { + textInputLayout.setError(context.getString(state.text)); + setEditTextIcon(errorDrawable); + } + button.setEnabled(false); + break; + case ReturnState.STATE_WARNING: + textInputLayout.setWarning(state.text); + setEditTextIcon(warningDrawable); + button.setEnabled(true); + break; + } + + return state.state; + } + + private void setEditTextIcon(@DrawableRes Integer drawable) { + @DrawableRes int drawableInt = drawable != null ? drawable : 0; + editText.setCompoundDrawablesWithIntrinsicBounds(0, 0, drawableInt, 0); + } + + public interface OnTextValidate { + ReturnState isTextValid(String text); + } + + public static class ReturnState { + public static final int STATE_NORMAL = 0, STATE_ERROR = -1, STATE_WARNING = -2; + + public final int state; + public final @StringRes int text; + + public ReturnState() { + state = STATE_NORMAL; + text = 0; + } + + public ReturnState(int state, @StringRes int text) { + this.state = state; + this.text = text; + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/views/appbar/AppBar.java b/app/src/main/java/com/amaze/filemanager/ui/views/appbar/AppBar.java new file mode 100644 index 0000000..d0edd12 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/views/appbar/AppBar.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.views.appbar; + +import static android.os.Build.VERSION.SDK_INT; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.ui.activities.MainActivity; +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants; +import com.google.android.material.appbar.AppBarLayout; + +import android.content.SharedPreferences; + +import androidx.annotation.StringRes; +import androidx.appcompat.widget.Toolbar; + +/** + * layout_appbar.xml contains the layout for AppBar and BottomBar + * + *

This is a class containing containing methods to each section of the AppBar, creating the + * object loads the views. + * + * @author Emmanuel on 2/8/2017, at 23:27. + */ +public class AppBar { + + private int TOOLBAR_START_INSET; + + private Toolbar toolbar; + private SearchView searchView; + private BottomBar bottomBar; + + private AppBarLayout appbarLayout; + + public AppBar(MainActivity a, SharedPreferences sharedPref) { + toolbar = a.findViewById(R.id.action_bar); + searchView = new SearchView(this, a); + bottomBar = new BottomBar(this, a); + + appbarLayout = a.findViewById(R.id.lin); + + if (SDK_INT >= 21) toolbar.setElevation(0); + /* For SearchView, see onCreateOptionsMenu(Menu menu)*/ + TOOLBAR_START_INSET = toolbar.getContentInsetStart(); + + if (!sharedPref.getBoolean(PreferencesConstants.PREFERENCE_INTELLI_HIDE_TOOLBAR, true)) { + AppBarLayout.LayoutParams params = (AppBarLayout.LayoutParams) toolbar.getLayoutParams(); + params.setScrollFlags(0); + appbarLayout.setExpanded(true, true); + } + } + + public Toolbar getToolbar() { + return toolbar; + } + + public SearchView getSearchView() { + return searchView; + } + + public BottomBar getBottomBar() { + return bottomBar; + } + + public AppBarLayout getAppbarLayout() { + return appbarLayout; + } + + public void setTitle(String title) { + if (toolbar != null) toolbar.setTitle(title); + } + + public void setTitle(@StringRes int title) { + if (toolbar != null) toolbar.setTitle(title); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/views/appbar/BottomBar.java b/app/src/main/java/com/amaze/filemanager/ui/views/appbar/BottomBar.java new file mode 100644 index 0000000..4385a62 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/views/appbar/BottomBar.java @@ -0,0 +1,588 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.views.appbar; + +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_CHANGEPATHS; + +import java.util.ArrayList; +import java.util.Objects; + +import com.amaze.filemanager.R; +import com.amaze.filemanager.fileoperations.filesystem.OpenMode; +import com.amaze.filemanager.filesystem.HybridFile; +import com.amaze.filemanager.filesystem.files.FileUtils; +import com.amaze.filemanager.ui.activities.MainActivity; +import com.amaze.filemanager.ui.dialogs.GeneralDialogCreation; +import com.amaze.filemanager.ui.fragments.CompressedExplorerFragment; +import com.amaze.filemanager.ui.fragments.MainFragment; +import com.amaze.filemanager.ui.fragments.TabFragment; +import com.amaze.filemanager.utils.BottomBarButtonPath; +import com.amaze.filemanager.utils.MainActivityHelper; +import com.amaze.filemanager.utils.Utils; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.os.CountDownTimer; +import android.os.Handler; +import android.view.GestureDetector; +import android.view.Gravity; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; +import android.widget.FrameLayout; +import android.widget.HorizontalScrollView; +import android.widget.LinearLayout; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.appcompat.widget.AppCompatButton; +import androidx.appcompat.widget.AppCompatImageButton; +import androidx.appcompat.widget.AppCompatImageView; +import androidx.appcompat.widget.AppCompatTextView; +import androidx.fragment.app.Fragment; + +/** + * layout_appbar.xml contains the layout for AppBar and BottomBar + * + *

BottomBar, it lays under the toolbar, used to show data of what is being displayed in the + * MainFragment, for example directory, folder and file amounts, etc. + * + * @author Emmanuel on 2/8/2017, at 23:31. + */ +public class BottomBar implements View.OnTouchListener { + private static final int PATH_ANIM_START_DELAY = 0; + private static final int PATH_ANIM_END_DELAY = 0; + + private MainActivity mainActivity; + private AppBar appbar; + private String newPath; + + private FrameLayout frame; + private LinearLayout pathLayout; + private LinearLayout buttons; + private HorizontalScrollView scroll, pathScroll; + private AppCompatTextView pathText, fullPathText, fullPathAnim; + + private LinearLayout.LayoutParams buttonParams; + private AppCompatImageButton buttonRoot; + private AppCompatImageButton buttonStorage; + private ArrayList arrowButtons = new ArrayList<>(); + private int lastUsedArrowButton = 0; + private ArrayList folderButtons = new ArrayList<>(); + private int lastUsedFolderButton = 0; + private Drawable arrow; + + private CountDownTimer timer; + private GestureDetector gestureDetector; + + public BottomBar(AppBar appbar, MainActivity a) { + mainActivity = a; + this.appbar = appbar; + + frame = a.findViewById(R.id.buttonbarframe); + + scroll = a.findViewById(R.id.scroll); + buttons = a.findViewById(R.id.buttons); + + pathLayout = a.findViewById(R.id.pathbar); + pathScroll = a.findViewById(R.id.scroll1); + fullPathText = a.findViewById(R.id.fullpath); + fullPathAnim = a.findViewById(R.id.fullpath_anim); + + pathText = a.findViewById(R.id.pathname); + + scroll.setSmoothScrollingEnabled(true); + pathScroll.setSmoothScrollingEnabled(true); + + pathScroll.setOnKeyListener( + (v, keyCode, event) -> { + if (event.getAction() == KeyEvent.ACTION_DOWN) { + if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_DOWN) { + mainActivity.findViewById(R.id.content_frame).requestFocus(); + } else if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_LEFT) { + mainActivity.getDrawer().getDonateImageView().requestFocus(); + } else if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) { + mainActivity.onBackPressed(); + } else { + return false; + } + } + return true; + }); + + buttonParams = + new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + buttonParams.gravity = Gravity.CENTER_VERTICAL; + + buttonRoot = new AppCompatImageButton(a); + buttonRoot.setBackgroundColor(Color.TRANSPARENT); + buttonRoot.setLayoutParams(buttonParams); + + buttonStorage = new AppCompatImageButton(a); + buttonStorage.setImageDrawable( + a.getResources().getDrawable(R.drawable.ic_sd_storage_white_24dp)); + buttonStorage.setBackgroundColor(Color.TRANSPARENT); + buttonStorage.setLayoutParams(buttonParams); + + arrow = mainActivity.getResources().getDrawable(R.drawable.ic_keyboard_arrow_right_white_24dp); + + timer = + new CountDownTimer(5000, 1000) { + @Override + public void onTick(long l) {} + + @Override + public void onFinish() { + FileUtils.crossfadeInverse(buttons, pathLayout); + } + }; + + gestureDetector = + new GestureDetector( + a.getApplicationContext(), + new GestureDetector.SimpleOnGestureListener() { + @Override + public boolean onDown(MotionEvent e) { + return true; + } + + @Override + public boolean onSingleTapConfirmed(MotionEvent e) { + Fragment fragmentAtFrame = mainActivity.getFragmentAtFrame(); + if (fragmentAtFrame instanceof TabFragment) { + final MainFragment mainFragment = mainActivity.getCurrentMainFragment(); + Objects.requireNonNull(mainFragment); + if (mainFragment.getMainFragmentViewModel() != null + && OpenMode.CUSTOM != mainFragment.getMainFragmentViewModel().getOpenMode() + && OpenMode.TRASH_BIN + != mainFragment.getMainFragmentViewModel().getOpenMode()) { + FileUtils.crossfade(buttons, pathLayout); + timer.cancel(); + timer.start(); + showButtons(mainFragment); + } + } else if (fragmentAtFrame instanceof CompressedExplorerFragment) { + FileUtils.crossfade(buttons, pathLayout); + timer.cancel(); + timer.start(); + showButtons((BottomBarButtonPath) fragmentAtFrame); + } + return false; + } + + @Override + public void onLongPress(MotionEvent e) { + final MainFragment mainFragment = mainActivity.getCurrentMainFragment(); + Objects.requireNonNull(mainFragment); + if (mainActivity.getBoolean(PREFERENCE_CHANGEPATHS) + && (mainFragment.getMainFragmentViewModel() != null + || buttons.getVisibility() == View.VISIBLE)) { + GeneralDialogCreation.showChangePathsDialog( + mainActivity, mainActivity.getPrefs()); + } + } + }); + } + + public void + setClickListener() { // TODO: 15/8/2017 this is a horrible hack, if you see this, correct it + frame.setOnTouchListener(this); + scroll.setOnTouchListener(this); + buttons.setOnTouchListener(this); + pathLayout.setOnTouchListener(this); + pathScroll.setOnTouchListener(this); + fullPathText.setOnTouchListener(this); + pathText.setOnTouchListener(this); + scroll.setOnTouchListener(this); + pathScroll.setOnTouchListener(this); + } + + public void resetClickListener() { + frame.setOnTouchListener(null); + } + + public void setPathText(String text) { + pathText.setText(text); + } + + public void setFullPathText(String text) { + fullPathText.setText(text); + } + + public String getFullPathText() { + return fullPathText.getText().toString(); + } + + public boolean areButtonsShowing() { + return buttons.getVisibility() == View.VISIBLE; + } + + public void showButtons(final BottomBarButtonPath buttonPathInterface) { + final String path = buttonPathInterface.getPath(); + if (buttons.getVisibility() == View.VISIBLE) { + lastUsedArrowButton = 0; + lastUsedFolderButton = 0; + buttons.removeAllViews(); + buttons.setMinimumHeight(pathLayout.getHeight()); + + buttonRoot.setImageDrawable( + mainActivity.getResources().getDrawable(buttonPathInterface.getRootDrawable())); + + String[] names = FileUtils.getFolderNamesInPath(path); + final String[] paths = FileUtils.getPathsInPath(path); + View view = new View(mainActivity); + LinearLayout.LayoutParams params1 = + new LinearLayout.LayoutParams( + appbar.getToolbar().getContentInsetLeft(), LinearLayout.LayoutParams.WRAP_CONTENT); + view.setLayoutParams(params1); + buttons.addView(view); + + for (int i = 0; i < names.length; i++) { + final int k = i; + if (i == 0) { + buttonRoot.setOnClickListener( + p1 -> { + if (paths.length != 0) { + buttonPathInterface.changePath(paths[k]); + timer.cancel(); + timer.start(); + } + }); + buttons.addView(buttonRoot); + } else if (FileUtils.isStorage(paths[i])) { + buttonStorage.setOnClickListener( + p1 -> { + buttonPathInterface.changePath(paths[k]); + timer.cancel(); + timer.start(); + }); + buttons.addView(buttonStorage); + } else { + AppCompatButton button = createFolderButton(names[i]); + button.setOnClickListener( + p1 -> { + buttonPathInterface.changePath(paths[k]); + timer.cancel(); + timer.start(); + }); + buttons.addView(button); + } + + if (names.length - i != 1) { + buttons.addView(createArrow()); + } + } + + scroll.post( + () -> { + sendScroll(scroll); + sendScroll(pathScroll); + }); + + if (buttons.getVisibility() == View.VISIBLE) { + timer.cancel(); + timer.start(); + } + } + } + + public FrameLayout getPathLayout() { + return this.frame; + } + + private AppCompatImageView createArrow() { + AppCompatImageView buttonArrow; + + if (lastUsedArrowButton >= arrowButtons.size()) { + buttonArrow = new AppCompatImageView(mainActivity); + buttonArrow.setImageDrawable(arrow); + buttonArrow.setLayoutParams(buttonParams); + arrowButtons.add(buttonArrow); + } else { + buttonArrow = arrowButtons.get(lastUsedArrowButton); + } + + lastUsedArrowButton++; + + return buttonArrow; + } + + private AppCompatButton createFolderButton(String text) { + AppCompatButton button; + + if (lastUsedFolderButton >= folderButtons.size()) { + button = new AppCompatButton(mainActivity); + button.setTextColor(Utils.getColor(mainActivity, android.R.color.white)); + button.setTextSize(13); + button.setLayoutParams(buttonParams); + button.setBackgroundResource(0); + folderButtons.add(button); + } else { + button = folderButtons.get(lastUsedFolderButton); + } + + button.setText(text); + + lastUsedFolderButton++; + + return button; + } + + public void setBackgroundColor(@ColorInt int color) { + frame.setBackgroundColor(color); + } + + public void setVisibility(int visibility) { + frame.setVisibility(visibility); + } + + public void updatePath( + @NonNull final String news, + OpenMode openmode, + int folderCount, + int fileCount, + BottomBarButtonPath buttonPathInterface) { + + if (news.length() == 0) return; + + MainActivityHelper mainActivityHelper = mainActivity.mainActivityHelper; + + switch (openmode) { + case SFTP: + case SMB: + case FTP: + newPath = HybridFile.parseAndFormatUriForDisplay(news); + break; + case OTG: + newPath = mainActivityHelper.parseOTGPath(news); + break; + case CUSTOM: + case TRASH_BIN: + newPath = mainActivityHelper.getIntegralNames(news); + break; + case DROPBOX: + case BOX: + case ONEDRIVE: + case GDRIVE: + newPath = mainActivityHelper.parseCloudPath(openmode, news); + break; + default: + newPath = news; + } + + pathText.setText(mainActivity.getString(R.string.folderfilecount, folderCount, fileCount)); + + final String oldPath = fullPathText.getText().toString(); + + if (oldPath.equals(newPath)) return; + + if (!areButtonsShowing()) { + final Animation slideIn = AnimationUtils.loadAnimation(mainActivity, R.anim.slide_in); + Animation slideOut = AnimationUtils.loadAnimation(mainActivity, R.anim.slide_out); + + if (newPath.length() > oldPath.length() + && newPath.contains(oldPath) + && oldPath.length() != 0) { + // navigate forward + fullPathAnim.setAnimation(slideIn); + fullPathAnim + .animate() + .setListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + super.onAnimationEnd(animation); + new Handler() + .postDelayed( + () -> { + fullPathAnim.setVisibility(View.GONE); + fullPathText.setText(newPath); + }, + PATH_ANIM_END_DELAY); + } + + @Override + public void onAnimationStart(Animator animation) { + super.onAnimationStart(animation); + fullPathAnim.setVisibility(View.VISIBLE); + fullPathAnim.setText(Utils.differenceStrings(oldPath, newPath)); + // fullPathText.setText(oldPath); + + scroll.post(() -> pathScroll.fullScroll(View.FOCUS_RIGHT)); + } + + @Override + public void onAnimationCancel(Animator animation) { + super.onAnimationCancel(animation); + // onAnimationEnd(animation); + } + }) + .setStartDelay(PATH_ANIM_START_DELAY) + .start(); + } else if (newPath.length() < oldPath.length() && oldPath.contains(newPath)) { + // navigate backwards + fullPathAnim.setAnimation(slideOut); + fullPathAnim + .animate() + .setListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + super.onAnimationEnd(animation); + fullPathAnim.setVisibility(View.GONE); + fullPathText.setText(newPath); + + scroll.post(() -> pathScroll.fullScroll(View.FOCUS_RIGHT)); + } + + @Override + public void onAnimationStart(Animator animation) { + super.onAnimationStart(animation); + fullPathAnim.setVisibility(View.VISIBLE); + fullPathAnim.setText(Utils.differenceStrings(newPath, oldPath)); + fullPathText.setText(newPath); + + scroll.post(() -> pathScroll.fullScroll(View.FOCUS_LEFT)); + } + }) + .setStartDelay(PATH_ANIM_START_DELAY) + .start(); + } else if (oldPath.isEmpty()) { + // case when app starts + fullPathAnim.setAnimation(slideIn); + fullPathAnim.setText(newPath); + fullPathAnim + .animate() + .setListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + super.onAnimationStart(animation); + fullPathAnim.setVisibility(View.VISIBLE); + fullPathText.setText(""); + scroll.post(() -> pathScroll.fullScroll(View.FOCUS_RIGHT)); + } + + @Override + public void onAnimationEnd(Animator animation) { + super.onAnimationEnd(animation); + new Handler() + .postDelayed( + () -> { + fullPathAnim.setVisibility(View.GONE); + fullPathText.setText(newPath); + }, + PATH_ANIM_END_DELAY); + } + + @Override + public void onAnimationCancel(Animator animation) { + super.onAnimationCancel(animation); + // onAnimationEnd(animation); + } + }) + .setStartDelay(PATH_ANIM_START_DELAY) + .start(); + } else { + // completely different path + // first slide out of old path followed by slide in of new path + fullPathAnim.setAnimation(slideOut); + fullPathAnim + .animate() + .setListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animator) { + super.onAnimationStart(animator); + fullPathAnim.setVisibility(View.VISIBLE); + fullPathAnim.setText(oldPath); + fullPathText.setText(""); + + scroll.post(() -> pathScroll.fullScroll(View.FOCUS_LEFT)); + } + + @Override + public void onAnimationEnd(Animator animator) { + super.onAnimationEnd(animator); + + // fullPathAnim.setVisibility(View.GONE); + fullPathAnim.setText(newPath); + fullPathText.setText(""); + fullPathAnim.setAnimation(slideIn); + + fullPathAnim + .animate() + .setListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + super.onAnimationEnd(animation); + new Handler() + .postDelayed( + () -> { + fullPathAnim.setVisibility(View.GONE); + fullPathText.setText(newPath); + }, + PATH_ANIM_END_DELAY); + } + + @Override + public void onAnimationStart(Animator animation) { + super.onAnimationStart(animation); + // we should not be having anything here in path bar + fullPathAnim.setVisibility(View.VISIBLE); + fullPathText.setText(""); + scroll.post(() -> pathScroll.fullScroll(View.FOCUS_RIGHT)); + } + }) + .start(); + } + + @Override + public void onAnimationCancel(Animator animation) { + super.onAnimationCancel(animation); + // onAnimationEnd(animation); + } + }) + .setStartDelay(PATH_ANIM_START_DELAY) + .start(); + } + } else { + showButtons(buttonPathInterface); + fullPathText.setText(newPath); + } + } + + private void sendScroll(final HorizontalScrollView scrollView) { + new Handler().postDelayed(() -> scrollView.fullScroll(View.FOCUS_RIGHT), 100); + } + + @Override + public boolean onTouch(View v, MotionEvent event) { + return gestureDetector.onTouchEvent(event); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/views/appbar/SearchView.java b/app/src/main/java/com/amaze/filemanager/ui/views/appbar/SearchView.java new file mode 100644 index 0000000..898eff2 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/views/appbar/SearchView.java @@ -0,0 +1,666 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.views.appbar; + +import static android.content.Context.INPUT_METHOD_SERVICE; +import static android.os.Build.VERSION.SDK_INT; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CancellationException; + +import com.afollestad.materialdialogs.MaterialDialog; +import com.amaze.filemanager.R; +import com.amaze.filemanager.adapters.SearchRecyclerViewAdapter; +import com.amaze.filemanager.asynchronous.asynctasks.searchfilesystem.SearchResult; +import com.amaze.filemanager.asynchronous.asynctasks.searchfilesystem.SearchResultListSorter; +import com.amaze.filemanager.filesystem.files.sort.DirSortBy; +import com.amaze.filemanager.filesystem.files.sort.SortBy; +import com.amaze.filemanager.filesystem.files.sort.SortOrder; +import com.amaze.filemanager.filesystem.files.sort.SortType; +import com.amaze.filemanager.ui.activities.MainActivity; +import com.amaze.filemanager.ui.activities.MainActivityViewModel; +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants; +import com.amaze.filemanager.ui.theme.AppTheme; +import com.amaze.filemanager.utils.Utils; +import com.google.android.material.chip.Chip; +import com.google.android.material.chip.ChipGroup; +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; + +import android.animation.Animator; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.text.Editable; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.TextWatcher; +import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; +import android.view.ContextThemeWrapper; +import android.view.View; +import android.view.ViewAnimationUtils; +import android.view.animation.AccelerateDecelerateInterpolator; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputMethodManager; +import android.widget.Toast; + +import androidx.appcompat.widget.AppCompatButton; +import androidx.appcompat.widget.AppCompatEditText; +import androidx.appcompat.widget.AppCompatImageView; +import androidx.appcompat.widget.AppCompatTextView; +import androidx.core.content.ContextCompat; +import androidx.core.content.res.ResourcesCompat; +import androidx.core.widget.NestedScrollView; +import androidx.lifecycle.LiveData; +import androidx.preference.PreferenceManager; +import androidx.recyclerview.widget.RecyclerView; + +import kotlinx.coroutines.Job; + +/** + * SearchView, a simple view to search + * + * @author Emmanuel on 2/8/2017, at 23:30. + */ +public class SearchView { + + private final MainActivity mainActivity; + private final AppBar appbar; + + private final NestedScrollView searchViewLayout; + private final AppCompatEditText searchViewEditText; + + private final AppCompatImageView clearImageView; + private final AppCompatImageView backImageView; + + private final AppCompatTextView recentHintTV; + private final AppCompatTextView searchResultsHintTV; + private final AppCompatTextView deepSearchTV; + + private final ChipGroup recentChipGroup; + private final RecyclerView recyclerView; + + private final SearchRecyclerViewAdapter searchRecyclerViewAdapter; + + /** Text to describe {@link SearchView#searchResultsSortButton} */ + private final AppCompatTextView searchResultsSortHintTV; + + /** The button to select how the results should be sorted */ + private final AppCompatButton searchResultsSortButton; + + /** The drawable used to indicate that the search results are sorted ascending */ + private final Drawable searchResultsSortAscDrawable; + + /** The drawable used to indicate that the search results are sorted descending */ + private final Drawable searchResultsSortDescDrawable; + + // 0 -> Basic Search + // 1 -> Indexed Search + // 2 -> Deep Search + private int searchMode; + + private boolean enabled = false; + + private final SortType defaultSortType = new SortType(SortBy.RELEVANCE, SortOrder.ASC); + + /** The selected sort type for the search results */ + private SortType sortType = defaultSortType; + + @SuppressWarnings("ConstantConditions") + public SearchView(final AppBar appbar, MainActivity mainActivity) { + + this.mainActivity = mainActivity; + this.appbar = appbar; + + searchViewLayout = mainActivity.findViewById(R.id.search_view); + searchViewEditText = mainActivity.findViewById(R.id.search_edit_text); + clearImageView = mainActivity.findViewById(R.id.search_close_btn); + backImageView = mainActivity.findViewById(R.id.img_view_back); + recentChipGroup = mainActivity.findViewById(R.id.searchRecentItemsChipGroup); + recentHintTV = mainActivity.findViewById(R.id.searchRecentHintTV); + searchResultsHintTV = mainActivity.findViewById(R.id.searchResultsHintTV); + deepSearchTV = mainActivity.findViewById(R.id.searchDeepSearchTV); + recyclerView = mainActivity.findViewById(R.id.searchRecyclerView); + searchResultsSortHintTV = mainActivity.findViewById(R.id.searchResultsSortHintTV); + searchResultsSortButton = mainActivity.findViewById(R.id.searchResultsSortButton); + searchResultsSortAscDrawable = + ResourcesCompat.getDrawable( + mainActivity.getResources(), + R.drawable.baseline_sort_24_asc_white, + mainActivity.getTheme()); + searchResultsSortDescDrawable = + ResourcesCompat.getDrawable( + mainActivity.getResources(), + R.drawable.baseline_sort_24_desc_white, + mainActivity.getTheme()); + + setUpSearchResultsSortButton(); + + initRecentSearches(mainActivity); + + searchRecyclerViewAdapter = new SearchRecyclerViewAdapter(); + recyclerView.setAdapter(searchRecyclerViewAdapter); + + clearImageView.setOnClickListener( + v -> { + // observers of last search are removed to stop updating the results + cancelLastSearch(); + + searchViewEditText.setText(""); + clearRecyclerView(); + }); + + backImageView.setOnClickListener(v -> appbar.getSearchView().hideSearchView()); + + searchViewEditText.addTextChangedListener( + new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + + if (count > 0) searchViewEditText.setError(null); + + if (count >= 3) onSearch(false); + } + + @Override + public void afterTextChanged(Editable s) {} + }); + + searchViewEditText.setOnEditorActionListener( + (v, actionId, event) -> { + if (actionId == EditorInfo.IME_ACTION_SEARCH) { + + Utils.hideKeyboard(mainActivity); + + return onSearch(true); + } + + return false; + }); + + deepSearchTV.setOnClickListener( + v -> { + String s = getSearchTerm(); + + cancelLastSearch(); + + if (searchMode == 1) { + + saveRecentPreference(s); + + mainActivity + .getCurrentMainFragment() + .getMainActivityViewModel() + .indexedSearch(mainActivity, s) + .observe( + mainActivity.getCurrentMainFragment().getViewLifecycleOwner(), + hybridFileParcelables -> updateResultList(hybridFileParcelables, s)); + + searchMode = 2; + + deepSearchTV.setText( + getSpannableText( + mainActivity.getString(R.string.not_finding_what_you_re_looking_for), + mainActivity.getString(R.string.try_deep_search))); + + } else if (searchMode == 2) { + + mainActivity + .getCurrentMainFragment() + .getMainActivityViewModel() + .deepSearch(mainActivity, s) + .observe( + mainActivity.getCurrentMainFragment().getViewLifecycleOwner(), + hybridFileParcelables -> updateResultList(hybridFileParcelables, s)); + + deepSearchTV.setVisibility(View.GONE); + } + }); + + initSearchViewColor(mainActivity); + } + + @SuppressWarnings("ConstantConditions") + private boolean onSearch(boolean shouldSave) { + + String s = getSearchTerm(); + + if (s.isEmpty()) { + searchViewEditText.setError(mainActivity.getString(R.string.field_empty)); + searchViewEditText.requestFocus(); + return false; + } + + basicSearch(s); + + if (shouldSave) saveRecentPreference(s); + + return true; + } + + private void basicSearch(String s) { + + clearRecyclerView(); + + searchResultsHintTV.setVisibility(View.VISIBLE); + searchResultsSortButton.setVisibility(View.VISIBLE); + searchResultsSortHintTV.setVisibility(View.VISIBLE); + deepSearchTV.setVisibility(View.VISIBLE); + searchMode = 1; + deepSearchTV.setText( + getSpannableText( + mainActivity.getString(R.string.not_finding_what_you_re_looking_for), + mainActivity.getString(R.string.try_indexed_search))); + + mainActivity + .getCurrentMainFragment() + .getMainActivityViewModel() + .basicSearch(mainActivity, s) + .observe( + mainActivity.getCurrentMainFragment().getViewLifecycleOwner(), + hybridFileParcelables -> updateResultList(hybridFileParcelables, s)); + } + + private void saveRecentPreference(String s) { + + String preferenceString = + PreferenceManager.getDefaultSharedPreferences(mainActivity) + .getString(PreferencesConstants.PREFERENCE_RECENT_SEARCH_ITEMS, null); + + ArrayList recentSearches = + preferenceString != null + ? new Gson().fromJson(preferenceString, new TypeToken>() {}.getType()) + : new ArrayList<>(); + + if (s.isEmpty() || recentSearches.contains(s)) return; + + recentSearches.add(s); + + if (recentSearches.size() > 5) recentSearches.remove(0); + + PreferenceManager.getDefaultSharedPreferences(mainActivity) + .edit() + .putString( + PreferencesConstants.PREFERENCE_RECENT_SEARCH_ITEMS, new Gson().toJson(recentSearches)) + .apply(); + + initRecentSearches(mainActivity); + } + + private void initRecentSearches(Context context) { + + String preferenceString = + PreferenceManager.getDefaultSharedPreferences(context) + .getString(PreferencesConstants.PREFERENCE_RECENT_SEARCH_ITEMS, null); + + if (preferenceString == null) { + recentHintTV.setVisibility(View.GONE); + recentChipGroup.setVisibility(View.GONE); + return; + } + + recentHintTV.setVisibility(View.VISIBLE); + recentChipGroup.setVisibility(View.VISIBLE); + + recentChipGroup.removeAllViews(); + + ArrayList recentSearches = + new Gson().fromJson(preferenceString, new TypeToken>() {}.getType()); + + for (String string : recentSearches) { + Chip chip = new Chip(new ContextThemeWrapper(context, R.style.ChipStyle)); + + chip.setText(string); + + recentChipGroup.addView(chip); + + chip.setOnClickListener( + v -> { + String s = ((Chip) v).getText().toString(); + + searchViewEditText.setText(s); + + Utils.hideKeyboard(mainActivity); + + basicSearch(s); + }); + } + } + + private void resetSearchMode() { + searchMode = 0; + deepSearchTV.setText( + getSpannableText( + mainActivity.getString(R.string.not_finding_what_you_re_looking_for), + mainActivity.getString(R.string.try_indexed_search))); + deepSearchTV.setVisibility(View.GONE); + } + + /** + * Updates the list of results displayed in {@link SearchView#searchRecyclerViewAdapter} sorted + * according to the current {@link SearchView#sortType} + * + * @param newResults The list of results that should be displayed + * @param searchTerm The search term that resulted in the search results + */ + private void updateResultList(List newResults, String searchTerm) { + if (newResults != null) { + ArrayList items = new ArrayList<>(newResults); + Collections.sort( + items, new SearchResultListSorter(DirSortBy.NONE_ON_TOP, sortType, searchTerm)); + searchRecyclerViewAdapter.submitList(items); + } else { + Toast.makeText(mainActivity, "No search result found", Toast.LENGTH_SHORT).show(); + } + } + + /** show search view with a circular reveal animation */ + public void revealSearchView() { + final int START_RADIUS = 16; + int endRadius = Math.max(appbar.getToolbar().getWidth(), appbar.getToolbar().getHeight()); + + resetSearchMode(); + resetSearchResultsSortButton(); + clearRecyclerView(); + + Animator animator; + if (SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) { + int[] searchCoords = new int[2]; + View searchItem = + appbar + .getToolbar() + .findViewById(R.id.search); // It could change position, get it every time + searchViewEditText.setText(""); + searchItem.getLocationOnScreen(searchCoords); + animator = + ViewAnimationUtils.createCircularReveal( + searchViewLayout, + searchCoords[0] + 32, + searchCoords[1] - 16, + START_RADIUS, + endRadius); + } else { + // TODO:ViewAnimationUtils.createCircularReveal + animator = ObjectAnimator.ofFloat(searchViewLayout, "alpha", 0f, 1f); + + searchViewLayout.bringToFront(); // since android:elevation won't work + searchViewEditText.requestFocus(); // for keyboard auto-popup + } + + mainActivity.showSmokeScreen(); + mainActivity.hideFab(); + + animator.setInterpolator(new AccelerateDecelerateInterpolator()); + animator.setDuration(600); + searchViewLayout.setVisibility(View.VISIBLE); + animator.start(); + animator.addListener( + new Animator.AnimatorListener() { + @Override + public void onAnimationStart(Animator animation) {} + + @Override + public void onAnimationEnd(Animator animation) { + searchViewEditText.requestFocus(); + InputMethodManager imm = + (InputMethodManager) mainActivity.getSystemService(Context.INPUT_METHOD_SERVICE); + imm.showSoftInput(searchViewEditText, InputMethodManager.SHOW_IMPLICIT); + enabled = true; + } + + @Override + public void onAnimationCancel(Animator animation) {} + + @Override + public void onAnimationRepeat(Animator animation) {} + }); + } + + /** + * Sets up the {@link SearchView#searchResultsSortButton} to show a dialog when it is clicked. The + * text and icon of {@link SearchView#searchResultsSortButton} is also set to the current {@link + * SearchView#sortType} + */ + private void setUpSearchResultsSortButton() { + searchResultsSortButton.setOnClickListener(v -> showSearchResultsSortDialog()); + updateSearchResultsSortButtonDisplay(); + } + + /** Builds and shows a dialog for selection which sort should be applied for the search results */ + private void showSearchResultsSortDialog() { + int accentColor = mainActivity.getAccent(); + new MaterialDialog.Builder(mainActivity) + .items(R.array.sortbySearch) + .itemsCallbackSingleChoice( + sortType.getSortBy().getIndex(), (dialog, itemView, which, text) -> true) + .negativeText(R.string.ascending) + .positiveColor(accentColor) + .onNegative( + (dialog, which) -> onSortTypeSelected(dialog, dialog.getSelectedIndex(), SortOrder.ASC)) + .positiveText(R.string.descending) + .negativeColor(accentColor) + .onPositive( + (dialog, which) -> + onSortTypeSelected(dialog, dialog.getSelectedIndex(), SortOrder.DESC)) + .title(R.string.sort_by) + .build() + .show(); + } + + private void onSortTypeSelected(MaterialDialog dialog, int index, SortOrder sortOrder) { + this.sortType = new SortType(SortBy.getSortBy(index), sortOrder); + dialog.dismiss(); + updateSearchResultsSortButtonDisplay(); + LiveData> lastSearchLiveData = + mainActivity.getCurrentMainFragment().getMainActivityViewModel().getLastSearchLiveData(); + updateResultList(lastSearchLiveData.getValue(), getSearchTerm()); + } + + private void resetSearchResultsSortButton() { + sortType = defaultSortType; + updateSearchResultsSortButtonDisplay(); + } + + /** Updates the text and icon of {@link SearchView#searchResultsSortButton} */ + private void updateSearchResultsSortButtonDisplay() { + searchResultsSortButton.setText(sortType.getSortBy().toResourceString(mainActivity)); + setSearchResultSortOrderIcon(); + } + + /** + * Updates the icon of {@link SearchView#searchResultsSortButton} and colors it to fit the text + * color + */ + private void setSearchResultSortOrderIcon() { + Drawable orderDrawable; + switch (sortType.getSortOrder()) { + default: + case ASC: + orderDrawable = searchResultsSortAscDrawable; + break; + case DESC: + orderDrawable = searchResultsSortDescDrawable; + break; + } + + orderDrawable.setColorFilter( + new PorterDuffColorFilter( + mainActivity.getResources().getColor(R.color.accent_material_light), + PorterDuff.Mode.SRC_ATOP)); + searchResultsSortButton.setCompoundDrawablesWithIntrinsicBounds( + null, null, orderDrawable, null); + } + + /** hide search view with a circular reveal animation */ + public void hideSearchView() { + final int END_RADIUS = 16; + int startRadius = Math.max(searchViewLayout.getWidth(), searchViewLayout.getHeight()); + Animator animator; + if (SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) { + int[] searchCoords = new int[2]; + View searchItem = + appbar + .getToolbar() + .findViewById(R.id.search); // It could change position, get it every time + searchViewEditText.setText(""); + searchItem.getLocationOnScreen(searchCoords); + animator = + ViewAnimationUtils.createCircularReveal( + searchViewLayout, + searchCoords[0] + 32, + searchCoords[1] - 16, + startRadius, + END_RADIUS); + } else { + // TODO: ViewAnimationUtils.createCircularReveal + animator = ObjectAnimator.ofFloat(searchViewLayout, "alpha", 1f, 0f); + } + + clearRecyclerView(); + + // removing background fade view + mainActivity.hideSmokeScreen(); + mainActivity.showFab(); + animator.setInterpolator(new AccelerateDecelerateInterpolator()); + animator.setDuration(600); + animator.start(); + animator.addListener( + new Animator.AnimatorListener() { + @Override + public void onAnimationStart(Animator animation) {} + + @Override + public void onAnimationEnd(Animator animation) { + searchViewLayout.setVisibility(View.GONE); + enabled = false; + InputMethodManager inputMethodManager = + (InputMethodManager) mainActivity.getSystemService(INPUT_METHOD_SERVICE); + inputMethodManager.hideSoftInputFromWindow( + searchViewEditText.getWindowToken(), InputMethodManager.HIDE_IMPLICIT_ONLY); + } + + @Override + public void onAnimationCancel(Animator animation) {} + + @Override + public void onAnimationRepeat(Animator animation) {} + }); + } + + public boolean isEnabled() { + return enabled; + } + + public boolean isShown() { + return searchViewLayout.isShown(); + } + + private void initSearchViewColor(MainActivity a) { + AppTheme theme = a.getAppTheme(); + switch (theme) { + case LIGHT: + searchViewLayout.setBackgroundResource(R.drawable.search_view_shape); + searchViewEditText.setTextColor(Utils.getColor(a, android.R.color.black)); + clearImageView.setColorFilter( + ContextCompat.getColor(a, android.R.color.black), PorterDuff.Mode.SRC_ATOP); + backImageView.setColorFilter( + ContextCompat.getColor(a, android.R.color.black), PorterDuff.Mode.SRC_ATOP); + break; + case DARK: + case BLACK: + if (theme == AppTheme.DARK) { + searchViewLayout.setBackgroundResource(R.drawable.search_view_shape_holo_dark); + } else { + searchViewLayout.setBackgroundResource(R.drawable.search_view_shape_black); + } + searchViewEditText.setTextColor(Utils.getColor(a, android.R.color.white)); + clearImageView.setColorFilter( + ContextCompat.getColor(a, android.R.color.white), PorterDuff.Mode.SRC_ATOP); + backImageView.setColorFilter( + ContextCompat.getColor(a, android.R.color.white), PorterDuff.Mode.SRC_ATOP); + break; + default: + break; + } + } + + private void clearRecyclerView() { + searchRecyclerViewAdapter.submitList(Collections.emptyList()); + + deepSearchTV.setVisibility(View.GONE); + + searchResultsHintTV.setVisibility(View.GONE); + searchResultsSortHintTV.setVisibility(View.GONE); + searchResultsSortButton.setVisibility(View.GONE); + } + + private SpannableString getSpannableText(String s1, String s2) { + + SpannableString spannableString = new SpannableString(s1 + " " + s2); + + spannableString.setSpan( + new ForegroundColorSpan(mainActivity.getCurrentColorPreference().getAccent()), + s1.length() + 1, + spannableString.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + spannableString.setSpan( + new StyleSpan(Typeface.BOLD), + s1.length() + 1, + spannableString.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + + return spannableString; + } + + /** + * Returns the current text in {@link SearchView#searchViewEditText} + * + * @return The current search text + */ + private String getSearchTerm() { + return searchViewEditText.getText().toString().trim(); + } + + private void cancelLastSearch() { + MainActivityViewModel viewModel = + mainActivity.getCurrentMainFragment().getMainActivityViewModel(); + + // remove all observers + viewModel + .getLastSearchLiveData() + .removeObservers(mainActivity.getCurrentMainFragment().getViewLifecycleOwner()); + + // stop the job + Job lastJob = viewModel.getLastSearchJob(); + if (lastJob != null) { + lastJob.cancel(new CancellationException("Search outdated")); + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/views/drawer/ActionViewStateManager.java b/app/src/main/java/com/amaze/filemanager/ui/views/drawer/ActionViewStateManager.java new file mode 100644 index 0000000..98fe9e8 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/views/drawer/ActionViewStateManager.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.views.drawer; + +import android.view.MenuItem; + +import androidx.annotation.ColorInt; +import androidx.appcompat.widget.AppCompatImageButton; + +/** + * This manages to set the color of the selected ActionView and unset the ActionView that is not + * selected anymore + */ +public class ActionViewStateManager { + + private AppCompatImageButton lastItemSelected = null; + private @ColorInt int idleIconColor; + private @ColorInt int selectedIconColor; + + public ActionViewStateManager(@ColorInt int idleColor, @ColorInt int accentColor) { + idleIconColor = idleColor; + selectedIconColor = accentColor; + } + + public void deselectCurrentActionView() { + if (lastItemSelected != null) { + lastItemSelected.setColorFilter(idleIconColor); + lastItemSelected = null; + } + } + + public void selectActionView(MenuItem item) { + if (lastItemSelected != null) { + lastItemSelected.setColorFilter(idleIconColor); + } + if (item.getActionView() != null) { + lastItemSelected = (AppCompatImageButton) item.getActionView(); + lastItemSelected.setColorFilter(selectedIconColor); + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/views/drawer/CustomNavigationView.java b/app/src/main/java/com/amaze/filemanager/ui/views/drawer/CustomNavigationView.java new file mode 100644 index 0000000..f09724f --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/views/drawer/CustomNavigationView.java @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.views.drawer; + +import com.google.android.material.navigation.NavigationView; + +import android.content.Context; +import android.os.Build; +import android.os.Parcel; +import android.os.Parcelable; +import android.util.AttributeSet; +import android.view.MenuItem; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** This class if for intercepting item selections so that they can be saved and restored. */ +public class CustomNavigationView extends NavigationView + implements NavigationView.OnNavigationItemSelectedListener { + + private OnNavigationItemSelectedListener subclassListener; + private int checkedId = -1; + + public CustomNavigationView(Context context, AttributeSet attrs) { + super(context, attrs); + + super.setNavigationItemSelectedListener(this); + } + + @Override + public void setNavigationItemSelectedListener( + @Nullable OnNavigationItemSelectedListener listener) { + subclassListener = listener; + } + + @Override + public boolean onNavigationItemSelected(@NonNull MenuItem item) { + if (subclassListener != null) { + boolean shouldBeSelected = subclassListener.onNavigationItemSelected(item); + + if (shouldBeSelected) { + onItemChecked(item); + } + + return shouldBeSelected; + } else { + onItemChecked(item); + return true; + } + } + + private void onItemChecked(MenuItem item) { + checkedId = item.getItemId(); + } + + public void setCheckedItem(MenuItem item) { + this.checkedId = item.getItemId(); + item.setChecked(true); + } + + public void deselectItems() { + checkedId = -1; + } + + public @Nullable MenuItem getSelected() { + if (checkedId == -1) return null; + return getMenu().findItem(checkedId); + } + + @Override + public Parcelable onSaveInstanceState() { + if (isNavigationViewSavedStateMissing()) { + return super.onSaveInstanceState(); + } + + // begin boilerplate code that allows parent classes to save state + Parcelable superState = super.onSaveInstanceState(); + + SavedState ss = new SavedState(superState); + // end + + ss.selectedId = this.checkedId; + + return ss; + } + + @Override + public void onRestoreInstanceState(Parcelable state) { + if (isNavigationViewSavedStateMissing()) { + super.onRestoreInstanceState(state); + return; + } + + // begin boilerplate code so parent classes can restore state + if (!(state instanceof SavedState)) { + super.onRestoreInstanceState(state); + return; + } + + SavedState ss = (SavedState) state; + super.onRestoreInstanceState(ss.getSuperState()); + // end + + this.checkedId = ss.selectedId; + } + + /** + * This is a hack, when the SavedState class is unmarshalled a "ClassNotFoundException" will be + * thrown (the actual class not found is + * "android.support.design.widget.NavigationView$SavedState") and I seem to only be able to + * replicate on Marshmallow (someone else replicated in N through O_MR1 see + * https://github.com/TeamAmaze/AmazeFileManager/issues/1400#issuecomment-413086603). Trying to + * find the class and returning false if Class.forName() throws "ClassNotFoundException" doesn't + * work because the class seems to have been loaded with the current loader (not the one the + * unmarshaller uses); of course I have no idea of what any of this means so I could be wrong. For + * the crash see https://github.com/TeamAmaze/AmazeFileManager/issues/1101. + */ + public boolean isNavigationViewSavedStateMissing() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; + } + + static class SavedState extends BaseSavedState { + int selectedId; + + SavedState(Parcelable superState) { + super(superState); + } + + private SavedState(Parcel in) { + super(in); + this.selectedId = in.readInt(); + } + + @Override + public void writeToParcel(Parcel out, int flags) { + super.writeToParcel(out, flags); + out.writeInt(this.selectedId); + } + + // required field that makes Parcelables from a Parcel + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + public SavedState createFromParcel(Parcel in) { + return new SavedState(in); + } + + public SavedState[] newArray(int size) { + return new SavedState[size]; + } + }; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/views/drawer/Drawer.java b/app/src/main/java/com/amaze/filemanager/ui/views/drawer/Drawer.java new file mode 100644 index 0000000..358bfeb --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/views/drawer/Drawer.java @@ -0,0 +1,1060 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.views.drawer; + +import static com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.FTPS_URI_PREFIX; +import static com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.FTP_URI_PREFIX; +import static com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.SSH_URI_PREFIX; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_SIDEBAR_FOLDERS; +import static com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_SHOW_SIDEBAR_QUICKACCESSES; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.afollestad.materialdialogs.DialogAction; +import com.afollestad.materialdialogs.MaterialDialog; +import com.amaze.filemanager.BuildConfig; +import com.amaze.filemanager.R; +import com.amaze.filemanager.adapters.data.StorageDirectoryParcelable; +import com.amaze.filemanager.application.AppConfig; +import com.amaze.filemanager.database.CloudHandler; +import com.amaze.filemanager.fileoperations.filesystem.OpenMode; +import com.amaze.filemanager.fileoperations.filesystem.usb.SingletonUsbOtg; +import com.amaze.filemanager.filesystem.HybridFile; +import com.amaze.filemanager.filesystem.RootHelper; +import com.amaze.filemanager.filesystem.cloud.CloudUtil; +import com.amaze.filemanager.filesystem.files.FileUtils; +import com.amaze.filemanager.ui.ExtensionsKt; +import com.amaze.filemanager.ui.activities.AboutActivity; +import com.amaze.filemanager.ui.activities.MainActivity; +import com.amaze.filemanager.ui.activities.PreferencesActivity; +import com.amaze.filemanager.ui.activities.UtilitiesAliasActivity; +import com.amaze.filemanager.ui.dialogs.GeneralDialogCreation; +import com.amaze.filemanager.ui.fragments.AppsListFragment; +import com.amaze.filemanager.ui.fragments.CloudSheetFragment; +import com.amaze.filemanager.ui.fragments.FtpServerFragment; +import com.amaze.filemanager.ui.fragments.MainFragment; +import com.amaze.filemanager.ui.fragments.preferencefragments.QuickAccessesPrefsFragment; +import com.amaze.filemanager.ui.theme.AppTheme; +import com.amaze.filemanager.utils.Billing; +import com.amaze.filemanager.utils.BookSorter; +import com.amaze.filemanager.utils.DataUtils; +import com.amaze.filemanager.utils.OTGUtil; +import com.amaze.filemanager.utils.PackageUtils; +import com.amaze.filemanager.utils.ScreenUtils; +import com.amaze.filemanager.utils.TinyDB; +import com.amaze.filemanager.utils.Utils; +import com.cloudrail.si.interfaces.CloudStorage; +import com.cloudrail.si.services.Box; +import com.cloudrail.si.services.Dropbox; +import com.cloudrail.si.services.GoogleDrive; +import com.cloudrail.si.services.OneDrive; +import com.google.android.material.navigation.NavigationView; + +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.content.res.ColorStateList; +import android.content.res.Configuration; +import android.graphics.Color; +import android.os.Build; +import android.text.SpannableString; +import android.text.TextUtils; +import android.text.format.Formatter; +import android.text.style.RelativeSizeSpan; +import android.text.style.TextAppearanceSpan; +import android.view.Gravity; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.animation.DecelerateInterpolator; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; + +import androidx.annotation.ColorInt; +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.widget.AppCompatImageButton; +import androidx.appcompat.widget.AppCompatImageView; +import androidx.appcompat.widget.AppCompatTextView; +import androidx.drawerlayout.widget.DrawerLayout; +import androidx.fragment.app.FragmentTransaction; +import androidx.legacy.app.ActionBarDrawerToggle; +import androidx.lifecycle.ViewModelProvider; + +public class Drawer implements NavigationView.OnNavigationItemSelectedListener { + + private static final Logger LOG = LoggerFactory.getLogger(Drawer.class); + + public static final int STORAGES_GROUP = 0, + SERVERS_GROUP = 1, + CLOUDS_GROUP = 2, + FOLDERS_GROUP = 3, + QUICKACCESSES_GROUP = 4, + LASTGROUP = 5; + public static final int[] GROUPS = { + STORAGES_GROUP, SERVERS_GROUP, CLOUDS_GROUP, FOLDERS_GROUP, QUICKACCESSES_GROUP, LASTGROUP + }; + + @NonNull private final MainActivity mainActivity; + private DataUtils dataUtils; + + private ActionViewStateManager actionViewStateManager; + private volatile int phoneStorageCount = + 0; // number of storage available (internal/external/otg etc) + private boolean isDrawerLocked = false; + private FragmentTransaction pending_fragmentTransaction; + private PendingPath pendingPath; + private String firstPath = null, secondPath = null; + + private DrawerLayout mDrawerLayout; + private ActionBarDrawerToggle mDrawerToggle; + private CustomNavigationView navView; + private RelativeLayout drawerHeaderParent; + private View drawerHeaderLayout, drawerHeaderView; + private AppCompatImageView donateImageView; + private AppCompatImageView telegramImageView; + private AppCompatImageView instagramImageView; + private AppCompatTextView appVersion; + + /** Tablet is defined as 'width > 720dp' */ + private boolean isOnTablet = false; + + private Billing billing; + + public Drawer(MainActivity mainActivity) { + this.mainActivity = mainActivity; + dataUtils = DataUtils.getInstance(); + + drawerHeaderLayout = mainActivity.getLayoutInflater().inflate(R.layout.drawerheader, null); + drawerHeaderParent = drawerHeaderLayout.findViewById(R.id.drawer_header_parent); + drawerHeaderView = drawerHeaderLayout.findViewById(R.id.drawer_header); + donateImageView = drawerHeaderLayout.findViewById(R.id.donate); + telegramImageView = drawerHeaderLayout.findViewById(R.id.telegram); + instagramImageView = drawerHeaderLayout.findViewById(R.id.instagram); + appVersion = drawerHeaderLayout.findViewById(R.id.app_version); + if (BuildConfig.DEBUG) { + appVersion.setVisibility(View.VISIBLE); + } + donateImageView.setOnClickListener(v -> new Billing(mainActivity)); + telegramImageView.setOnClickListener(v -> Utils.openTelegramURL(mainActivity)); + instagramImageView.setOnClickListener(v -> Utils.openInstagramURL(mainActivity)); + initDrawerFocusItems(); + /*drawerHeaderView.setOnLongClickListener( + v -> { + Intent intent1; + if (SDK_INT < Build.VERSION_CODES.KITKAT) { + intent1 = new Intent(); + intent1.setAction(Intent.ACTION_GET_CONTENT); + } else { + intent1 = new Intent(Intent.ACTION_OPEN_DOCUMENT); + } + intent1.addCategory(Intent.CATEGORY_OPENABLE); + intent1.setType("image/*"); + mainActivity.startActivityForResult(intent1, image_selector_request_code); + return false; + });*/ + + navView = mainActivity.findViewById(R.id.navigation); + + // set width of drawer in portrait to follow material guidelines + /*if(!Utils.isDeviceInLandScape(mainActivity)){ + setNavViewDimension(navView); + }*/ + + navView.setNavigationItemSelectedListener(this); + + int accentColor = mainActivity.getAccent(), idleColor; + + if (mainActivity.getAppTheme().equals(AppTheme.LIGHT)) { + idleColor = mainActivity.getResources().getColor(R.color.item_light_theme); + } else { + idleColor = Color.WHITE; + } + + actionViewStateManager = new ActionViewStateManager(idleColor, accentColor); + + ColorStateList drawerColors = + new ColorStateList( + new int[][] { + new int[] {android.R.attr.state_checked}, + new int[] {android.R.attr.state_enabled}, + new int[] {android.R.attr.state_pressed}, + new int[] {android.R.attr.state_focused}, + new int[] {android.R.attr.state_pressed} + }, + new int[] {accentColor, idleColor, idleColor, idleColor, idleColor}); + + navView.setItemTextColor(drawerColors); + navView.setItemIconTintList(drawerColors); + + if (mainActivity.getAppTheme().equals(AppTheme.DARK)) { + navView.setBackgroundColor(Utils.getColor(mainActivity, R.color.holo_dark_background)); + } else if (mainActivity.getAppTheme().equals(AppTheme.BLACK)) { + navView.setBackgroundColor(Utils.getColor(mainActivity, android.R.color.black)); + } else { + navView.setBackgroundColor(Color.WHITE); + } + + mDrawerLayout = mainActivity.findViewById(R.id.drawer_layout); + // mDrawerLayout.setStatusBarBackgroundColor(Color.parseColor((currentTab==1 ? skinTwo : + // skin))); + drawerHeaderView.setBackgroundResource(R.drawable.amaze_header); + // drawerHeaderParent.setBackgroundColor(Color.parseColor((currentTab==1 ? skinTwo : skin))); + if (mainActivity.findViewById(R.id.tab_frame) != null) { + isOnTablet = true; + mDrawerLayout.setScrimColor(Color.TRANSPARENT); + } + navView.addHeaderView(drawerHeaderLayout); + + if (!isOnTablet) { + mDrawerToggle = + new ActionBarDrawerToggle( + mainActivity, /* host Activity */ + mDrawerLayout, /* DrawerLayout object */ + R.drawable.ic_drawer_l, /* nav drawer image to replace 'Up' caret */ + R.string.drawer_open, /* "open drawer" description for accessibility */ + R.string.drawer_close /* "close drawer" description for accessibility */) { + public void onDrawerClosed(View view) { + Drawer.this.onDrawerClosed(); + } + + public void onDrawerOpened(View drawerView) { + // title.setText("Amaze File Manager"); + // creates call to onPrepareOptionsMenu() + } + }; + mDrawerLayout.setDrawerListener(mDrawerToggle); + mainActivity.getSupportActionBar().setHomeAsUpIndicator(R.drawable.ic_drawer_l); + mainActivity.getSupportActionBar().setDisplayHomeAsUpEnabled(true); + mainActivity.getSupportActionBar().setHomeButtonEnabled(true); + mDrawerToggle.syncState(); + } + } + + private void setNavViewDimension(CustomNavigationView navView) { + int screenWidth = AppConfig.getInstance().getScreenUtils().getScreenWidthInDp(); + int desiredWidthInDp = screenWidth - ScreenUtils.TOOLBAR_HEIGHT_IN_DP; + int desiredWidthInPx = AppConfig.getInstance().getScreenUtils().convertDbToPx(desiredWidthInDp); + + navView.setLayoutParams( + new DrawerLayout.LayoutParams( + desiredWidthInPx, LinearLayout.LayoutParams.MATCH_PARENT, Gravity.START)); + } + + /** Refactors lock mode based on orientation */ + public void refactorDrawerLockMode() { + if (mainActivity.findViewById(R.id.tab_frame) != null) { + isOnTablet = true; + mDrawerLayout.setScrimColor(Color.TRANSPARENT); + open(); + lock(DrawerLayout.LOCK_MODE_LOCKED_OPEN); + } else { + unlockIfNotOnTablet(); + close(); + } + } + + public void refreshDrawer() { + Menu menu = navView.getMenu(); + menu.clear(); + actionViewStateManager.deselectCurrentActionView(); + + int order = 0; + ArrayList storageDirectories = mainActivity.getStorageDirectories(); + ArrayList storageDirectoryPaths = new ArrayList<>(); + phoneStorageCount = 0; + for (StorageDirectoryParcelable storageDirectory : storageDirectories) { + String file = storageDirectory.path; + File f = new File(file); + String name = storageDirectory.name; + int icon = storageDirectory.iconRes; + + HybridFile hybridFile = new HybridFile(OpenMode.UNKNOWN, file); + hybridFile.generateMode(mainActivity); + + long totalSpace = hybridFile.getTotal(mainActivity); + long freeSpace = hybridFile.getUsableSpace(); + + storageDirectoryPaths.add(file); + + if (file.contains(OTGUtil.PREFIX_OTG) || file.startsWith(OTGUtil.PREFIX_MEDIA_REMOVABLE)) { + addNewItem( + menu, + STORAGES_GROUP, + order++, + "OTG", + new MenuMetadata(file, false), + R.drawable.ic_usb_white_24dp, + R.drawable.ic_show_chart_black_24dp, + Formatter.formatFileSize(mainActivity, freeSpace), + Formatter.formatFileSize(mainActivity, totalSpace)); + continue; + } + + if (f.isDirectory() || f.canExecute()) { + addNewItem( + menu, + STORAGES_GROUP, + order++, + name, + new MenuMetadata(file, false), + icon, + R.drawable.ic_show_chart_black_24dp, + Formatter.formatFileSize(mainActivity, freeSpace), + Formatter.formatFileSize(mainActivity, totalSpace)); + if (phoneStorageCount == 0) firstPath = file; + else if (phoneStorageCount == 1) secondPath = file; + + phoneStorageCount++; + } + } + dataUtils.setStorages(storageDirectoryPaths); + + if (dataUtils.getServers().size() > 0) { + Collections.sort(dataUtils.getServers(), new BookSorter()); + synchronized (dataUtils.getServers()) { + for (String[] file : dataUtils.getServers()) { + addNewItem( + menu, + SERVERS_GROUP, + order++, + file[0], + new MenuMetadata(file[1], false), + R.drawable.ic_settings_remote_white_24dp, + R.drawable.ic_edit_24dp); + } + } + } + + ArrayList accountAuthenticationList = new ArrayList<>(); + + if (CloudSheetFragment.isCloudProviderAvailable(mainActivity)) { + for (CloudStorage cloudStorage : dataUtils.getAccounts()) { + @DrawableRes int deleteIcon = R.drawable.ic_delete_grey_24dp; + + if (cloudStorage instanceof Dropbox) { + addNewItem( + menu, + CLOUDS_GROUP, + order++, + CloudHandler.CLOUD_NAME_DROPBOX, + new MenuMetadata(CloudHandler.CLOUD_PREFIX_DROPBOX + "/", false), + R.drawable.ic_dropbox_white_24dp, + deleteIcon); + + accountAuthenticationList.add( + new String[] { + CloudHandler.CLOUD_NAME_DROPBOX, CloudHandler.CLOUD_PREFIX_DROPBOX + "/", + }); + } else if (cloudStorage instanceof Box) { + addNewItem( + menu, + CLOUDS_GROUP, + order++, + CloudHandler.CLOUD_NAME_BOX, + new MenuMetadata(CloudHandler.CLOUD_PREFIX_BOX + "/", false), + R.drawable.ic_box_white_24dp, + deleteIcon); + + accountAuthenticationList.add( + new String[] { + CloudHandler.CLOUD_NAME_BOX, CloudHandler.CLOUD_PREFIX_BOX + "/", + }); + } else if (cloudStorage instanceof OneDrive) { + addNewItem( + menu, + CLOUDS_GROUP, + order++, + CloudHandler.CLOUD_NAME_ONE_DRIVE, + new MenuMetadata(CloudHandler.CLOUD_PREFIX_ONE_DRIVE + "/", false), + R.drawable.ic_onedrive_white_24dp, + deleteIcon); + + accountAuthenticationList.add( + new String[] { + CloudHandler.CLOUD_NAME_ONE_DRIVE, CloudHandler.CLOUD_PREFIX_ONE_DRIVE + "/", + }); + } else if (cloudStorage instanceof GoogleDrive) { + addNewItem( + menu, + CLOUDS_GROUP, + order++, + CloudHandler.CLOUD_NAME_GOOGLE_DRIVE, + new MenuMetadata(CloudHandler.CLOUD_PREFIX_GOOGLE_DRIVE + "/", false), + R.drawable.ic_google_drive_white_24dp, + deleteIcon); + + accountAuthenticationList.add( + new String[] { + CloudHandler.CLOUD_NAME_GOOGLE_DRIVE, CloudHandler.CLOUD_PREFIX_GOOGLE_DRIVE + "/", + }); + } + } + Collections.sort(accountAuthenticationList, new BookSorter()); + } + + if (mainActivity.getBoolean(PREFERENCE_SHOW_SIDEBAR_FOLDERS)) { + if (dataUtils.getBooks().size() > 0) { + + Collections.sort(dataUtils.getBooks(), new BookSorter()); + + synchronized (dataUtils.getBooks()) { + for (String[] file : dataUtils.getBooks()) { + addNewItem( + menu, + FOLDERS_GROUP, + order++, + file[0], + new MenuMetadata(file[1], false), + R.drawable.ic_folder_white_24dp, + R.drawable.ic_edit_24dp); + } + } + } + } + + Boolean[] quickAccessPref = + TinyDB.getBooleanArray( + mainActivity.getPrefs(), + QuickAccessesPrefsFragment.KEY, + QuickAccessesPrefsFragment.Companion.getDEFAULT()); + + if (mainActivity.getBoolean(PREFERENCE_SHOW_SIDEBAR_QUICKACCESSES)) { + if (quickAccessPref[0]) { + addNewItem( + menu, + QUICKACCESSES_GROUP, + order++, + R.string.quick, + new MenuMetadata("5", true), + R.drawable.ic_star_white_24dp, + null); + } + if (quickAccessPref[1]) { + addNewItem( + menu, + QUICKACCESSES_GROUP, + order++, + R.string.recent, + new MenuMetadata("6", true), + R.drawable.ic_history_white_24dp, + null); + } + if (quickAccessPref[2]) { + addNewItem( + menu, + QUICKACCESSES_GROUP, + order++, + R.string.images, + new MenuMetadata("0", true), + R.drawable.ic_photo_library_white_24dp, + null); + } + if (quickAccessPref[3]) { + addNewItem( + menu, + QUICKACCESSES_GROUP, + order++, + R.string.videos, + new MenuMetadata("1", true), + R.drawable.ic_video_library_white_24dp, + null); + } + if (quickAccessPref[4]) { + addNewItem( + menu, + QUICKACCESSES_GROUP, + order++, + R.string.audio, + new MenuMetadata("2", true), + R.drawable.ic_library_music_white_24dp, + null); + } + if (quickAccessPref[5]) { + addNewItem( + menu, + QUICKACCESSES_GROUP, + order++, + R.string.documents, + new MenuMetadata("3", true), + R.drawable.ic_library_books_white_24dp, + null); + } + if (quickAccessPref[6]) { + addNewItem( + menu, + QUICKACCESSES_GROUP, + order++, + R.string.apks, + new MenuMetadata("4", true), + R.drawable.ic_apk_library_white_24dp, + null); + } + } + + addNewItem( + menu, + LASTGROUP, + order++, + R.string.ftp, + new MenuMetadata( + () -> { + FragmentTransaction transaction2 = + mainActivity.getSupportFragmentManager().beginTransaction(); + transaction2.replace(R.id.content_frame, new FtpServerFragment()); + mainActivity + .getAppbar() + .getAppbarLayout() + .animate() + .translationY(0) + .setInterpolator(new DecelerateInterpolator(2)) + .start(); + pending_fragmentTransaction = transaction2; + if (!isDrawerLocked) close(); + else onDrawerClosed(); + }), + R.drawable.ic_ftp_white_24dp, + null); + + addNewItem( + menu, + LASTGROUP, + order++, + R.string.wifip2p, + new MenuMetadata( + () -> { + boolean isAUInstalled = + PackageUtils.Companion.appInstalledOrNot( + AboutActivity.PACKAGE_AMAZE_UTILS, mainActivity.getPackageManager()); + if (isAUInstalled) { + try { + Utils.openURL("amaze://teamamaze.xyz/transfer", mainActivity); + } catch (ActivityNotFoundException e) { + mainActivity.startActivity( + new Intent(mainActivity, UtilitiesAliasActivity.class)); + } + } else { + mainActivity.startActivity(new Intent(mainActivity, UtilitiesAliasActivity.class)); + } + }), + R.drawable.ic_round_connect_without_contact_24, + null); + + addNewItem( + menu, + LASTGROUP, + order++, + R.string.analyse_storage, + new MenuMetadata( + () -> { + boolean isAUInstalled = + PackageUtils.Companion.appInstalledOrNot( + AboutActivity.PACKAGE_AMAZE_UTILS, mainActivity.getPackageManager()); + if (isAUInstalled) { + try { + Utils.openURL("amaze://teamamaze.xyz/analyse", mainActivity); + } catch (ActivityNotFoundException e) { + mainActivity.startActivity( + new Intent(mainActivity, UtilitiesAliasActivity.class)); + } + } else { + mainActivity.startActivity(new Intent(mainActivity, UtilitiesAliasActivity.class)); + } + }), + R.drawable.ic_round_analytics_24, + null); + + // initially load trash bin items with "7" but ones listed they're referred as + // @link{OpenMode.TRASH_BIN} + addNewItem( + menu, + LASTGROUP, + order++, + R.string.trash_bin, + new MenuMetadata("7", true), + R.drawable.round_delete_outline_24, + null); + + addNewItem( + menu, + LASTGROUP, + order++, + R.string.apps, + new MenuMetadata( + () -> { + FragmentTransaction transaction2 = + mainActivity.getSupportFragmentManager().beginTransaction(); + transaction2.replace(R.id.content_frame, new AppsListFragment()); + mainActivity + .getAppbar() + .getAppbarLayout() + .animate() + .translationY(0) + .setInterpolator(new DecelerateInterpolator(2)) + .start(); + pending_fragmentTransaction = transaction2; + if (!isDrawerLocked) close(); + else onDrawerClosed(); + }), + R.drawable.ic_android_white_24dp, + null); + + addNewItem( + menu, + LASTGROUP, + order++, + R.string.setting, + new MenuMetadata( + () -> { + Intent in = new Intent(mainActivity, PreferencesActivity.class); + mainActivity.startActivity(in); + mainActivity.finish(); + }), + R.drawable.ic_settings_white_24dp, + null); + + for (int i = 0; i < navView.getMenu().size(); i++) { + navView.getMenu().getItem(i).setEnabled(true); + } + + for (int group : GROUPS) { + menu.setGroupCheckable(group, true, true); + } + + MenuItem item = navView.getSelected(); + if (item != null) { + item.setChecked(true); + actionViewStateManager.selectActionView(item); + } + } + + private void initDrawerFocusItems() { + donateImageView.setOnKeyListener( + (v, keyCode, event) -> { + if (event.getAction() == KeyEvent.ACTION_DOWN) { + if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_RIGHT) { + mainActivity.getAppbar().getAppbarLayout().requestFocus(); + mainActivity.getAppbar().getToolbar().requestFocus(); + } else if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_CENTER) { + new Billing(mainActivity); + } else if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) { + mainActivity.onBackPressed(); + } else { + return false; + } + } + return true; + }); + } + + public AppCompatImageView getDonateImageView() { + return this.donateImageView; + } + + private void addNewItem( + Menu menu, + int group, + int order, + @StringRes int text, + MenuMetadata meta, + @DrawableRes int icon, + @DrawableRes Integer actionViewIcon) { + addNewItem( + menu, group, order, mainActivity.getString(text), meta, icon, actionViewIcon, null, null); + } + + private void addNewItem( + Menu menu, + int group, + int order, + String text, + MenuMetadata meta, + @DrawableRes int icon, + @DrawableRes Integer actionViewIcon) { + addNewItem(menu, group, order, text, meta, icon, actionViewIcon, null, null); + } + + private void addNewItem( + @NonNull Menu menu, + int group, + int order, + String text, + @NonNull MenuMetadata meta, + @DrawableRes int icon, + @DrawableRes Integer actionViewIcon, + @Nullable String freeSpace, + @Nullable String totalSpace) { + if (BuildConfig.DEBUG && menu.findItem(order) != null) + throw new IllegalStateException("Item already id exists: " + order); + + MenuItem item = null; + + if (freeSpace != null && totalSpace != null) + item = + menu.add(group, order, order, getSpannableText(text, freeSpace, totalSpace)) + .setIcon(icon); + else item = menu.add(group, order, order, text).setIcon(icon); + + if (TextUtils.isEmpty(meta.path)) { + DrawerViewModel model = new ViewModelProvider(mainActivity).get(DrawerViewModel.class); + model.putDrawerMetadata(item, meta); + } else { + boolean success = dataUtils.putDrawerPath(item, meta.path); + if (success) { + DrawerViewModel model = new ViewModelProvider(mainActivity).get(DrawerViewModel.class); + model.putDrawerMetadata(item, meta); + } + } + + if (actionViewIcon != null) { + item.setActionView(R.layout.layout_draweractionview); + + AppCompatImageButton imageView = item.getActionView().findViewById(R.id.imageButton); + imageView.setImageResource(actionViewIcon); + if (!mainActivity.getAppTheme().equals(AppTheme.LIGHT)) { + imageView.setColorFilter(Color.WHITE); + } + + MenuItem finalItem = item; + item.getActionView().setOnClickListener((view) -> onNavigationItemActionClick(finalItem)); + } + } + + public void closeIfNotLocked() { + if (!isLocked()) { + close(); + } + } + + public boolean isLocked() { + return isDrawerLocked; + } + + public boolean isOnTablet() { + return isOnTablet; + } + + public boolean isOpen() { + return mDrawerLayout.isDrawerOpen(navView); + } + + public void open() { + mDrawerLayout.openDrawer(navView); + } + + public void close() { + mDrawerLayout.closeDrawer(navView); + } + + public void onDrawerClosed() { + if (pending_fragmentTransaction != null) { + pending_fragmentTransaction.commit(); + pending_fragmentTransaction = null; + } + + if (pendingPath != null) { + HybridFile hFile = new HybridFile(OpenMode.UNKNOWN, pendingPath.getPath()); + hFile.generateMode(mainActivity); + if (hFile.isSimpleFile()) { + FileUtils.openFile(new File(pendingPath.getPath()), mainActivity, mainActivity.getPrefs()); + resetPendingPath(); + return; + } + + MainFragment mainFragment = mainActivity.getCurrentMainFragment(); + if (mainFragment != null) { + mainFragment.loadlist(pendingPath.getPath(), false, OpenMode.UNKNOWN, false); + // Set if the FAB should be hidden when displaying the pendingPath + mainFragment.setHideFab(pendingPath.getHideFabInMainFragment()); + resetPendingPath(); + } else { + mainActivity.goToMain(pendingPath.getPath(), pendingPath.getHideFabInMainFragment()); + resetPendingPath(); + return; + } + } + mainActivity.supportInvalidateOptionsMenu(); + } + + @Override + public boolean onNavigationItemSelected(@NonNull MenuItem item) { + actionViewStateManager.deselectCurrentActionView(); + actionViewStateManager.selectActionView(item); + + DrawerViewModel model = new ViewModelProvider(mainActivity).get(DrawerViewModel.class); + String title = item.getTitle().toString(); + MenuMetadata meta = model.getDrawerMetadata(item); + + switch (meta.type) { + case MenuMetadata.ITEM_ENTRY: + if (dataUtils.containsBooks(new String[] {title, meta.path}) != -1) { + FileUtils.checkForPath(mainActivity, meta.path, mainActivity.isRootExplorer()); + } + + if (dataUtils.getAccounts().size() > 0 + && (meta.path.startsWith(CloudHandler.CLOUD_PREFIX_BOX) + || meta.path.startsWith(CloudHandler.CLOUD_PREFIX_DROPBOX) + || meta.path.startsWith(CloudHandler.CLOUD_PREFIX_ONE_DRIVE) + || meta.path.startsWith(CloudHandler.CLOUD_PREFIX_GOOGLE_DRIVE))) { + // we have cloud accounts, try see if token is expired or not + CloudUtil.checkToken(meta.path, mainActivity); + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + && (meta.path.contains(OTGUtil.PREFIX_OTG) + || meta.path.startsWith(OTGUtil.PREFIX_MEDIA_REMOVABLE)) + && SingletonUsbOtg.getInstance().getUsbOtgRoot() == null) { + MaterialDialog dialog = GeneralDialogCreation.showOtgSafExplanationDialog(mainActivity); + dialog + .getActionButton(DialogAction.POSITIVE) + .setOnClickListener( + (v) -> { + Intent safIntent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + + ExtensionsKt.runIfDocumentsUIExists( + safIntent, + mainActivity, + () -> + mainActivity.startActivityForResult( + safIntent, MainActivity.REQUEST_CODE_SAF)); + + dialog.dismiss(); + }); + dialog.show(); + } else { + pendingPath = new PendingPath(meta.path, meta.hideFabInMainFragment); + closeIfNotLocked(); + if (isLocked()) { + onDrawerClosed(); + } + } + + break; + case MenuMetadata.ITEM_INTENT: + meta.onClickListener.onClick(); + break; + } + + return true; + } + + public void onNavigationItemActionClick(MenuItem item) { + DrawerViewModel model = new ViewModelProvider(mainActivity).get(DrawerViewModel.class); + String title = item.getTitle().toString(); + MenuMetadata meta = model.getDrawerMetadata(item); + String path = meta.path; + + switch (item.getGroupId()) { + case STORAGES_GROUP: + if (!path.equals("/")) { + GeneralDialogCreation.showPropertiesDialogForStorage( + RootHelper.generateBaseFile(new File(path), true), + mainActivity, + mainActivity.getAppTheme()); + } + break; + // not to remove the first bookmark (storage) and permanent bookmarks + case SERVERS_GROUP: + case CLOUDS_GROUP: + case FOLDERS_GROUP: + if (dataUtils.containsBooks(new String[] {title, path}) != -1) { + mainActivity.renameBookmark(title, path); + } else if (path.startsWith("smb:/")) { + mainActivity.showSMBDialog(title, path, true); + } else if (path.startsWith(SSH_URI_PREFIX) + || path.startsWith(FTP_URI_PREFIX) + || path.startsWith(FTPS_URI_PREFIX)) { + mainActivity.showSftpDialog(title, path, true); + } else if (path.startsWith(CloudHandler.CLOUD_PREFIX_DROPBOX)) { + GeneralDialogCreation.showCloudDialog( + mainActivity, mainActivity.getAppTheme(), OpenMode.DROPBOX); + } else if (path.startsWith(CloudHandler.CLOUD_PREFIX_GOOGLE_DRIVE)) { + GeneralDialogCreation.showCloudDialog( + mainActivity, mainActivity.getAppTheme(), OpenMode.GDRIVE); + } else if (path.startsWith(CloudHandler.CLOUD_PREFIX_BOX)) { + GeneralDialogCreation.showCloudDialog( + mainActivity, mainActivity.getAppTheme(), OpenMode.BOX); + } else if (path.startsWith(CloudHandler.CLOUD_PREFIX_ONE_DRIVE)) { + GeneralDialogCreation.showCloudDialog( + mainActivity, mainActivity.getAppTheme(), OpenMode.ONEDRIVE); + } + } + } + + public int getPhoneStorageCount() { + return phoneStorageCount; + } + + public void selectCorrectDrawerItemForPath(final String path) { + Integer id = dataUtils.findLongestContainingDrawerItem(path); + + if (id == null) deselectEverything(); + else { + selectCorrectDrawerItem(id); + } + } + + /** + * Select given item id in navigation drawer + * + * @param id given item id from menu + */ + public void selectCorrectDrawerItem(int id) { + if (id < 0) { + deselectEverything(); + } else { + MenuItem item = navView.getMenu().findItem(id); + navView.setCheckedItem(item); + actionViewStateManager.selectActionView(item); + } + } + + /** + * Get selected item id + * + * @return item id from menu + */ + public int getDrawerSelectedItem() { + if (navView.getSelected() == null) { + return -1; + } + return navView.getSelected().getItemId(); + } + + public void setBackgroundColor(@ColorInt int color) { + mDrawerLayout.setStatusBarBackgroundColor(color); + drawerHeaderParent.setBackgroundColor(color); + } + + public void resetPendingPath() { + pendingPath = null; + } + + public void syncState() { + if (mDrawerToggle != null) { + mDrawerToggle.syncState(); + } + } + + public void onConfigurationChanged(Configuration newConfig) { + if (mDrawerToggle != null) mDrawerToggle.onConfigurationChanged(newConfig); + } + + public boolean onOptionsItemSelected(MenuItem item) { + return mDrawerToggle != null && mDrawerToggle.onOptionsItemSelected(item); + } + + public void setDrawerIndicatorEnabled() { + if (mDrawerToggle != null) { + mDrawerToggle.setDrawerIndicatorEnabled(true); + mDrawerToggle.setHomeAsUpIndicator(R.drawable.ic_drawer_l); + } + } + + public void deselectEverything() { + actionViewStateManager + .deselectCurrentActionView(); // If you set the item as checked the listener doesn't trigger + if (navView.getSelected() == null) { + return; + } + + navView.deselectItems(); + + for (int i = 0; i < navView.getMenu().size(); i++) { + navView.getMenu().getItem(i).setChecked(false); + } + } + + /** + * @param mode {@link DrawerLayout#LOCK_MODE_LOCKED_CLOSED}, {@link + * DrawerLayout#LOCK_MODE_LOCKED_OPEN} or {@link DrawerLayout#LOCK_MODE_UNDEFINED} + * @throws IllegalArgumentException if you try to {{@link DrawerLayout#LOCK_MODE_LOCKED_OPEN} or + * {@link DrawerLayout#LOCK_MODE_UNDEFINED} on a tablet + */ + private void lock(int mode) { + if (isOnTablet && mode != DrawerLayout.LOCK_MODE_LOCKED_OPEN) { + throw new IllegalArgumentException("You can't lock closed or unlock drawer in tablet!"); + } + + mDrawerLayout.setDrawerLockMode(mode, navView); + isDrawerLocked = true; + } + + /** + * Does nothing on tablets {@link #isOnTablet} + * + * @param mode {@link DrawerLayout#LOCK_MODE_LOCKED_CLOSED}, {@link + * DrawerLayout#LOCK_MODE_LOCKED_OPEN} or {@link DrawerLayout#LOCK_MODE_UNDEFINED} + */ + public void lockIfNotOnTablet(int mode) { + if (isOnTablet) { + return; + } + + mDrawerLayout.setDrawerLockMode(mode, navView); + isDrawerLocked = true; + } + + public void unlockIfNotOnTablet() { + if (isOnTablet) { + return; + } + + mDrawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, navView); + isDrawerLocked = false; + } + + public String getFirstPath() { + return firstPath; + } + + public String getSecondPath() { + return secondPath; + } + + public Billing getBilling() { + return this.billing; + } + + private SpannableString getSpannableText(String text, String freeSpace, String totalSpace) { + + String s = mainActivity.getString(R.string.free_of, text, freeSpace, totalSpace); + + SpannableString spannableString = new SpannableString(s); + + spannableString.setSpan(new RelativeSizeSpan(0.8f), text.length() + 1, s.length(), 0); + + spannableString.setSpan( + new TextAppearanceSpan(mainActivity, R.style.DrawerItemDriveSizeTextStyle), + text.length() + 1, + s.length(), + 0); + + return spannableString; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/views/drawer/DrawerViewModel.kt b/app/src/main/java/com/amaze/filemanager/ui/views/drawer/DrawerViewModel.kt new file mode 100644 index 0000000..6a00f76 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/views/drawer/DrawerViewModel.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.views.drawer + +import android.view.MenuItem +import androidx.lifecycle.ViewModel + +class DrawerViewModel : ViewModel() { + private val menuMetadataMap = HashMap() + + fun getDrawerMetadata(item: MenuItem): MenuMetadata { + return requireNotNull(menuMetadataMap[item.toNonLeaking()]) + } + + /** + * Put drawer meta data + * @param item menu item + * @param metadata menu meta data + */ + fun putDrawerMetadata( + item: MenuItem, + metadata: MenuMetadata, + ) { + menuMetadataMap[item.toNonLeaking()] = metadata + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/views/drawer/GestureExclusionView.kt b/app/src/main/java/com/amaze/filemanager/ui/views/drawer/GestureExclusionView.kt new file mode 100644 index 0000000..32e8ce3 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/views/drawer/GestureExclusionView.kt @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.views.drawer + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Rect +import android.os.Build +import android.util.AttributeSet +import android.view.View + +class GestureExclusionView( + context: Context, + attrs: AttributeSet? = null, +) : View(context, attrs) { + private val gestureExclusionRects = mutableListOf() + + override fun onLayout( + changed: Boolean, + left: Int, + top: Int, + right: Int, + bottom: Int, + ) { + super.onLayout(changed, left, top, right, bottom) + + if (changed) { + updateGestureExclusion() + } + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + updateGestureExclusion() + } + + private fun updateGestureExclusion() { + // Skip this call if we're not running on Android 10+ + if (Build.VERSION.SDK_INT < 29) { + visibility = GONE + return + } + visibility = VISIBLE + setBackgroundColor(resources.getColor(android.R.color.transparent)) + gestureExclusionRects.clear() + val rect = Rect() + this.getGlobalVisibleRect(rect) + gestureExclusionRects += rect + systemGestureExclusionRects = gestureExclusionRects + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/views/drawer/HasherOfMenuItem.kt b/app/src/main/java/com/amaze/filemanager/ui/views/drawer/HasherOfMenuItem.kt new file mode 100644 index 0000000..5fb3a2c --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/views/drawer/HasherOfMenuItem.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.views.drawer + +import android.view.MenuItem + +/** + * This is a sort of wrapper, only used to provide a hash for MenuItems + */ +data class HasherOfMenuItem( + val groupId: Int, + val itemId: Int, + val title: CharSequence?, + val ordering: Int, +) + +fun MenuItem.toNonLeaking(): HasherOfMenuItem { + return HasherOfMenuItem(groupId, itemId, title, order) +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/views/drawer/MenuMetadata.java b/app/src/main/java/com/amaze/filemanager/ui/views/drawer/MenuMetadata.java new file mode 100644 index 0000000..b5f1bf2 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/views/drawer/MenuMetadata.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.views.drawer; + +public final class MenuMetadata { + + public static final int ITEM_ENTRY = 1, ITEM_INTENT = 2; + + public final int type; + public final String path; + public final boolean hideFabInMainFragment; + public final OnClickListener onClickListener; + + public MenuMetadata(String path, boolean hideFabInMainFragment) { + this.type = ITEM_ENTRY; + this.path = path; + this.hideFabInMainFragment = hideFabInMainFragment; + this.onClickListener = null; + } + + public MenuMetadata(OnClickListener onClickListener) { + this.type = ITEM_INTENT; + this.onClickListener = onClickListener; + this.hideFabInMainFragment = false; + this.path = null; + } + + public interface OnClickListener { + void onClick(); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/views/drawer/PendingPath.kt b/app/src/main/java/com/amaze/filemanager/ui/views/drawer/PendingPath.kt new file mode 100644 index 0000000..ec07212 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/views/drawer/PendingPath.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.views.drawer + +data class PendingPath(val path: String, val hideFabInMainFragment: Boolean) diff --git a/app/src/main/java/com/amaze/filemanager/ui/views/preference/CheckBox.kt b/app/src/main/java/com/amaze/filemanager/ui/views/preference/CheckBox.kt new file mode 100644 index 0000000..40a50c5 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/views/preference/CheckBox.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.views.preference + +import android.content.Context +import android.util.AttributeSet +import android.view.ViewGroup +import android.widget.Switch +import androidx.preference.PreferenceViewHolder +import androidx.preference.SwitchPreference + +/** Created by Arpit on 10/18/2015 edited by Emmanuel Messulam @gmail.com> */ +class CheckBox(context: Context, attrs: AttributeSet) : SwitchPreference(context, attrs) { + override fun onBindViewHolder(holder: PreferenceViewHolder) { + clearListenerInViewGroup(holder.itemView as ViewGroup) + super.onBindViewHolder(holder) + } + + /** + * Clear listener in Switch for specify ViewGroup. + * + * @param viewGroup The ViewGroup that will need to clear the listener. + */ + private fun clearListenerInViewGroup(viewGroup: ViewGroup) { + for (n in 0 until viewGroup.childCount) { + val childView = viewGroup.getChildAt(n) + if (childView is Switch) { + childView.setOnCheckedChangeListener(null) + return + } else if (childView is ViewGroup) { + clearListenerInViewGroup(childView) + } + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/views/preference/PathSwitchPreference.kt b/app/src/main/java/com/amaze/filemanager/ui/views/preference/PathSwitchPreference.kt new file mode 100644 index 0000000..bbf218f --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/views/preference/PathSwitchPreference.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.views.preference + +import android.content.Context +import android.view.View +import androidx.preference.Preference +import androidx.preference.PreferenceViewHolder +import com.amaze.filemanager.R + +/** @author Emmanuel on 17/4/2017, at 22:22. + */ +class PathSwitchPreference( + context: Context, + private val onEdit: (PathSwitchPreference) -> Unit, + private val onDelete: (PathSwitchPreference) -> Unit, +) : Preference(context) { + var lastItemClicked = -1 + private set + + init { + widgetLayoutResource = R.layout.namepathswitch_preference + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + holder.itemView.let { view -> + view.findViewById(R.id.edit).setOnClickListener { onEdit(this) } + view.findViewById(R.id.delete).setOnClickListener { onDelete(this) } + view.setOnClickListener(null) + } + + // Keep this before things that need changing what's on screen + super.onBindViewHolder(holder) + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/views/preference/SelectedColorsPreference.kt b/app/src/main/java/com/amaze/filemanager/ui/views/preference/SelectedColorsPreference.kt new file mode 100644 index 0000000..9ad24d9 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/views/preference/SelectedColorsPreference.kt @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.views.preference + +import android.content.Context +import android.content.res.TypedArray +import android.graphics.Color +import android.os.Parcelable +import android.util.AttributeSet +import android.view.View +import androidx.preference.DialogPreference +import androidx.preference.PreferenceViewHolder +import com.amaze.filemanager.R +import com.amaze.filemanager.ui.dialogs.ColorPickerDialog +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants +import com.amaze.filemanager.ui.views.CircularColorsView + +/** + * This is the external notification that shows some text and a CircularColorsView. + * + * @author Emmanuel on 6/10/2017, at 15:36. + */ +class SelectedColorsPreference(context: Context, attrs: AttributeSet) : + DialogPreference(context, attrs) { + private var colors = + intArrayOf( + Color.TRANSPARENT, + Color.TRANSPARENT, + Color.TRANSPARENT, + Color.TRANSPARENT, + ) + private var backgroundColor = 0 + private var visibility = View.VISIBLE + private var selectedIndex = -1 + + init { + widgetLayoutResource = R.layout.selectedcolors_preference + dialogLayoutResource = R.layout.dialog_colorpicker + setPositiveButtonText(android.R.string.ok) + setNegativeButtonText(android.R.string.cancel) + dialogIcon = null + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + (holder.findViewById(R.id.colorsection) as CircularColorsView).let { colorsView -> + colorsView.setColors(colors[0], colors[1], colors[2], colors[3]) + colorsView.setDividerColor(backgroundColor) + colorsView.visibility = visibility + } + } + + override fun getSummary(): CharSequence { + val colorPickerPref = + sharedPreferences?.getInt( + PreferencesConstants.PREFERENCE_COLOR_CONFIG, + ColorPickerDialog.NO_DATA, + ) ?: ColorPickerDialog.NO_DATA + return context.getString(ColorPickerDialog.getTitle(colorPickerPref)) + } + + override fun onGetDefaultValue( + a: TypedArray, + index: Int, + ): Any { + return a.getString(index)!! + } + + override fun onSaveInstanceState(): Parcelable { + val myState = ColorPickerDialog.SavedState(super.onSaveInstanceState()) + myState.selectedItem = selectedIndex + return myState + } + + override fun onRestoreInstanceState(state: Parcelable?) { + if (state == null || state.javaClass != ColorPickerDialog.SavedState::class.java) { + // Didn't save state for us in onSaveInstanceState + super.onRestoreInstanceState(state) + return + } + + val myState = state as ColorPickerDialog.SavedState + selectedIndex = myState.selectedItem + super.onRestoreInstanceState(myState.superState) // onBindDialogView(View view) + // select(selectedItem, true) + } + + /** + * Set colours' visibility. + */ + fun setColorsVisibility(visibility: Int) { + this.visibility = visibility + notifyChanged() + } + + /** + * Sets the divider's colour. + */ + fun setDividerColor(color: Int) { + backgroundColor = color + } + + /** + * set colours to specified and notify colour changed. + */ + fun setColors( + color: Int, + color1: Int, + color2: Int, + color3: Int, + ) { + colors = intArrayOf(color, color1, color2, color3) + notifyChanged() + } + + /** + * notify colour changed. + */ + fun invalidateColors() { + notifyChanged() + } +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/AESCrypt.kt b/app/src/main/java/com/amaze/filemanager/utils/AESCrypt.kt new file mode 100644 index 0000000..2731f21 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/AESCrypt.kt @@ -0,0 +1,427 @@ +/* + * Copyright (C) 2014-2008 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils + +import android.util.Log +import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil +import com.amaze.filemanager.utils.AESCrypt.DecryptFailureException +import com.amaze.filemanager.utils.AESCrypt.IncorrectEncryptedDataException +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.security.GeneralSecurityException +import java.security.MessageDigest +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.Mac +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import kotlin.text.Charsets.UTF_16LE +import kotlin.text.Charsets.UTF_8 + +/** + * This is code comment from original AESCrypt.java. + * + * This class provides methods to encrypt and decrypt files using + * [aescrypt file format](http://www.aescrypt.com/aes_file_format.html), + * version 1 or 2. + * + * + * Requires Java 6 and [Java + * Cryptography Extension (JCE) Unlimited Strength Jurisdiction Policy Files](http://java.sun.com/javase/downloads/index.jsp). + * + * + * Thread-safety and sharing: this class is not thread-safe.

+ * AESCrypt objects can be used as Commands (create, use once and dispose), + * or reused to perform multiple operations (not concurrently though). + * + * @author Vócali Sistemas Inteligentes + */ + +/** + * A modified version of original AESCrypt and converted to Kotlin. + * + * Changes from original version: + * - only handles and output streams + * - use [android.util.Log] instead of System.out.println + * - made protected methods private + * - not using device MAC address to generate IV1 + * - use of Kotlin shorthands instead of reinventing the wheel + * - throw more precise [IncorrectEncryptedDataException] and [DecryptFailureException] for better + * error handling + * + * Wishlist: + * - implement ChaCha20-Poly1305 cipher, which is faster for mobile devices at the cost of only + * Amaze and implementations that knows Amaze can decrypt. Shall add flags in extensions header + * + * @author TranceLove + * + */ +class AESCrypt(password: String) { + private lateinit var password: ByteArray + private val cipher: Cipher + private val hmac: Mac + private val random: SecureRandom + private val digest: MessageDigest + private lateinit var ivSpec1: IvParameterSpec + private lateinit var aesKey1: SecretKeySpec + private lateinit var ivSpec2: IvParameterSpec + private lateinit var aesKey2: SecretKeySpec + + /******************* + * PRIVATE METHODS * + */ + + /** + * Generates a pseudo-random byte array. + * @return pseudo-random byte array of len bytes. + */ + private fun generateRandomBytes(len: Int): ByteArray { + val bytes = ByteArray(len) + random.nextBytes(bytes) + return bytes + } + + /** + * SHA256 digest over given byte array and random bytes.

+ * bytes.length * num random bytes are added to the digest. + * + * + * The generated hash is saved back to the original byte array.

+ * Maximum array size is [.SHA_SIZE] bytes. + */ + private fun digestRandomBytes( + bytes: ByteArray, + num: Int, + ) { + require(bytes.size <= SHA_SIZE) + digest.reset() + digest.update(bytes) + for (i in 0 until num) { + random.nextBytes(bytes) + digest.update(bytes) + } + digest.digest().copyInto(bytes, endIndex = bytes.size) + } + + /** + * Generates a pseudo-random IV based on time and 8 more random bytes. + * + * Changes from original implementation: it is never a good idea to get hardware MAC address + * anyway, and Android effectively prevented this since Marshmallow. So why not just make all + * 8 bytes completely random? At the end it is embedded into the AESCrypted file, and in fact + * it may further reduce the possibility of IV being guessed if generated from the same device. + * + * The first 8 bytes is generated using the original method, and the remaining 8 bytes are + * generated using the [random] we have here. - TranceLove + * + * This IV is used to crypt IV 2 and AES key 2 in the file. + * @return IV. + */ + private fun generateIv1(): ByteArray { + val iv = ByteArray(BLOCK_SIZE) + val time = System.currentTimeMillis() + for (i in 0..7) { + iv[i] = (time shr i * 8).toByte() + } + ByteArray(8).apply { + random.nextBytes(this) + copyInto(iv, destinationOffset = 8, startIndex = 0, endIndex = this.size) + } + digestRandomBytes(iv, 256) + return iv + } + + /** + * Generates an AES key starting with an IV and applying the supplied user password. + * + * + * This AES key is used to crypt IV 2 and AES key 2. + * @return AES key of [.KEY_SIZE] bytes. + */ + private fun generateAESKey1( + iv: ByteArray, + password: ByteArray, + ): ByteArray { + var aesKey = ByteArray(KEY_SIZE) + iv.copyInto(aesKey, endIndex = iv.size) + for (i in 0..8191) { + digest.reset() + digest.update(aesKey) + digest.update(password) + aesKey = digest.digest() + } + return aesKey + } + + /** + * Generates the random IV used to crypt file contents. + * @return IV 2. + */ + private fun generateIV2(): ByteArray { + val iv = generateRandomBytes(BLOCK_SIZE) + digestRandomBytes(iv, 256) + return iv + } + + /** + * Generates the random AES key used to crypt file contents. + * @return AES key of [.KEY_SIZE] bytes. + */ + private fun generateAESKey2(): ByteArray { + val aesKey = generateRandomBytes(KEY_SIZE) + digestRandomBytes(aesKey, 32) + return aesKey + } + + /** + * Changes the password this object uses to encrypt and decrypt. + */ + private fun setPassword(password: String) { + this.password = password.toByteArray(UTF_16LE) + Log.v(TAG, "Using password: $password") + } + + /************** + * PUBLIC API * + */ + + /** + * The input stream is encrypted and saved to the output stream. + * + * + * version can be either 1 or 2.

+ * None of the streams are closed. + * @throws IOException when there are I/O errors. + * @throws GeneralSecurityException if the platform does not support the required cryptographic methods. + */ + @Suppress("LongMethod", "ComplexMethod") + @Throws(IOException::class, GeneralSecurityException::class) + fun encrypt( + version: Int = AESCRYPT_SPEC_VERSION, + `in`: InputStream, + out: OutputStream, + progressHandler: ProgressHandler, + ) { + var text: ByteArray? + ivSpec1 = IvParameterSpec(generateIv1()) + aesKey1 = SecretKeySpec(generateAESKey1(ivSpec1.iv, password), CRYPT_ALG) + ivSpec2 = IvParameterSpec(generateIV2()) + aesKey2 = SecretKeySpec(generateAESKey2(), CRYPT_ALG) + Log.v(TAG, "IV1: ${ivSpec1.iv.toHex()}") + Log.v(TAG, "AES1: ${aesKey1.encoded.toHex()}") + Log.v(TAG, "IV2: ${ivSpec2.iv.toHex()}") + Log.v(TAG, "AES2: ${aesKey2.encoded.toHex()}") + out.write(AESCRYPT_HEADER.toByteArray(UTF_8)) // Heading. + out.write(version) // Version. + out.write(0) // Reserved. + if (version == AESCRYPT_SPEC_VERSION) { // No extensions. + out.write(0) + out.write(0) + } + out.write(ivSpec1.iv) // Initialization Vector. + text = ByteArray(BLOCK_SIZE + KEY_SIZE) + cipher.init(Cipher.ENCRYPT_MODE, aesKey1, ivSpec1) + cipher.update(ivSpec2.iv, 0, BLOCK_SIZE, text) + cipher.doFinal(aesKey2.encoded, 0, KEY_SIZE, text, BLOCK_SIZE) + out.write(text) // Crypted IV and key. + Log.v(TAG, "IV2 + AES2 ciphertext: ${text.toHex()}") + hmac.init(SecretKeySpec(aesKey1.encoded, HMAC_ALG)) + text = hmac.doFinal(text) + out.write(text) // HMAC from previous cyphertext. + Log.v(TAG, "HMAC1: ${text.toHex()}") + cipher.init(Cipher.ENCRYPT_MODE, aesKey2, ivSpec2) + hmac.init(SecretKeySpec(aesKey2.encoded, HMAC_ALG)) + text = ByteArray(BLOCK_SIZE) + var len: Int + var last = 0 + while (`in`.read(text).also { len = it } > 0) { + if (!progressHandler.cancelled) { + cipher.update(text, 0, BLOCK_SIZE, text) + hmac.update(text) + out.write(text) // Crypted file data block. + last = len + ServiceWatcherUtil.position += len + } + } + last = last and 0x0f + out.write(last) // Last block size mod 16. + Log.v(TAG, "Last block size mod 16: $last") + text = hmac.doFinal() + out.write(text) // HMAC from previous cyphertext. + Log.v(TAG, "HMAC2: ${text.toHex()}") + + out.flush() + `in`.close() + out.close() + } + + /** + * The input stream is decrypted and saved to the output stream. + * + * The input file size is needed in advance.

+ * The input stream can be encrypted using version 1 or 2 of aescrypt.

+ * None of the streams are closed. + * + * Changes from original implementation: will flush and close input and output streams + * gracefully, in align with our own encryption routine. - TranceLove + * + * @param inSize input stream size, for sanity checking + * @param `in` AESCrypted source stream + * @param out decrypted data output stream + * @throws IncorrectEncryptedDataException if provided encrypted data cannot be parsed correctly + * @throws DecryptFailureException if there is any problem during decryption + * @throws GeneralSecurityException if the platform does not support the required cryptographic methods. + */ + @Suppress("LongMethod", "ComplexMethod") + @Throws(GeneralSecurityException::class) + fun decrypt( + inSize: Long, + `in`: InputStream, + out: OutputStream, + ) { + var text: ByteArray + var total = + (3 + 1 + 1 + BLOCK_SIZE + BLOCK_SIZE + KEY_SIZE + SHA_SIZE + 1 + SHA_SIZE).toLong() + text = ByteArray(3) + `in`.read(text) // Heading. + if (text.toString(UTF_8) != "AES") { + throw IncorrectEncryptedDataException("Invalid file header") + } + val version: Int = `in`.read() // Version. + if (version < 1 || version > 2) { + throw IncorrectEncryptedDataException("Unsupported version number: $version") + } + Log.v(TAG, "Version: $version") + `in`.read() // Reserved. + if (version == 2) { // Extensions. + text = ByteArray(2) + var len: Int + do { + `in`.read(text) + len = 0xff and text[0].toInt() shl 8 or (0xff and text[1].toInt()) + if (`in`.skip(len.toLong()) != len.toLong()) { + throw IncorrectEncryptedDataException("Unexpected end of extension") + } + total += (2 + len).toLong() + Log.i(TAG, "Skipped extension sized: $len") + } while (len != 0) + } + text = ByteArray(BLOCK_SIZE) + `in`.read(text) // Initialization Vector. + ivSpec1 = IvParameterSpec(text) + aesKey1 = SecretKeySpec(generateAESKey1(ivSpec1.iv, password), CRYPT_ALG) + Log.v(TAG, "IV1: ${ivSpec1.iv.toHex()}") + Log.v(TAG, "AES1: ${aesKey1.encoded.toHex()}") + cipher.init(Cipher.DECRYPT_MODE, aesKey1, ivSpec1) + var backup = ByteArray(BLOCK_SIZE + KEY_SIZE) + `in`.read(backup) // IV and key to decrypt file contents. + Log.v(TAG, "IV2 + AES2 ciphertext: ${backup.toHex()}") + text = cipher.doFinal(backup) + ivSpec2 = IvParameterSpec(text, 0, BLOCK_SIZE) + aesKey2 = SecretKeySpec(text, BLOCK_SIZE, KEY_SIZE, CRYPT_ALG) + Log.v(TAG, "IV2: ${ivSpec2.iv.toHex()}") + Log.v(TAG, "AES2: ${aesKey2.encoded.toHex()}") + hmac.init(SecretKeySpec(aesKey1.encoded, HMAC_ALG)) + backup = hmac.doFinal(backup) + text = ByteArray(SHA_SIZE) + `in`.read(text) // HMAC and authenticity test. + if (!backup.contentEquals(text)) { + throw DecryptFailureException("Message has been altered or password incorrect") + } + Log.v(TAG, "HMAC1: ${text.toHex()}") + total = inSize - total // Payload size. + if (total % BLOCK_SIZE != 0L) { + throw DecryptFailureException( + "Input file is corrupt. BLOCK_SIZE = $BLOCK_SIZE, total was $total", + ) + } + if (total == 0L) { // Hack: empty files won't enter block-processing for-loop below. + `in`.read() // Skip last block size mod 16. + } + Log.v(TAG, "Payload size: $total") + cipher.init(Cipher.DECRYPT_MODE, aesKey2, ivSpec2) + hmac.init(SecretKeySpec(aesKey2.encoded, HMAC_ALG)) + backup = ByteArray(BLOCK_SIZE) + text = ByteArray(BLOCK_SIZE) + for (block in (total / BLOCK_SIZE).toInt() downTo 1) { + var len = BLOCK_SIZE + if (`in`.read(backup, 0, len) != len) { // Cyphertext block. + throw DecryptFailureException("Unexpected end of file contents") + } + cipher.update(backup, 0, len, text) + hmac.update(backup, 0, len) + if (block == 1) { + val last = `in`.read() // Last block size mod 16. + Log.i(TAG, "Last block size mod 16: $last") + len = if (last > 0) last else BLOCK_SIZE + } + out.write(text, 0, len) + } + out.write(cipher.doFinal()) + backup = hmac.doFinal() + text = ByteArray(SHA_SIZE) + `in`.read(text) // HMAC and authenticity test. + if (!backup.contentEquals(text)) { + throw DecryptFailureException("Message has been altered or password incorrect") + } + Log.v(TAG, "HMAC2: ${text.toHex()}") + out.flush() + `in`.close() + out.close() + } + + companion object { + @JvmStatic + private val TAG = AESCrypt::class.java.simpleName + const val AESCRYPT_SPEC_VERSION = 2 + private const val AESCRYPT_HEADER = "AES" + private const val RANDOM_ALG = "SHA1PRNG" + private const val DIGEST_ALG = "SHA-256" + private const val HMAC_ALG = "HmacSHA256" + private const val CRYPT_ALG = "AES" + private const val CRYPT_TRANS = "AES/CBC/NoPadding" + private const val KEY_SIZE = 32 + private const val BLOCK_SIZE = 16 + private const val SHA_SIZE = 32 + } + + /** + * Builds an object to encrypt or decrypt files with the given password. + */ + init { + setPassword(password) + random = SecureRandom.getInstance(RANDOM_ALG) + digest = MessageDigest.getInstance(DIGEST_ALG) + cipher = Cipher.getInstance(CRYPT_TRANS) + hmac = Mac.getInstance(HMAC_ALG) + } + + /** + * Exception representing provided encrypted data is incorrect + */ + class IncorrectEncryptedDataException(message: String) : GeneralSecurityException(message) + + /** + * Exception representing decryption errors + */ + class DecryptFailureException(message: String) : GeneralSecurityException(message) +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/AnimUtils.kt b/app/src/main/java/com/amaze/filemanager/utils/AnimUtils.kt new file mode 100644 index 0000000..89f4356 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/AnimUtils.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils + +import android.content.Context +import android.os.Handler +import android.view.animation.AnimationUtils +import android.view.animation.Interpolator +import com.amaze.filemanager.R +import com.amaze.filemanager.ui.views.ThemedTextView + +/** Utility methods for working with animations. */ +object AnimUtils { + private var fastOutSlowIn: Interpolator? = null + + @JvmStatic + fun getFastOutSlowInInterpolator(context: Context?): Interpolator? { + if (fastOutSlowIn == null) { + fastOutSlowIn = + AnimationUtils.loadInterpolator(context, R.interpolator.fast_out_slow_in) + } + return fastOutSlowIn + } + + /** + * Animates filenames textview to marquee after a delay. Make sure to set [ ][TextView.setSelected] to false in order to stop the marquee later + */ + @JvmStatic + fun marqueeAfterDelay( + delayInMillis: Int, + marqueeView: ThemedTextView, + ) { + Handler() + .postDelayed( + { + // marquee works only when text view has focus + marqueeView.isSelected = true + }, + delayInMillis.toLong(), + ) + } +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/AppConstants.kt b/app/src/main/java/com/amaze/filemanager/utils/AppConstants.kt new file mode 100644 index 0000000..dfaa2d9 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/AppConstants.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils + +object AppConstants { + const val NEW_FILE_DELIMITER = "." + const val NEW_FILE_EXTENSION_TXT = "txt" + const val NEW_LINE = "\n" +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/BookSorter.kt b/app/src/main/java/com/amaze/filemanager/utils/BookSorter.kt new file mode 100644 index 0000000..46a8b82 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/BookSorter.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils + +/** Created by Arpit on 20-11-2015. */ +class BookSorter : Comparator> { + override fun compare( + lhs: Array, + rhs: Array, + ): Int { + var result = lhs[0].compareTo(rhs[0], ignoreCase = true) + if (result == 0) { + // the title is same, compare their paths + result = lhs[1].compareTo(rhs[1], ignoreCase = true) + } + return result + } +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/BottomBarButtonPath.kt b/app/src/main/java/com/amaze/filemanager/utils/BottomBarButtonPath.kt new file mode 100644 index 0000000..ca91daa --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/BottomBarButtonPath.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils + +import androidx.annotation.DrawableRes + +/** + * This lets BottomBar be independent of the Fragment MainActivity is housing + */ +interface BottomBarButtonPath { + /** + * This allows the fragment to change the path represented in the BottomBar directly + */ + fun changePath(path: String) + + val path: String? + + @get:DrawableRes + val rootDrawable: Int +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/ComputerParcelable.kt b/app/src/main/java/com/amaze/filemanager/utils/ComputerParcelable.kt new file mode 100644 index 0000000..6de6cbb --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/ComputerParcelable.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class ComputerParcelable(val addr: String, val name: String) : Parcelable { + override fun toString(): String = "$name [$addr]" + + override fun hashCode(): Int = addr.hashCode() + + override fun equals(other: Any?): Boolean = other is ComputerParcelable && other.addr == this.addr +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/ContextLocaleExt.kt b/app/src/main/java/com/amaze/filemanager/utils/ContextLocaleExt.kt new file mode 100644 index 0000000..a6a2314 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/ContextLocaleExt.kt @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2014-2023 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils + +import android.content.Context +import android.os.Build +import android.os.Build.VERSION_CODES.N +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat +import com.amaze.filemanager.R +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserException +import java.io.IOException +import java.util.Locale + +/** + * [Context] extension to return app's available locales, from locales_config.xml. + */ +fun Context.getLocaleListFromXml(): LocaleListCompat { + val tagsList = mutableListOf() + try { + val xpp: XmlPullParser = resources.getXml(R.xml.locales_config) + while (xpp.eventType != XmlPullParser.END_DOCUMENT) { + if (xpp.eventType == XmlPullParser.START_TAG) { + if (xpp.name == "locale") { + tagsList.add(xpp.getAttributeValue(0)) + } + } + xpp.next() + } + } catch (e: XmlPullParserException) { + e.printStackTrace() + } catch (e: IOException) { + e.printStackTrace() + } + + // Remove locale tags that would produce same locale on Android N or above + if (Build.VERSION.SDK_INT >= N) { + tagsList.remove("id") + tagsList.remove("he") + } + + return LocaleListCompat.forLanguageTags(tagsList.joinToString(",")) +} + +/** + * [Context] extension to return a [Map] of [Locale] with its display name as key. + * + * For preference drop down convenience. + */ +fun Context.getLangPreferenceDropdownEntries(): Map { + val localeList = getLocaleListFromXml() + val currentLocaleList: List = + ( + if (!AppCompatDelegate.getApplicationLocales().isEmpty) { + AppCompatDelegate.getApplicationLocales() + } else { + LocaleListCompat.getDefault() + } + ).let { appLocales -> + ArrayList().apply { + for (x in 0 until appLocales.size()) { + appLocales.get(x)?.let { + this.add(it) + } + } + } + } + val map = mutableMapOf() + + for (a in 0 until localeList.size()) { + localeList[a].let { + it?.run { + val displayName: String = + if (currentLocaleList.isEmpty()) { + this.getDisplayName(Locale.getDefault()) + } else { + this.getDisplayName( + currentLocaleList.first { locale -> + this.getDisplayName(locale).isNotEmpty() + }, + ) + } + map.put(displayName, this) + } + } + } + return map +} + +/** + * [Context] extension to set app locale fluently. + * + * Calls [AppCompatDelegate.setApplicationLocales] under the hood. + */ +fun Context.setLocale(langTag: String) { + val appLocale: LocaleListCompat = LocaleListCompat.forLanguageTags(langTag) + AppCompatDelegate.setApplicationLocales(appLocale) +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/DataUtils.java b/app/src/main/java/com/amaze/filemanager/utils/DataUtils.java new file mode 100644 index 0000000..8e09041 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/DataUtils.java @@ -0,0 +1,455 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.amaze.filemanager.adapters.data.LayoutElementParcelable; +import com.amaze.filemanager.application.AppConfig; +import com.amaze.filemanager.fileoperations.filesystem.OpenMode; +import com.cloudrail.si.interfaces.CloudStorage; +import com.cloudrail.si.services.Box; +import com.cloudrail.si.services.Dropbox; +import com.cloudrail.si.services.GoogleDrive; +import com.cloudrail.si.services.OneDrive; +import com.googlecode.concurrenttrees.radix.ConcurrentRadixTree; +import com.googlecode.concurrenttrees.radix.node.concrete.DefaultCharArrayNodeFactory; +import com.googlecode.concurrenttrees.radix.node.concrete.voidvalue.VoidValue; +import com.googlecode.concurrenttrees.radixinverted.ConcurrentInvertedRadixTree; +import com.googlecode.concurrenttrees.radixinverted.InvertedRadixTree; + +import android.text.TextUtils; +import android.view.MenuItem; + +import androidx.annotation.Nullable; + +/** Singleton class to handle data for various services */ + +// Central data being used across activity,fragments and classes +public class DataUtils { + + private static final Logger LOG = LoggerFactory.getLogger(DataUtils.class); + + private ConcurrentRadixTree hiddenfiles = + new ConcurrentRadixTree<>(new DefaultCharArrayNodeFactory()); + + public static final int LIST = 0, GRID = 1; + + private InvertedRadixTree filesGridOrList = + new ConcurrentInvertedRadixTree<>(new DefaultCharArrayNodeFactory()); + + private LinkedList history = new LinkedList<>(); + private ArrayList storages = new ArrayList<>(); + + private InvertedRadixTree tree = + new ConcurrentInvertedRadixTree<>(new DefaultCharArrayNodeFactory()); + + private ArrayList servers = new ArrayList<>(); + private ArrayList books = new ArrayList<>(); + + private ArrayList accounts = new ArrayList<>(4); + + /** List of checked items to persist when drag and drop from one tab to another */ + private ArrayList checkedItemsList; + + private DataChangeListener dataChangeListener; + + private DataUtils() {} + + private static class DataUtilsHolder { + private static final DataUtils INSTANCE = new DataUtils(); + } + + public static DataUtils getInstance() { + return DataUtilsHolder.INSTANCE; + } + + public int containsServer(String[] a) { + return contains(a, servers); + } + + public int containsServer(String path) { + + synchronized (servers) { + if (servers == null) return -1; + int i = 0; + for (String[] x : servers) { + if (x[1].equals(path)) return i; + i++; + } + } + return -1; + } + + public int containsBooks(String[] a) { + return contains(a, books); + } + + /*public int containsAccounts(CloudEntry cloudEntry) { + return contains(a, accounts); + }*/ + + /** + * Checks whether cloud account of certain type is present or not + * + * @param serviceType the {@link OpenMode} of account to check + * @return the index of account, -1 if not found + */ + public synchronized int containsAccounts(OpenMode serviceType) { + int i = 0; + for (CloudStorage storage : accounts) { + + switch (serviceType) { + case BOX: + if (storage instanceof Box) return i; + break; + case DROPBOX: + if (storage instanceof Dropbox) return i; + break; + case GDRIVE: + if (storage instanceof GoogleDrive) return i; + break; + case ONEDRIVE: + if (storage instanceof OneDrive) return i; + break; + default: + return -1; + } + i++; + } + return -1; + } + + public void clear() { + hiddenfiles = new ConcurrentRadixTree<>(new DefaultCharArrayNodeFactory()); + filesGridOrList = new ConcurrentInvertedRadixTree<>(new DefaultCharArrayNodeFactory()); + history.clear(); + storages = new ArrayList<>(); + tree = new ConcurrentInvertedRadixTree<>(new DefaultCharArrayNodeFactory()); + servers = new ArrayList<>(); + books = new ArrayList<>(); + accounts = new ArrayList<>(); + } + + public void registerOnDataChangedListener(DataChangeListener l) { + + dataChangeListener = l; + clear(); + } + + int contains(String a, ArrayList b) { + int i = 0; + for (String[] x : b) { + if (x[1].equals(a)) return i; + i++; + } + return -1; + } + + int contains(String[] a, ArrayList b) { + if (b == null) return -1; + int i = 0; + for (String[] x : b) { + if (x[0].equals(a[0]) && x[1].equals(a[1])) return i; + i++; + } + return -1; + } + + public void removeBook(int i) { + synchronized (books) { + if (books.size() > i) books.remove(i); + } + } + + public synchronized void removeAccount(OpenMode serviceType) { + for (CloudStorage storage : accounts) { + switch (serviceType) { + case BOX: + if (storage instanceof Box) { + accounts.remove(storage); + return; + } + break; + case DROPBOX: + if (storage instanceof Dropbox) { + accounts.remove(storage); + return; + } + break; + case GDRIVE: + if (storage instanceof GoogleDrive) { + accounts.remove(storage); + return; + } + break; + case ONEDRIVE: + if (storage instanceof OneDrive) { + accounts.remove(storage); + return; + } + break; + default: + return; + } + } + } + + public void removeServer(int i) { + synchronized (servers) { + if (servers.size() > i) servers.remove(i); + } + } + + public void addBook(String[] i) { + if (containsBooks(i) != -1) { + return; + } + synchronized (books) { + books.add(i); + } + } + + /** + * @param i The bookmark name and path. + * @param refreshdrawer boolean flag to indicate if drawer refresh is desired. + * @return True if operation successful, false if failure. + */ + public boolean addBook(final String[] i, boolean refreshdrawer) { + if (containsBooks(i) != -1) { + // book exists + return false; + } else { + synchronized (books) { + books.add(i); + } + + if (dataChangeListener != null) { + dataChangeListener.onBookAdded(i, refreshdrawer); + } + + return true; + } + } + + public void addAccount(CloudStorage storage) { + accounts.add(storage); + } + + public void addServer(String[] i) { + servers.add(i); + } + + public void addHiddenFile(final String i) { + + synchronized (hiddenfiles) { + hiddenfiles.put(i, VoidValue.SINGLETON); + } + if (dataChangeListener != null) { + dataChangeListener.onHiddenFileAdded(i); + } + } + + public void removeHiddenFile(final String i) { + + synchronized (hiddenfiles) { + hiddenfiles.remove(i); + } + if (dataChangeListener != null) { + dataChangeListener.onHiddenFileRemoved(i); + } + } + + public void setHistory(LinkedList s) { + history.clear(); + history.addAll(s); + } + + public LinkedList getHistory() { + return history; + } + + public void addHistoryFile(final String i) { + history.push(i); + if (dataChangeListener != null) { + dataChangeListener.onHistoryAdded(i); + } + } + + public void sortBook() { + Collections.sort(books, new BookSorter()); + } + + public synchronized void setServers(ArrayList servers) { + if (servers != null) this.servers = servers; + } + + public synchronized void setBooks(ArrayList books) { + if (books != null) this.books = books; + } + + public synchronized void setAccounts(ArrayList accounts) { + if (accounts != null) this.accounts = accounts; + } + + public synchronized ArrayList getServers() { + return servers; + } + + public synchronized ArrayList getBooks() { + return books; + } + + public synchronized ArrayList getAccounts() { + return accounts; + } + + public synchronized CloudStorage getAccount(OpenMode serviceType) { + for (CloudStorage storage : accounts) { + switch (serviceType) { + case BOX: + if (storage instanceof Box) return storage; + break; + case DROPBOX: + if (storage instanceof Dropbox) return storage; + break; + case GDRIVE: + if (storage instanceof GoogleDrive) return storage; + break; + case ONEDRIVE: + if (storage instanceof OneDrive) return storage; + break; + default: + LOG.error("Unable to determine service type of storage {}", storage); + return null; + } + } + return null; + } + + public boolean isFileHidden(String path) { + try { + return getHiddenFiles().getValueForExactKey(path) != null; + } catch (IllegalStateException e) { + LOG.warn("failed to get hidden file", e); + return false; + } + } + + public ConcurrentRadixTree getHiddenFiles() { + return hiddenfiles; + } + + public synchronized void setHiddenFiles(ConcurrentRadixTree hiddenfiles) { + if (hiddenfiles != null) this.hiddenfiles = hiddenfiles; + } + + public synchronized void setGridfiles(ArrayList gridfiles) { + if (gridfiles != null) { + for (String gridfile : gridfiles) { + setPathAsGridOrList(gridfile, GRID); + } + } + } + + public synchronized void setListfiles(ArrayList listfiles) { + if (listfiles != null) { + for (String gridfile : listfiles) { + setPathAsGridOrList(gridfile, LIST); + } + } + } + + public void setPathAsGridOrList(String path, int value) { + filesGridOrList.put(path, value); + } + + public int getListOrGridForPath(String path, int defaultValue) { + Integer value = filesGridOrList.getValueForLongestKeyPrefixing(path); + return value != null ? value : defaultValue; + } + + public void clearHistory() { + history.clear(); + if (dataChangeListener != null) { + AppConfig.getInstance().runInBackground(() -> dataChangeListener.onHistoryCleared()); + } + } + + public synchronized List getStorages() { + return storages; + } + + public synchronized void setStorages(ArrayList storages) { + this.storages = storages; + } + + public boolean putDrawerPath(MenuItem item, String path) { + if (!TextUtils.isEmpty(path)) { + try { + tree.put(path, item.getItemId()); + return true; + } catch (IllegalStateException e) { + LOG.warn("failed to put drawer path", e); + return false; + } + } + return false; + } + + /** + * @param path the path to find + * @return the id of the longest containing MenuMetadata.path in getDrawerMetadata() or null + */ + public @Nullable Integer findLongestContainingDrawerItem(CharSequence path) { + return tree.getValueForLongestKeyPrefixing(path); + } + + public ArrayList getCheckedItemsList() { + return this.checkedItemsList; + } + + public void setCheckedItemsList(ArrayList layoutElementParcelables) { + this.checkedItemsList = layoutElementParcelables; + } + + /** + * Callbacks to do original changes in database (and ui if required) The callbacks are called in a + * main thread + */ + public interface DataChangeListener { + void onHiddenFileAdded(String path); + + void onHiddenFileRemoved(String path); + + void onHistoryAdded(String path); + + void onBookAdded(String path[], boolean refreshdrawer); + + void onHistoryCleared(); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/DatapointParcelable.kt b/app/src/main/java/com/amaze/filemanager/utils/DatapointParcelable.kt new file mode 100644 index 0000000..017633f --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/DatapointParcelable.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Class stores the [AbstractProgressiveService] progress variables. This class also acts + * as data carrier to communicate with [ProcessViewerFragment] + * + * @param name name of source file being copied + * @param amountOfSourceFiles total number of source files to be copied + * @param sourceProgress which file is being copied from total number of files + * @param totalSize total size of all source files combined + * @param byteProgress current byte position in total bytes pool + * @param speedRaw bytes being copied per sec + * @param move allows changing the text from "Copying" to "Moving" in case of copy + * @param completed if the operation has finished + */ +@Parcelize +data class DatapointParcelable( + val name: String?, + val amountOfSourceFiles: Int, + val sourceProgress: Int, + val totalSize: Long, + val byteProgress: Long, + val speedRaw: Long, + val move: Boolean, + val completed: Boolean, +) : Parcelable { + companion object { + /** + * For the first datapoint, everything is 0 or false except the params. Allows move boolean to + * change the text from "Copying" to "Moving" in case of copy. + * + * @param name name of source file being copied + * @param amountOfSourceFiles total number of source files to be copied + * @param totalSize total size of all source files combined + * @param move allows changing the text from "Copying" to "Moving" in case of copy + */ + fun buildDatapointParcelable( + name: String?, + amountOfSourceFiles: Int, + totalSize: Long, + move: Boolean, + ): DatapointParcelable = + DatapointParcelable( + name, + amountOfSourceFiles, + 0, + totalSize, + 0, + 0, + move, + false, + ) + } +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/EditTextColorStateUtil.java b/app/src/main/java/com/amaze/filemanager/utils/EditTextColorStateUtil.java new file mode 100644 index 0000000..1a99254 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/EditTextColorStateUtil.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils; + +import com.amaze.filemanager.R; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.os.Build; +import android.widget.EditText; + +import androidx.appcompat.widget.AppCompatEditText; + +/** + * Created by vishal on 20/2/17. + * + *

Use this class when dealing with {@link androidx.appcompat.widget.AppCompatEditText} for it's + * color states for different user interactions + */ +public class EditTextColorStateUtil { + + public static void setTint(Context context, EditText editText, int color) { + if (Build.VERSION.SDK_INT >= 21) return; + ColorStateList editTextColorStateList = createEditTextColorStateList(context, color); + if (editText instanceof AppCompatEditText) { + ((AppCompatEditText) editText).setSupportBackgroundTintList(editTextColorStateList); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + editText.setBackgroundTintList(editTextColorStateList); + } + } + + private static ColorStateList createEditTextColorStateList(Context context, int color) { + int[][] states = new int[3][]; + int[] colors = new int[3]; + int i = 0; + states[i] = new int[] {-android.R.attr.state_enabled}; + colors[i] = Utils.getColor(context, R.color.text_disabled); + i++; + states[i] = new int[] {-android.R.attr.state_pressed, -android.R.attr.state_focused}; + colors[i] = Utils.getColor(context, R.color.grey); + i++; + states[i] = new int[] {}; + colors[i] = color; + return new ColorStateList(states, colors); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/FingerprintHandler.kt b/app/src/main/java/com/amaze/filemanager/utils/FingerprintHandler.kt new file mode 100644 index 0000000..45f109f --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/FingerprintHandler.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.amaze.filemanager.utils + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.biometric.BiometricPrompt +import androidx.biometric.BiometricPrompt.PromptInfo +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import com.amaze.filemanager.filesystem.files.EncryptDecryptUtils.DecryptButtonCallbackInterface +import com.amaze.filemanager.ui.activities.MainActivity + +/** Created by vishal on 15/4/17. */ +@RequiresApi(api = Build.VERSION_CODES.M) +class FingerprintHandler( + private val mainActivity: MainActivity, + private val decryptIntent: Intent, + private val promptInfo: PromptInfo, + private val decryptButtonCallbackInterface: DecryptButtonCallbackInterface, +) : BiometricPrompt.AuthenticationCallback() { + /** + * Authenticate user to perform decryption. + */ + @RequiresApi(api = Build.VERSION_CODES.M) + fun authenticate(cryptoObject: BiometricPrompt.CryptoObject) { + if (ActivityCompat.checkSelfPermission(mainActivity, Manifest.permission.USE_FINGERPRINT) + != PackageManager.PERMISSION_GRANTED + ) { + return + } + + val prompt = + BiometricPrompt(mainActivity, ContextCompat.getMainExecutor(mainActivity), this) + prompt.authenticate(promptInfo, cryptoObject) + } + + override fun onAuthenticationError( + errMsgId: Int, + errString: CharSequence, + ) = Unit + + override fun onAuthenticationFailed() { + decryptButtonCallbackInterface.failed() + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + decryptButtonCallbackInterface.confirm(decryptIntent) + } +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/GenericExt.kt b/app/src/main/java/com/amaze/filemanager/utils/GenericExt.kt new file mode 100644 index 0000000..02f93d5 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/GenericExt.kt @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils + +import java.net.URLDecoder.decode +import java.net.URLEncoder.encode +import java.nio.charset.Charset + +/** + * Allow null checks on more than one parameters at the same time. + * Alternative of doing nested p1?.let p2?.let + */ +inline fun safeLet( + p1: T1?, + p2: T2?, + p3: T3?, + p4: T4?, + p5: T5?, + block: (T1, T2, T3, T4, T5) -> R?, +): R? { + return if (p1 != null && p2 != null && p3 != null && p4 != null && p5 != null) { + block( + p1, + p2, + p3, + p4, + p5, + ) + } else { + null + } +} + +/** + * Allow null checks on more than one parameters at the same time. + * Alternative of doing nested p1?.let p2?.let + */ +inline fun safeLet( + p1: T1?, + p2: T2?, + p3: T3?, + block: (T1, T2, T3) -> R?, +): R? { + return if (p1 != null && p2 != null && p3 != null) { + block( + p1, + p2, + p3, + ) + } else { + null + } +} + +/** + * Allow null checks on more than one parameters at the same time. + * Alternative of doing nested p1?.let p2?.let + */ +inline fun safeLet( + p1: T1?, + p2: T2?, + block: (T1, T2) -> R?, +): R? { + return if (p1 != null && p2 != null) { + block( + p1, + p2, + ) + } else { + null + } +} + +/** + * Convert a byte array to its hex string representation. + * + * Optionally takes a separator parameter. + */ +fun ByteArray.toHex(separatorStr: String = ""): String = + joinToString(separator = separatorStr) { + eachByte -> + "%02x".format(eachByte) + } + +/** + * Test a [List] for given path. Assumed paths in the list are not ending with /, so check for + * both ended with or not ended with / with the given path parameter. + */ +fun List<*>.containsPath(path: String): Boolean { + return this.contains(path) || + (path.endsWith('/') && this.contains(path.substringBeforeLast('/'))) +} + +/** + * Convenience method to return a string in URL encoded form, with specified [Charset]. + * + * @param charset [Charset] to encode string. Default is UTF-8 + */ +fun String.urlEncoded(charset: Charset = Charsets.UTF_8): String { + return encode(this, charset.name()) +} + +/** + * Convenience method to return a string in URL decoded form, with specified [Charset]. + * + * @param charset [Charset] to decode string. Default is UTF-8 + */ +fun String.urlDecoded(charset: Charset = Charsets.UTF_8): String { + return decode(this, charset.name()) +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/GlideConstants.java b/app/src/main/java/com/amaze/filemanager/utils/GlideConstants.java new file mode 100644 index 0000000..c8ec849 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/GlideConstants.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils; + +/** + * @author Emmanuel Messulam on 8/12/2017, at 16:33. + */ +public class GlideConstants { + + public static final int MAX_PRELOAD_FILES = 50; + public static final int MAX_PRELOAD_APPSADAPTER = 100; +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/ImmutableEntry.java b/app/src/main/java/com/amaze/filemanager/utils/ImmutableEntry.java new file mode 100644 index 0000000..8d51ff7 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/ImmutableEntry.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils; + +import java.util.Map; + +import androidx.annotation.Nullable; + +/** + * From: + * https://github.com/google/guava/blob/master/guava/src/com/google/common/collect/ImmutableEntry.java + * Author: Guava + */ +public class ImmutableEntry implements Map.Entry { + private final K key; + private final V value; + + public ImmutableEntry(@Nullable K key, @Nullable V value) { + this.key = key; + this.value = value; + } + + @Nullable + @Override + public final K getKey() { + return key; + } + + @Nullable + @Override + public final V getValue() { + return value; + } + + @Override + public final V setValue(V value) { + throw new UnsupportedOperationException(); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/InterestingConfigChange.java b/app/src/main/java/com/amaze/filemanager/utils/InterestingConfigChange.java new file mode 100644 index 0000000..f9100fd --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/InterestingConfigChange.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils; + +import android.content.pm.ActivityInfo; +import android.content.res.Configuration; +import android.content.res.Resources; + +/** + * Created by vishal on 23/2/17. + * + *

Class determines whether there was a config change + * + *

Supposed to be used to determine recursive callbacks to fragment/activity/loader Make sure to + * recycle after you're done + */ +public class InterestingConfigChange { + + private static Configuration lastConfiguration = new Configuration(); + private static int lastDensity = -1; + + /** + * Check for any config change between various callbacks to this method. Make sure to recycle + * after done + */ + public static boolean isConfigChanged(Resources resources) { + int changedFieldsMask = lastConfiguration.updateFrom(resources.getConfiguration()); + boolean densityChanged = lastDensity != resources.getDisplayMetrics().densityDpi; + int mode = + ActivityInfo.CONFIG_SCREEN_LAYOUT + | ActivityInfo.CONFIG_UI_MODE + | ActivityInfo.CONFIG_LOCALE; + return densityChanged || (changedFieldsMask & mode) != 0; + } + + /** Recycle after usage, to avoid getting inconsistent result because of static modifiers */ + public static void recycle() { + lastConfiguration = new Configuration(); + lastDensity = -1; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/MainActivityActionMode.kt b/app/src/main/java/com/amaze/filemanager/utils/MainActivityActionMode.kt new file mode 100644 index 0000000..4af20b4 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/MainActivityActionMode.kt @@ -0,0 +1,456 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils + +import android.content.Intent +import android.graphics.drawable.ColorDrawable +import android.os.Build +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.widget.Toast +import androidx.appcompat.view.ActionMode +import androidx.appcompat.widget.AppCompatTextView +import androidx.drawerlayout.widget.DrawerLayout +import com.amaze.filemanager.R +import com.amaze.filemanager.adapters.data.LayoutElementParcelable +import com.amaze.filemanager.fileoperations.filesystem.OpenMode +import com.amaze.filemanager.filesystem.HybridFileParcelable +import com.amaze.filemanager.filesystem.PasteHelper +import com.amaze.filemanager.filesystem.files.FileUtils +import com.amaze.filemanager.ui.activities.MainActivity +import com.amaze.filemanager.ui.dialogs.GeneralDialogCreation +import com.amaze.filemanager.ui.selection.SelectionPopupMenu.Companion.invokeSelectionDropdown +import java.io.File +import java.lang.ref.WeakReference + +class MainActivityActionMode(private val mainActivityReference: WeakReference) : + ActionMode.Callback { + var actionModeView: View? = null + var actionMode: ActionMode? = null + var pasteHelper: PasteHelper? = null + + private fun hideOption( + id: Int, + menu: Menu, + ) { + val item = menu.findItem(id) + item.isVisible = false + } + + private fun showOption( + id: Int, + menu: Menu, + ) { + val item = menu.findItem(id) + item.isVisible = true + } + + // called when the action mode is created; startActionMode() was called + override fun onCreateActionMode( + mode: ActionMode, + menu: Menu, + ): Boolean { + // Inflate a menu resource providing context menu items + val inflater = mode.menuInflater + mainActivityReference.get()?.let { + mainActivity -> + actionModeView = mainActivity.layoutInflater.inflate(R.layout.actionmode, null) + mode.customView = actionModeView + mainActivity.setPagingEnabled(false) + mainActivity.hideFab() + if (mainActivity.mReturnIntent && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN + ) { + mainActivity.showFabConfirmSelection() + } + + // translates the drawable content down + // if (mainActivity.isDrawerLocked) mainActivity.translateDrawerList(true); + + // assumes that you have "contexual.xml" menu resources + inflater.inflate(R.menu.contextual, menu) + hideOption(R.id.addshortcut, menu) + hideOption(R.id.share, menu) + hideOption(R.id.openwith, menu) + // hideOption(R.id.setringtone,menu); + mode.title = mainActivity.resources.getString(R.string.select) + mainActivity + .updateViews( + ColorDrawable( + mainActivity.resources + .getColor(R.color.holo_dark_action_mode), + ), + ) + + // do not allow drawer to open when item gets selected + if (!mainActivity.drawer.isLocked) { + mainActivity.drawer.lockIfNotOnTablet(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) + } + } + return true + } + + /** + * the following method is called each time the action mode is shown. Always called after + * onCreateActionMode, but may be called multiple times if the mode is invalidated. + */ + override fun onPrepareActionMode( + mode: ActionMode, + menu: Menu, + ): Boolean { + safeLet( + mainActivityReference.get(), + mainActivityReference.get()?.currentMainFragment?.mainFragmentViewModel, + mainActivityReference.get()?.currentMainFragment?.adapter, + ) { + mainActivity, mainFragmentViewModel, adapter -> + val checkedItems: ArrayList = + mainFragmentViewModel.getCheckedItems() + actionModeView?.setOnClickListener { + invokeSelectionDropdown( + adapter, + actionModeView!!, + mainFragmentViewModel.currentPath!!, + mainActivity, + ) + } + val textView: AppCompatTextView = actionModeView!!.findViewById(R.id.item_count) + textView.text = checkedItems.size.toString() + + if (mainActivity.mReturnIntent && + !mainActivity.intent.getBooleanExtra( + Intent.EXTRA_ALLOW_MULTIPLE, + false, + ) + ) { + // Only one item can be returned, so there should not be a "Select all" button + hideOption(R.id.all, menu) + } else { + menu.findItem(R.id.all) + .setTitle( + if (checkedItems.size + == mainFragmentViewModel.folderCount + + mainFragmentViewModel.fileCount + ) { + R.string.deselect_all + } else { + R.string.select_all + }, + ) + } + + if (mainFragmentViewModel.openMode != OpenMode.FILE && + mainFragmentViewModel.openMode != OpenMode.TRASH_BIN && + !mainFragmentViewModel.getIsCloudOpenMode() + ) { + hideOption(R.id.addshortcut, menu) + hideOption(R.id.compress, menu) + return true + } + // tv.setText(checkedItems.size()); + + hideOption(R.id.openparent, menu) + if (checkedItems.size == 1) { + showOption(R.id.addshortcut, menu) + showOption(R.id.openwith, menu) + showOption(R.id.share, menu) + if (mainFragmentViewModel.getCheckedItems()[0].isDirectory) { + hideOption(R.id.openwith, menu) + hideOption(R.id.share, menu) + } + } else { + showOption(R.id.share, menu) + for (e in mainFragmentViewModel.getCheckedItems()) { + if (e.isDirectory) { + hideOption(R.id.share, menu) + break + } + } + hideOption(R.id.openwith, menu) + hideOption(R.id.addshortcut, menu) + } + if (mainFragmentViewModel.openMode != OpenMode.FILE) { + hideOption(R.id.openwith, menu) + hideOption(R.id.compress, menu) + hideOption(R.id.hide, menu) + hideOption(R.id.addshortcut, menu) + if (mainFragmentViewModel.openMode == OpenMode.TRASH_BIN) { + hideOption(R.id.cpy, menu) + hideOption(R.id.cut, menu) + hideOption(R.id.share, menu) + hideOption(R.id.hide, menu) + hideOption(R.id.addshortcut, menu) + hideOption(R.id.ex, menu) + showOption(R.id.delete, menu) + showOption(R.id.restore, menu) + } + } + } + return true // Return false if nothing is done + } + + // called when the user selects a contextual menu item + override fun onActionItemClicked( + mode: ActionMode, + item: MenuItem, + ): Boolean { + mainActivityReference.get()?.currentMainFragment?.computeScroll() + safeLet( + mainActivityReference.get(), + mainActivityReference + .get()?.currentMainFragment?.mainFragmentViewModel?.getCheckedItems(), + ) { + mainActivity, checkedItems -> + return when (item.itemId) { + R.id.about -> { + val x = checkedItems[0] + mainActivity.currentMainFragment?.also { + GeneralDialogCreation.showPropertiesDialogWithPermissions( + x.generateBaseFile(), + x.permissions, + mainActivity, + it, + mainActivity.isRootExplorer, + mainActivity.utilsProvider.appTheme, + ) + } + mode.finish() + true + } + R.id.delete -> { + GeneralDialogCreation.deleteFilesDialog( + mainActivity, + mainActivity, + checkedItems, + mainActivity.utilsProvider.appTheme, + ) + true + } + R.id.restore -> { + GeneralDialogCreation.restoreFilesDialog( + mainActivity, + mainActivity, + checkedItems, + mainActivity.utilsProvider.appTheme, + ) + true + } + R.id.share -> { + if (checkedItems.size > 100) { + Toast.makeText( + mainActivity, + mainActivity.resources.getString(R.string.share_limit), + Toast.LENGTH_SHORT, + ) + .show() + } else { + mainActivity.currentMainFragment?.mainFragmentViewModel?.also { + mainFragmentViewModel -> + when (checkedItems[0].mode) { + OpenMode.DROPBOX, OpenMode.BOX, OpenMode.GDRIVE, + OpenMode.ONEDRIVE, + -> + FileUtils.shareCloudFiles( + checkedItems, + checkedItems[0].mode, + mainActivity, + ) + else -> { + val arrayList = ArrayList() + for (e in checkedItems) { + arrayList.add(File(e.desc)) + } + FileUtils.shareFiles( + arrayList, + mainActivity, + mainActivity.utilsProvider.appTheme, + mainFragmentViewModel.accentColor, + ) + } + } + } + } + true + } + R.id.openparent -> { + mainActivity.currentMainFragment?.loadlist( + File(checkedItems[0].desc).parent, + false, + OpenMode.FILE, + false, + ) + + true + } + R.id.all -> { + safeLet( + mainActivity.currentMainFragment?.mainFragmentViewModel, + mainActivity.currentMainFragment?.adapter, + ) { + mainFragmentViewModel, adapter -> + if (adapter.areAllChecked(mainFragmentViewModel.currentPath)) { + adapter.toggleChecked( + false, + mainFragmentViewModel.currentPath, + ) + item.setTitle(R.string.select_all) + } else { + adapter.toggleChecked( + true, + mainFragmentViewModel.currentPath, + ) + item.setTitle(R.string.deselect_all) + } + } + mode.invalidate() + true + } + R.id.rename -> { + val f: HybridFileParcelable = checkedItems[0].generateBaseFile() + mainActivity.currentMainFragment?.rename(f) + mode.finish() + true + } + R.id.hide -> { + var i1 = 0 + while (i1 < checkedItems.size) { + mainActivity.currentMainFragment?.hide(checkedItems[i1].desc) + i1++ + } + mainActivity.currentMainFragment?.updateList(false) + mode.finish() + true + } + R.id.ex -> { + mainActivity.mainActivityHelper.extractFile(File(checkedItems[0].desc)) + mode.finish() + true + } + R.id.cpy, R.id.cut -> { + val copies = arrayOfNulls(checkedItems.size) + var i = 0 + while (i < checkedItems.size) { + copies[i] = checkedItems[i].generateBaseFile() + i++ + } + val op = + if (item.itemId == R.id.cpy) { + PasteHelper.OPERATION_COPY + } else { + PasteHelper.OPERATION_CUT + } + // Making sure we don't cause an IllegalArgumentException + // when passing copies to PasteHelper + if (copies.isNotEmpty()) { + pasteHelper = PasteHelper(mainActivity, op, copies) + mainActivity.paste = pasteHelper + } + mode.finish() + true + } + R.id.compress -> { + val copies1 = ArrayList() + var i4 = 0 + while (i4 < checkedItems.size) { + copies1.add(checkedItems[i4].generateBaseFile()) + i4++ + } + GeneralDialogCreation.showCompressDialog( + mainActivity, + copies1, + mainActivity.currentMainFragment?.mainFragmentViewModel?.currentPath, + ) + mode.finish() + true + } + R.id.openwith -> { + FileUtils.openFile( + File(checkedItems[0].desc), + mainActivity, + mainActivity.prefs, + ) + true + } + R.id.addshortcut -> { + Utils.addShortcut( + mainActivity, + mainActivity.componentName, + checkedItems[0], + ) + mode.finish() + true + } + else -> false + } + } + return false + } + + // called when the user exits the action mode + override fun onDestroyActionMode(mode: ActionMode) { + actionMode = null + mainActivityReference.get()?.let { + mainActivity -> + mainActivity.listItemSelected = false + + // translates the drawer content up + // if (mainActivity.isDrawerLocked) mainActivity.translateDrawerList(false); + mainActivity.showFab() + mainActivity.hideFabConfirmSelection() + + mainActivity.setPagingEnabled(true) + safeLet( + mainActivity.currentMainFragment?.mainFragmentViewModel, + mainActivity.currentMainFragment?.adapter, + ) { + mainFragmentViewModel, adapter -> + adapter.toggleChecked(false, mainFragmentViewModel.currentPath) + mainActivity + .updateViews( + ColorDrawable( + if (MainActivity.currentTab == 1) { + mainFragmentViewModel.primaryTwoColor + } else { + mainFragmentViewModel.primaryColor + }, + ), + ) + } + + if (mainActivity.drawer.isLocked) { + mainActivity.drawer.unlockIfNotOnTablet() + } + } + } + + /** + * Finishes the action mode + */ + fun disableActionMode() { + mainActivityReference.get()?.let { + it.listItemSelected = false + it.hideFabConfirmSelection() + } + actionMode?.finish() + actionMode = null + } +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/MainActivityHelper.java b/app/src/main/java/com/amaze/filemanager/utils/MainActivityHelper.java new file mode 100644 index 0000000..8eeaabe --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/MainActivityHelper.java @@ -0,0 +1,741 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils; + +import static com.amaze.filemanager.fileoperations.filesystem.FolderStateKt.CAN_CREATE_FILES; +import static com.amaze.filemanager.fileoperations.filesystem.FolderStateKt.DOESNT_EXIST; +import static com.amaze.filemanager.fileoperations.filesystem.FolderStateKt.WRITABLE_OR_ON_SDCARD; +import static com.amaze.filemanager.fileoperations.filesystem.OperationTypeKt.COMPRESS; +import static com.amaze.filemanager.fileoperations.filesystem.OperationTypeKt.DELETE; +import static com.amaze.filemanager.fileoperations.filesystem.OperationTypeKt.EXTRACT; +import static com.amaze.filemanager.fileoperations.filesystem.OperationTypeKt.NEW_FILE; +import static com.amaze.filemanager.fileoperations.filesystem.OperationTypeKt.NEW_FOLDER; +import static com.amaze.filemanager.fileoperations.filesystem.OperationTypeKt.RENAME; + +import java.io.File; +import java.util.ArrayList; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.afollestad.materialdialogs.MaterialDialog; +import com.amaze.filemanager.R; +import com.amaze.filemanager.application.AppConfig; +import com.amaze.filemanager.asynchronous.asynctasks.DeleteTask; +import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil; +import com.amaze.filemanager.asynchronous.services.ZipService; +import com.amaze.filemanager.database.CloudHandler; +import com.amaze.filemanager.database.CryptHandler; +import com.amaze.filemanager.database.models.explorer.EncryptedEntry; +import com.amaze.filemanager.fileoperations.filesystem.FolderState; +import com.amaze.filemanager.fileoperations.filesystem.OpenMode; +import com.amaze.filemanager.filesystem.ExternalSdCardOperation; +import com.amaze.filemanager.filesystem.FileProperties; +import com.amaze.filemanager.filesystem.HybridFile; +import com.amaze.filemanager.filesystem.HybridFileParcelable; +import com.amaze.filemanager.filesystem.Operations; +import com.amaze.filemanager.filesystem.SafRootHolder; +import com.amaze.filemanager.filesystem.compressed.CompressedHelper; +import com.amaze.filemanager.filesystem.compressed.showcontents.Decompressor; +import com.amaze.filemanager.filesystem.files.CryptUtil; +import com.amaze.filemanager.filesystem.files.FileUtils; +import com.amaze.filemanager.filesystem.ftp.NetCopyClientUtils; +import com.amaze.filemanager.ui.ExtensionsKt; +import com.amaze.filemanager.ui.activities.MainActivity; +import com.amaze.filemanager.ui.dialogs.GeneralDialogCreation; +import com.amaze.filemanager.ui.fragments.MainFragment; +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants; +import com.amaze.filemanager.ui.views.WarnableTextInputValidator; +import com.amaze.filemanager.utils.smb.SmbUtil; +import com.leinardi.android.speeddial.SpeedDialView; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Build; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.appcompat.widget.AppCompatEditText; +import androidx.appcompat.widget.AppCompatImageView; +import androidx.appcompat.widget.AppCompatTextView; +import androidx.documentfile.provider.DocumentFile; +import androidx.preference.PreferenceManager; + +public class MainActivityHelper { + + private static final Logger LOG = LoggerFactory.getLogger(MainActivityHelper.class); + + private MainActivity mainActivity; + private DataUtils dataUtils = DataUtils.getInstance(); + private int accentColor; + private SpeedDialView.OnActionSelectedListener fabActionListener; + + public MainActivityHelper(MainActivity mainActivity) { + this.mainActivity = mainActivity; + accentColor = mainActivity.getAccent(); + } + + public void showFailedOperationDialog( + ArrayList failedOps, Context context) { + MaterialDialog.Builder mat = new MaterialDialog.Builder(context); + mat.title(context.getString(R.string.operation_unsuccesful)); + mat.theme(mainActivity.getAppTheme().getMaterialDialogTheme()); + mat.positiveColor(accentColor); + mat.positiveText(R.string.cancel); + String content = context.getString(R.string.operation_fail_following); + int k = 1; + for (HybridFileParcelable s : failedOps) { + content = content + "\n" + (k) + ". " + s.getName(context); + k++; + } + mat.content(content); + mat.build().show(); + } + + public final BroadcastReceiver mNotificationReceiver = + new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (intent != null) { + if (intent.getAction().equals(Intent.ACTION_MEDIA_MOUNTED)) { + Toast.makeText(mainActivity, "Media Mounted", Toast.LENGTH_SHORT).show(); + String a = intent.getData().getPath(); + if (a != null + && a.trim().length() != 0 + && new File(a).exists() + && new File(a).canExecute()) { + dataUtils.getStorages().add(a); + mainActivity.getDrawer().refreshDrawer(); + } else { + mainActivity.getDrawer().refreshDrawer(); + } + } else if (intent.getAction().equals(Intent.ACTION_MEDIA_UNMOUNTED)) { + + mainActivity.getDrawer().refreshDrawer(); + } + } + } + }; + + /** + * Prompt a dialog to user to input directory name + * + * @param path current path at which directory to create + * @param ma {@link MainFragment} current fragment + */ + public void mkdir(final OpenMode openMode, final String path, final MainFragment ma) { + mk( + R.string.newfolder, + "", + (dialog, which) -> { + AppCompatEditText textfield = + dialog.getCustomView().findViewById(R.id.singleedittext_input); + String parentPath = path; + if (OpenMode.DOCUMENT_FILE.equals(openMode) + && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + parentPath = FileProperties.remapPathForApi30OrAbove(path, false); + } + mkDir( + new HybridFile(openMode, parentPath), + new HybridFile(openMode, parentPath, textfield.getText().toString().trim(), true), + ma); + dialog.dismiss(); + }, + (text) -> { + boolean isValidFilename = FileProperties.isValidFilename(text); + + if (!isValidFilename || text.startsWith(" ")) { + return new WarnableTextInputValidator.ReturnState( + WarnableTextInputValidator.ReturnState.STATE_ERROR, R.string.invalid_name); + } else if (text.length() < 1) { + return new WarnableTextInputValidator.ReturnState( + WarnableTextInputValidator.ReturnState.STATE_ERROR, R.string.field_empty); + } + + return new WarnableTextInputValidator.ReturnState(); + }); + } + + /** + * Prompt a dialog to user to input file name + * + * @param path current path at which file to create + * @param ma {@link MainFragment} current fragment + */ + public void mkfile(final OpenMode openMode, final String path, final MainFragment ma) { + mk( + R.string.newfile, + AppConstants.NEW_FILE_DELIMITER.concat(AppConstants.NEW_FILE_EXTENSION_TXT), + (dialog, which) -> { + AppCompatEditText textfield = + dialog.getCustomView().findViewById(R.id.singleedittext_input); + mkFile( + new HybridFile(openMode, path), + new HybridFile(openMode, path, textfield.getText().toString().trim(), false), + ma); + dialog.dismiss(); + }, + (text) -> { + boolean isValidFilename = FileProperties.isValidFilename(text); + + // The redundant equalsIgnoreCase() is needed since ".txt" itself does not end with .txt + // (i.e. recommended as ".txt.txt" + if (text.length() > 0) { + if (!isValidFilename || text.startsWith(" ")) { + return new WarnableTextInputValidator.ReturnState( + WarnableTextInputValidator.ReturnState.STATE_ERROR, R.string.invalid_name); + } else { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(mainActivity); + if (text.startsWith(".") + && !prefs.getBoolean(PreferencesConstants.PREFERENCE_SHOW_HIDDENFILES, false)) { + return new WarnableTextInputValidator.ReturnState( + WarnableTextInputValidator.ReturnState.STATE_WARNING, + R.string.create_hidden_file_warn); + } else if (!text.toLowerCase() + .endsWith( + AppConstants.NEW_FILE_DELIMITER.concat( + AppConstants.NEW_FILE_EXTENSION_TXT))) { + return new WarnableTextInputValidator.ReturnState( + WarnableTextInputValidator.ReturnState.STATE_WARNING, + R.string.create_file_suggest_txt_extension); + } + } + } else { + return new WarnableTextInputValidator.ReturnState( + WarnableTextInputValidator.ReturnState.STATE_ERROR, R.string.field_empty); + } + return new WarnableTextInputValidator.ReturnState(); + }); + } + + private void mk( + @StringRes int newText, + String prefill, + final MaterialDialog.SingleButtonCallback onPositiveAction, + final WarnableTextInputValidator.OnTextValidate validator) { + MaterialDialog dialog = + GeneralDialogCreation.showNameDialog( + mainActivity, + mainActivity.getResources().getString(R.string.entername), + prefill, + mainActivity.getResources().getString(newText), + mainActivity.getResources().getString(R.string.create), + mainActivity.getResources().getString(R.string.cancel), + null, + onPositiveAction, + validator); + dialog.show(); + + // place cursor at the beginning + AppCompatEditText textfield = dialog.getCustomView().findViewById(R.id.singleedittext_input); + textfield.post( + () -> { + textfield.setSelection(0); + }); + } + + public String getIntegralNames(String path) { + String newPath = ""; + switch (Integer.parseInt(path)) { + case 0: + newPath = mainActivity.getString(R.string.images); + break; + case 1: + newPath = mainActivity.getString(R.string.videos); + break; + case 2: + newPath = mainActivity.getString(R.string.audio); + break; + case 3: + newPath = mainActivity.getString(R.string.documents); + break; + case 4: + newPath = mainActivity.getString(R.string.apks); + break; + case 5: + newPath = mainActivity.getString(R.string.quick); + break; + case 6: + newPath = mainActivity.getString(R.string.recent); + break; + case 7: + newPath = mainActivity.getString(R.string.trash_bin); + break; + } + return newPath; + } + + public void guideDialogForLEXA(String path) { + guideDialogForLEXA(path, 3); + } + + public void guideDialogForLEXA(String path, int requestCode) { + final MaterialDialog.Builder x = new MaterialDialog.Builder(mainActivity); + x.theme(mainActivity.getAppTheme().getMaterialDialogTheme()); + x.title(R.string.needs_access); + LayoutInflater layoutInflater = + (LayoutInflater) mainActivity.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + View view = layoutInflater.inflate(R.layout.lexadrawer, null); + x.customView(view, true); + // textView + AppCompatTextView textView = view.findViewById(R.id.description); + textView.setText( + mainActivity.getString(R.string.needs_access_summary) + + path + + mainActivity.getString(R.string.needs_access_summary1)); + ((AppCompatImageView) view.findViewById(R.id.icon)) + .setImageResource(R.drawable.sd_operate_step); + x.positiveText(R.string.open) + .negativeText(R.string.cancel) + .positiveColor(accentColor) + .negativeColor(accentColor) + .onPositive((dialog, which) -> triggerStorageAccessFramework(requestCode)) + .onNegative( + (dialog, which) -> + Toast.makeText(mainActivity, R.string.error, Toast.LENGTH_SHORT).show()); + final MaterialDialog y = x.build(); + y.show(); + } + + @SuppressLint("InlinedApi") + private void triggerStorageAccessFramework(int requestCode) { + + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + + ExtensionsKt.runIfDocumentsUIExists( + intent, mainActivity, () -> mainActivity.startActivityForResult(intent, requestCode)); + } + + public void rename( + OpenMode mode, + final String oldPath, + final String newPath, + final String newName, + final boolean isDirectory, + final Activity context, + boolean rootmode) { + final Toast toast = + Toast.makeText(context, context.getString(R.string.renaming), Toast.LENGTH_SHORT); + toast.show(); + HybridFile oldFile = new HybridFile(mode, oldPath); + HybridFile newFile; + if (Utils.isNullOrEmpty(newName)) { + newFile = new HybridFile(mode, newPath); + } else { + newFile = new HybridFile(mode, newPath, newName, isDirectory); + } + Operations.rename( + oldFile, + newFile, + rootmode, + context, + new Operations.ErrorCallBack() { + @Override + public void exists(HybridFile file) { + context.runOnUiThread( + () -> { + if (toast != null) toast.cancel(); + Toast.makeText( + mainActivity, context.getString(R.string.fileexist), Toast.LENGTH_SHORT) + .show(); + }); + } + + @Override + public void launchSAF(HybridFile file) {} + + @Override + public void launchSAF(final HybridFile file, final HybridFile file1) { + context.runOnUiThread( + () -> { + if (toast != null) toast.cancel(); + mainActivity.oppathe = file.getPath(); + mainActivity.oppathe1 = file1.getPath(); + mainActivity.operation = RENAME; + guideDialogForLEXA(mainActivity.oppathe1); + }); + } + + @Override + public void done(final HybridFile hFile, final boolean b) { + context.runOnUiThread( + () -> { + /* + * DocumentFile.renameTo() may return false even when rename is successful. Hence we need an extra check + * instead of merely looking at the return value + */ + if (b || newFile.exists(context)) { + Intent intent = new Intent(MainActivity.KEY_INTENT_LOAD_LIST); + + intent.putExtra( + MainActivity.KEY_INTENT_LOAD_LIST_FILE, hFile.getParent(context)); + mainActivity.sendBroadcast(intent); + + // update the database entry to reflect rename for encrypted file + if (oldPath.endsWith(CryptUtil.CRYPT_EXTENSION)) { + try { + CryptHandler cryptHandler = CryptHandler.INSTANCE; + EncryptedEntry oldEntry = cryptHandler.findEntry(oldPath); + EncryptedEntry newEntry = new EncryptedEntry(); + newEntry.setId(oldEntry.getId()); + newEntry.setPassword(oldEntry.getPassword()); + newEntry.setPath(newPath); + cryptHandler.updateEntry(oldEntry, newEntry); + } catch (Exception e) { + LOG.warn("failure after rename, couldn't change the encrypted entry", e); + // couldn't change the entry, leave it alone + } + } + } else + Toast.makeText( + context, + context.getString(R.string.operation_unsuccesful), + Toast.LENGTH_SHORT) + .show(); + }); + } + + @Override + public void invalidName(final HybridFile file) { + context.runOnUiThread( + () -> { + if (toast != null) toast.cancel(); + Toast.makeText( + context, + context.getString(R.string.invalid_name) + ": " + file.getName(context), + Toast.LENGTH_LONG) + .show(); + }); + } + }); + } + + public @FolderState int checkFolder(final @NonNull File folder, Context context) { + return checkFolder(folder.getAbsolutePath(), OpenMode.FILE, context); + } + + public @FolderState int checkFolder(final String path, OpenMode openMode, Context context) { + if (OpenMode.SMB.equals(openMode)) { + return SmbUtil.checkFolder(path); + } else if (OpenMode.SFTP.equals(openMode) || OpenMode.FTP.equals(openMode)) { + int result = NetCopyClientUtils.INSTANCE.checkFolder(path); + return result; + } else if (OpenMode.DOCUMENT_FILE.equals(openMode)) { + DocumentFile d = + DocumentFile.fromTreeUri(AppConfig.getInstance(), SafRootHolder.getUriRoot()); + if (d == null) return DOESNT_EXIST; + else { + return WRITABLE_OR_ON_SDCARD; + } + } else { + File folder = new File(path); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + if (ExternalSdCardOperation.isOnExtSdCard(folder, context)) { + if (!folder.exists() || !folder.isDirectory()) { + return DOESNT_EXIST; + } + + // On Android 5, trigger storage access framework. + if (!FileProperties.isWritableNormalOrSaf(folder, context)) { + guideDialogForLEXA(folder.getPath()); + return CAN_CREATE_FILES; + } + + return WRITABLE_OR_ON_SDCARD; + } else if (FileProperties.isWritable(new File(folder, FileUtils.DUMMY_FILE))) { + return WRITABLE_OR_ON_SDCARD; + } else return DOESNT_EXIST; + } else if (Build.VERSION.SDK_INT == 19) { + if (ExternalSdCardOperation.isOnExtSdCard(folder, context)) { + // Assume that Kitkat workaround works + return WRITABLE_OR_ON_SDCARD; + } else if (FileProperties.isWritable(new File(folder, FileUtils.DUMMY_FILE))) { + return WRITABLE_OR_ON_SDCARD; + } else return DOESNT_EXIST; + } else if (FileProperties.isWritable(new File(folder, FileUtils.DUMMY_FILE))) { + return WRITABLE_OR_ON_SDCARD; + } else { + return DOESNT_EXIST; + } + } + } + + /** + * Helper method to start Compress service + * + * @param file the new compressed file + * @param baseFiles list of {@link HybridFileParcelable} to be compressed + */ + public void compressFiles(File file, ArrayList baseFiles) { + int mode = checkFolder(file.getParentFile(), mainActivity); + if (mode == 2) { + mainActivity.oppathe = (file.getPath()); + mainActivity.operation = COMPRESS; + mainActivity.oparrayList = baseFiles; + } else if (mode == 1) { + Intent intent2 = new Intent(mainActivity, ZipService.class); + intent2.putExtra(ZipService.KEY_COMPRESS_PATH, file.getPath()); + intent2.putExtra(ZipService.KEY_COMPRESS_FILES, baseFiles); + ServiceWatcherUtil.runService(mainActivity, intent2); + } else Toast.makeText(mainActivity, R.string.not_allowed, Toast.LENGTH_SHORT).show(); + } + + public void mkFile(final HybridFile parentFile, final HybridFile path, final MainFragment ma) { + final Toast toast = + Toast.makeText(ma.getActivity(), ma.getString(R.string.creatingfile), Toast.LENGTH_SHORT); + toast.show(); + Operations.mkfile( + parentFile, + path, + ma.getActivity(), + mainActivity.isRootExplorer(), + new Operations.ErrorCallBack() { + @Override + public void exists(final HybridFile file) { + ma.getActivity() + .runOnUiThread( + () -> { + if (toast != null) toast.cancel(); + Toast.makeText( + mainActivity, + mainActivity.getString(R.string.fileexist), + Toast.LENGTH_SHORT) + .show(); + if (ma != null && ma.getActivity() != null) { + // retry with dialog prompted again + mkfile( + file.getMode(), + file.getParent(mainActivity.getApplicationContext()), + ma); + } + }); + } + + @Override + public void launchSAF(HybridFile file) { + + ma.getActivity() + .runOnUiThread( + () -> { + if (toast != null) toast.cancel(); + mainActivity.oppathe = path.getPath(); + mainActivity.operation = NEW_FILE; + guideDialogForLEXA(mainActivity.oppathe); + }); + } + + @Override + public void launchSAF(HybridFile file, HybridFile file1) {} + + @Override + public void done(HybridFile hFile, final boolean b) { + ma.getActivity() + .runOnUiThread( + () -> { + if (b) { + ma.updateList(false); + } else { + Toast.makeText( + ma.getActivity(), + ma.getString(R.string.operation_unsuccesful), + Toast.LENGTH_SHORT) + .show(); + } + }); + } + + @Override + public void invalidName(final HybridFile file) { + ma.getActivity() + .runOnUiThread( + () -> { + if (toast != null) toast.cancel(); + Toast.makeText( + ma.getActivity(), + ma.getString(R.string.invalid_name) + + ": " + + file.getName(ma.getMainActivity()), + Toast.LENGTH_LONG) + .show(); + }); + } + }); + } + + public void mkDir(final HybridFile parentPath, final HybridFile path, final MainFragment ma) { + final Toast toast = + Toast.makeText(ma.getActivity(), ma.getString(R.string.creatingfolder), Toast.LENGTH_SHORT); + toast.show(); + Operations.mkdir( + parentPath, + path, + ma.getActivity(), + mainActivity.isRootExplorer(), + new Operations.ErrorCallBack() { + @Override + public void exists(final HybridFile file) { + ma.getActivity() + .runOnUiThread( + () -> { + if (toast != null) toast.cancel(); + Toast.makeText( + mainActivity, + mainActivity.getString(R.string.fileexist), + Toast.LENGTH_SHORT) + .show(); + if (ma != null && ma.getActivity() != null) { + // retry with dialog prompted again + mkdir( + file.getMode(), + file.getParent(mainActivity.getApplicationContext()), + ma); + } + }); + } + + @Override + public void launchSAF(HybridFile file) { + if (toast != null) toast.cancel(); + ma.getActivity() + .runOnUiThread( + () -> { + mainActivity.oppathe = path.getPath(); + mainActivity.operation = NEW_FOLDER; + guideDialogForLEXA(mainActivity.oppathe); + }); + } + + @Override + public void launchSAF(HybridFile file, HybridFile file1) {} + + @Override + public void done(HybridFile hFile, final boolean b) { + ma.getActivity() + .runOnUiThread( + () -> { + if (b) { + ma.updateList(false); + } else { + Toast.makeText( + ma.getActivity(), + ma.getString(R.string.operation_unsuccesful), + Toast.LENGTH_SHORT) + .show(); + } + }); + } + + @Override + public void invalidName(final HybridFile file) { + ma.getActivity() + .runOnUiThread( + () -> { + if (toast != null) toast.cancel(); + Toast.makeText( + ma.getActivity(), + ma.getString(R.string.invalid_name) + + ": " + + file.getName(ma.getMainActivity()), + Toast.LENGTH_LONG) + .show(); + }); + } + }); + } + + public void deleteFiles(ArrayList files, boolean doDeletePermanently) { + if (files == null || files.size() == 0) return; + if (files.get(0).isSmb() || files.get(0).isFtp()) { + new DeleteTask(mainActivity, doDeletePermanently).execute(files); + return; + } + @FolderState + int mode = + checkFolder(files.get(0).getParent(mainActivity), files.get(0).getMode(), mainActivity); + if (mode == CAN_CREATE_FILES) { + mainActivity.oparrayList = (files); + mainActivity.operation = DELETE; + } else if (mode == WRITABLE_OR_ON_SDCARD || mode == DOESNT_EXIST) + new DeleteTask(mainActivity, doDeletePermanently).execute((files)); + else Toast.makeText(mainActivity, R.string.not_allowed, Toast.LENGTH_SHORT).show(); + } + + public void extractFile(@NonNull File file) { + final File parent = file.getParentFile(); + if (parent == null) { + Toast.makeText(mainActivity, R.string.error, Toast.LENGTH_SHORT).show(); + LOG.warn("File's parent is null " + file.getPath()); + return; + } + + @FolderState int mode = checkFolder(parent, mainActivity); + switch (mode) { + case WRITABLE_OR_ON_SDCARD: + Decompressor decompressor = CompressedHelper.getCompressorInstance(mainActivity, file); + if (decompressor == null) { + Toast.makeText(mainActivity, R.string.error_cant_decompress_that_file, Toast.LENGTH_LONG) + .show(); + return; + } + decompressor.decompress(file.getPath()); + break; + case CAN_CREATE_FILES: + mainActivity.oppathe = file.getPath(); + mainActivity.operation = EXTRACT; + break; + default: + Toast.makeText(mainActivity, R.string.not_allowed, Toast.LENGTH_SHORT).show(); + break; + } + } + + /** Retrieve a path with {@link OTGUtil#PREFIX_OTG} as prefix */ + public String parseOTGPath(String path) { + if (path.contains(OTGUtil.PREFIX_OTG)) return path; + else return OTGUtil.PREFIX_OTG + path.substring(path.indexOf(":") + 1); + } + + public String parseCloudPath(OpenMode serviceType, String path) { + switch (serviceType) { + case DROPBOX: + if (path.contains(CloudHandler.CLOUD_PREFIX_DROPBOX)) return path; + else return CloudHandler.CLOUD_PREFIX_DROPBOX + path.substring(path.indexOf(":") + 1); + case BOX: + if (path.contains(CloudHandler.CLOUD_PREFIX_BOX)) return path; + else return CloudHandler.CLOUD_PREFIX_BOX + path.substring(path.indexOf(":") + 1); + case GDRIVE: + if (path.contains(CloudHandler.CLOUD_PREFIX_GOOGLE_DRIVE)) return path; + else return CloudHandler.CLOUD_PREFIX_GOOGLE_DRIVE + path.substring(path.indexOf(":") + 1); + case ONEDRIVE: + if (path.contains(CloudHandler.CLOUD_PREFIX_ONE_DRIVE)) return path; + else return CloudHandler.CLOUD_PREFIX_ONE_DRIVE + path.substring(path.indexOf(":") + 1); + default: + return path; + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/MinMaxInputFilter.kt b/app/src/main/java/com/amaze/filemanager/utils/MinMaxInputFilter.kt new file mode 100644 index 0000000..c38a7d9 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/MinMaxInputFilter.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils + +import android.text.InputFilter +import android.text.Spanned + +class MinMaxInputFilter(private val min: Int, private val max: Int) : InputFilter { + constructor(range: IntRange) : this(range.first, range.last) + + override fun filter( + source: CharSequence?, + start: Int, + end: Int, + dest: Spanned?, + dstart: Int, + dend: Int, + ): CharSequence? { + runCatching { + val input = (dest.toString() + source.toString()).toInt() + if (isInRange(min, max, input)) { + return null + } + } + return "" + } + + private fun isInRange( + minValue: Int, + maxValue: Int, + input: Int, + ): Boolean { + return if (maxValue > minValue) { + input in minValue..maxValue + } else { + input in maxValue..minValue + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/NetworkUtil.kt b/app/src/main/java/com/amaze/filemanager/utils/NetworkUtil.kt new file mode 100644 index 0000000..c6c7bc9 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/NetworkUtil.kt @@ -0,0 +1,156 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils + +import android.app.Service +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.wifi.WifiManager +import android.os.Build +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.net.Inet4Address +import java.net.InetAddress +import java.net.NetworkInterface +import java.net.UnknownHostException + +object NetworkUtil { + private val log: Logger = LoggerFactory.getLogger(NetworkUtil::class.java) + + private fun getConnectivityManager(context: Context) = + context.applicationContext.getSystemService(Service.CONNECTIVITY_SERVICE) + as ConnectivityManager + + /** + * Is the device connected to local network, either Ethernet or Wifi? + */ + @JvmStatic + fun isConnectedToLocalNetwork(context: Context): Boolean { + val cm = getConnectivityManager(context) + var connected: Boolean + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + connected = cm.activeNetwork?.let { activeNetwork -> + cm.getNetworkCapabilities(activeNetwork)?.let { ni -> + ni.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) or + ni.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) + } ?: false + } ?: false + } else { + connected = cm.activeNetworkInfo?.let { ni -> + ni.isConnected && ( + ni.type and ( + ConnectivityManager.TYPE_WIFI + or ConnectivityManager.TYPE_ETHERNET + ) != 0 + ) + } ?: false + } + + if (!connected) { + connected = runCatching { + NetworkInterface.getNetworkInterfaces().toList().find { netInterface -> + netInterface.displayName.startsWith("rndis") or + netInterface.displayName.startsWith("wlan") + } + }.getOrElse { null } != null + } + + return connected + } + + /** + * Is the device connected to Wifi? + */ + @JvmStatic + fun isConnectedToWifi(context: Context): Boolean { + val cm = getConnectivityManager(context) + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + cm.activeNetwork?.let { + cm.getNetworkCapabilities(it)?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + } ?: false + } else { + cm.activeNetworkInfo?.let { + it.isConnected && it.type == ConnectivityManager.TYPE_WIFI + } ?: false + } + } + + /** + * Determine device's IP address. + * + * Caveat: doesn't handle IPv6 addresses well. Forcing return IPv4 if possible. + */ + @JvmStatic + fun getLocalInetAddress(context: Context): InetAddress? { + if (!isConnectedToLocalNetwork(context)) { + return null + } + if (isConnectedToWifi(context)) { + val wm = + context.applicationContext.getSystemService(Service.WIFI_SERVICE) + as WifiManager + val ipAddress = wm.connectionInfo.ipAddress + return if (ipAddress == 0) null else intToInet(ipAddress) + } + runCatching { + NetworkInterface.getNetworkInterfaces().iterator().forEach { netinterface -> + netinterface.inetAddresses.iterator().forEach { address -> + // this is the condition that sometimes gives problems + if (!address.isLoopbackAddress && + !address.isLinkLocalAddress && + address is Inet4Address + ) { + return address + } + } + } + }.onFailure { e -> + log.warn("failed to get local inet address", e) + } + return null + } + + /** + * Utility method to convert an IPv4 address in integer representation to [InetAddress]. + */ + @JvmStatic + fun intToInet(value: Int): InetAddress? { + val bytes = ByteArray(4) + for (i in 0..3) { + bytes[i] = byteOfInt(value, i) + } + return try { + InetAddress.getByAddress(bytes) + } catch (e: UnknownHostException) { + // This only happens if the byte array has a bad length + null + } + } + + private fun byteOfInt( + value: Int, + which: Int, + ): Byte { + val shift = which * 8 + return (value shr shift).toByte() + } +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/OTGUtil.kt b/app/src/main/java/com/amaze/filemanager/utils/OTGUtil.kt new file mode 100644 index 0000000..cad7ae0 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/OTGUtil.kt @@ -0,0 +1,249 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils + +import android.content.Context +import android.hardware.usb.UsbConstants +import android.hardware.usb.UsbManager +import android.net.Uri +import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES.KITKAT +import android.os.Build.VERSION_CODES.LOLLIPOP +import android.provider.DocumentsContract +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.documentfile.provider.DocumentFile +import com.amaze.filemanager.exceptions.DocumentFileNotFoundException +import com.amaze.filemanager.fileoperations.filesystem.OpenMode +import com.amaze.filemanager.fileoperations.filesystem.usb.SingletonUsbOtg +import com.amaze.filemanager.fileoperations.filesystem.usb.UsbOtgRepresentation +import com.amaze.filemanager.filesystem.HybridFileParcelable +import com.amaze.filemanager.filesystem.RootHelper +import java.net.URLDecoder + +/** Created by Vishal on 27-04-2017. */ +object OTGUtil { + const val PREFIX_OTG = "otg:/" + private const val PREFIX_DOCUMENT_FILE = "content:/" + const val PREFIX_MEDIA_REMOVABLE = "/mnt/media_rw" + + private val TAG = OTGUtil::class.java.simpleName + + // URLEncoder.encode("/", Charsets.UTF_8.name()) + private const val PATH_SEPARATOR_ENCODED = "%2F" + private const val PRIMARY_STORAGE_PREFIX = "primary%3AA" + private const val PATH_ELEMENT_DOCUMENT = "document" + + /** + * Returns an array of list of files at a specific path in OTG + * + * @param path the path to the directory tree, starts with prefix 'otg:/' Independent of URI (or + * mount point) for the OTG + * @param context context for loading + * @return an array of list of files at the path + */ + @Deprecated("use getDocumentFiles()") + @JvmStatic + fun getDocumentFilesList( + path: String, + context: Context, + ): ArrayList { + val files = ArrayList() + getDocumentFiles( + path, + context, + object : OnFileFound { + override fun onFileFound(file: HybridFileParcelable) { + files.add(file) + } + }, + ) + return files + } + + /** + * Get the files at a specific path in OTG + * + * @param path the path to the directory tree, starts with prefix 'otg:/' Independent of URI (or + * mount point) for the OTG + * @param context context for loading + */ + @JvmStatic + fun getDocumentFiles( + path: String, + context: Context, + fileFound: OnFileFound, + ) { + val rootUriString = + SingletonUsbOtg.getInstance().usbOtgRoot + ?: throw NullPointerException("USB OTG root not set!") + return getDocumentFiles(rootUriString, path, context, OpenMode.OTG, fileFound) + } + + @JvmStatic + fun getDocumentFiles( + rootUriString: Uri, + path: String, + context: Context, + openMode: OpenMode, + fileFound: OnFileFound, + ) { + var rootUri = DocumentFile.fromTreeUri(context, rootUriString) + + val parts: Array = + if (openMode == OpenMode.DOCUMENT_FILE) { + path.substringAfter(rootUriString.toString()) + .split("/", PATH_SEPARATOR_ENCODED).toTypedArray() + } else { + path.split("/").toTypedArray() + } + for (part in parts.filterNot { it.isEmpty() or it.isBlank() }) { + // first omit 'otg:/' before iterating through DocumentFile + if (path == "$PREFIX_OTG/" || path == "$PREFIX_DOCUMENT_FILE/") break + if (part == "otg:" || part == "" || part == "content:") continue + + // iterating through the required path to find the end point + rootUri = rootUri?.findFile(part) ?: rootUri + } + + if (rootUri == null) { + throw DocumentFileNotFoundException(rootUriString, path) + } + + // we have the end point DocumentFile, list the files inside it and return + for (file in rootUri.listFiles()) { + if (file.exists()) { + var size: Long = 0 + if (!file.isDirectory) size = file.length() + Log.d(context.javaClass.simpleName, "Found file: ${file.name}") + val baseFile = + HybridFileParcelable( + path + "/" + file.name, + RootHelper.parseDocumentFilePermission(file), + file.lastModified(), + size, + file.isDirectory, + ) + baseFile.name = file.name + baseFile.mode = openMode + baseFile.fullUri = file.uri + fileFound.onFileFound(baseFile) + } + } + } + + /** + * Traverse to a specified path in OTG + * + * @param createRecursive flag used to determine whether to create new file while traversing to + * path, in case path is not present. Notably useful in opening an output stream. + */ + @JvmStatic + fun getDocumentFile( + path: String, + context: Context, + createRecursive: Boolean, + ): DocumentFile? { + val rootUriString = + SingletonUsbOtg.getInstance().usbOtgRoot + ?: throw NullPointerException("USB OTG root not set!") + + return getDocumentFile(path, rootUriString, context, OpenMode.OTG, createRecursive) + } + + @JvmStatic + fun getDocumentFile( + path: String, + rootUri: Uri, + context: Context, + openMode: OpenMode, + createRecursive: Boolean, + ): DocumentFile? { + // start with root of SD card and then parse through document tree. + var retval: DocumentFile? = + DocumentFile.fromTreeUri(context, rootUri) + ?: throw DocumentFileNotFoundException(rootUri, path) + val parts: Array = + if (openMode == OpenMode.DOCUMENT_FILE) { + URLDecoder.decode(path, Charsets.UTF_8.name()).substringAfter( + URLDecoder.decode(rootUri.toString(), Charsets.UTF_8.name()), + ) + .split("/", PATH_SEPARATOR_ENCODED).toTypedArray() + } else { + path.split("/").toTypedArray() + } + for (part in parts.filterNot { it.isEmpty() or it.isBlank() }) { + if (path == "otg:/" || path == "content:/") break + if (part == "otg:" || part == "" || part == "content:") continue + + // iterating through the required path to find the end point + var nextDocument = retval?.findFile(part) + if (createRecursive && (nextDocument == null || !nextDocument.exists())) { + nextDocument = retval?.createFile(part.substring(part.lastIndexOf(".")), part) + } + retval = nextDocument + } + return retval + } + + /** Check if the usb uri is still accessible */ + @RequiresApi(api = KITKAT) + @JvmStatic + fun isUsbUriAccessible(context: Context?): Boolean { + val rootUriString = SingletonUsbOtg.getInstance().usbOtgRoot + return DocumentsContract.isDocumentUri(context, rootUriString) + } + + /** Checks if there is at least one USB device connected with class MASS STORAGE. */ + @JvmStatic + fun getMassStorageDevicesConnected(context: Context): List { + val usbManager = context.getSystemService(Context.USB_SERVICE) as? UsbManager + val devices = usbManager?.deviceList ?: mapOf() + return devices.mapNotNullTo( + ArrayList(), + ) { entry -> + val device = entry.value + var retval: UsbOtgRepresentation? = null + for (i in 0 until device.interfaceCount) { + if (device.getInterface(i).interfaceClass + == UsbConstants.USB_CLASS_MASS_STORAGE + ) { + var serial: String? = null + if (SDK_INT >= LOLLIPOP) { + try { + serial = device.serialNumber + } catch (ifPermissionDenied: SecurityException) { + // May happen when device is running Android 10 or above. + Log.w( + TAG, + "Permission denied reading serial number of device " + + "${device.vendorId}:${device.productId}", + ifPermissionDenied, + ) + } + } + retval = UsbOtgRepresentation(device.productId, device.vendorId, serial) + } + } + retval + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/ObtainableServiceBinder.java b/app/src/main/java/com/amaze/filemanager/utils/ObtainableServiceBinder.java new file mode 100644 index 0000000..6eee3c6 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/ObtainableServiceBinder.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils; + +import android.app.Service; +import android.os.Binder; + +/** + * @author Emmanuel on 28/11/2017, at 19:04. + */ +public class ObtainableServiceBinder extends Binder { + + private final T service; + + public ObtainableServiceBinder(T service) { + this.service = service; + } + + public T getService() { + return service; + } +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/OnAsyncTaskFinished.kt b/app/src/main/java/com/amaze/filemanager/utils/OnAsyncTaskFinished.kt new file mode 100644 index 0000000..c4b3590 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/OnAsyncTaskFinished.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils + +/** @author Emmanuel on 14/9/2017, at 21:00. + */ +interface OnAsyncTaskFinished { + @Suppress("UndocumentedPublicFunction") + fun onAsyncTaskFinished(data: T) +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/OnFileFound.kt b/app/src/main/java/com/amaze/filemanager/utils/OnFileFound.kt new file mode 100644 index 0000000..4c4b68f --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/OnFileFound.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils + +import com.amaze.filemanager.filesystem.HybridFileParcelable + +/** + * This allows the caller of a function to know when a file has ben found and deal with it ASAP + * + * @author Emmanuel on 21/9/2017, at 15:23. + */ +fun interface OnFileFound { + @Suppress("UndocumentedPublicFunction") + fun onFileFound(file: HybridFileParcelable) +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/OnProgressUpdate.kt b/app/src/main/java/com/amaze/filemanager/utils/OnProgressUpdate.kt new file mode 100644 index 0000000..08e99fd --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/OnProgressUpdate.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils + +/** + * General inteface for updating data before it's finished loading + * + * @author Emmanuel on 13/5/2017, at 22:45. + */ +interface OnProgressUpdate { + @Suppress("UndocumentedPublicFunction") + fun onUpdate(data: T) +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/OneCharacterCharSequence.kt b/app/src/main/java/com/amaze/filemanager/utils/OneCharacterCharSequence.kt new file mode 100644 index 0000000..4059bc2 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/OneCharacterCharSequence.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils + +import java.util.Arrays + +class OneCharacterCharSequence(private val value: Char, override val length: Int) : CharSequence { + override fun get(index: Int): Char = + if (index < length) { + value + } else { + throw IndexOutOfBoundsException() + } + + override fun subSequence( + startIndex: Int, + endIndex: Int, + ): CharSequence = OneCharacterCharSequence(value, endIndex - startIndex) + + override fun toString(): String { + val array = CharArray(length) + Arrays.fill(array, value) + return String(array) + } +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/PackageUtils.kt b/app/src/main/java/com/amaze/filemanager/utils/PackageUtils.kt new file mode 100644 index 0000000..3bba3c4 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/PackageUtils.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils + +import android.content.pm.PackageManager + +class PackageUtils { + companion object { + /** + * Checks whether a package name is installed or not + */ + fun appInstalledOrNot( + uri: String, + pm: PackageManager, + ): Boolean { + return try { + pm.getPackageInfo(uri, PackageManager.GET_ACTIVITIES) + true + } catch (e: PackageManager.NameNotFoundException) { + false + } + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/PasswordUtil.kt b/app/src/main/java/com/amaze/filemanager/utils/PasswordUtil.kt new file mode 100644 index 0000000..0dc1239 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/PasswordUtil.kt @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils + +import android.content.Context +import android.os.Build +import android.util.Base64 +import androidx.annotation.RequiresApi +import com.amaze.filemanager.BuildConfig +import com.amaze.filemanager.filesystem.files.CryptUtil +import com.amaze.filemanager.utils.security.SecretKeygen +import java.io.IOException +import java.security.GeneralSecurityException +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.IvParameterSpec + +object PasswordUtil { + // 12 byte long IV supported by android for GCM + private const val IV = BuildConfig.CRYPTO_IV + + /** Helper method to encrypt plain text password */ + @RequiresApi(api = Build.VERSION_CODES.M) + @Throws( + GeneralSecurityException::class, + IOException::class, + ) + private fun aesEncryptPassword( + plainTextPassword: String, + base64Options: Int, + ): String? { + val cipher = Cipher.getInstance(CryptUtil.ALGO_AES) + val gcmParameterSpec = GCMParameterSpec(128, IV.toByteArray()) + cipher.init(Cipher.ENCRYPT_MODE, SecretKeygen.getSecretKey(), gcmParameterSpec) + val encodedBytes = cipher.doFinal(plainTextPassword.toByteArray()) + return Base64.encodeToString(encodedBytes, base64Options) + } + + /** Helper method to decrypt cipher text password */ + @RequiresApi(api = Build.VERSION_CODES.M) + @Throws( + GeneralSecurityException::class, + IOException::class, + ) + private fun aesDecryptPassword( + cipherPassword: String, + base64Options: Int, + ): String { + val cipher = Cipher.getInstance(CryptUtil.ALGO_AES) + val gcmParameterSpec = GCMParameterSpec(128, IV.toByteArray()) + cipher.init(Cipher.DECRYPT_MODE, SecretKeygen.getSecretKey(), gcmParameterSpec) + val decryptedBytes = cipher.doFinal(Base64.decode(cipherPassword, base64Options)) + return String(decryptedBytes) + } + + @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR2) + @Throws( + GeneralSecurityException::class, + IOException::class, + ) + private fun rsaEncryptPassword( + context: Context, + password: String, + base64Options: Int, + ): String? { + val cipher = Cipher.getInstance(CryptUtil.ALGO_AES) + val ivParameterSpec = IvParameterSpec(IV.toByteArray()) + cipher.init(Cipher.ENCRYPT_MODE, SecretKeygen.getSecretKey(), ivParameterSpec) + return Base64.encodeToString(cipher.doFinal(password.toByteArray()), base64Options) + } + + @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR2) + @Throws( + GeneralSecurityException::class, + IOException::class, + ) + private fun rsaDecryptPassword( + context: Context, + cipherText: String, + base64Options: Int, + ): String { + val cipher = Cipher.getInstance(CryptUtil.ALGO_AES) + val ivParameterSpec = IvParameterSpec(IV.toByteArray()) + cipher.init(Cipher.DECRYPT_MODE, SecretKeygen.getSecretKey(), ivParameterSpec) + val decryptedBytes = cipher.doFinal(Base64.decode(cipherText, base64Options)) + return String(decryptedBytes) + } + + /** Method handles encryption of plain text on various APIs */ + @Throws(GeneralSecurityException::class, IOException::class) + fun encryptPassword( + context: Context, + plainText: String, + base64Options: Int = Base64.URL_SAFE, + ): String? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + aesEncryptPassword(plainText, base64Options) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { + rsaEncryptPassword(context, plainText, base64Options) + } else { + plainText + } + } + + /** Method handles decryption of cipher text on various APIs */ + @Throws(GeneralSecurityException::class, IOException::class) + fun decryptPassword( + context: Context, + cipherText: String, + base64Options: Int = Base64.URL_SAFE, + ): String { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + aesDecryptPassword(cipherText, base64Options) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { + rsaDecryptPassword(context, cipherText, base64Options) + } else { + cipherText + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/PreferenceUtils.kt b/app/src/main/java/com/amaze/filemanager/utils/PreferenceUtils.kt new file mode 100644 index 0000000..660cf45 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/PreferenceUtils.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils + +import android.graphics.Color + +object PreferenceUtils { + const val DEFAULT_CURRENT_TAB = 1 + + const val DEFAULT_SAVED_PATHS = true + + @JvmStatic + fun getStatusColor(color: Int): Int = + Color.argb( + Color.alpha(color), + (Color.red(color) * 0.6f).toInt().coerceAtLeast(0), + (Color.green(color) * 0.6f).toInt().coerceAtLeast(0), + (Color.blue(color) * 0.6f).toInt().coerceAtLeast(0), + ) +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/ProgressHandler.java b/app/src/main/java/com/amaze/filemanager/utils/ProgressHandler.java new file mode 100644 index 0000000..86a7602 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/ProgressHandler.java @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils; + +import com.amaze.filemanager.ui.fragments.ProcessViewerFragment; + +/** + * Created by arpitkh96 on 18/8/16. + * + *

Base class to handle progress of services operation Utilized for generation of notification, + * talking to {@link ProcessViewerFragment} through {@link DatapointParcelable} + */ +public class ProgressHandler { + + /** + * total number of bytes to be processed Volatile because non volatile long r/w are not atomic + * (see Java Language Specification 17.7) + */ + private volatile long totalSize = 0L; + + /** + * total bytes written in process so far Volatile because non volatile long r/w are not atomic + * (see Java Language Specification 17.7) + */ + private volatile long writtenSize = 0L; + + /** total number of source files to be processed */ + private volatile int sourceFiles = 0; + + /** number of source files processed so far */ + private volatile int sourceFilesProcessed = 0; + + /** file name currently being processed */ + private volatile String fileName; + + /** boolean manages the lifecycle of service and whether it should be canceled */ + private volatile boolean isCancelled = false; + + /** callback interface to interact with process viewer fragment and notification */ + private volatile ProgressListener progressListener; + + /** Constructor to start an instance when we don't know of total files or size */ + public ProgressHandler() {} + + /** + * publish progress after calculating the write length + * + * @param newPosition the position of byte for file being processed + */ + public synchronized void addWrittenLength(long newPosition) { + long speedRaw = (newPosition - writtenSize); + this.writtenSize = newPosition; + + progressListener.onProgressed(speedRaw); + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public String getFileName() { + return fileName; + } + + public void setSourceFilesProcessed(int sourceFilesProcessed) { + this.sourceFilesProcessed = sourceFilesProcessed; + } + + public int getSourceFilesProcessed() { + return sourceFilesProcessed; + } + + public void setSourceSize(int sourceFiles) { + this.sourceFiles = sourceFiles; + } + + public int getSourceSize() { + return sourceFiles; + } + + // dynamically setting total size, useful in case files are compressed + public void setTotalSize(long totalSize) { + this.totalSize = totalSize; + } + + public long getTotalSize() { + return this.totalSize; + } + + public void setCancelled(boolean isCancelled) { + this.isCancelled = isCancelled; + } + + public boolean getCancelled() { + return isCancelled; + } + + public long getWrittenSize() { + return writtenSize; + } + + public void setProgressListener(ProgressListener progressListener) { + this.progressListener = progressListener; + } + + public synchronized float getPercentProgress() { + if (totalSize == 0) + return 0f; // Sometimes the total size is 0, because of metadata not being measured + return ((float) writtenSize / totalSize) * 100; + } + + /** + * An interface responsible for talking to this object Utilized by relevant service and eventually + * for notification generation and process viewer fragment + */ + public interface ProgressListener { + /** + * @param speed raw write speed in bytes + */ + void onProgressed(long speed); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/ScreenUtils.kt b/app/src/main/java/com/amaze/filemanager/utils/ScreenUtils.kt new file mode 100644 index 0000000..0f073f2 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/ScreenUtils.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils + +import android.app.Activity +import android.util.DisplayMetrics +import java.lang.ref.WeakReference +import kotlin.math.roundToInt + +class ScreenUtils(act: Activity) { + private val _activity: WeakReference = WeakReference(act) + private val activity: Activity? + get() = _activity.get() + + /** + * Converts Density Pixels to real Pixels in screen + * It uses context to retrieve the density. + */ + fun convertDbToPx(dp: Float): Int = + activity?.let { + (it.resources.displayMetrics.density * dp).roundToInt() + } ?: 0 + + /** + * Converts real Pixels in screen to Density Pixels + * It uses context to retrieve the density. + */ + fun convertPxToDb(px: Float): Int = + activity?.let { + (px / it.resources.displayMetrics.density).roundToInt() + } ?: 0 + + private val screenWidthInPx: Int + get() { + val displayMetrics = DisplayMetrics() + activity?.windowManager?.defaultDisplay?.getMetrics(displayMetrics) + return displayMetrics.widthPixels + } + + private val screenHeightInPx: Int + get() { + val displayMetrics = DisplayMetrics() + activity?.windowManager?.defaultDisplay?.getMetrics(displayMetrics) + return displayMetrics.heightPixels + } + + val screenWidthInDp: Int + get() = convertPxToDb(screenWidthInPx.toFloat()) + val screeHeightInDb: Int + get() = convertPxToDb(screenHeightInPx.toFloat()) + + companion object { + const val TOOLBAR_HEIGHT_IN_DP = 128 // 160 dpi + } +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/SimpleTextWatcher.kt b/app/src/main/java/com/amaze/filemanager/utils/SimpleTextWatcher.kt new file mode 100644 index 0000000..acd8395 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/SimpleTextWatcher.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils + +import android.text.Editable +import android.text.TextWatcher + +open class SimpleTextWatcher : TextWatcher { + override fun beforeTextChanged( + s: CharSequence, + start: Int, + count: Int, + after: Int, + ) = Unit + + override fun onTextChanged( + s: CharSequence, + start: Int, + before: Int, + count: Int, + ) = Unit + + override fun afterTextChanged(s: Editable) = Unit +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/TinyDB.kt b/app/src/main/java/com/amaze/filemanager/utils/TinyDB.kt new file mode 100644 index 0000000..c02c9d4 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/TinyDB.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils + +import android.content.SharedPreferences +import android.text.TextUtils + +/** + * Extract from: https://github.com/kcochibili/TinyDB--Android-Shared-Preferences-Turbo Author: + * https://github.com/kcochibili + */ +object TinyDB { + /* + * The "‚" character is not a comma, it is the SINGLE LOW-9 QUOTATION MARK. U-201A + * + U-2017 + U-201A are used for separating the items in a list. + */ + private const val DIVIDER = "‚‗‚" + + /** + * Put array of Boolean into SharedPreferences with 'key' and save + * + * @param key SharedPreferences key + * @param array array of Booleans to be added + */ + @JvmStatic + fun putBooleanArray( + preferences: SharedPreferences, + key: String?, + array: Array, + ) { + preferences.edit().putString(key, TextUtils.join(DIVIDER, array)).apply() + } + + /** + * Get parsed array of Booleans from SharedPreferences at 'key' + * + * @param key SharedPreferences key + * @return Array of Booleans + */ + @JvmStatic + fun getBooleanArray( + preferences: SharedPreferences, + key: String?, + defaultValue: Array?, + ): Array? { + val prefValue = preferences.getString(key, "") + if (prefValue == "") { + return defaultValue + } + + return TextUtils.split(prefValue, DIVIDER).map { + java.lang.Boolean.valueOf(it) + }.toTypedArray() + } +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/UUIDv5.kt b/app/src/main/java/com/amaze/filemanager/utils/UUIDv5.kt new file mode 100644 index 0000000..6dad912 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/UUIDv5.kt @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils + +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException +import java.util.UUID + +/** + * UUIDv5 implementation, referenced from + * https://gist.github.com/icedraco/00118b4d3c91d96d8c58e837a448f1b8 + */ +object UUIDv5 { + // Constants defined in RFC4122 https://www.ietf.org/rfc/rfc4122.txt + @JvmStatic + val DNS: UUID = UUID.fromString("6ba7b810-9dad-11d1-80b4-00c04fd430c8") + + @JvmStatic + val URL: UUID = UUID.fromString("6ba7b811-9dad-11d1-80b4-00c04fd430c8") + + @JvmStatic + val OID: UUID = UUID.fromString("6ba7b812-9dad-11d1-80b4-00c04fd430c8") + + @JvmStatic + val X500: UUID = UUID.fromString("6ba7b814-9dad-11d1-80b4-00c04fd430c8") + + /** + * Generate an UUIDv5 UUID from given namespace UUID and name. + * + * [namespaceUUID] must be one of [DNS], [URL], [OID], [X500]. + */ + @JvmStatic + @Suppress("TooGenericExceptionThrown") + fun fromString( + namespaceUUID: UUID, + name: String, + ): UUID { + val md: MessageDigest + try { + md = MessageDigest.getInstance("SHA-1") + } catch (ex: NoSuchAlgorithmException) { + throw Exception("SHA-1 not supported", ex) + } + + md.update(toBytes(namespaceUUID)) + md.update(name.toByteArray()) + val bytes = md.digest() + // clear version; set to version 5 + bytes[6] = ((bytes[6].toInt() and 0x0F) or 0x50).toByte() + // clear variant; set to IETF variant + bytes[8] = ((bytes[8].toInt() and 0x3F) or 0x80).toByte() + return fromBytes(bytes) + } + + private fun fromBytes(data: ByteArray): UUID { + // Based on the private UUID(bytes[]) constructor + assert(data.size >= 16) + var msb = 0L + var lsb = 0L + for (i in 0..7) + msb = msb shl 8 or (data[i].toLong() and 0xff) + for (i in 8..15) + lsb = lsb shl 8 or (data[i].toLong() and 0xff) + return UUID(msb, lsb) + } + + private fun toBytes(uuid: UUID): ByteArray { + // inverted logic of fromBytes() + val out = ByteArray(16) + val msb = uuid.mostSignificantBits + val lsb = uuid.leastSignificantBits + for (i in 0..7) + out[i] = (msb shr (7 - i) * 8 and 0xff).toByte() + for (i in 8..15) + out[i] = (lsb shr (15 - i) * 8 and 0xff).toByte() + return out + } +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/Utils.java b/app/src/main/java/com/amaze/filemanager/utils/Utils.java new file mode 100644 index 0000000..c748439 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/Utils.java @@ -0,0 +1,493 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils; + +import java.io.File; +import java.lang.reflect.Field; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.amaze.filemanager.BuildConfig; +import com.amaze.filemanager.R; +import com.amaze.filemanager.adapters.data.LayoutElementParcelable; +import com.amaze.filemanager.filesystem.HybridFileParcelable; +import com.amaze.filemanager.ui.activities.MainActivity; +import com.amaze.filemanager.ui.theme.AppTheme; +import com.google.android.material.snackbar.Snackbar; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.res.ColorStateList; +import android.content.res.Configuration; +import android.graphics.Color; +import android.graphics.PointF; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Build; +import android.os.Handler; +import android.os.storage.StorageVolume; +import android.text.format.DateUtils; +import android.util.DisplayMetrics; +import android.view.View; +import android.view.inputmethod.InputMethodManager; +import android.widget.Button; +import android.widget.Toast; + +import androidx.annotation.ColorRes; +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.appcompat.widget.AppCompatCheckBox; +import androidx.appcompat.widget.AppCompatTextView; +import androidx.cardview.widget.CardView; +import androidx.core.content.ContextCompat; +import androidx.core.content.FileProvider; +import androidx.core.content.pm.ShortcutInfoCompat; +import androidx.core.content.pm.ShortcutManagerCompat; +import androidx.core.graphics.drawable.DrawableCompat; +import androidx.core.graphics.drawable.IconCompat; + +/** + * Contains useful functions and methods (NOTHING HERE DEALS WITH FILES) + * + * @author Emmanuel on 14/5/2017, at 14:39. + */ +public class Utils { + + private static final int INDEX_NOT_FOUND = -1; + private static final String INPUT_INTENT_BLACKLIST_COLON = ";"; + private static final String INPUT_INTENT_BLACKLIST_PIPE = "\\|"; + private static final String INPUT_INTENT_BLACKLIST_AMP = "&&"; + private static final String INPUT_INTENT_BLACKLIST_DOTS = "\\.\\.\\."; + private static final String DATE_TIME_FORMAT = "%s | %s"; + private static final String EMAIL_EMMANUEL = "emmanuelbendavid@gmail.com"; + private static final String EMAIL_RAYMOND = "airwave209gt@gmail.com"; + private static final String EMAIL_VISHNU = "t.v.s10123@gmail.com"; + private static final String EMAIL_VISHAL = "vishalmeham2@gmail.com"; + private static final String URL_TELEGRAM = "https://t.me/AmazeFileManager"; + private static final String URL_INSTGRAM = "https://www.instagram.com/teamamaze.xyz/"; + + public static final String EMAIL_NOREPLY_REPORTS = "no-reply@teamamaze.xyz"; + public static final String EMAIL_SUPPORT = "support@teamamaze.xyz"; + + private static final Logger log = LoggerFactory.getLogger(Utils.class); + private static boolean isToastShowing = false; + + // methods for fastscroller + public static float clamp(float min, float max, float value) { + float minimum = Math.max(min, value); + return Math.min(minimum, max); + } + + public static float getViewRawY(View view) { + int[] location = new int[2]; + location[0] = 0; + location[1] = (int) view.getY(); + ((View) view.getParent()).getLocationInWindow(location); + return location[1]; + } + + public static void setTint(Context context, AppCompatCheckBox box, int color) { + if (Build.VERSION.SDK_INT >= 21) return; + ColorStateList sl = + new ColorStateList( + new int[][] { + new int[] {-android.R.attr.state_checked}, new int[] {android.R.attr.state_checked} + }, + new int[] {getColor(context, R.color.grey), color}); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + box.setButtonTintList(sl); + } else { + Drawable drawable = + DrawableCompat.wrap( + ContextCompat.getDrawable(box.getContext(), R.drawable.abc_btn_check_material)); + DrawableCompat.setTintList(drawable, sl); + box.setButtonDrawable(drawable); + } + } + + public static String getDate(@NonNull Context c, long f) { + return String.format( + DATE_TIME_FORMAT, + DateUtils.formatDateTime(c, f, DateUtils.FORMAT_ABBREV_MONTH), + DateUtils.formatDateTime(c, f, DateUtils.FORMAT_SHOW_TIME)); + } + + /** + * Gets color + * + * @param color the resource id for the color + * @return the color + */ + @SuppressWarnings("deprecation") + public static int getColor(Context c, @ColorRes int color) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + return c.getColor(color); + } else { + return c.getResources().getColor(color); + } + } + + public static int dpToPx(Context c, int dp) { + DisplayMetrics displayMetrics = c.getResources().getDisplayMetrics(); + return Math.round(dp * (displayMetrics.xdpi / DisplayMetrics.DENSITY_DEFAULT)); + } + + /** + * Compares two Strings, and returns the portion where they differ. (More precisely, return the + * remainder of the second String, starting from where it's different from the first.) + * + *

For example, difference("i am a machine", "i am a robot") -> "robot". + * + *

StringUtils.difference(null, null) = null StringUtils.difference("", "") = "" + * StringUtils.difference("", "abc") = "abc" StringUtils.difference("abc", "") = "" + * StringUtils.difference("abc", "abc") = "" StringUtils.difference("ab", "abxyz") = "xyz" + * StringUtils.difference("abcde", "abxyz") = "xyz" StringUtils.difference("abcde", "xyz") = "xyz" + * + * @param str1 - the first String, may be null + * @param str2 - the second String, may be null + * @return the portion of str2 where it differs from str1; returns the empty String if they are + * equal + *

Stolen from Apache's StringUtils + * (https://commons.apache.org/proper/commons-lang/javadocs/api-2.6/org/apache/commons/lang/StringUtils.html#difference(java.lang.String,%20java.lang.String)) + */ + public static String differenceStrings(String str1, String str2) { + if (str1 == null) return str2; + if (str2 == null) return str1; + + int at = indexOfDifferenceStrings(str1, str2); + + if (at == INDEX_NOT_FOUND) return ""; + + return str2.substring(at); + } + + private static int indexOfDifferenceStrings(CharSequence cs1, CharSequence cs2) { + if (cs1 == cs2) return INDEX_NOT_FOUND; + if (cs1 == null || cs2 == null) return 0; + + int i; + for (i = 0; i < cs1.length() && i < cs2.length(); ++i) { + if (cs1.charAt(i) != cs2.charAt(i)) break; + } + + if (i < cs2.length() || i < cs1.length()) return i; + + return INDEX_NOT_FOUND; + } + + /** + * Force disables screen rotation. Useful when we're temporarily in activity because of external + * intent, and don't have to really deal much with filesystem. + */ + public static void disableScreenRotation(@NonNull Activity activity) { + int screenOrientation = activity.getResources().getConfiguration().orientation; + + if (screenOrientation == Configuration.ORIENTATION_LANDSCAPE) { + activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); + } else if (screenOrientation == Configuration.ORIENTATION_PORTRAIT) { + activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); + } + } + + public static void enableScreenRotation(@NonNull Activity activity) { + activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); + } + + public static boolean isDeviceInLandScape(Activity activity) { + return activity.getResources().getConfiguration().orientation + == Configuration.ORIENTATION_LANDSCAPE; + } + + /** Sanitizes input from external application to avoid any attempt of command injection */ + public static String sanitizeInput(String input) { + // iterate through input and keep sanitizing until it's fully injection proof + String sanitizedInput; + String sanitizedInputTemp = input; + + while (true) { + sanitizedInput = sanitizeInputOnce(sanitizedInputTemp); + if (sanitizedInput.equals(sanitizedInputTemp)) break; + sanitizedInputTemp = sanitizedInput; + } + + return sanitizedInput; + } + + private static String sanitizeInputOnce(String input) { + return input + .replaceAll(INPUT_INTENT_BLACKLIST_PIPE, "") + .replaceAll(INPUT_INTENT_BLACKLIST_AMP, "") + .replaceAll(INPUT_INTENT_BLACKLIST_DOTS, "") + .replaceAll(INPUT_INTENT_BLACKLIST_COLON, ""); + } + + /** Returns uri associated to specific basefile */ + public static Uri getUriForBaseFile( + @NonNull Context context, @NonNull HybridFileParcelable baseFile) { + switch (baseFile.getMode()) { + case FILE: + case ROOT: + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + return FileProvider.getUriForFile( + context, context.getPackageName(), new File(baseFile.getPath())); + } else { + return Uri.fromFile(new File(baseFile.getPath())); + } + case OTG: + return OTGUtil.getDocumentFile(baseFile.getPath(), context, true).getUri(); + case SMB: + case DROPBOX: + case GDRIVE: + case ONEDRIVE: + case BOX: + Toast.makeText(context, context.getString(R.string.smb_launch_error), Toast.LENGTH_LONG) + .show(); + return null; + default: + return null; + } + } + + /** + * Gets position of nth to last char in String. nthToLastCharIndex(1, "a.tar.gz") = 1 + * nthToLastCharIndex(0, "a.tar.gz") = 5 + */ + public static int nthToLastCharIndex(int elementNumber, String str, char element) { + if (elementNumber <= 0) throw new IllegalArgumentException(); + + int occurencies = 0; + for (int i = str.length() - 1; i >= 0; i--) { + if (str.charAt(i) == element && ++occurencies == elementNumber) { + return i; + } + } + return -1; + } + + /** + * Formats input to plain mm:ss format + * + * @param timerInSeconds duration in seconds + * @return time in mm:ss format + */ + public static String formatTimer(long timerInSeconds) { + final long min = TimeUnit.SECONDS.toMinutes(timerInSeconds); + final long sec = TimeUnit.SECONDS.toSeconds(timerInSeconds - TimeUnit.MINUTES.toSeconds(min)); + return String.format("%02d:%02d", min, sec); + } + + @TargetApi(Build.VERSION_CODES.N) + public static File getVolumeDirectory(StorageVolume volume) { + try { + Field f = StorageVolume.class.getDeclaredField("mPath"); + f.setAccessible(true); + return (File) f.get(volume); + } catch (Exception e) { + // This shouldn't fail, as mPath has been there in every version + throw new RuntimeException(e); + } + } + + public static boolean isNullOrEmpty(final Collection list) { + return list == null || list.size() == 0; + } + + public static boolean isNullOrEmpty(final String string) { + return string == null || string.length() == 0; + } + + public static Snackbar showThemedSnackbar( + MainActivity mainActivity, + CharSequence text, + int length, + @StringRes int actionTextId, + Runnable actionCallback) { + Snackbar snackbar = + Snackbar.make(mainActivity.findViewById(R.id.content_frame), text, length) + .setAction(actionTextId, v -> actionCallback.run()); + if (mainActivity.getAppTheme().equals(AppTheme.LIGHT)) { + snackbar + .getView() + .setBackgroundColor(mainActivity.getResources().getColor(android.R.color.white)); + snackbar.setTextColor(mainActivity.getResources().getColor(android.R.color.black)); + } + snackbar.show(); + return snackbar; + } + + public static Snackbar showCutCopySnackBar( + MainActivity mainActivity, + CharSequence text, + int length, + @StringRes int actionTextId, + Runnable actionCallback, + Runnable cancelCallback) { + + final Snackbar snackbar = + Snackbar.make(mainActivity.findViewById(R.id.content_frame), "", length); + + View customSnackView = + View.inflate(mainActivity.getApplicationContext(), R.layout.snackbar_view, null); + snackbar.getView().setBackgroundColor(Color.TRANSPARENT); + + Snackbar.SnackbarLayout snackBarLayout = (Snackbar.SnackbarLayout) snackbar.getView(); + snackBarLayout.setPadding(0, 0, 0, 0); + + Button actionButton = customSnackView.findViewById(R.id.snackBarActionButton); + Button cancelButton = customSnackView.findViewById(R.id.snackBarCancelButton); + AppCompatTextView textView = customSnackView.findViewById(R.id.snackBarTextTV); + + actionButton.setText(actionTextId); + textView.setText(text); + + actionButton.setOnClickListener(v -> actionCallback.run()); + cancelButton.setOnClickListener(v -> cancelCallback.run()); + + snackBarLayout.addView(customSnackView, 0); + + ((CardView) snackBarLayout.findViewById(R.id.snackBarCardView)) + .setCardBackgroundColor(mainActivity.getAccent()); + + snackbar.show(); + return snackbar; + } + + /** + * Open url in browser + * + * @param url given url + */ + public static void openURL(String url, Context context) { + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setData(Uri.parse(url)); + + PackageManager packageManager = context.getPackageManager(); + List webViews = + packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); + + if (!webViews.isEmpty()) { + context.startActivity(intent); + } else { + log.warn("A browser is not available"); + if (!isToastShowing) { + isToastShowing = true; + Toast.makeText(context, R.string.not_found_enabled_webview, Toast.LENGTH_SHORT).show(); + // Prevents a myriad of duplicates + new Handler().postDelayed(() -> isToastShowing = false, 2200); + } + } + } + + /** Open telegram in browser */ + public static void openTelegramURL(Context context) { + openURL(URL_TELEGRAM, context); + } + + /** Open instagram in browser */ + public static void openInstagramURL(Context context) { + openURL(URL_INSTGRAM, context); + } + + /** + * Builds a email intent for amaze feedback + * + * @param text email content + * @param supportMail support mail for given intent + * @return intent + */ + public static Intent buildEmailIntent(Context context, String text, String supportMail) { + Intent emailIntent = new Intent(Intent.ACTION_SEND); + String[] aEmailList = {supportMail}; + String[] aEmailCCList = {EMAIL_VISHAL, EMAIL_EMMANUEL, EMAIL_RAYMOND, EMAIL_VISHNU}; + emailIntent.putExtra(Intent.EXTRA_EMAIL, aEmailList); + emailIntent.putExtra(Intent.EXTRA_CC, aEmailCCList); + emailIntent.putExtra( + Intent.EXTRA_SUBJECT, "Feedback : Amaze File Manager for " + BuildConfig.VERSION_NAME); + Uri logUri = + FileProvider.getUriForFile( + context, + context.getPackageName(), + new File(String.format("/data/data/%s/cache/logs.txt", context.getPackageName()))); + emailIntent.putExtra(Intent.EXTRA_STREAM, logUri); + if (!Utils.isNullOrEmpty(text)) { + emailIntent.putExtra(Intent.EXTRA_TEXT, text); + } + emailIntent.setType("message/rfc822"); + return emailIntent; + } + + public static void zoom(Float scaleX, Float scaleY, PointF pivot, View view) { + view.setPivotX(pivot.x); + view.setPivotY(pivot.y); + view.setScaleX(scaleX); + view.setScaleY(scaleY); + } + + public static void addShortcut( + Context context, ComponentName componentName, LayoutElementParcelable path) { + // Adding shortcut for MainActivity + // on Home screen + + if (!ShortcutManagerCompat.isRequestPinShortcutSupported(context)) { + Toast.makeText( + context, + context.getString(R.string.add_shortcut_not_supported_by_launcher), + Toast.LENGTH_SHORT) + .show(); + return; + } + + Intent shortcutIntent = new Intent(context, MainActivity.class); + shortcutIntent.putExtra("path", path.desc); + shortcutIntent.setAction(Intent.ACTION_MAIN); + shortcutIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); + + // Using file path as shortcut id. + ShortcutInfoCompat info = + new ShortcutInfoCompat.Builder(context, path.desc) + .setActivity(componentName) + .setIcon(IconCompat.createWithResource(context, R.mipmap.ic_launcher)) + .setIntent(shortcutIntent) + .setLongLabel(path.title) + .setShortLabel(path.title) + .build(); + + ShortcutManagerCompat.requestPinShortcut(context, info, null); + } + + public static void hideKeyboard(MainActivity mainActivity) { + View view = mainActivity.getCurrentFocus(); + if (view != null) + ((InputMethodManager) mainActivity.getSystemService(Context.INPUT_METHOD_SERVICE)) + .hideSoftInputFromWindow(view.getWindowToken(), 0); + } +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/X509CertificateUtil.kt b/app/src/main/java/com/amaze/filemanager/utils/X509CertificateUtil.kt new file mode 100644 index 0000000..7e988ff --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/X509CertificateUtil.kt @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils + +import net.schmizz.sshj.common.ByteArrayUtils +import org.json.JSONObject +import java.security.MessageDigest +import java.util.WeakHashMap + +object X509CertificateUtil { + const val SUBJECT = "subject" + const val ISSUER = "issuer" + const val SERIAL = "serial" + const val FINGERPRINT = "sha256Fingerprint" + + private fun colonSeparatedHex(array: ByteArray) = ByteArrayUtils.toHex(array).chunked(2).joinToString(":") + + /** + * Parse a [javax.security.cert.X509Certificate] and return part of its information in a JSON object. + * + * Includes the certificate's subject, issuer, serial number and SHA-256 fingerprint. + * + * @param certificate [javax.security.cert.X509Certificate] + * @return [JSONObject] + */ + fun parse(certificate: javax.security.cert.X509Certificate): Map { + val retval = WeakHashMap() + retval[SUBJECT] = certificate.subjectDN.name + retval[ISSUER] = certificate.issuerDN.name + retval[SERIAL] = colonSeparatedHex(certificate.serialNumber.toByteArray()) + retval[FINGERPRINT] = + MessageDigest.getInstance("sha-256").run { + colonSeparatedHex(digest(certificate.encoded)) + } + return retval + } + + /** + * Parse a [java.security.cert.X509Certificate] and return part of its information in a JSON object. + * + * Includes the certificate's subject, issuer, serial number and SHA-256 fingerprint. + * + * @param certificate [java.security.cert.X509Certificate] + * @return [JSONObject] + */ + fun parse(certificate: java.security.cert.X509Certificate): Map { + val retval = WeakHashMap() + retval[SUBJECT] = certificate.subjectDN.name + retval[ISSUER] = certificate.issuerDN.name + retval[SERIAL] = colonSeparatedHex(certificate.serialNumber.toByteArray()) + retval[FINGERPRINT] = + MessageDigest.getInstance("sha-256").run { + colonSeparatedHex(digest(certificate.encoded)) + } + return retval + } +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/security/SecretKeygen.kt b/app/src/main/java/com/amaze/filemanager/utils/security/SecretKeygen.kt new file mode 100644 index 0000000..6e4123a --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/security/SecretKeygen.kt @@ -0,0 +1,200 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils.security + +import android.content.Context +import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES.JELLY_BEAN_MR2 +import android.os.Build.VERSION_CODES.M +import android.security.KeyPairGeneratorSpec +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import androidx.annotation.RequiresApi +import androidx.preference.PreferenceManager +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.filesystem.files.CryptUtil +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.math.BigInteger +import java.security.GeneralSecurityException +import java.security.Key +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.SecureRandom +import java.util.Calendar +import javax.crypto.Cipher +import javax.crypto.CipherInputStream +import javax.crypto.CipherOutputStream +import javax.crypto.KeyGenerator +import javax.crypto.spec.SecretKeySpec +import javax.security.auth.x500.X500Principal + +object SecretKeygen { + private const val PREFERENCE_KEY = "aes_key" + private const val ALGO_RSA = "RSA/ECB/PKCS1Padding" + + /** + * Return [Key] in application. Generate one if it doesn't exist in AndroidKeyStore. + * + * @return AES key for API 23 or above, RSA key for API 18 or above, or else null + */ + fun getSecretKey(): Key? { + return if (SDK_INT >= M) { + getAesSecretKey() + } else if (SDK_INT >= JELLY_BEAN_MR2) { + getRsaSecretKey() + } else { + null + } + } + + /** + * Gets a secret key from Android key store. If no key has been generated with a given alias then + * generate a new one + */ + @RequiresApi(api = M) + @Throws( + GeneralSecurityException::class, + IOException::class, + ) + private fun getAesSecretKey(): Key { + val keyStore = KeyStore.getInstance(CryptUtil.KEY_STORE_ANDROID) + keyStore.load(null) + return if (!keyStore.containsAlias(CryptUtil.KEY_ALIAS_AMAZE)) { + val keyGenerator = + KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + CryptUtil.KEY_STORE_ANDROID, + ) + val builder = + KeyGenParameterSpec.Builder( + CryptUtil.KEY_ALIAS_AMAZE, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + builder.setBlockModes(KeyProperties.BLOCK_MODE_GCM) + builder.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + builder.setRandomizedEncryptionRequired(false) + keyGenerator.init(builder.build()) + keyGenerator.generateKey() + } else { + keyStore.getKey(CryptUtil.KEY_ALIAS_AMAZE, null) + } + } + + @Throws(GeneralSecurityException::class, IOException::class) + @RequiresApi(JELLY_BEAN_MR2) + private fun getRsaSecretKey(): Key { + val preferences = PreferenceManager.getDefaultSharedPreferences(AppConfig.getInstance()) + val encodedString = preferences.getString(PREFERENCE_KEY, null) + return if (encodedString != null) { + SecretKeySpec( + decryptAESKey(Base64.decode(encodedString, Base64.DEFAULT)), + "AES", + ) + } else { + generateRsaKeyPair(AppConfig.getInstance()) + setKeyPreference() + getRsaSecretKey() + } + } + + /** Generates a RSA public/private key pair to encrypt AES key */ + @RequiresApi(api = JELLY_BEAN_MR2) + private fun generateRsaKeyPair(context: Context) { + val keyStore = KeyStore.getInstance(CryptUtil.KEY_STORE_ANDROID) + keyStore.load(null) + if (!keyStore.containsAlias(CryptUtil.KEY_ALIAS_AMAZE)) { + // generate a RSA key pair to encrypt/decrypt AES key from preferences + val start = Calendar.getInstance() + val end = Calendar.getInstance() + end.add(Calendar.YEAR, 30) + val keyPairGenerator = KeyPairGenerator.getInstance("RSA", CryptUtil.KEY_STORE_ANDROID) + val spec = + KeyPairGeneratorSpec.Builder(context) + .setAlias(CryptUtil.KEY_ALIAS_AMAZE) + .setSubject(X500Principal("CN=" + CryptUtil.KEY_ALIAS_AMAZE)) + .setSerialNumber(BigInteger.TEN) + .setStartDate(start.time) + .setEndDate(end.time) + .build() + keyPairGenerator.initialize(spec) + keyPairGenerator.generateKeyPair() + } + } + + /** Encrypts AES key and set into preference */ + @Throws(GeneralSecurityException::class, IOException::class) + private fun setKeyPreference() { + PreferenceManager.getDefaultSharedPreferences(AppConfig.getInstance()).run { + var encodedAesKey = getString(PREFERENCE_KEY, null) + if (encodedAesKey == null) { + // generate encrypted aes key and save to preference + val key = ByteArray(16) + val secureRandom = SecureRandom() + secureRandom.nextBytes(key) + val encryptedKey: ByteArray = encryptAESKey(key) + encodedAesKey = Base64.encodeToString(encryptedKey, Base64.DEFAULT) + edit().putString(PREFERENCE_KEY, encodedAesKey).apply() + } + } + } + + /** Encrypts randomly generated AES key using RSA public key */ + @Throws(GeneralSecurityException::class, IOException::class) + private fun encryptAESKey(secretKey: ByteArray): ByteArray { + val keyStore = KeyStore.getInstance(CryptUtil.KEY_STORE_ANDROID) + keyStore.load(null) + val keyEntry = + keyStore.getEntry(CryptUtil.KEY_ALIAS_AMAZE, null) as KeyStore.PrivateKeyEntry + val cipher = Cipher.getInstance(ALGO_RSA, "AndroidOpenSSL") + cipher.init(Cipher.ENCRYPT_MODE, keyEntry.certificate.publicKey) + val byteArrayOutputStream = ByteArrayOutputStream() + val outputStream = CipherOutputStream(byteArrayOutputStream, cipher) + outputStream.write(secretKey) + outputStream.close() + return byteArrayOutputStream.toByteArray() + } + + /** Decrypts AES decoded key from preference using RSA private key */ + @Throws(GeneralSecurityException::class, IOException::class) + private fun decryptAESKey(encodedBytes: ByteArray): ByteArray { + val keyStore = KeyStore.getInstance(CryptUtil.KEY_STORE_ANDROID) + keyStore.load(null) + val keyEntry = + keyStore.getEntry(CryptUtil.KEY_ALIAS_AMAZE, null) as KeyStore.PrivateKeyEntry + val cipher = Cipher.getInstance(ALGO_RSA, "AndroidOpenSSL") + cipher.init(Cipher.DECRYPT_MODE, keyEntry.privateKey) + val byteArrayInputStream = ByteArrayInputStream(encodedBytes) + val inputStream = CipherInputStream(byteArrayInputStream, cipher) + val bytes = ArrayList() + var nextByte: Int + while (inputStream.read().also { nextByte = it } != -1) { + bytes.add(nextByte.toByte()) + } + val decryptedBytes = ByteArray(bytes.size) + for (i in bytes.indices) { + decryptedBytes[i] = bytes[i] + } + return decryptedBytes + } +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/smb/SameSubnetDiscoverDeviceStrategy.kt b/app/src/main/java/com/amaze/filemanager/utils/smb/SameSubnetDiscoverDeviceStrategy.kt new file mode 100644 index 0000000..66e4739 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/smb/SameSubnetDiscoverDeviceStrategy.kt @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils.smb + +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.utils.ComputerParcelable +import com.amaze.filemanager.utils.NetworkUtil +import com.stealthcopter.networktools.PortScan +import io.reactivex.Flowable +import io.reactivex.disposables.Disposable +import io.reactivex.schedulers.Schedulers +import java.net.Inet6Address +import java.net.InetAddress + +/** + * [SmbDeviceScannerObservable.DiscoverDeviceStrategy] to just loop through other addresses within + * same subnet (/24 netmask) and knock their SMB service ports for reachability. + * + * Will bypass [Inet6Address] device addresses. They may have much bigger neighourhood host count; + * also for devices using IPv6, they shall be covered by [WsddDiscoverDeviceStrategy] anyway. + * + * TODO: if we can get the gateway using __legit__ API, may swarm the network in broader netmasks + */ +class SameSubnetDiscoverDeviceStrategy : SmbDeviceScannerObservable.DiscoverDeviceStrategy { + private lateinit var worker: Disposable + + companion object { + private const val HOST_UP_TIMEOUT = 1000 + private const val PARALLELISM = 10 + private val TCP_PORTS = arrayListOf(139, 445) + } + + /** + * No need to cleanup resources + */ + override fun onCancel() { + if (!worker.isDisposed) { + worker.dispose() + } + } + + override fun discoverDevices(callback: (ComputerParcelable) -> Unit) { + val neighbourhoods = getNeighbourhoodHosts() + worker = + Flowable.fromIterable(neighbourhoods) + .parallel(PARALLELISM) + .runOn(Schedulers.io()) + .map { addr -> + if (addr.isReachable(HOST_UP_TIMEOUT)) { + val portsReachable = + listOf( + PortScan.onAddress(addr).setPorts(TCP_PORTS).setMethodTCP().doScan(), + ).flatten() + if (portsReachable.isNotEmpty()) { + addr + } else { + false + } + } else { + false + } + }.filter { + it is InetAddress + }.doOnNext { addr -> + addr as InetAddress + callback.invoke( + ComputerParcelable( + addr.hostAddress, + if (addr.hostName == addr.hostAddress) { + addr.canonicalHostName + } else { + addr.hostName + }, + ), + ) + }.sequential().subscribe() + } + + private fun getNeighbourhoodHosts(): List { + val deviceAddress = NetworkUtil.getLocalInetAddress(AppConfig.getInstance()) + return deviceAddress?.let { addr -> + if (addr is Inet6Address) { + // IPv6 neigbourhood hosts can be very big - that should use wsdd instead; hence + // empty list here + emptyList() + } else { + val networkPrefix: String = addr.hostAddress.substringBeforeLast('.') + (1..254).map { + InetAddress.getByName("$networkPrefix.$it") + } + } + } ?: emptyList() + } +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/smb/SmbDeviceScannerObservable.kt b/app/src/main/java/com/amaze/filemanager/utils/smb/SmbDeviceScannerObservable.kt new file mode 100644 index 0000000..b4c8d8c --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/smb/SmbDeviceScannerObservable.kt @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils.smb + +import androidx.annotation.VisibleForTesting +import com.amaze.filemanager.utils.ComputerParcelable +import com.amaze.filemanager.utils.smb.SmbDeviceScannerObservable.DiscoverDeviceStrategy +import io.reactivex.Observable +import io.reactivex.Observer +import io.reactivex.disposables.Disposable +import io.reactivex.schedulers.Schedulers +import java.net.InetAddress + +/** + * Observable to discover reachable SMB nodes on the network. + * + * Uses a series of [DiscoverDeviceStrategy] instances to discover nodes. + */ +class SmbDeviceScannerObservable : Observable() { + /** + * Device discovery strategy interface. + */ + interface DiscoverDeviceStrategy { + /** + * Implement this method to return list of [InetAddress] which has SMB service running. + */ + fun discoverDevices(callback: (ComputerParcelable) -> Unit) + + /** + * Implement this method to cleanup resources + */ + fun onCancel() + } + + var discoverDeviceStrategies: Array = + arrayOf( + WsddDiscoverDeviceStrategy(), + SameSubnetDiscoverDeviceStrategy(), + ) + @VisibleForTesting set + + @VisibleForTesting get + + private lateinit var observer: Observer + + private lateinit var disposable: Disposable + + /** + * Stop discovering hosts. Notify containing strategies to stop, then stop the created + * [Observer] obtained at [subscribeActual]. + */ + fun stop() { + if (!disposable.isDisposed) { + disposable.dispose() + } + observer.onComplete() + } + + /** + * Call all strategies one by one to discover nodes. + * + * Given observer must be able to drop duplicated entries (which ComputerParcelable already + * has implemented equals() and hashCode()). + */ + override fun subscribeActual(observer: Observer) { + this.observer = observer + this.disposable = + merge( + discoverDeviceStrategies.map { strategy -> + fromCallable { + strategy.discoverDevices { addr -> + observer.onNext(ComputerParcelable(addr.addr, addr.name)) + } + }.subscribeOn(Schedulers.io()) + }, + ).observeOn(Schedulers.computation()).doOnComplete { + discoverDeviceStrategies.forEach { strategy -> + strategy.onCancel() + } + }.subscribe() + } +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/smb/SmbUtil.kt b/app/src/main/java/com/amaze/filemanager/utils/smb/SmbUtil.kt new file mode 100644 index 0000000..b7aa0ff --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/smb/SmbUtil.kt @@ -0,0 +1,195 @@ +/* + * Copyright (C) 2014-2020 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils.smb + +import android.content.Context +import android.net.Uri +import android.text.TextUtils +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.fileoperations.filesystem.DOESNT_EXIST +import com.amaze.filemanager.fileoperations.filesystem.WRITABLE_ON_REMOTE +import com.amaze.filemanager.filesystem.ftp.NetCopyConnectionInfo +import com.amaze.filemanager.filesystem.ftp.NetCopyConnectionInfo.Companion.AT +import com.amaze.filemanager.filesystem.ftp.NetCopyConnectionInfo.Companion.COLON +import com.amaze.filemanager.filesystem.ftp.NetCopyConnectionInfo.Companion.QUESTION_MARK +import com.amaze.filemanager.filesystem.smb.CifsContexts.createWithDisableIpcSigningCheck +import com.amaze.filemanager.utils.PasswordUtil +import com.amaze.filemanager.utils.urlDecoded +import io.reactivex.Single +import io.reactivex.schedulers.Schedulers +import jcifs.smb.NtlmPasswordAuthenticator +import jcifs.smb.SmbException +import jcifs.smb.SmbFile +import org.slf4j.LoggerFactory +import java.net.MalformedURLException + +/** + * Created by Vishal on 30-05-2017. + * + * + * Class provides various utility methods for SMB client + */ +object SmbUtil { + @JvmStatic + private val LOG = LoggerFactory.getLogger(SmbUtil::class.java) + + const val PARAM_DISABLE_IPC_SIGNING_CHECK = "disableIpcSigningCheck" + + /** Parse path to decrypt smb password */ + @JvmStatic + fun getSmbDecryptedPath( + context: Context, + path: String, + ): String { + return buildPath(path, withPassword = { + PasswordUtil.decryptPassword(context, it.urlDecoded()) + }) + } + + /** Parse path to encrypt smb password */ + @JvmStatic + fun getSmbEncryptedPath( + context: Context, + path: String, + ): String { + return buildPath(path, withPassword = { + PasswordUtil.encryptPassword(context, it) + }) + } + + // At this point, credential is URL encoded to be safe from special chars. + // No need to call URLEncoder.encode() again + private fun buildPath( + path: String, + withPassword: (String) -> String?, + ): String { + if (!(path.contains(COLON) && path.contains(AT))) { + // smb path doesn't have any credentials + return path + } + val buffer = StringBuilder() + NetCopyConnectionInfo(path).let { connectionInfo -> + buffer.append(connectionInfo.prefix).append( + connectionInfo.username.ifEmpty { "" }, + ) + if (false == connectionInfo.password?.isEmpty()) { + val password = withPassword.invoke(connectionInfo.password) + buffer.append(COLON).append(password?.replace("\n", "")) + } + buffer.append(AT).append(connectionInfo.host) + if (connectionInfo.port > 0) { + buffer.append(COLON).append(connectionInfo.port) + } + connectionInfo.defaultPath?.apply { + buffer.append(this) + } + if (path.contains(QUESTION_MARK)) { + buffer.append(QUESTION_MARK).append(path.substringAfter(QUESTION_MARK)) + } + } + return buffer.toString().replace("\n", "") + } + + /** + * Factory method to return [SmbFile] from given path. + */ + @JvmStatic + @Throws(MalformedURLException::class) + fun create(path: String): SmbFile { + val uri = Uri.parse(getSmbDecryptedPath(AppConfig.getInstance(), path)) + val disableIpcSigningCheck = + uri.getQueryParameter( + PARAM_DISABLE_IPC_SIGNING_CHECK, + ).toBoolean() + + val userInfo = uri.userInfo + return SmbFile( + if (path.indexOf('?') < 0) path else path.substring(0, path.indexOf('?')), + createWithDisableIpcSigningCheck(path, disableIpcSigningCheck) + .withCredentials(createFrom(userInfo)), + ) + } + + /** + * Create [NtlmPasswordAuthenticator] from given userInfo parameter. + * + * + * Logic borrowed directly from jcifs-ng's own code. They should make that protected + * constructor public... + * + * @param userInfo authentication string, must be already URL decoded. [Uri] shall do this + * for you already + * @return [NtlmPasswordAuthenticator] instance + */ + fun createFrom(userInfo: String?): NtlmPasswordAuthenticator { + return if (!TextUtils.isEmpty(userInfo)) { + var dom: String? = null + var user: String? = null + var pass: String? = null + var i: Int + var u: Int + val end = userInfo!!.length + i = 0 + u = 0 + while (i < end) { + val c = userInfo[i] + if (c == ';') { + dom = userInfo.substring(0, i) + u = i + 1 + } else if (c == ':') { + pass = userInfo.substring(i + 1) + break + } + i++ + } + user = userInfo.substring(u, i) + NtlmPasswordAuthenticator(dom, user, pass) + } else { + NtlmPasswordAuthenticator() + } + } + + /** + * SMB version of [MainActivityHelper.checkFolder]. + * + * @param path SMB path + * @return [com.amaze.filemanager.filesystem.FolderStateKt.DOESNT_EXIST] if specified SMB + * path doesn't exist on server, else [com.amaze.filemanager.filesystem.FolderStateKt.WRITABLE_ON_REMOTE] + */ + @Suppress("LabeledExpression") + @JvmStatic + fun checkFolder(path: String): Int { + return Single.fromCallable { + try { + val smbFile = create(path) + if (!smbFile.exists() || !smbFile.isDirectory) return@fromCallable DOESNT_EXIST + } catch (e: SmbException) { + LOG.warn("Error checking folder existence, assuming not exist", e) + return@fromCallable DOESNT_EXIST + } catch (e: MalformedURLException) { + LOG.warn("Error checking folder existence, assuming not exist", e) + return@fromCallable DOESNT_EXIST + } + WRITABLE_ON_REMOTE + }.subscribeOn(Schedulers.io()) + .blockingGet() + } +} diff --git a/app/src/main/java/com/amaze/filemanager/utils/smb/WsddDiscoverDeviceStrategy.kt b/app/src/main/java/com/amaze/filemanager/utils/smb/WsddDiscoverDeviceStrategy.kt new file mode 100644 index 0000000..3fe8e2f --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/smb/WsddDiscoverDeviceStrategy.kt @@ -0,0 +1,301 @@ +/* + * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.utils.smb + +import androidx.annotation.VisibleForTesting +import com.amaze.filemanager.R +import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.filesystem.ftp.NetCopyConnectionInfo.Companion.SLASH +import com.amaze.filemanager.utils.ComputerParcelable +import com.amaze.filemanager.utils.NetworkUtil +import okhttp3.Headers.Companion.toHeaders +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserException +import org.xmlpull.v1.XmlPullParserFactory +import java.io.StringReader +import java.net.DatagramPacket +import java.net.InetAddress +import java.net.MulticastSocket +import java.util.UUID +import java.util.WeakHashMap + +/** + * [SmbDeviceScannerObservable.DiscoverDeviceStrategy] implementation to discover SMB devices using + * [Web service discovery](https://en.wikipedia.org/wiki/WS-Discovery), which is used by SMBv2 or + * above. + * + * Discovery method goes this way: + * 1. send a SOAP request to multicast address 239.255.255.250 port 3702 over UDP + * 2. for each reply as SOAP XML too, extract their URN and record the address the packets are from + * 3. if the reply indicates sender is a computer, send a HTTP POST to the address recorded in 2, port 5357 + * 4. verify result and send [ComputerParcelable] in callback + * + * Implementation is after reference: https://fitzcarraldoblog.wordpress.com/2020/07/08/a-linux-command-line-utility-to-discover-and-list-wsd-enabled-computers-and-printers-on-a-home-network/ + * (Python though). + * + * Original implementation calls for UUIDv5 which will use hash value of the device's MAC address; + * this implementation is not using, since MAC address poses privacy concern, and newer Androids are + * making difficult to fetch MAC addresses anyway. + * + * Manually setting [multicastSocketFactory] Allows customized method to be specified for creating [MulticastSocket] + * for convenience of testing. + * + * @author TranceLove + */ +class WsddDiscoverDeviceStrategy : SmbDeviceScannerObservable.DiscoverDeviceStrategy { + private val multicastRequestTemplate = + AppConfig.getInstance() + .resources.openRawResource(R.raw.wsdd_discovery) + .reader(Charsets.UTF_8).readText() + + private val wsdRequestTemplate = + AppConfig.getInstance() + .resources.openRawResource(R.raw.wsd_request) + .reader(Charsets.UTF_8).readText() + + private val wsdRequestHeaders = + mutableMapOf( + Pair("Accept-Encoding", "Identity"), + Pair("Connection", "Close"), + Pair("User-Agent", "wsd"), + ) + + var multicastSocketFactory: () -> MulticastSocket = DEFAULT_MULTICAST_SOCKET_FACTORY + @VisibleForTesting + get + + @VisibleForTesting + set + + private val queue = OkHttpClient() + + private var cancelled = false + + override fun discoverDevices(callback: (ComputerParcelable) -> Unit) { + multicastForDevice { addr -> + callback.invoke(addr) + } + } + + @Suppress("LabeledExpression") + private fun multicastForDevice(callback: (ComputerParcelable) -> Unit) { + NetworkUtil.getLocalInetAddress(AppConfig.getInstance())?.let { addr -> + val multicastAddressV4 = InetAddress.getByName(BROADCAST_IPV4) + val multicastAddressV6 = InetAddress.getByName(BROADCAST_IPV6_LINK_LOCAL) + + while (!cancelled) { + val socket: MulticastSocket = multicastSocketFactory.invoke() + socket.timeToLive = 1 + socket.soTimeout = SOCKET_RECEIVE_TIMEOUT + socket.reuseAddress = true + socket.joinGroup(multicastAddressV4) + socket.joinGroup(multicastAddressV6) + + // Specification said UUIDv5 which is device dependent. But random-based UUID should + // also work here + val tempDeviceUuid = UUID.randomUUID() + val request = + multicastRequestTemplate + .replace("##MY_UUID##", tempDeviceUuid.toString()) + .toByteArray(Charsets.UTF_8) + + val requestPacket = + DatagramPacket( + request, + request.size, + multicastAddressV4, + UDP_PORT, + ) + socket.send(requestPacket) + + runCatching { + while (!socket.isClosed) { + val buffer = ByteArray(4096) + val replyPacket = DatagramPacket(buffer, buffer.size) + socket.receive(replyPacket) + if (replyPacket.data.isNotEmpty() && replyPacket.address != null) { + val sentFromAddress = replyPacket.address + queryWithResponseAsNecessary( + sentFromAddress, + tempDeviceUuid.toString(), + replyPacket.data, + callback, + ) + } + } + }.onFailure { + if (log.isWarnEnabled) log.warn("Error receiving reply", it) + socket.close() + } + } + } + } + + private fun queryWithResponseAsNecessary( + sourceAddress: InetAddress, + tempDeviceId: String, + response: ByteArray, + callback: (ComputerParcelable) -> Unit, + ) { + val values = parseXmlForResponse(response, arrayOf(WSD_TYPES, WSA_ADDRESS)) + val type = values[WSD_TYPES] + val urn = values[WSA_ADDRESS] + + if (true == type?.isNotEmpty() && true == urn?.isNotEmpty()) { + queryEndpointForResponse(type, sourceAddress, urn, tempDeviceId, callback) + } + } + + private fun queryEndpointForResponse( + type: String, + sourceAddress: InetAddress, + urn: String, + tempDeviceId: String, + callback: (ComputerParcelable) -> Unit, + ) { + if (type.endsWith(PUB_COMPUTER)) { + val messageId = UUID.randomUUID().toString() + + val endpoint = urn.substringAfter(URN_UUID) + val dest = + "http://${sourceAddress.hostAddress}:$TCP_PORT/$endpoint" + val requestBody = + wsdRequestTemplate + .replace("##MESSAGE_ID##", "$URN_UUID$messageId") + .replace("##DEST_UUID##", urn) + .replace("##MY_UUID##", "$URN_UUID$tempDeviceId") + .toRequestBody("application/soap+xml".toMediaType()) + queue.newCall( + Request.Builder() + .url(dest) + .post(requestBody) + .headers(wsdRequestHeaders.toHeaders()) + .build(), + ).execute().use { resp -> + if (resp.isSuccessful && resp.body != null) { + resp.body?.run { + if (log.isTraceEnabled) log.trace("Response: $resp") + val values = + parseXmlForResponse( + this.string(), + arrayOf(WSDP_TYPES, WSA_ADDRESS, PUB_COMPUTER), + ) + if (PUB_COMPUTER == values[WSDP_TYPES] && urn == values[WSA_ADDRESS]) { + if (true == values[PUB_COMPUTER]?.isNotEmpty()) { + val computerName: String = + values[PUB_COMPUTER].let { + if (it!!.contains(SLASH)) { + it.substringBefore(SLASH) + } else { + it + } + } + callback( + ComputerParcelable(sourceAddress.hostAddress, computerName), + ) + } + } + } + } else { + log.error("Error querying endpoint", resp) + } + } + } + } + + override fun onCancel() { + cancelled = true + } + + private fun parseXmlForResponse( + xml: ByteArray, + tags: Array, + ) = parseXmlForResponse(xml.toString(Charsets.UTF_8), tags) + + private fun parseXmlForResponse( + xml: String, + tags: Array, + ): Map { + if (xml.isEmpty()) { + return emptyMap() + } else { + val xmlParser = + XmlPullParserFactory.newInstance().also { + it.isNamespaceAware = false + it.isValidating = false + }.newPullParser().also { + it.setInput(StringReader(xml)) + } + val retval = WeakHashMap() + var currentTag: String = "" + var currentValue: String = "" + var event = xmlParser.eventType + try { + while (event != XmlPullParser.END_DOCUMENT) { + if (event == XmlPullParser.START_TAG) { + currentTag = xmlParser.name + } else if (event == XmlPullParser.TEXT) { + currentValue = xmlParser.text + } else if (event == XmlPullParser.END_TAG) { + if (tags.contains(currentTag)) { + retval[currentTag] = currentValue + currentTag = "" + currentValue = "" + } + } + event = xmlParser.next() + } + } catch (parseError: XmlPullParserException) { + log.warn("Error parsing XML", parseError) + // Combination of parsed result is required, hence it's all or nothing situation - + // if one error found, whole XML will not be valid. Clear for "no result" answer + retval.clear() + } + return retval + } + } + + companion object { + private const val BROADCAST_IPV4 = "239.255.255.250" + private const val BROADCAST_IPV6_LINK_LOCAL = "[FF02::C]" + private const val UDP_PORT = 3702 + private const val TCP_PORT = 5357 + private const val SOCKET_RECEIVE_TIMEOUT = 60000 // 1 minute receive timeout + + private const val URN_UUID = "urn:uuid:" + private const val WSA_ADDRESS = "wsa:Address" + private const val WSD_TYPES = "wsd:Types" + private const val WSDP_TYPES = "wsdp:Types" + private const val PUB_COMPUTER = "pub:Computer" + + private val log: Logger = LoggerFactory.getLogger(WsddDiscoverDeviceStrategy::class.java) + + private val DEFAULT_MULTICAST_SOCKET_FACTORY: () -> MulticastSocket = { + MulticastSocket() + } + } +} diff --git a/app/src/main/res/anim/check_in.xml b/app/src/main/res/anim/check_in.xml new file mode 100644 index 0000000..9d60973 --- /dev/null +++ b/app/src/main/res/anim/check_in.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/check_out.xml b/app/src/main/res/anim/check_out.xml new file mode 100644 index 0000000..9525e34 --- /dev/null +++ b/app/src/main/res/anim/check_out.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/fade_in_top.xml b/app/src/main/res/anim/fade_in_top.xml new file mode 100644 index 0000000..2677abc --- /dev/null +++ b/app/src/main/res/anim/fade_in_top.xml @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/fade_out_top.xml b/app/src/main/res/anim/fade_out_top.xml new file mode 100644 index 0000000..814163e --- /dev/null +++ b/app/src/main/res/anim/fade_out_top.xml @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_in.xml b/app/src/main/res/anim/slide_in.xml new file mode 100644 index 0000000..5c0037d --- /dev/null +++ b/app/src/main/res/anim/slide_in.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_in_bottom.xml b/app/src/main/res/anim/slide_in_bottom.xml new file mode 100644 index 0000000..1442111 --- /dev/null +++ b/app/src/main/res/anim/slide_in_bottom.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_in_top.xml b/app/src/main/res/anim/slide_in_top.xml new file mode 100644 index 0000000..7e32e70 --- /dev/null +++ b/app/src/main/res/anim/slide_in_top.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_out.xml b/app/src/main/res/anim/slide_out.xml new file mode 100644 index 0000000..2a75c4a --- /dev/null +++ b/app/src/main/res/anim/slide_out.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_out_bottom.xml b/app/src/main/res/anim/slide_out_bottom.xml new file mode 100644 index 0000000..b30c90d --- /dev/null +++ b/app/src/main/res/anim/slide_out_bottom.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-anydpi-v21/ic_zip_box_grey.xml b/app/src/main/res/drawable-anydpi-v21/ic_zip_box_grey.xml new file mode 100644 index 0000000..87608eb --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v21/ic_zip_box_grey.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/drawable-hdpi/amaze_header.png b/app/src/main/res/drawable-hdpi/amaze_header.png new file mode 100644 index 0000000..0b49962 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/amaze_header.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_all_inclusive_white_36dp.png b/app/src/main/res/drawable-hdpi/ic_all_inclusive_white_36dp.png new file mode 100644 index 0000000..2ccf539 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_all_inclusive_white_36dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_arrow_left_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_arrow_left_white_24dp.png new file mode 100644 index 0000000..f3dedd1 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_arrow_left_white_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_checkmark_selected.png b/app/src/main/res/drawable-hdpi/ic_checkmark_selected.png new file mode 100644 index 0000000..a2b1fc8 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_checkmark_selected.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_cloud_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_cloud_white_24dp.png new file mode 100644 index 0000000..fbe6a39 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_cloud_white_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_content_copy_grey600_36dp.png b/app/src/main/res/drawable-hdpi/ic_content_copy_grey600_36dp.png new file mode 100644 index 0000000..dbacacc Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_content_copy_grey600_36dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_content_copy_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_content_copy_white_24dp.png new file mode 100644 index 0000000..2171a08 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_content_copy_white_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_content_copy_white_36dp.png b/app/src/main/res/drawable-hdpi/ic_content_copy_white_36dp.png new file mode 100644 index 0000000..bd8ec1e Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_content_copy_white_36dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_doc_video_am.png b/app/src/main/res/drawable-hdpi/ic_doc_video_am.png new file mode 100644 index 0000000..57c9fa5 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_doc_video_am.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_doc_video_dark.png b/app/src/main/res/drawable-hdpi/ic_doc_video_dark.png new file mode 100644 index 0000000..e9c288c Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_doc_video_dark.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_eye_grey600_24dp.png b/app/src/main/res/drawable-hdpi/ic_eye_grey600_24dp.png new file mode 100644 index 0000000..426b939 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_eye_grey600_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_eye_off_grey600_24dp.png b/app/src/main/res/drawable-hdpi/ic_eye_off_grey600_24dp.png new file mode 100644 index 0000000..a21e0f4 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_eye_off_grey600_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_file_lock_white_36dp.png b/app/src/main/res/drawable-hdpi/ic_file_lock_white_36dp.png new file mode 100644 index 0000000..dfcd6c4 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_file_lock_white_36dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_folder_lock_grey600_36dp.png b/app/src/main/res/drawable-hdpi/ic_folder_lock_grey600_36dp.png new file mode 100644 index 0000000..9108426 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_folder_lock_grey600_36dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_folder_lock_open_grey600_36dp.png b/app/src/main/res/drawable-hdpi/ic_folder_lock_open_grey600_36dp.png new file mode 100644 index 0000000..16c8b5c Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_folder_lock_open_grey600_36dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_folder_lock_open_white_36dp.png b/app/src/main/res/drawable-hdpi/ic_folder_lock_open_white_36dp.png new file mode 100644 index 0000000..1785df1 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_folder_lock_open_white_36dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_folder_lock_white_36dp.png b/app/src/main/res/drawable-hdpi/ic_folder_lock_white_36dp.png new file mode 100644 index 0000000..ac0db7f Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_folder_lock_white_36dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_keyboard_arrow_down_white.png b/app/src/main/res/drawable-hdpi/ic_keyboard_arrow_down_white.png new file mode 100644 index 0000000..bbb4fb4 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_keyboard_arrow_down_white.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_keyboard_arrow_up_white.png b/app/src/main/res/drawable-hdpi/ic_keyboard_arrow_up_white.png new file mode 100644 index 0000000..dea8988 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_keyboard_arrow_up_white.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_star_grey600_24dp.png b/app/src/main/res/drawable-hdpi/ic_star_grey600_24dp.png new file mode 100644 index 0000000..0d32b1d Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_star_grey600_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_xda_grey600_24dp.png b/app/src/main/res/drawable-hdpi/ic_xda_grey600_24dp.png new file mode 100644 index 0000000..4b492a9 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_xda_grey600_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/sd_operate_step.png b/app/src/main/res/drawable-hdpi/sd_operate_step.png new file mode 100644 index 0000000..eea86a1 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/sd_operate_step.png differ diff --git a/app/src/main/res/drawable-mdpi/amaze_header.png b/app/src/main/res/drawable-mdpi/amaze_header.png new file mode 100644 index 0000000..a78df81 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/amaze_header.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_all_inclusive_white_36dp.png b/app/src/main/res/drawable-mdpi/ic_all_inclusive_white_36dp.png new file mode 100644 index 0000000..e7fd5a5 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_all_inclusive_white_36dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_arrow_left_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_arrow_left_white_24dp.png new file mode 100644 index 0000000..e523231 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_arrow_left_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_cloud_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_cloud_white_24dp.png new file mode 100644 index 0000000..cdcecfd Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_cloud_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_content_copy_grey600_36dp.png b/app/src/main/res/drawable-mdpi/ic_content_copy_grey600_36dp.png new file mode 100644 index 0000000..b7d4325 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_content_copy_grey600_36dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_content_copy_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_content_copy_white_24dp.png new file mode 100644 index 0000000..2f26f0f Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_content_copy_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_content_copy_white_36dp.png b/app/src/main/res/drawable-mdpi/ic_content_copy_white_36dp.png new file mode 100644 index 0000000..2171a08 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_content_copy_white_36dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_doc_video_am.png b/app/src/main/res/drawable-mdpi/ic_doc_video_am.png new file mode 100644 index 0000000..c61e948 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_doc_video_am.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_doc_video_dark.png b/app/src/main/res/drawable-mdpi/ic_doc_video_dark.png new file mode 100644 index 0000000..d78c57b Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_doc_video_dark.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_eye_grey600_24dp.png b/app/src/main/res/drawable-mdpi/ic_eye_grey600_24dp.png new file mode 100644 index 0000000..b9a7c73 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_eye_grey600_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_eye_off_grey600_24dp.png b/app/src/main/res/drawable-mdpi/ic_eye_off_grey600_24dp.png new file mode 100644 index 0000000..119c2a4 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_eye_off_grey600_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_file_lock_white_36dp.png b/app/src/main/res/drawable-mdpi/ic_file_lock_white_36dp.png new file mode 100644 index 0000000..c9f62be Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_file_lock_white_36dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_folder_lock_grey600_36dp.png b/app/src/main/res/drawable-mdpi/ic_folder_lock_grey600_36dp.png new file mode 100644 index 0000000..923239f Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_folder_lock_grey600_36dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_folder_lock_open_grey600_36dp.png b/app/src/main/res/drawable-mdpi/ic_folder_lock_open_grey600_36dp.png new file mode 100644 index 0000000..1ad49a6 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_folder_lock_open_grey600_36dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_folder_lock_open_white_36dp.png b/app/src/main/res/drawable-mdpi/ic_folder_lock_open_white_36dp.png new file mode 100644 index 0000000..4f465c9 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_folder_lock_open_white_36dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_folder_lock_white_36dp.png b/app/src/main/res/drawable-mdpi/ic_folder_lock_white_36dp.png new file mode 100644 index 0000000..8d7189e Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_folder_lock_white_36dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_keyboard_arrow_down_white.png b/app/src/main/res/drawable-mdpi/ic_keyboard_arrow_down_white.png new file mode 100644 index 0000000..ef8a4b6 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_keyboard_arrow_down_white.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_keyboard_arrow_up_white.png b/app/src/main/res/drawable-mdpi/ic_keyboard_arrow_up_white.png new file mode 100644 index 0000000..a2e4baa Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_keyboard_arrow_up_white.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_star_grey600_24dp.png b/app/src/main/res/drawable-mdpi/ic_star_grey600_24dp.png new file mode 100644 index 0000000..2fecbfd Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_star_grey600_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_xda_grey600_24dp.png b/app/src/main/res/drawable-mdpi/ic_xda_grey600_24dp.png new file mode 100644 index 0000000..4277c4f Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_xda_grey600_24dp.png differ diff --git a/app/src/main/res/drawable-v21/ripple.xml b/app/src/main/res/drawable-v21/ripple.xml new file mode 100644 index 0000000..e6026d4 --- /dev/null +++ b/app/src/main/res/drawable-v21/ripple.xml @@ -0,0 +1,8 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-v21/ripple_focusable.xml b/app/src/main/res/drawable-v21/ripple_focusable.xml new file mode 100644 index 0000000..b1b0c4c --- /dev/null +++ b/app/src/main/res/drawable-v21/ripple_focusable.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-v21/safr_ripple_black.xml b/app/src/main/res/drawable-v21/safr_ripple_black.xml new file mode 100644 index 0000000..9fa8356 --- /dev/null +++ b/app/src/main/res/drawable-v21/safr_ripple_black.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-v21/safr_ripple_white.xml b/app/src/main/res/drawable-v21/safr_ripple_white.xml new file mode 100644 index 0000000..e6ac462 --- /dev/null +++ b/app/src/main/res/drawable-v21/safr_ripple_white.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-xhdpi/amaze_header.png b/app/src/main/res/drawable-xhdpi/amaze_header.png new file mode 100644 index 0000000..e68d27a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/amaze_header.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_cancel.png b/app/src/main/res/drawable-xhdpi/ic_action_cancel.png new file mode 100644 index 0000000..d163420 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_cancel.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_cancel_light.png b/app/src/main/res/drawable-xhdpi/ic_action_cancel_light.png new file mode 100644 index 0000000..c5ee917 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_cancel_light.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_all_inclusive_white_36dp.png b/app/src/main/res/drawable-xhdpi/ic_all_inclusive_white_36dp.png new file mode 100644 index 0000000..749138d Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_all_inclusive_white_36dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_arrow_left_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_arrow_left_white_24dp.png new file mode 100644 index 0000000..39eff9f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_arrow_left_white_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_cloud_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_cloud_white_24dp.png new file mode 100644 index 0000000..038ce1f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_cloud_white_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_content_copy_grey600_36dp.png b/app/src/main/res/drawable-xhdpi/ic_content_copy_grey600_36dp.png new file mode 100644 index 0000000..7471253 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_content_copy_grey600_36dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_content_copy_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_content_copy_white_24dp.png new file mode 100644 index 0000000..afd28dd Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_content_copy_white_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_content_copy_white_36dp.png b/app/src/main/res/drawable-xhdpi/ic_content_copy_white_36dp.png new file mode 100644 index 0000000..d5f64bc Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_content_copy_white_36dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_doc_video_am.png b/app/src/main/res/drawable-xhdpi/ic_doc_video_am.png new file mode 100644 index 0000000..a3c80e7 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_doc_video_am.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_doc_video_dark.png b/app/src/main/res/drawable-xhdpi/ic_doc_video_dark.png new file mode 100644 index 0000000..f208795 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_doc_video_dark.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_eye_grey600_24dp.png b/app/src/main/res/drawable-xhdpi/ic_eye_grey600_24dp.png new file mode 100644 index 0000000..d1abda9 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_eye_grey600_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_eye_off_grey600_24dp.png b/app/src/main/res/drawable-xhdpi/ic_eye_off_grey600_24dp.png new file mode 100644 index 0000000..bff6ca5 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_eye_off_grey600_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_file_lock_white_36dp.png b/app/src/main/res/drawable-xhdpi/ic_file_lock_white_36dp.png new file mode 100644 index 0000000..17ebec5 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_file_lock_white_36dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_folder_lock_grey600_36dp.png b/app/src/main/res/drawable-xhdpi/ic_folder_lock_grey600_36dp.png new file mode 100644 index 0000000..d76c8a7 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_folder_lock_grey600_36dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_folder_lock_open_grey600_36dp.png b/app/src/main/res/drawable-xhdpi/ic_folder_lock_open_grey600_36dp.png new file mode 100644 index 0000000..16e76c2 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_folder_lock_open_grey600_36dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_folder_lock_open_white_36dp.png b/app/src/main/res/drawable-xhdpi/ic_folder_lock_open_white_36dp.png new file mode 100644 index 0000000..37dc48e Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_folder_lock_open_white_36dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_folder_lock_white_36dp.png b/app/src/main/res/drawable-xhdpi/ic_folder_lock_white_36dp.png new file mode 100644 index 0000000..f6f50cc Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_folder_lock_white_36dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_ftp_dark.png b/app/src/main/res/drawable-xhdpi/ic_ftp_dark.png new file mode 100644 index 0000000..7752fbe Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_ftp_dark.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_ftp_light.png b/app/src/main/res/drawable-xhdpi/ic_ftp_light.png new file mode 100644 index 0000000..0549905 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_ftp_light.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_grid_folder_new.png b/app/src/main/res/drawable-xhdpi/ic_grid_folder_new.png new file mode 100644 index 0000000..7226323 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_grid_folder_new.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_keyboard_arrow_down_white.png b/app/src/main/res/drawable-xhdpi/ic_keyboard_arrow_down_white.png new file mode 100644 index 0000000..058cebb Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_keyboard_arrow_down_white.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_keyboard_arrow_up_white.png b/app/src/main/res/drawable-xhdpi/ic_keyboard_arrow_up_white.png new file mode 100644 index 0000000..ae36d91 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_keyboard_arrow_up_white.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_star_grey600_24dp.png b/app/src/main/res/drawable-xhdpi/ic_star_grey600_24dp.png new file mode 100644 index 0000000..cfbb318 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_star_grey600_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_xda_grey600_24dp.png b/app/src/main/res/drawable-xhdpi/ic_xda_grey600_24dp.png new file mode 100644 index 0000000..5560708 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_xda_grey600_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/amaze_header.png b/app/src/main/res/drawable-xxhdpi/amaze_header.png new file mode 100644 index 0000000..269b25e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/amaze_header.png differ diff --git a/app/src/main/res/drawable-xxhdpi/amaze_header_2.png b/app/src/main/res/drawable-xxhdpi/amaze_header_2.png new file mode 100644 index 0000000..b4435fe Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/amaze_header_2.png differ diff --git a/app/src/main/res/drawable-xxhdpi/fab_shadow_black.9.png b/app/src/main/res/drawable-xxhdpi/fab_shadow_black.9.png new file mode 100644 index 0000000..f698934 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/fab_shadow_black.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/fab_shadow_dark.9.png b/app/src/main/res/drawable-xxhdpi/fab_shadow_dark.9.png new file mode 100644 index 0000000..76c04e9 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/fab_shadow_dark.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/fab_shadow_light.9.png b/app/src/main/res/drawable-xxhdpi/fab_shadow_light.9.png new file mode 100644 index 0000000..08389df Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/fab_shadow_light.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/folder_fab.png b/app/src/main/res/drawable-xxhdpi/folder_fab.png new file mode 100644 index 0000000..4ca3613 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/folder_fab.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_action_save.png b/app/src/main/res/drawable-xxhdpi/ic_action_save.png new file mode 100644 index 0000000..38f50e6 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_action_save.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_action_search.png b/app/src/main/res/drawable-xxhdpi/ic_action_search.png new file mode 100644 index 0000000..a6df595 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_action_search.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_all_inclusive_white_36dp.png b/app/src/main/res/drawable-xxhdpi/ic_all_inclusive_white_36dp.png new file mode 100644 index 0000000..4c8596b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_all_inclusive_white_36dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_arrow_back_white_36dp.png b/app/src/main/res/drawable-xxhdpi/ic_arrow_back_white_36dp.png new file mode 100644 index 0000000..4057cc5 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_arrow_back_white_36dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_arrow_left_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_arrow_left_white_24dp.png new file mode 100644 index 0000000..31aad3f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_arrow_left_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_cloud_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_cloud_white_24dp.png new file mode 100644 index 0000000..867bbba Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_cloud_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_content_copy_grey600_36dp.png b/app/src/main/res/drawable-xxhdpi/ic_content_copy_grey600_36dp.png new file mode 100644 index 0000000..e2d69b3 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_content_copy_grey600_36dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_content_copy_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_content_copy_white_24dp.png new file mode 100644 index 0000000..f601fec Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_content_copy_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_content_copy_white_36dp.png b/app/src/main/res/drawable-xxhdpi/ic_content_copy_white_36dp.png new file mode 100644 index 0000000..f98d9d8 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_content_copy_white_36dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_content_cut_white_36dp.png b/app/src/main/res/drawable-xxhdpi/ic_content_cut_white_36dp.png new file mode 100644 index 0000000..31c2108 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_content_cut_white_36dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_delete_white_36dp.png b/app/src/main/res/drawable-xxhdpi/ic_delete_white_36dp.png new file mode 100644 index 0000000..0e95e9b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_delete_white_36dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_doc_album.png b/app/src/main/res/drawable-xxhdpi/ic_doc_album.png new file mode 100644 index 0000000..319ac38 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_doc_album.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_doc_apk.png b/app/src/main/res/drawable-xxhdpi/ic_doc_apk.png new file mode 100644 index 0000000..4712c95 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_doc_apk.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_doc_apk_white.png b/app/src/main/res/drawable-xxhdpi/ic_doc_apk_white.png new file mode 100644 index 0000000..bcb9352 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_doc_apk_white.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_doc_audio_am.png b/app/src/main/res/drawable-xxhdpi/ic_doc_audio_am.png new file mode 100644 index 0000000..93ad338 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_doc_audio_am.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_doc_certificate.png b/app/src/main/res/drawable-xxhdpi/ic_doc_certificate.png new file mode 100644 index 0000000..67dc6af Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_doc_certificate.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_doc_codes.png b/app/src/main/res/drawable-xxhdpi/ic_doc_codes.png new file mode 100644 index 0000000..b35f0d4 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_doc_codes.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_doc_contact_am.png b/app/src/main/res/drawable-xxhdpi/ic_doc_contact_am.png new file mode 100644 index 0000000..9a1a6f0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_doc_contact_am.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_doc_doc_am.png b/app/src/main/res/drawable-xxhdpi/ic_doc_doc_am.png new file mode 100644 index 0000000..db96f22 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_doc_doc_am.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_doc_event_am.png b/app/src/main/res/drawable-xxhdpi/ic_doc_event_am.png new file mode 100644 index 0000000..eee6d25 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_doc_event_am.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_doc_font.png b/app/src/main/res/drawable-xxhdpi/ic_doc_font.png new file mode 100644 index 0000000..e31bdc5 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_doc_font.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_doc_generic_am.png b/app/src/main/res/drawable-xxhdpi/ic_doc_generic_am.png new file mode 100644 index 0000000..fd24777 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_doc_generic_am.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_doc_image.png b/app/src/main/res/drawable-xxhdpi/ic_doc_image.png new file mode 100644 index 0000000..7d2a152 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_doc_image.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_doc_pdf.png b/app/src/main/res/drawable-xxhdpi/ic_doc_pdf.png new file mode 100644 index 0000000..a6ad380 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_doc_pdf.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_doc_presentation.png b/app/src/main/res/drawable-xxhdpi/ic_doc_presentation.png new file mode 100644 index 0000000..c1a93ce Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_doc_presentation.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_doc_spreadsheet_am.png b/app/src/main/res/drawable-xxhdpi/ic_doc_spreadsheet_am.png new file mode 100644 index 0000000..3bf6504 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_doc_spreadsheet_am.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_doc_text_am.png b/app/src/main/res/drawable-xxhdpi/ic_doc_text_am.png new file mode 100644 index 0000000..8cac119 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_doc_text_am.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_doc_video_am.png b/app/src/main/res/drawable-xxhdpi/ic_doc_video_am.png new file mode 100644 index 0000000..547ef30 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_doc_video_am.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_doc_video_dark.png b/app/src/main/res/drawable-xxhdpi/ic_doc_video_dark.png new file mode 100644 index 0000000..f208795 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_doc_video_dark.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_drawer_l.png b/app/src/main/res/drawable-xxhdpi/ic_drawer_l.png new file mode 100644 index 0000000..19bae3e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_drawer_l.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_drawer_root_white.png b/app/src/main/res/drawable-xxhdpi/ic_drawer_root_white.png new file mode 100644 index 0000000..14bd84a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_drawer_root_white.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_eye_grey600_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_eye_grey600_24dp.png new file mode 100644 index 0000000..bb809d5 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_eye_grey600_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_eye_off_grey600_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_eye_off_grey600_24dp.png new file mode 100644 index 0000000..233f749 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_eye_off_grey600_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_file_lock_white_36dp.png b/app/src/main/res/drawable-xxhdpi/ic_file_lock_white_36dp.png new file mode 100644 index 0000000..f2c6265 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_file_lock_white_36dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_folder_lock_grey600_36dp.png b/app/src/main/res/drawable-xxhdpi/ic_folder_lock_grey600_36dp.png new file mode 100644 index 0000000..43e8f16 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_folder_lock_grey600_36dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_folder_lock_open_grey600_36dp.png b/app/src/main/res/drawable-xxhdpi/ic_folder_lock_open_grey600_36dp.png new file mode 100644 index 0000000..3bbdb88 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_folder_lock_open_grey600_36dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_folder_lock_open_white_36dp.png b/app/src/main/res/drawable-xxhdpi/ic_folder_lock_open_white_36dp.png new file mode 100644 index 0000000..3617a55 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_folder_lock_open_white_36dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_folder_lock_white_36dp.png b/app/src/main/res/drawable-xxhdpi/ic_folder_lock_white_36dp.png new file mode 100644 index 0000000..519505d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_folder_lock_white_36dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_grid_card_background.9.png b/app/src/main/res/drawable-xxhdpi/ic_grid_card_background.9.png new file mode 100644 index 0000000..553a18c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_grid_card_background.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_grid_card_background_dark.9.png b/app/src/main/res/drawable-xxhdpi/ic_grid_card_background_dark.9.png new file mode 100644 index 0000000..c38fb1e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_grid_card_background_dark.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_grid_card_focused.9.png b/app/src/main/res/drawable-xxhdpi/ic_grid_card_focused.9.png new file mode 100644 index 0000000..901af80 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_grid_card_focused.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_grid_card_pressed.9.png b/app/src/main/res/drawable-xxhdpi/ic_grid_card_pressed.9.png new file mode 100644 index 0000000..e21e350 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_grid_card_pressed.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_insert_drive_file_white_36dp.png b/app/src/main/res/drawable-xxhdpi/ic_insert_drive_file_white_36dp.png new file mode 100644 index 0000000..be06d6e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_insert_drive_file_white_36dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_insert_drive_file_white_48dp.png b/app/src/main/res/drawable-xxhdpi/ic_insert_drive_file_white_48dp.png new file mode 100644 index 0000000..2a22b46 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_insert_drive_file_white_48dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_keyboard_arrow_down_white.png b/app/src/main/res/drawable-xxhdpi/ic_keyboard_arrow_down_white.png new file mode 100644 index 0000000..f9622b7 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_keyboard_arrow_down_white.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_keyboard_arrow_up_white.png b/app/src/main/res/drawable-xxhdpi/ic_keyboard_arrow_up_white.png new file mode 100644 index 0000000..ce4aa56 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_keyboard_arrow_up_white.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_more_vert_black_48dp.png b/app/src/main/res/drawable-xxhdpi/ic_more_vert_black_48dp.png new file mode 100644 index 0000000..a82bca3 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_more_vert_black_48dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_select_all_white_36dp.png b/app/src/main/res/drawable-xxhdpi/ic_select_all_white_36dp.png new file mode 100644 index 0000000..f0a0b73 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_select_all_white_36dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_settings_bluetooth_black_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_settings_bluetooth_black_24dp.png new file mode 100644 index 0000000..cf842dc Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_settings_bluetooth_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_settings_bluetooth_white_36dp.png b/app/src/main/res/drawable-xxhdpi/ic_settings_bluetooth_white_36dp.png new file mode 100644 index 0000000..7063904 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_settings_bluetooth_white_36dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_settings_grey600_48dp.png b/app/src/main/res/drawable-xxhdpi/ic_settings_grey600_48dp.png new file mode 100644 index 0000000..a45f2ea Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_settings_grey600_48dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_settings_remote_white_48dp.png b/app/src/main/res/drawable-xxhdpi/ic_settings_remote_white_48dp.png new file mode 100644 index 0000000..7b56586 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_settings_remote_white_48dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_settings_white_48dp.png b/app/src/main/res/drawable-xxhdpi/ic_settings_white_48dp.png new file mode 100644 index 0000000..97e9ca9 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_settings_white_48dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_star_grey600_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_star_grey600_24dp.png new file mode 100644 index 0000000..9b87111 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_star_grey600_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_xda_grey600_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_xda_grey600_24dp.png new file mode 100644 index 0000000..8053e51 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_xda_grey600_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/about_header.png b/app/src/main/res/drawable-xxxhdpi/about_header.png new file mode 100644 index 0000000..721ede4 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/about_header.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_all_inclusive_white_36dp.png b/app/src/main/res/drawable-xxxhdpi/ic_all_inclusive_white_36dp.png new file mode 100644 index 0000000..e361f6d Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_all_inclusive_white_36dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_arrow_left_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_arrow_left_white_24dp.png new file mode 100644 index 0000000..9a6e0e2 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_arrow_left_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_cloud_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_cloud_white_24dp.png new file mode 100644 index 0000000..36922ef Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_cloud_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_content_copy_grey600_36dp.png b/app/src/main/res/drawable-xxxhdpi/ic_content_copy_grey600_36dp.png new file mode 100644 index 0000000..f9fe6b3 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_content_copy_grey600_36dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_content_copy_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_content_copy_white_24dp.png new file mode 100644 index 0000000..eb7ba56 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_content_copy_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_content_copy_white_36dp.png b/app/src/main/res/drawable-xxxhdpi/ic_content_copy_white_36dp.png new file mode 100644 index 0000000..868e457 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_content_copy_white_36dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_doc_video_am.png b/app/src/main/res/drawable-xxxhdpi/ic_doc_video_am.png new file mode 100644 index 0000000..be5c062 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_doc_video_am.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_doc_video_dark.png b/app/src/main/res/drawable-xxxhdpi/ic_doc_video_dark.png new file mode 100644 index 0000000..d12d495 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_doc_video_dark.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_eye_grey600_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_eye_grey600_24dp.png new file mode 100644 index 0000000..f965296 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_eye_grey600_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_eye_off_grey600_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_eye_off_grey600_24dp.png new file mode 100644 index 0000000..555fb31 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_eye_off_grey600_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_file_lock_white_36dp.png b/app/src/main/res/drawable-xxxhdpi/ic_file_lock_white_36dp.png new file mode 100644 index 0000000..772d67b Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_file_lock_white_36dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_folder_lock_grey600_36dp.png b/app/src/main/res/drawable-xxxhdpi/ic_folder_lock_grey600_36dp.png new file mode 100644 index 0000000..f32be5d Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_folder_lock_grey600_36dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_folder_lock_open_grey600_36dp.png b/app/src/main/res/drawable-xxxhdpi/ic_folder_lock_open_grey600_36dp.png new file mode 100644 index 0000000..9b3c4e7 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_folder_lock_open_grey600_36dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_folder_lock_open_white_36dp.png b/app/src/main/res/drawable-xxxhdpi/ic_folder_lock_open_white_36dp.png new file mode 100644 index 0000000..c241313 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_folder_lock_open_white_36dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_folder_lock_white_36dp.png b/app/src/main/res/drawable-xxxhdpi/ic_folder_lock_white_36dp.png new file mode 100644 index 0000000..74fca81 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_folder_lock_white_36dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_grid_selection_check.png b/app/src/main/res/drawable-xxxhdpi/ic_grid_selection_check.png new file mode 100755 index 0000000..faf54d9 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_grid_selection_check.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_keyboard_arrow_down_white.png b/app/src/main/res/drawable-xxxhdpi/ic_keyboard_arrow_down_white.png new file mode 100644 index 0000000..30948d9 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_keyboard_arrow_down_white.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_keyboard_arrow_up_white.png b/app/src/main/res/drawable-xxxhdpi/ic_keyboard_arrow_up_white.png new file mode 100644 index 0000000..4261551 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_keyboard_arrow_up_white.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_star_grey600_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_star_grey600_24dp.png new file mode 100644 index 0000000..c676818 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_star_grey600_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_xda_grey600_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_xda_grey600_24dp.png new file mode 100644 index 0000000..6f4ece9 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_xda_grey600_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/twotone_credit_card_white_48dp.png b/app/src/main/res/drawable-xxxhdpi/twotone_credit_card_white_48dp.png new file mode 100644 index 0000000..4a2441f Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/twotone_credit_card_white_48dp.png differ diff --git a/app/src/main/res/drawable/background_curved.xml b/app/src/main/res/drawable/background_curved.xml new file mode 100644 index 0000000..167c7b6 --- /dev/null +++ b/app/src/main/res/drawable/background_curved.xml @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/baseline_sort_24_asc_white.xml b/app/src/main/res/drawable/baseline_sort_24_asc_white.xml new file mode 100644 index 0000000..218906d --- /dev/null +++ b/app/src/main/res/drawable/baseline_sort_24_asc_white.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/baseline_sort_24_desc_white.xml b/app/src/main/res/drawable/baseline_sort_24_desc_white.xml new file mode 100644 index 0000000..d79a05b --- /dev/null +++ b/app/src/main/res/drawable/baseline_sort_24_desc_white.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/circle_shape.xml b/app/src/main/res/drawable/circle_shape.xml new file mode 100644 index 0000000..8e2f357 --- /dev/null +++ b/app/src/main/res/drawable/circle_shape.xml @@ -0,0 +1,29 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/disableable_ic_edit_24dp.xml b/app/src/main/res/drawable/disableable_ic_edit_24dp.xml new file mode 100644 index 0000000..3cb086d --- /dev/null +++ b/app/src/main/res/drawable/disableable_ic_edit_24dp.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/fab_label_background.xml b/app/src/main/res/drawable/fab_label_background.xml new file mode 100644 index 0000000..4f2670d --- /dev/null +++ b/app/src/main/res/drawable/fab_label_background.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/fastscroller_handle_normal.xml b/app/src/main/res/drawable/fastscroller_handle_normal.xml new file mode 100644 index 0000000..3d91a5f --- /dev/null +++ b/app/src/main/res/drawable/fastscroller_handle_normal.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/fastscroller_handle_pressed.xml b/app/src/main/res/drawable/fastscroller_handle_pressed.xml new file mode 100644 index 0000000..6774430 --- /dev/null +++ b/app/src/main/res/drawable/fastscroller_handle_pressed.xml @@ -0,0 +1,10 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/google_blue_background.xml b/app/src/main/res/drawable/google_blue_background.xml new file mode 100644 index 0000000..181a387 --- /dev/null +++ b/app/src/main/res/drawable/google_blue_background.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/ic_account_circle_grey_24dp.xml b/app/src/main/res/drawable/ic_account_circle_grey_24dp.xml new file mode 100644 index 0000000..005e86f --- /dev/null +++ b/app/src/main/res/drawable/ic_account_circle_grey_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_add_white_24dp.xml b/app/src/main/res/drawable/ic_add_white_24dp.xml new file mode 100644 index 0000000..b9b8eca --- /dev/null +++ b/app/src/main/res/drawable/ic_add_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_android_white_24dp.xml b/app/src/main/res/drawable/ic_android_white_24dp.xml new file mode 100644 index 0000000..594f358 --- /dev/null +++ b/app/src/main/res/drawable/ic_android_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_apk_library_white_24dp.xml b/app/src/main/res/drawable/ic_apk_library_white_24dp.xml new file mode 100644 index 0000000..95633b3 --- /dev/null +++ b/app/src/main/res/drawable/ic_apk_library_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_back_black_24dp.xml b/app/src/main/res/drawable/ic_arrow_back_black_24dp.xml new file mode 100644 index 0000000..a5b378a --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_back_black_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_add_24.xml b/app/src/main/res/drawable/ic_baseline_add_24.xml new file mode 100644 index 0000000..eb23254 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_add_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_brush_white_24.xml b/app/src/main/res/drawable/ic_baseline_brush_white_24.xml new file mode 100644 index 0000000..7bda920 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_brush_white_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_content_copy_24.xml b/app/src/main/res/drawable/ic_baseline_content_copy_24.xml new file mode 100644 index 0000000..c764a3f --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_content_copy_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_content_cut_24.xml b/app/src/main/res/drawable/ic_baseline_content_cut_24.xml new file mode 100644 index 0000000..d0f638f --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_content_cut_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_info_white_24.xml b/app/src/main/res/drawable/ic_baseline_info_white_24.xml new file mode 100644 index 0000000..9f62d94 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_info_white_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_input_24.xml b/app/src/main/res/drawable/ic_baseline_input_24.xml new file mode 100644 index 0000000..8eeaaf5 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_input_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_lock_white_24.xml b/app/src/main/res/drawable/ic_baseline_lock_white_24.xml new file mode 100644 index 0000000..c3c7ac2 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_lock_white_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_settings_white_24.xml b/app/src/main/res/drawable/ic_baseline_settings_white_24.xml new file mode 100644 index 0000000..b240b83 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_settings_white_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_vertical_split_white_24.xml b/app/src/main/res/drawable/ic_baseline_vertical_split_white_24.xml new file mode 100644 index 0000000..eed2e0b --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_vertical_split_white_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_box_grey_24dp.xml b/app/src/main/res/drawable/ic_box_grey_24dp.xml new file mode 100644 index 0000000..ef8c907 --- /dev/null +++ b/app/src/main/res/drawable/ic_box_grey_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_box_white_24dp.xml b/app/src/main/res/drawable/ic_box_white_24dp.xml new file mode 100644 index 0000000..c9d066c --- /dev/null +++ b/app/src/main/res/drawable/ic_box_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_broken_image_white_24dp.xml b/app/src/main/res/drawable/ic_broken_image_white_24dp.xml new file mode 100644 index 0000000..229235f --- /dev/null +++ b/app/src/main/res/drawable/ic_broken_image_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_bug_report_grey_24dp.xml b/app/src/main/res/drawable/ic_bug_report_grey_24dp.xml new file mode 100644 index 0000000..13eb045 --- /dev/null +++ b/app/src/main/res/drawable/ic_bug_report_grey_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_check_white_24dp.xml b/app/src/main/res/drawable/ic_check_white_24dp.xml new file mode 100644 index 0000000..bbc1970 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_white_24dp.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_clear_all.xml b/app/src/main/res/drawable/ic_clear_all.xml new file mode 100644 index 0000000..f255acd --- /dev/null +++ b/app/src/main/res/drawable/ic_clear_all.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_close_black_24dp.xml b/app/src/main/res/drawable/ic_close_black_24dp.xml new file mode 100644 index 0000000..ede4b71 --- /dev/null +++ b/app/src/main/res/drawable/ic_close_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_close_white_24dp.xml b/app/src/main/res/drawable/ic_close_white_24dp.xml new file mode 100644 index 0000000..1d6834e --- /dev/null +++ b/app/src/main/res/drawable/ic_close_white_24dp.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_compressed_white_24dp.xml b/app/src/main/res/drawable/ic_compressed_white_24dp.xml new file mode 100644 index 0000000..866551c --- /dev/null +++ b/app/src/main/res/drawable/ic_compressed_white_24dp.xml @@ -0,0 +1,8 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_content_paste_white_24dp.xml b/app/src/main/res/drawable/ic_content_paste_white_24dp.xml new file mode 100644 index 0000000..3ee1da3 --- /dev/null +++ b/app/src/main/res/drawable/ic_content_paste_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_delete_forever_black_24dp.xml b/app/src/main/res/drawable/ic_delete_forever_black_24dp.xml new file mode 100644 index 0000000..951403a --- /dev/null +++ b/app/src/main/res/drawable/ic_delete_forever_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_delete_grey_24dp.xml b/app/src/main/res/drawable/ic_delete_grey_24dp.xml new file mode 100644 index 0000000..1d54ba6 --- /dev/null +++ b/app/src/main/res/drawable/ic_delete_grey_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_drag_to_trash.xml b/app/src/main/res/drawable/ic_drag_to_trash.xml new file mode 100644 index 0000000..add2cff --- /dev/null +++ b/app/src/main/res/drawable/ic_drag_to_trash.xml @@ -0,0 +1,14 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_dropbox_grey_24dp.xml b/app/src/main/res/drawable/ic_dropbox_grey_24dp.xml new file mode 100644 index 0000000..ac5ec05 --- /dev/null +++ b/app/src/main/res/drawable/ic_dropbox_grey_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_dropbox_white_24dp.xml b/app/src/main/res/drawable/ic_dropbox_white_24dp.xml new file mode 100644 index 0000000..c3df55c --- /dev/null +++ b/app/src/main/res/drawable/ic_dropbox_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_edit_24dp.xml b/app/src/main/res/drawable/ic_edit_24dp.xml new file mode 100644 index 0000000..256234b --- /dev/null +++ b/app/src/main/res/drawable/ic_edit_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_edit_24dp_disabled.xml b/app/src/main/res/drawable/ic_edit_24dp_disabled.xml new file mode 100644 index 0000000..ae6829a --- /dev/null +++ b/app/src/main/res/drawable/ic_edit_24dp_disabled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_error_24dp.xml b/app/src/main/res/drawable/ic_error_24dp.xml new file mode 100644 index 0000000..67e849e --- /dev/null +++ b/app/src/main/res/drawable/ic_error_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_file_download_white_24dp.xml b/app/src/main/res/drawable/ic_file_download_white_24dp.xml new file mode 100644 index 0000000..e43b864 --- /dev/null +++ b/app/src/main/res/drawable/ic_file_download_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_flag_grey_24dp.xml b/app/src/main/res/drawable/ic_flag_grey_24dp.xml new file mode 100644 index 0000000..45f5b66 --- /dev/null +++ b/app/src/main/res/drawable/ic_flag_grey_24dp.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_folder_white_24dp.xml b/app/src/main/res/drawable/ic_folder_white_24dp.xml new file mode 100644 index 0000000..693eee3 --- /dev/null +++ b/app/src/main/res/drawable/ic_folder_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_ftp_white_24dp.xml b/app/src/main/res/drawable/ic_ftp_white_24dp.xml new file mode 100644 index 0000000..7a86fc3 --- /dev/null +++ b/app/src/main/res/drawable/ic_ftp_white_24dp.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_google_drive_grey_24dp.xml b/app/src/main/res/drawable/ic_google_drive_grey_24dp.xml new file mode 100644 index 0000000..d9abd45 --- /dev/null +++ b/app/src/main/res/drawable/ic_google_drive_grey_24dp.xml @@ -0,0 +1,8 @@ + + + diff --git a/app/src/main/res/drawable/ic_google_drive_white_24dp.xml b/app/src/main/res/drawable/ic_google_drive_white_24dp.xml new file mode 100644 index 0000000..4781427 --- /dev/null +++ b/app/src/main/res/drawable/ic_google_drive_white_24dp.xml @@ -0,0 +1,8 @@ + + + diff --git a/app/src/main/res/drawable/ic_google_logo.xml b/app/src/main/res/drawable/ic_google_logo.xml new file mode 100644 index 0000000..c2274da --- /dev/null +++ b/app/src/main/res/drawable/ic_google_logo.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_group_add_grey_24dp.xml b/app/src/main/res/drawable/ic_group_add_grey_24dp.xml new file mode 100644 index 0000000..163cb43 --- /dev/null +++ b/app/src/main/res/drawable/ic_group_add_grey_24dp.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_history_grey_24dp.xml b/app/src/main/res/drawable/ic_history_grey_24dp.xml new file mode 100644 index 0000000..ada5adb --- /dev/null +++ b/app/src/main/res/drawable/ic_history_grey_24dp.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_history_white_24dp.xml b/app/src/main/res/drawable/ic_history_white_24dp.xml new file mode 100644 index 0000000..90185c2 --- /dev/null +++ b/app/src/main/res/drawable/ic_history_white_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_home_white_24dp.xml b/app/src/main/res/drawable/ic_home_white_24dp.xml new file mode 100644 index 0000000..4acef8d --- /dev/null +++ b/app/src/main/res/drawable/ic_home_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_info_outline_gray_24dp.xml b/app/src/main/res/drawable/ic_info_outline_gray_24dp.xml new file mode 100644 index 0000000..236d70b --- /dev/null +++ b/app/src/main/res/drawable/ic_info_outline_gray_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_instagram_24.xml b/app/src/main/res/drawable/ic_instagram_24.xml new file mode 100644 index 0000000..865c504 --- /dev/null +++ b/app/src/main/res/drawable/ic_instagram_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_keyboard_arrow_right_white_24dp.xml b/app/src/main/res/drawable/ic_keyboard_arrow_right_white_24dp.xml new file mode 100644 index 0000000..6b9ebcd --- /dev/null +++ b/app/src/main/res/drawable/ic_keyboard_arrow_right_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_library_books_white_24dp.xml b/app/src/main/res/drawable/ic_library_books_white_24dp.xml new file mode 100644 index 0000000..d5f5778 --- /dev/null +++ b/app/src/main/res/drawable/ic_library_books_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_library_code_white_24dp.xml b/app/src/main/res/drawable/ic_library_code_white_24dp.xml new file mode 100644 index 0000000..bacbfbb --- /dev/null +++ b/app/src/main/res/drawable/ic_library_code_white_24dp.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_library_music_white_24dp.xml b/app/src/main/res/drawable/ic_library_music_white_24dp.xml new file mode 100644 index 0000000..1cd2166 --- /dev/null +++ b/app/src/main/res/drawable/ic_library_music_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_linux_grey_24dp.xml b/app/src/main/res/drawable/ic_linux_grey_24dp.xml new file mode 100644 index 0000000..9de5713 --- /dev/null +++ b/app/src/main/res/drawable/ic_linux_grey_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_onedrive_grey_24dp.xml b/app/src/main/res/drawable/ic_onedrive_grey_24dp.xml new file mode 100644 index 0000000..a9acd2d --- /dev/null +++ b/app/src/main/res/drawable/ic_onedrive_grey_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_onedrive_white_24dp.xml b/app/src/main/res/drawable/ic_onedrive_white_24dp.xml new file mode 100644 index 0000000..3adada5 --- /dev/null +++ b/app/src/main/res/drawable/ic_onedrive_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_payment_black_24dp.xml b/app/src/main/res/drawable/ic_payment_black_24dp.xml new file mode 100644 index 0000000..67c6706 --- /dev/null +++ b/app/src/main/res/drawable/ic_payment_black_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_phone_android_white_24dp.xml b/app/src/main/res/drawable/ic_phone_android_white_24dp.xml new file mode 100644 index 0000000..1cf0d80 --- /dev/null +++ b/app/src/main/res/drawable/ic_phone_android_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_photo_library_white_24dp.xml b/app/src/main/res/drawable/ic_photo_library_white_24dp.xml new file mode 100644 index 0000000..aba730c --- /dev/null +++ b/app/src/main/res/drawable/ic_photo_library_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_preference_appearance.xml b/app/src/main/res/drawable/ic_preference_appearance.xml new file mode 100644 index 0000000..c132c36 --- /dev/null +++ b/app/src/main/res/drawable/ic_preference_appearance.xml @@ -0,0 +1,14 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_preference_behavior.xml b/app/src/main/res/drawable/ic_preference_behavior.xml new file mode 100644 index 0000000..a85f58d --- /dev/null +++ b/app/src/main/res/drawable/ic_preference_behavior.xml @@ -0,0 +1,14 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_preference_info.xml b/app/src/main/res/drawable/ic_preference_info.xml new file mode 100644 index 0000000..b515fbf --- /dev/null +++ b/app/src/main/res/drawable/ic_preference_info.xml @@ -0,0 +1,14 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_preference_security.xml b/app/src/main/res/drawable/ic_preference_security.xml new file mode 100644 index 0000000..d9aad66 --- /dev/null +++ b/app/src/main/res/drawable/ic_preference_security.xml @@ -0,0 +1,14 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_preference_settings_backup_restore.xml b/app/src/main/res/drawable/ic_preference_settings_backup_restore.xml new file mode 100644 index 0000000..2373199 --- /dev/null +++ b/app/src/main/res/drawable/ic_preference_settings_backup_restore.xml @@ -0,0 +1,14 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_preference_ui.xml b/app/src/main/res/drawable/ic_preference_ui.xml new file mode 100644 index 0000000..7197378 --- /dev/null +++ b/app/src/main/res/drawable/ic_preference_ui.xml @@ -0,0 +1,14 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_rate_review_grey_24dp.xml b/app/src/main/res/drawable/ic_rate_review_grey_24dp.xml new file mode 100644 index 0000000..d7e1a18 --- /dev/null +++ b/app/src/main/res/drawable/ic_rate_review_grey_24dp.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/drawable/ic_remote_grey_24dp.xml b/app/src/main/res/drawable/ic_remote_grey_24dp.xml new file mode 100644 index 0000000..49fabfb --- /dev/null +++ b/app/src/main/res/drawable/ic_remote_grey_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_root_white_24px.xml b/app/src/main/res/drawable/ic_root_white_24px.xml new file mode 100644 index 0000000..299a111 --- /dev/null +++ b/app/src/main/res/drawable/ic_root_white_24px.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_round_analytics_24.xml b/app/src/main/res/drawable/ic_round_analytics_24.xml new file mode 100644 index 0000000..7428ec8 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_analytics_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_connect_without_contact_24.xml b/app/src/main/res/drawable/ic_round_connect_without_contact_24.xml new file mode 100644 index 0000000..a3ceb90 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_connect_without_contact_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_delete_outline_32.xml b/app/src/main/res/drawable/ic_round_delete_outline_32.xml new file mode 100644 index 0000000..a36dbca --- /dev/null +++ b/app/src/main/res/drawable/ic_round_delete_outline_32.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_sd_storage_white_24dp.xml b/app/src/main/res/drawable/ic_sd_storage_white_24dp.xml new file mode 100644 index 0000000..a7950a1 --- /dev/null +++ b/app/src/main/res/drawable/ic_sd_storage_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_search_white_24dp.xml b/app/src/main/res/drawable/ic_search_white_24dp.xml new file mode 100644 index 0000000..92b2cf1 --- /dev/null +++ b/app/src/main/res/drawable/ic_search_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings_backup_restore.xml b/app/src/main/res/drawable/ic_settings_backup_restore.xml new file mode 100644 index 0000000..8ff868a --- /dev/null +++ b/app/src/main/res/drawable/ic_settings_backup_restore.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings_remote_white_24dp.xml b/app/src/main/res/drawable/ic_settings_remote_white_24dp.xml new file mode 100644 index 0000000..48c1132 --- /dev/null +++ b/app/src/main/res/drawable/ic_settings_remote_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings_white_24dp.xml b/app/src/main/res/drawable/ic_settings_white_24dp.xml new file mode 100644 index 0000000..2000f7b --- /dev/null +++ b/app/src/main/res/drawable/ic_settings_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_shortcut_android.xml b/app/src/main/res/drawable/ic_shortcut_android.xml new file mode 100644 index 0000000..ab3d3bc --- /dev/null +++ b/app/src/main/res/drawable/ic_shortcut_android.xml @@ -0,0 +1,15 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_shortcut_ftp.xml b/app/src/main/res/drawable/ic_shortcut_ftp.xml new file mode 100644 index 0000000..afa5779 --- /dev/null +++ b/app/src/main/res/drawable/ic_shortcut_ftp.xml @@ -0,0 +1,15 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_shortcut_history.xml b/app/src/main/res/drawable/ic_shortcut_history.xml new file mode 100644 index 0000000..09a63d5 --- /dev/null +++ b/app/src/main/res/drawable/ic_shortcut_history.xml @@ -0,0 +1,15 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_shortcut_star.xml b/app/src/main/res/drawable/ic_shortcut_star.xml new file mode 100644 index 0000000..a08de1c --- /dev/null +++ b/app/src/main/res/drawable/ic_shortcut_star.xml @@ -0,0 +1,15 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_show_chart_black_24dp.xml b/app/src/main/res/drawable/ic_show_chart_black_24dp.xml new file mode 100644 index 0000000..acddd8f --- /dev/null +++ b/app/src/main/res/drawable/ic_show_chart_black_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_sign_in_with_google_roboto_text.xml b/app/src/main/res/drawable/ic_sign_in_with_google_roboto_text.xml new file mode 100644 index 0000000..a1981c4 --- /dev/null +++ b/app/src/main/res/drawable/ic_sign_in_with_google_roboto_text.xml @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_sort_white_24dp.xml b/app/src/main/res/drawable/ic_sort_white_24dp.xml new file mode 100644 index 0000000..6304b55 --- /dev/null +++ b/app/src/main/res/drawable/ic_sort_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_star_white_24dp.xml b/app/src/main/res/drawable/ic_star_white_24dp.xml new file mode 100644 index 0000000..5f1faef --- /dev/null +++ b/app/src/main/res/drawable/ic_star_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_telegram_24.xml b/app/src/main/res/drawable/ic_telegram_24.xml new file mode 100644 index 0000000..f64dc2b --- /dev/null +++ b/app/src/main/res/drawable/ic_telegram_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_usb_white_24dp.xml b/app/src/main/res/drawable/ic_usb_white_24dp.xml new file mode 100644 index 0000000..24a7f00 --- /dev/null +++ b/app/src/main/res/drawable/ic_usb_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_video_library_white_24dp.xml b/app/src/main/res/drawable/ic_video_library_white_24dp.xml new file mode 100644 index 0000000..b5b6f3c --- /dev/null +++ b/app/src/main/res/drawable/ic_video_library_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_warning_24dp.xml b/app/src/main/res/drawable/ic_warning_24dp.xml new file mode 100644 index 0000000..2a62114 --- /dev/null +++ b/app/src/main/res/drawable/ic_warning_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_zip_box_grey.png b/app/src/main/res/drawable/ic_zip_box_grey.png new file mode 100644 index 0000000..e995274 Binary files /dev/null and b/app/src/main/res/drawable/ic_zip_box_grey.png differ diff --git a/app/src/main/res/drawable/ic_zip_box_white.xml b/app/src/main/res/drawable/ic_zip_box_white.xml new file mode 100644 index 0000000..df45ca1 --- /dev/null +++ b/app/src/main/res/drawable/ic_zip_box_white.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/item_backgrund.xml b/app/src/main/res/drawable/item_backgrund.xml new file mode 100644 index 0000000..e6ca785 --- /dev/null +++ b/app/src/main/res/drawable/item_backgrund.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/item_doc_grid.xml b/app/src/main/res/drawable/item_doc_grid.xml new file mode 100644 index 0000000..bfac16f --- /dev/null +++ b/app/src/main/res/drawable/item_doc_grid.xml @@ -0,0 +1,24 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/item_focusable.xml b/app/src/main/res/drawable/item_focusable.xml new file mode 100644 index 0000000..b28025f --- /dev/null +++ b/app/src/main/res/drawable/item_focusable.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/popup_black.xml b/app/src/main/res/drawable/popup_black.xml new file mode 100644 index 0000000..4b38723 --- /dev/null +++ b/app/src/main/res/drawable/popup_black.xml @@ -0,0 +1,12 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ripple.xml b/app/src/main/res/drawable/ripple.xml new file mode 100644 index 0000000..4671772 --- /dev/null +++ b/app/src/main/res/drawable/ripple.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/ripple_focusable.xml b/app/src/main/res/drawable/ripple_focusable.xml new file mode 100644 index 0000000..d2dea9f --- /dev/null +++ b/app/src/main/res/drawable/ripple_focusable.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/round_delete_outline_24.xml b/app/src/main/res/drawable/round_delete_outline_24.xml new file mode 100644 index 0000000..465e04b --- /dev/null +++ b/app/src/main/res/drawable/round_delete_outline_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/round_restore_from_trash_24.xml b/app/src/main/res/drawable/round_restore_from_trash_24.xml new file mode 100644 index 0000000..038ef7e --- /dev/null +++ b/app/src/main/res/drawable/round_restore_from_trash_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/safr_ripple_black.xml b/app/src/main/res/drawable/safr_ripple_black.xml new file mode 100644 index 0000000..fd21a5d --- /dev/null +++ b/app/src/main/res/drawable/safr_ripple_black.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/safr_ripple_white.xml b/app/src/main/res/drawable/safr_ripple_white.xml new file mode 100644 index 0000000..1615f11 --- /dev/null +++ b/app/src/main/res/drawable/safr_ripple_white.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/search_view_shape.xml b/app/src/main/res/drawable/search_view_shape.xml new file mode 100644 index 0000000..9814fac --- /dev/null +++ b/app/src/main/res/drawable/search_view_shape.xml @@ -0,0 +1,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/search_view_shape_black.xml b/app/src/main/res/drawable/search_view_shape_black.xml new file mode 100644 index 0000000..7f1db0f --- /dev/null +++ b/app/src/main/res/drawable/search_view_shape_black.xml @@ -0,0 +1,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/search_view_shape_holo_dark.xml b/app/src/main/res/drawable/search_view_shape_holo_dark.xml new file mode 100644 index 0000000..6b3d75f --- /dev/null +++ b/app/src/main/res/drawable/search_view_shape_holo_dark.xml @@ -0,0 +1,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_dialog_bottomsheet_black.xml b/app/src/main/res/drawable/shape_dialog_bottomsheet_black.xml new file mode 100644 index 0000000..345710a --- /dev/null +++ b/app/src/main/res/drawable/shape_dialog_bottomsheet_black.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_dialog_bottomsheet_dark.xml b/app/src/main/res/drawable/shape_dialog_bottomsheet_dark.xml new file mode 100644 index 0000000..f496e87 --- /dev/null +++ b/app/src/main/res/drawable/shape_dialog_bottomsheet_dark.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_dialog_bottomsheet_white.xml b/app/src/main/res/drawable/shape_dialog_bottomsheet_white.xml new file mode 100644 index 0000000..8e478a0 --- /dev/null +++ b/app/src/main/res/drawable/shape_dialog_bottomsheet_white.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-v16/dialog_delete.xml b/app/src/main/res/layout-v16/dialog_delete.xml new file mode 100644 index 0000000..d547a13 --- /dev/null +++ b/app/src/main/res/layout-v16/dialog_delete.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout-v16/grid_header.xml b/app/src/main/res/layout-v16/grid_header.xml new file mode 100644 index 0000000..3f1caef --- /dev/null +++ b/app/src/main/res/layout-v16/grid_header.xml @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-v16/list_header.xml b/app/src/main/res/layout-v16/list_header.xml new file mode 100644 index 0000000..a8d4bff --- /dev/null +++ b/app/src/main/res/layout-v16/list_header.xml @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/app/src/main/res/layout-v21/layout_appbar.xml b/app/src/main/res/layout-v21/layout_appbar.xml new file mode 100644 index 0000000..350683a --- /dev/null +++ b/app/src/main/res/layout-v21/layout_appbar.xml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-v21/layout_search.xml b/app/src/main/res/layout-v21/layout_search.xml new file mode 100644 index 0000000..17f265b --- /dev/null +++ b/app/src/main/res/layout-v21/layout_search.xml @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +