diff --git a/.envrc b/.envrc deleted file mode 100644 index 3550a30f..00000000 --- a/.envrc +++ /dev/null @@ -1 +0,0 @@ -use flake diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md deleted file mode 100644 index a7596e08..00000000 --- a/.github/ISSUE_TEMPLATE/bug.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -name: Bug Report -about: Something is broken in Restoid and needs fixing. -title: '[BUG] ' -labels: 'bug' -assignees: '' ---- - -### Actual Behavior -What is actually happening? - -### Expected Behavior -What did you expect the app to do instead? - -### Steps to Reproduce -Drop the exact steps to trigger this issue: -1. Open the app... -2. Tap on... -3. Enter... -4. See the error... - -### Device Info -- **Device Model:** [e.g., Google Pixel 10 Pro] -- **Android Version:** [e.g., Android 16 QPR2] -- **ROM Version:** [e.g., Stock, LineageOS 23.2] -- **Root Method & Version:** [e.g., Magisk 30.7] - -### App Info -- **Restoid Version:** [e.g., v0.5.0] -- **Installation Source:** [e.g., GitHub Releases, F-Droid, other] - -### Additional Context -Drop additional info, logs, your screenshots or screen recordings here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index b2298c8b..00000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -name: Feature Request -about: Suggest an idea or new feature for Restoid. -title: '[FEATURE] ' -labels: 'enhancement' -assignees: '' ---- - -### Is your feature request related to a problem? -A clear description of the problem and why solving it matters to you. - -### Describe the solution you'd like -A clear and concise description of what you want to happen in Restoid. - -### Describe alternatives you've considered -A clear and concise description of any alternative solutions or features you've thought about. - -### Additional context -Add any other context, mockups, or screenshots about the feature request here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md deleted file mode 100644 index 5822b611..00000000 --- a/.github/ISSUE_TEMPLATE/question.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -name: Question -about: Ask a question about using, configuring, or understanding Restoid. -title: '[QUESTION] ' -labels: 'question' -assignees: '' ---- - -### What do you want to know? -Ask your question clearly and directly. - -### What are you trying to do? -Describe the goal or task you are trying to accomplish in Restoid. - -### What have you tried so far? -List any steps, settings, documentation, or workarounds you already tried. - -### Device Info -- **Device Model:** [e.g., Google Pixel 10 Pro] -- **Android Version:** [e.g., Android 16 QPR2] -- **ROM Version:** [e.g., Stock, LineageOS 23.2] -- **Root Method & Version:** [e.g., Magisk 30.7] - -### App Info -- **Restoid Version:** [e.g., v0.5.0] -- **Installation Source:** [e.g., GitHub Releases, F-Droid, other] - -### Additional Context -Add any extra details, logs, screenshots, or references that would help answer the question. diff --git a/.github/renovate.json b/.github/renovate.json deleted file mode 100644 index e9960e6b..00000000 --- a/.github/renovate.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "config:recommended" - ], - "customManagers": [ - { - "customType": "regex", - "managerFilePatterns": [ - "/^\\.go-version$/" - ], - "matchStrings": [ - "^(?\\d+\\.\\d+\\.\\d+)\\s*$" - ], - "depNameTemplate": "go", - "datasourceTemplate": "custom.go-official", - "versioningTemplate": "semver" - }, - { - "customType": "regex", - "managerFilePatterns": [ - "/^flake\\.nix$/" - ], - "matchStrings": [ - "# renovate: datasource=custom.go-official depName=go versioning=semver currentValue=(?\\S+)\\s+hash = \"sha256:(?[a-f0-9]{64})\";" - ], - "depNameTemplate": "go", - "datasourceTemplate": "custom.go-official", - "versioningTemplate": "semver", - "autoReplaceStringTemplate": "# renovate: datasource=custom.go-official depName=go versioning=semver currentValue={{{newValue}}}\n hash = \"sha256:{{{newDigest}}}\";" - } - ], - "customDatasources": { - "go-official": { - "defaultRegistryUrlTemplate": "https://go.dev/dl/?mode=json", - "transformTemplates": [ - "{ \"releases\": $[stable = true].{ \"version\": $substring(version, 2), \"digest\": files[os='linux' and arch='amd64' and kind='archive'][0].sha256 } }" - ] - } - }, - "packageRules": [ - { - "matchDatasources": [ - "custom.go-official" - ], - "matchDepNames": [ - "go" - ], - "groupName": "go toolchain" - } - ] -} diff --git a/.github/workflows/android-debug.yml b/.github/workflows/android-debug.yml deleted file mode 100644 index 23f613bc..00000000 --- a/.github/workflows/android-debug.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: Android Debug CI - -on: - push: - branches: - - master - pull_request: - -concurrency: - group: android-debug-${{ github.ref }} - cancel-in-progress: true - -jobs: - build-debug: - if: github.head_ref != 'weblate' - runs-on: ubuntu-latest - env: - JAVA_VERSION: "21" - - steps: - - name: Checkout repository - uses: actions/checkout@v7 - with: - submodules: recursive - - - name: Set up JDK - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: ${{ env.JAVA_VERSION }} - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version-file: '.go-version' - cache-dependency-path: 'restic/go.sum' - - - name: Set up Android SDK - uses: android-actions/setup-android@v4 - - - name: Install Android NDK - run: | - set -euo pipefail - NDK_VER=$(sed -n -E 's/.*ndkVersion = "(.*)".*/\1/p' app/build.gradle.kts | tr -d ' ') - echo "y" | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --install "ndk;$NDK_VER" - echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/$NDK_VER" >> "$GITHUB_ENV" - - - name: Verify Android NDK toolchain - run: | - set -euo pipefail - "$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/clang" --version - - - name: Build restic binaries - run: | - set -euo pipefail - ./scripts/build_restic.sh - - - name: Set up Gradle cache - uses: gradle/actions/setup-gradle@v6 - - - name: Build debug APKs - run: | - set -euo pipefail - ./gradlew --no-daemon clean assembleDebug diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml deleted file mode 100644 index dcc8621c..00000000 --- a/.github/workflows/android-release.yml +++ /dev/null @@ -1,252 +0,0 @@ -name: Android Release - -on: - push: - tags: - - "v*.*.*" - -permissions: - contents: write - -concurrency: - group: android-release-${{ github.ref }} - cancel-in-progress: false - -jobs: - build-and-release: - runs-on: ubuntu-latest - env: - JAVA_VERSION: "21" - - steps: - - name: Checkout repository - uses: actions/checkout@v7 - with: - submodules: recursive - - - name: Set up JDK - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: ${{ env.JAVA_VERSION }} - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version-file: '.go-version' - cache-dependency-path: 'restic/go.sum' - - - name: Set up Android SDK - uses: android-actions/setup-android@v4 - - - name: Install Android NDK - run: | - set -euo pipefail - NDK_VER=$(sed -n -E 's/.*ndkVersion = "(.*)".*/\1/p' app/build.gradle.kts | tr -d ' ') - echo "y" | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --install "ndk;$NDK_VER" - echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/$NDK_VER" >> "$GITHUB_ENV" - - - name: Verify Android NDK toolchain - run: | - set -euo pipefail - "$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/clang" --version - - - name: Build restic binaries - run: | - set -euo pipefail - ./scripts/build_restic.sh - - - name: Set up Gradle cache - uses: gradle/actions/setup-gradle@v6 - - - name: Validate signing secrets - env: - ANDROID_SIGNING_KEYSTORE_BASE64: ${{ secrets.ANDROID_SIGNING_KEYSTORE_BASE64 }} - ANDROID_SIGNING_STORE_PASSWORD: ${{ secrets.ANDROID_SIGNING_STORE_PASSWORD }} - ANDROID_SIGNING_KEY_ALIAS: ${{ secrets.ANDROID_SIGNING_KEY_ALIAS }} - ANDROID_SIGNING_KEY_PASSWORD: ${{ secrets.ANDROID_SIGNING_KEY_PASSWORD }} - run: | - set -euo pipefail - test -n "$ANDROID_SIGNING_KEYSTORE_BASE64" - test -n "$ANDROID_SIGNING_STORE_PASSWORD" - test -n "$ANDROID_SIGNING_KEY_ALIAS" - test -n "$ANDROID_SIGNING_KEY_PASSWORD" - - - name: Decode keystore - env: - ANDROID_SIGNING_KEYSTORE_BASE64: ${{ secrets.ANDROID_SIGNING_KEYSTORE_BASE64 }} - run: | - set -euo pipefail - KEYSTORE_PATH="$RUNNER_TEMP/android-release-signing.jks" - printf '%s' "$ANDROID_SIGNING_KEYSTORE_BASE64" | base64 --decode > "$KEYSTORE_PATH" - test -s "$KEYSTORE_PATH" - chmod 600 "$KEYSTORE_PATH" - echo "ANDROID_SIGNING_KEYSTORE_FILE=$KEYSTORE_PATH" >> "$GITHUB_ENV" - - - name: Build signed release APKs - env: - ANDROID_SIGNING_STORE_PASSWORD: ${{ secrets.ANDROID_SIGNING_STORE_PASSWORD }} - ANDROID_SIGNING_KEY_ALIAS: ${{ secrets.ANDROID_SIGNING_KEY_ALIAS }} - ANDROID_SIGNING_KEY_PASSWORD: ${{ secrets.ANDROID_SIGNING_KEY_PASSWORD }} - run: | - set -euo pipefail - ./gradlew --no-daemon clean assembleRelease - - - name: Collect release APKs - id: collect_apks - run: | - set -euo pipefail - mapfile -t apks < <(find app/build/outputs/apk -type f -path '*/release/*.apk' | sort) - if [ ${#apks[@]} -eq 0 ]; then - echo "No APK files were produced." - exit 1 - fi - - if find app/build/outputs/apk -type f -path '*/release/*unsigned*.apk' | grep -q .; then - echo "Unsigned APKs were found in release outputs." - find app/build/outputs/apk -type f -path '*/release/*unsigned*.apk' - exit 1 - fi - - mkdir -p release-assets - for apk in "${apks[@]}"; do - cp "$apk" release-assets/ - done - - { - echo "files<> "$GITHUB_OUTPUT" - - - name: Generate SHA256 checksums - run: | - set -euo pipefail - (cd release-assets && sha256sum *.apk > SHA256SUMS.txt) - - - name: Grab latest Fastlane changelog - id: changelog - run: | - set -euo pipefail - CHANGELOG_FILE=$(find fastlane/metadata/android/en-US/changelogs -type f -name '*.txt' | sort -V | tail -n 1) - echo "Found changelog: $CHANGELOG_FILE" - echo "path=$CHANGELOG_FILE" >> "$GITHUB_OUTPUT" - - - name: Publish GitHub Release - uses: softprops/action-gh-release@v3 - with: - name: Restoid ${{ github.ref_name }} - tag_name: ${{ github.ref_name }} - generate_release_notes: false - body_path: ${{ steps.changelog.outputs.path }} - files: | - ${{ steps.collect_apks.outputs.files }} - release-assets/SHA256SUMS.txt - - - name: Build F-Droid repo index - env: - FDROID_REPO_KEYSTORE_BASE64: ${{ secrets.FDROID_REPO_KEYSTORE_BASE64 }} - FDROID_REPO_KEY_ALIAS: ${{ secrets.FDROID_REPO_KEY_ALIAS }} - FDROID_REPO_KEYSTORE_PASSWORD: ${{ secrets.FDROID_REPO_KEYSTORE_PASSWORD }} - FDROID_REPO_KEY_PASSWORD: ${{ secrets.FDROID_REPO_KEY_PASSWORD }} - run: | - set -euo pipefail - test -n "$FDROID_REPO_KEYSTORE_BASE64" - test -n "$FDROID_REPO_KEY_ALIAS" - test -n "$FDROID_REPO_KEYSTORE_PASSWORD" - test -n "$FDROID_REPO_KEY_PASSWORD" - - mkdir -p fdroid/repo - printf '%s' "$FDROID_REPO_KEYSTORE_BASE64" | base64 --decode > fdroid/keystore.jks - chmod 600 fdroid/keystore.jks - - cat > fdroid/config.yml < "fdroid/metadata/${APP_ID}.yml" < - 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 -. diff --git a/README.md b/README.md deleted file mode 100644 index 03888fa0..00000000 --- a/README.md +++ /dev/null @@ -1,67 +0,0 @@ -# Restoid -Root-based Android backup tool powered by [`restic`](https://github.com/restic/restic/). - -Restoid gives you control over your app backups through a clean and simple user interface. It's built for users who want robust, encrypted, and deduplicated backups. - -## 📥 Download - -

- Get it on F-Droid  - Get it on GitHub  - Get it on Obtainium -

- -

- Official F-Droid updates may be delayed. For instant updates directly from CI, add the custom repository -

- -## 📸 Screenshots - -

- Restoid app screenshot 1 - Restoid app screenshot 2 - Restoid app screenshot 3 -

- -## ✨ Features - -* **Restic-Powered**: Leverages the speed, security, and efficiency of `restic` for deduplicated and encrypted backups. -* **Selective App Backup**: Choose exactly which user-installed applications you want to back up. -* **Full Control Over What You Back Up**: Granularly select what to include for each app: APK files, user data, device-protected data, external/OBB/media files. -* **Flexible Repository Management**: Create and manage repositories across native restic backends: Local directory, SFTP, REST server, and Amazon S3/MinIO. -* **Snapshot Management**: Easily browse backup snapshots, view details of what was backed up, and forget old snapshots. -* **Flexible Restore**: Restore entire apps or just specific parts (like only app data). -* **Automated Schedules**: Set up recurring backups with customizable intervals and trigger conditions (battery, charging, and network status). -* **Downgrade Protection**: Prevents you from accidentally restoring an older app version over a newer one (can be overridden). - -## ⚠️ Requirements - -* **Root Access**: Restoid requires elevated privileges to access app data directories. It uses `libsu` for robust root command execution. -* **Android Version**: Minimum SDK 33 (Android 13). - -## 🤝 How to Contribute - -If you find a bug, have a feature request, or want to help clean up the code, please: - -1. **Open an issue** to discuss the change. -2. Fork the repository and submit a pull request. - -All contributions are welcome! - -## 🌐 Translations - -Translations for Restoid are managed via Weblate. If you'd like to help translate the app, [please visit the project page](https://hosted.weblate.org/projects/restoid/) - -Translation status - -### 🌍 How to contribute translations: - -- Create an account on Weblate (if you don't already have one). -- Pick a language and translate strings using Weblate's web interface. -- Submitted translations will be reviewed by the maintainers and integrated into the project. - -If you prefer to contribute translations through GitHub (by editing Android string resources directly), open a pull request. - -## 📜 License - -This project is licensed under the **GNU General Public License v3.0**. See the `LICENSE` file for details. diff --git a/app/.gitignore b/app/.gitignore deleted file mode 100644 index 42afabfd..00000000 --- a/app/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts deleted file mode 100644 index 405964a0..00000000 --- a/app/build.gradle.kts +++ /dev/null @@ -1,188 +0,0 @@ -import java.io.File - -// This task builds the restic binary from the submodule for each required Android architecture. -tasks.register("buildBundledRestic") { - group = "restic" - description = "Builds restic binary from submodule using bash script" - - inputs.dir(rootProject.file("restic")) - outputs.dir(file("src/main/jniLibs")) - - onlyIf("binaries not yet built") { - !file("src/main/jniLibs/arm64-v8a/librestic.so").exists() || - !file("src/main/jniLibs/x86_64/librestic.so").exists() - } - - workingDir = rootProject.projectDir - commandLine = listOf("bash", "scripts/build_restic.sh") -} - -plugins { - alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.compose) - alias(libs.plugins.kotlin.serialization) - alias(libs.plugins.aboutlibraries) -} - -val signingKeystorePath = providers.environmentVariable("ANDROID_SIGNING_KEYSTORE_FILE") - .orElse(providers.gradleProperty("ANDROID_SIGNING_KEYSTORE_FILE")) - .orNull -val signingStorePassword = providers.environmentVariable("ANDROID_SIGNING_STORE_PASSWORD") - .orElse(providers.gradleProperty("ANDROID_SIGNING_STORE_PASSWORD")) - .orNull -val signingKeyAlias = providers.environmentVariable("ANDROID_SIGNING_KEY_ALIAS") - .orElse(providers.gradleProperty("ANDROID_SIGNING_KEY_ALIAS")) - .orNull -val signingKeyPassword = providers.environmentVariable("ANDROID_SIGNING_KEY_PASSWORD") - .orElse(providers.gradleProperty("ANDROID_SIGNING_KEY_PASSWORD")) - .orNull - -val hasReleaseSigning = listOf( - signingKeystorePath, - signingStorePassword, - signingKeyAlias, - signingKeyPassword -).all { !it.isNullOrBlank() } - -val baseAppVersionCode = 19 - -android { - namespace = "io.github.hddq.restoid" - compileSdk = 37 - buildToolsVersion = "37.0.0" - ndkVersion = "29.0.14206865" - defaultConfig { - applicationId = "io.github.hddq.restoid" - minSdk = 33 - targetSdk = 36 - versionCode = 19 - versionName = "0.6.0" - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - - ndk { - abiFilters.add("arm64-v8a") - abiFilters.add("x86_64") - } - } - - splits { - abi { - isEnable = true - reset() - include("x86_64", "arm64-v8a") - isUniversalApk = true - } - } - - signingConfigs { - if (hasReleaseSigning) { - create("release") { - storeFile = file(signingKeystorePath!!) - storePassword = signingStorePassword - keyAlias = signingKeyAlias - keyPassword = signingKeyPassword - } - } - } - - buildTypes { - release { - isMinifyEnabled = true - isShrinkResources = true - if (hasReleaseSigning) { - signingConfig = signingConfigs.getByName("release") - } else { - logger.lifecycle("Release signing not configured; release APKs will be unsigned.") - } - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - debug { - applicationIdSuffix = ".debug" - versionNameSuffix = "-debug" - } - } - dependenciesInfo { - includeInApk = false - includeInBundle = false - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 - } - kotlin { - compilerOptions { - jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21) - } - } - buildFeatures { - compose = true - buildConfig = true - } - - packaging { - jniLibs { - keepDebugSymbols += "**/*.so" - useLegacyPackaging = true - } - } - androidResources { - generateLocaleConfig = true - } -} - -androidComponents { - onVariants { variant -> - variant.outputs.forEach { output -> - val abi = output.filters - .find { it.filterType.name == "ABI" } - ?.identifier - - val abiCode = when (abi) { - "arm64-v8a" -> 1 - "x86_64" -> 2 - else -> 0 - } - - output.versionCode.set((baseAppVersionCode * 10) + abiCode) - } - } -} - -dependencies { - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.appcompat) - implementation(libs.material) - implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(libs.androidx.activity.compose) - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.compose.ui) - implementation(libs.androidx.compose.ui.graphics) - implementation(libs.androidx.compose.ui.tooling.preview) - implementation(libs.androidx.compose.material3) - implementation(libs.androidx.navigation.compose) - implementation(libs.androidx.compose.material.icons.extended) - implementation(libs.libsu.core) - implementation(libs.androidx.lifecycle.viewmodel.compose) - implementation(libs.androidx.lifecycle.runtime.compose) - implementation(libs.coil.compose) - implementation(libs.kotlinx.serialization.json) - implementation(libs.androidx.biometric) - implementation(libs.androidx.work.runtime.ktx) - implementation(libs.aboutlibraries.compose) - - testImplementation(libs.junit) - androidTestImplementation(libs.androidx.junit) - androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(platform(libs.androidx.compose.bom)) - androidTestImplementation(libs.androidx.compose.ui.test.junit4) - debugImplementation(libs.androidx.compose.ui.tooling) - debugImplementation(libs.androidx.compose.ui.test.manifest) -} - -tasks.named("preBuild").configure { - dependsOn(tasks.named("buildBundledRestic")) -} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro deleted file mode 100644 index 481bb434..00000000 --- a/app/proguard-rules.pro +++ /dev/null @@ -1,21 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/io/github/hddq/restoid/ExampleInstrumentedTest.kt b/app/src/androidTest/java/io/github/hddq/restoid/ExampleInstrumentedTest.kt deleted file mode 100644 index 16b6f3bd..00000000 --- a/app/src/androidTest/java/io/github/hddq/restoid/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package io.github.hddq.restoid - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("app.restoid", appContext.packageName) - } -} \ No newline at end of file diff --git a/app/src/debug/res/mipmap-anydpi/ic_launcher.xml b/app/src/debug/res/mipmap-anydpi/ic_launcher.xml deleted file mode 100644 index 097c28fd..00000000 --- a/app/src/debug/res/mipmap-anydpi/ic_launcher.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/debug/res/values/colors.xml b/app/src/debug/res/values/colors.xml deleted file mode 100644 index 6f35cb24..00000000 --- a/app/src/debug/res/values/colors.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #FF0000 - \ No newline at end of file diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml deleted file mode 100644 index a4fba46c..00000000 --- a/app/src/debug/res/values/strings.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - Restoid Debug - \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml deleted file mode 100644 index 02a2168e..00000000 --- a/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png deleted file mode 100644 index f7459609..00000000 Binary files a/app/src/main/ic_launcher-playstore.png and /dev/null differ diff --git a/app/src/main/java/io/github/hddq/restoid/MainActivity.kt b/app/src/main/java/io/github/hddq/restoid/MainActivity.kt deleted file mode 100644 index 95ee4a4d..00000000 --- a/app/src/main/java/io/github/hddq/restoid/MainActivity.kt +++ /dev/null @@ -1,725 +0,0 @@ -package io.github.hddq.restoid - -import android.content.BroadcastReceiver -import android.content.Intent -import android.content.IntentFilter -import android.os.Bundle -import android.os.SystemClock -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.biometric.BiometricManager -import androidx.biometric.BiometricPrompt -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Restore -import androidx.compose.material.icons.filled.Schedule -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationBar -import androidx.compose.material3.NavigationBarItem -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.core.content.ContextCompat -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation.NavDestination.Companion.hierarchy -import androidx.navigation.NavGraph.Companion.findStartDestination -import androidx.navigation.NavType -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.navigation.compose.navigation -import androidx.navigation.compose.rememberNavController -import androidx.navigation.navArgument -import io.github.hddq.restoid.data.NotificationRepository -import io.github.hddq.restoid.ui.home.HomeViewModel -import io.github.hddq.restoid.ui.home.HomeViewModelFactory -import io.github.hddq.restoid.ui.restore.RestoreViewModel -import io.github.hddq.restoid.ui.restore.RestoreViewModelFactory -import io.github.hddq.restoid.ui.runtasks.BackupConfigScreen -import io.github.hddq.restoid.ui.runtasks.CustomDirectoriesConfigScreen -import io.github.hddq.restoid.ui.runtasks.CheckConfigScreen -import io.github.hddq.restoid.ui.runtasks.ForgetConfigScreen -import io.github.hddq.restoid.ui.runtasks.RunTasksRoutes -import io.github.hddq.restoid.ui.runtasks.RunTasksScreen -import io.github.hddq.restoid.ui.runtasks.RunTasksViewModel -import io.github.hddq.restoid.ui.runtasks.RunTasksViewModelFactory -import io.github.hddq.restoid.ui.schedules.AddEditScheduleScreen -import io.github.hddq.restoid.ui.schedules.ScheduleBackupConfigScreen -import io.github.hddq.restoid.ui.schedules.ScheduleCustomDirectoriesConfigScreen -import io.github.hddq.restoid.ui.schedules.ScheduleCheckConfigScreen -import io.github.hddq.restoid.ui.schedules.ScheduleForgetConfigScreen -import io.github.hddq.restoid.ui.schedules.SchedulesRoutes -import io.github.hddq.restoid.ui.schedules.SchedulesScreen -import io.github.hddq.restoid.ui.schedules.SchedulesViewModel -import io.github.hddq.restoid.ui.schedules.SchedulesViewModelFactory -import io.github.hddq.restoid.ui.screens.HomeScreen -import io.github.hddq.restoid.ui.screens.LicensesScreen -import io.github.hddq.restoid.ui.screens.OperationProgressScreen -import io.github.hddq.restoid.ui.screens.RestoreScreen -import io.github.hddq.restoid.ui.screens.SettingsScreen -import io.github.hddq.restoid.ui.screens.SnapshotDetailsScreen -import io.github.hddq.restoid.ui.settings.SettingsViewModel -import io.github.hddq.restoid.ui.settings.SettingsViewModelFactory -import io.github.hddq.restoid.ui.theme.RestoidTheme - -val LocalAppBarActions = compositionLocalOf Unit)?>> { - error("LocalAppBarActions not provided") -} - -class MainActivity : FragmentActivity() { - private companion object { - const val STATE_APP_UNLOCKED = "state_app_unlocked" - const val APP_UNLOCK_BACKGROUND_TIMEOUT_MS = 60_000L - } - - private var isAppUnlocked = false - private var isContentInitialized = false - private var backgroundedAtElapsedRealtimeMs: Long? = null - private var isAuthenticationInProgress = false - private var openOperationProgressOnLaunch by mutableStateOf(false) - - private val screenOffReceiver = object : BroadcastReceiver() { - override fun onReceive(context: android.content.Context?, intent: Intent?) { - if (intent?.action == Intent.ACTION_SCREEN_OFF) { - isAppUnlocked = false - } - } - } - - @OptIn(ExperimentalMaterial3Api::class) - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val app = applicationContext as RestoidApplication - isAppUnlocked = savedInstanceState?.getBoolean(STATE_APP_UNLOCKED) ?: false - openOperationProgressOnLaunch = consumeOpenOperationProgressFlag(intent) - registerReceiver(screenOffReceiver, IntentFilter(Intent.ACTION_SCREEN_OFF)) - enableEdgeToEdge() - - if (app.preferencesRepository.loadRequireAppUnlock() && !isAppUnlocked) { - authenticateAndLaunch(app) - return - } - - launchAppContent(app) - } - - override fun onSaveInstanceState(outState: Bundle) { - outState.putBoolean(STATE_APP_UNLOCKED, isAppUnlocked) - super.onSaveInstanceState(outState) - } - - override fun onStart() { - super.onStart() - - val app = applicationContext as RestoidApplication - if (!app.preferencesRepository.loadRequireAppUnlock()) { - backgroundedAtElapsedRealtimeMs = null - return - } - - if (!isAppUnlocked) { - authenticateAndLaunch(app) - return - } - - val backgroundedAt = backgroundedAtElapsedRealtimeMs - backgroundedAtElapsedRealtimeMs = null - if (backgroundedAt == null) { - return - } - - val timeInBackgroundMs = SystemClock.elapsedRealtime() - backgroundedAt - if (timeInBackgroundMs >= APP_UNLOCK_BACKGROUND_TIMEOUT_MS) { - isAppUnlocked = false - authenticateAndLaunch(app) - } - } - - override fun onDestroy() { - unregisterReceiver(screenOffReceiver) - super.onDestroy() - } - - override fun onStop() { - if (!isChangingConfigurations) { - backgroundedAtElapsedRealtimeMs = SystemClock.elapsedRealtime() - } - super.onStop() - } - - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - if (consumeOpenOperationProgressFlag(intent)) { - openOperationProgressOnLaunch = true - } - } - - private fun consumeOpenOperationProgressFlag(sourceIntent: Intent?): Boolean { - if (sourceIntent == null) return false - val shouldOpen = sourceIntent.getBooleanExtra(NotificationRepository.EXTRA_OPEN_OPERATION_PROGRESS, false) - if (shouldOpen) { - sourceIntent.removeExtra(NotificationRepository.EXTRA_OPEN_OPERATION_PROGRESS) - } - setIntent(sourceIntent) - return shouldOpen - } - - private fun authenticateAndLaunch(app: RestoidApplication) { - if (isAuthenticationInProgress) { - return - } - - val authenticators = BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL - val canAuthenticate = BiometricManager.from(this).canAuthenticate(authenticators) - - if (canAuthenticate != BiometricManager.BIOMETRIC_SUCCESS) { - isAppUnlocked = true - launchAppContent(app) - return - } - - val promptInfo = BiometricPrompt.PromptInfo.Builder() - .setTitle(getString(R.string.app_unlock_prompt_title)) - .setSubtitle(getString(R.string.app_unlock_prompt_subtitle)) - .setAllowedAuthenticators(authenticators) - .build() - - val biometricPrompt = BiometricPrompt( - this, - ContextCompat.getMainExecutor(this), - object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - super.onAuthenticationSucceeded(result) - isAuthenticationInProgress = false - isAppUnlocked = true - launchAppContent(app) - } - - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - super.onAuthenticationError(errorCode, errString) - isAuthenticationInProgress = false - if (!isFinishing && !isDestroyed) { - finish() - } - } - } - ) - - isAuthenticationInProgress = true - biometricPrompt.authenticate(promptInfo) - } - - @OptIn(ExperimentalMaterial3Api::class) - private fun launchAppContent(app: RestoidApplication) { - if (isContentInitialized) { - return - } - isContentInitialized = true - - setContent { - RestoidTheme { - val navController = rememberNavController() - val navBackStackEntry by navController.currentBackStackEntryAsState() - val currentDestination = navBackStackEntry?.destination - val showBottomBar = currentDestination?.route in listOf(Screen.Home.route, SchedulesRoutes.Main, Screen.Settings.route) - val homeViewModel: HomeViewModel = viewModel( - factory = HomeViewModelFactory(app, app.repositoriesRepository, app.resticBinaryManager, app.resticRepository, app.appInfoRepository, app.metadataRepository) - ) - val homeUiState by homeViewModel.uiState.collectAsState() - val appBarActions = remember { mutableStateOf<(@Composable RowScope.() -> Unit)?>(null) } - - if (openOperationProgressOnLaunch) { - androidx.compose.runtime.LaunchedEffect(openOperationProgressOnLaunch) { - navController.navigate(Screen.OperationProgress.route) { - launchSingleTop = true - } - openOperationProgressOnLaunch = false - } - } - - CompositionLocalProvider(LocalAppBarActions provides appBarActions) { - Scaffold( - topBar = { - if (!showBottomBar) { - TopAppBar( - title = { - val titleRes = when { - currentDestination?.route == RunTasksRoutes.Main -> R.string.topbar_tasks - currentDestination?.route == RunTasksRoutes.BackupConfig -> R.string.topbar_backup_config - currentDestination?.route == RunTasksRoutes.CustomDirectoriesConfig -> R.string.title_custom_directories - currentDestination?.route == RunTasksRoutes.ForgetConfig -> R.string.topbar_forget_config - currentDestination?.route == RunTasksRoutes.CheckConfig -> R.string.topbar_check_config - currentDestination?.route == SchedulesRoutes.Main -> R.string.topbar_schedules - currentDestination?.route == SchedulesRoutes.AddEdit -> { - val entry = navBackStackEntry ?: return@TopAppBar - val parentEntry = try { navController.getBackStackEntry(Screen.Schedules.route) } catch (e: Exception) { entry } - val vm: SchedulesViewModel = viewModel(viewModelStoreOwner = parentEntry, factory = SchedulesViewModelFactory(app, app.scheduleRepository, app.repositoriesRepository, app.appInfoRepository)) - val state by vm.addEditState.collectAsState() - if (state.id == null) R.string.topbar_add_schedule else R.string.topbar_edit_schedule - } - currentDestination?.route == SchedulesRoutes.BackupConfig -> R.string.topbar_backup_config - currentDestination?.route == SchedulesRoutes.ForgetConfig -> R.string.topbar_forget_config - currentDestination?.route == SchedulesRoutes.CheckConfig -> R.string.topbar_check_config - currentDestination?.route == Screen.OperationProgress.route -> R.string.topbar_operation_progress - currentDestination?.route == Screen.Licenses.route -> R.string.topbar_open_source_licenses - currentDestination?.route?.startsWith(Screen.SnapshotDetails.route) == true -> R.string.topbar_snapshot_details - currentDestination?.route?.startsWith(Screen.Restore.route) == true -> R.string.topbar_restore_snapshot - else -> null - } - titleRes?.let { Text(stringResource(it)) } - }, - navigationIcon = { - IconButton(onClick = { - this@MainActivity.onBackPressedDispatcher.onBackPressed() - }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.cd_back)) - } - }, - actions = { - appBarActions.value?.invoke(this) - } - ) - } - }, - bottomBar = { - if (showBottomBar) { - val items = listOf( - Screen.Home to Icons.Default.Home, - Screen.Schedules to Icons.Default.Schedule, - Screen.Settings to Icons.Default.Settings - ) - NavigationBar { - items.forEach { (screen, icon) -> - NavigationBarItem( - icon = { Icon(icon, contentDescription = null) }, - label = { Text(stringResource(screen.titleRes)) }, - selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true, - onClick = { - navController.navigate(screen.route) { - popUpTo(navController.graph.findStartDestination().id) { saveState = true } - launchSingleTop = true - restoreState = true - } - } - ) - } - } - } - }, - floatingActionButton = { - when (currentDestination?.route) { - Screen.Home.route -> { - val isRepoReady = homeUiState.isRepoReady - ExtendedFloatingActionButton( - text = { Text(stringResource(R.string.screen_tasks)) }, - icon = { Icon(Icons.Default.PlayArrow, contentDescription = stringResource(R.string.screen_tasks)) }, - onClick = { if (isRepoReady) { navController.navigate(Screen.RunTasks.route) } }, - containerColor = if (isRepoReady) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), - contentColor = if (isRepoReady) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - } - RunTasksRoutes.Main -> { - val currentEntry = navBackStackEntry ?: return@Scaffold - val parentEntry = remember(currentEntry) { - navController.getBackStackEntry(Screen.RunTasks.route) - } - val viewModel: RunTasksViewModel = viewModel( - viewModelStoreOwner = parentEntry, - factory = RunTasksViewModelFactory( - app, - app.repositoriesRepository, - app.resticBinaryManager, - app.appInfoRepository, - app.preferencesRepository, - app.operationWorkRepository - ) - ) - val uiState by viewModel.uiState.collectAsState() - if (!uiState.isRunning) { - ExtendedFloatingActionButton( - onClick = { viewModel.run() }, - icon = { Icon(Icons.Default.PlayArrow, contentDescription = stringResource(R.string.fab_run_tasks)) }, - text = { Text(stringResource(R.string.fab_run_tasks)) } - ) - } - } - Screen.SnapshotDetails.route + "/{snapshotId}" -> { - ExtendedFloatingActionButton( - text = { Text(stringResource(R.string.fab_restore)) }, - icon = { Icon(Icons.Default.Restore, contentDescription = stringResource(R.string.topbar_restore_snapshot)) }, - onClick = { navController.navigate("${Screen.Restore.route}/${navBackStackEntry?.arguments?.getString("snapshotId")}") } - ) - } - Screen.Restore.route + "/{snapshotId}" -> { - val parentEntry = remember(navBackStackEntry) { - navController.getBackStackEntry("restore_graph/{snapshotId}") - } - val viewModel: RestoreViewModel = viewModel( - viewModelStoreOwner = parentEntry, - factory = RestoreViewModelFactory( - app, - app.repositoriesRepository, - app.resticBinaryManager, - app.resticRepository, - app.appInfoRepository, - app.metadataRepository, - app.preferencesRepository, - app.operationWorkRepository, - navBackStackEntry?.arguments?.getString("snapshotId") ?: "" - ) - ) - val isRestoring by viewModel.isRestoring.collectAsState() - if (!isRestoring) { - ExtendedFloatingActionButton( - onClick = { viewModel.startRestore() }, - icon = { Icon(Icons.Default.Restore, contentDescription = stringResource(R.string.fab_start_restore)) }, - text = { Text(stringResource(R.string.fab_start_restore)) } - ) - } - } - SchedulesRoutes.AddEdit -> { - val currentEntry = navBackStackEntry ?: return@Scaffold - val parentEntry = remember(currentEntry) { - navController.getBackStackEntry(Screen.Schedules.route) - } - val viewModel: SchedulesViewModel = viewModel( - viewModelStoreOwner = parentEntry, - factory = SchedulesViewModelFactory(app, app.scheduleRepository, app.repositoriesRepository, app.appInfoRepository) - ) - val state by viewModel.addEditState.collectAsState() - if (!state.isSaving) { - ExtendedFloatingActionButton( - onClick = { viewModel.saveSchedule() }, - icon = { Icon(Icons.Default.Check, contentDescription = stringResource(R.string.action_save)) }, - text = { Text(stringResource(R.string.action_save)) } - ) - } - } - } - } - ) { innerPadding -> - NavHost( - navController = navController, - startDestination = Screen.Home.route, - modifier = Modifier.padding(innerPadding) - ) { - composable(Screen.Home.route) { - HomeScreen( - onSnapshotClick = { snapshotId -> navController.navigate("${Screen.SnapshotDetails.route}/$snapshotId") }, - uiState = homeUiState, - onRefresh = { homeViewModel.refreshSnapshots() }, - onPasswordEntered = { p, s -> homeViewModel.onPasswordEntered(p, s) }, - onSftpPasswordEntered = { keyOrPassword, passphrase, save -> - homeViewModel.onSftpPasswordEntered(keyOrPassword, passphrase, save) - }, - onRestCredentialsEntered = { u, p, s -> homeViewModel.onRestCredentialsEntered(u, p, s) }, - onS3CredentialsEntered = { u, p, s -> homeViewModel.onS3CredentialsEntered(u, p, s) }, - onRetryRepositoryPasswordEntry = { homeViewModel.onRetryRepositoryPasswordEntry() }, - onRetrySftpPasswordEntry = { homeViewModel.onRetrySftpPasswordEntry() }, - onRetryRestCredentialsEntry = { homeViewModel.onRetryRestCredentialsEntry() }, - onRetryS3CredentialsEntry = { homeViewModel.onRetryS3CredentialsEntry() }, - onDismissPasswordDialog = { homeViewModel.onDismissPasswordDialog() }, - onDismissSftpPasswordDialog = { homeViewModel.onDismissSftpPasswordDialog() }, - onDismissRestCredentialsDialog = { homeViewModel.onDismissRestCredentialsDialog() }, - onDismissS3CredentialsDialog = { homeViewModel.onDismissS3CredentialsDialog() } - ) - } - composable(Screen.Settings.route) { - val vm: SettingsViewModel = viewModel( - factory = SettingsViewModelFactory(app, app.rootRepository, app.resticBinaryManager, app.resticRepository, app.repositoriesRepository, app.notificationRepository, app.preferencesRepository) - ) - SettingsScreen(viewModel = vm, onNavigateToLicenses = { navController.navigate(Screen.Licenses.route) }) - } - navigation( - startDestination = SchedulesRoutes.Main, - route = Screen.Schedules.route - ) { - composable(SchedulesRoutes.Main) { backStackEntry -> - val parentEntry = remember(backStackEntry) { - navController.getBackStackEntry(Screen.Schedules.route) - } - val vm: SchedulesViewModel = viewModel( - viewModelStoreOwner = parentEntry, - factory = SchedulesViewModelFactory(app, app.scheduleRepository, app.repositoriesRepository, app.appInfoRepository) - ) - SchedulesScreen( - viewModel = vm, - onNavigateToAddEditSchedule = { navController.navigate(SchedulesRoutes.AddEdit) } - ) - } - composable(SchedulesRoutes.AddEdit) { backStackEntry -> - val parentEntry = remember(backStackEntry) { - navController.getBackStackEntry(Screen.Schedules.route) - } - val vm: SchedulesViewModel = viewModel( - viewModelStoreOwner = parentEntry, - factory = SchedulesViewModelFactory(app, app.scheduleRepository, app.repositoriesRepository, app.appInfoRepository) - ) - AddEditScheduleScreen( - viewModel = vm, - onNavigateBack = { navController.navigateUp() }, - onNavigateToBackupConfig = { navController.navigate(SchedulesRoutes.BackupConfig) }, - onNavigateToCustomDirectoriesConfig = { navController.navigate(SchedulesRoutes.CustomDirectoriesConfig) }, - onNavigateToForgetConfig = { navController.navigate(SchedulesRoutes.ForgetConfig) }, - onNavigateToCheckConfig = { navController.navigate(SchedulesRoutes.CheckConfig) } - ) - } - composable(SchedulesRoutes.BackupConfig) { backStackEntry -> - val parentEntry = remember(backStackEntry) { - navController.getBackStackEntry(Screen.Schedules.route) - } - val vm: SchedulesViewModel = viewModel( - viewModelStoreOwner = parentEntry, - factory = SchedulesViewModelFactory(app, app.scheduleRepository, app.repositoriesRepository, app.appInfoRepository) - ) - ScheduleBackupConfigScreen(viewModel = vm) - } - composable(SchedulesRoutes.ForgetConfig) { backStackEntry -> - val parentEntry = remember(backStackEntry) { - navController.getBackStackEntry(Screen.Schedules.route) - } - val vm: SchedulesViewModel = viewModel( - viewModelStoreOwner = parentEntry, - factory = SchedulesViewModelFactory(app, app.scheduleRepository, app.repositoriesRepository, app.appInfoRepository) - ) - ScheduleForgetConfigScreen(viewModel = vm) - } - composable(SchedulesRoutes.CheckConfig) { backStackEntry -> - val parentEntry = remember(backStackEntry) { - navController.getBackStackEntry(Screen.Schedules.route) - } - val vm: SchedulesViewModel = viewModel( - viewModelStoreOwner = parentEntry, - factory = SchedulesViewModelFactory(app, app.scheduleRepository, app.repositoriesRepository, app.appInfoRepository) - ) - ScheduleCheckConfigScreen(viewModel = vm) - } - composable(SchedulesRoutes.CustomDirectoriesConfig) { backStackEntry -> - val parentEntry = remember(backStackEntry) { - navController.getBackStackEntry(Screen.Schedules.route) - } - val vm: SchedulesViewModel = viewModel( - viewModelStoreOwner = parentEntry, - factory = SchedulesViewModelFactory(app, app.scheduleRepository, app.repositoriesRepository, app.appInfoRepository) - ) - ScheduleCustomDirectoriesConfigScreen(viewModel = vm) - } - } - navigation( - startDestination = RunTasksRoutes.Main, - route = Screen.RunTasks.route - ) { - composable(RunTasksRoutes.Main) { backStackEntry -> - val parentEntry = remember(backStackEntry) { - navController.getBackStackEntry(Screen.RunTasks.route) - } - val vm: RunTasksViewModel = viewModel( - viewModelStoreOwner = parentEntry, - factory = RunTasksViewModelFactory( - app, - app.repositoriesRepository, - app.resticBinaryManager, - app.appInfoRepository, - app.preferencesRepository, - app.operationWorkRepository - ) - ) - RunTasksScreen( - viewModel = vm, - onNavigateToOperationProgress = { - navController.navigate(Screen.OperationProgress.route) { launchSingleTop = true } - }, - onNavigateToBackupConfig = { navController.navigate(RunTasksRoutes.BackupConfig) }, - onNavigateToCustomDirectoriesConfig = { navController.navigate(RunTasksRoutes.CustomDirectoriesConfig) }, - onNavigateToForgetConfig = { navController.navigate(RunTasksRoutes.ForgetConfig) }, - onNavigateToCheckConfig = { navController.navigate(RunTasksRoutes.CheckConfig) } - ) - } - composable(RunTasksRoutes.BackupConfig) { backStackEntry -> - val parentEntry = remember(backStackEntry) { - navController.getBackStackEntry(Screen.RunTasks.route) - } - val vm: RunTasksViewModel = viewModel( - viewModelStoreOwner = parentEntry, - factory = RunTasksViewModelFactory( - app, - app.repositoriesRepository, - app.resticBinaryManager, - app.appInfoRepository, - app.preferencesRepository, - app.operationWorkRepository - ) - ) - BackupConfigScreen(viewModel = vm) - } - composable(RunTasksRoutes.CustomDirectoriesConfig) { backStackEntry -> - val parentEntry = remember(backStackEntry) { - navController.getBackStackEntry(Screen.RunTasks.route) - } - val vm: RunTasksViewModel = viewModel( - viewModelStoreOwner = parentEntry, - factory = RunTasksViewModelFactory( - app, - app.repositoriesRepository, - app.resticBinaryManager, - app.appInfoRepository, - app.preferencesRepository, - app.operationWorkRepository - ) - ) - CustomDirectoriesConfigScreen(viewModel = vm) - } - composable(RunTasksRoutes.ForgetConfig) { backStackEntry -> - val parentEntry = remember(backStackEntry) { - navController.getBackStackEntry(Screen.RunTasks.route) - } - val vm: RunTasksViewModel = viewModel( - viewModelStoreOwner = parentEntry, - factory = RunTasksViewModelFactory( - app, - app.repositoriesRepository, - app.resticBinaryManager, - app.appInfoRepository, - app.preferencesRepository, - app.operationWorkRepository - ) - ) - ForgetConfigScreen(viewModel = vm) - } - composable(RunTasksRoutes.CheckConfig) { backStackEntry -> - val parentEntry = remember(backStackEntry) { - navController.getBackStackEntry(Screen.RunTasks.route) - } - val vm: RunTasksViewModel = viewModel( - viewModelStoreOwner = parentEntry, - factory = RunTasksViewModelFactory( - app, - app.repositoriesRepository, - app.resticBinaryManager, - app.appInfoRepository, - app.preferencesRepository, - app.operationWorkRepository - ) - ) - CheckConfigScreen(viewModel = vm) - } - } - composable( - route = "${Screen.SnapshotDetails.route}/{snapshotId}", - arguments = listOf(navArgument("snapshotId") { type = NavType.StringType }) - ) { backStackEntry -> - SnapshotDetailsScreen( - navController = navController, - snapshotId = backStackEntry.arguments?.getString("snapshotId") - ) - } - navigation( - startDestination = "${Screen.Restore.route}/{snapshotId}", - route = "restore_graph/{snapshotId}", - arguments = listOf(navArgument("snapshotId") { type = NavType.StringType }) - ) { - composable( - route = "${Screen.Restore.route}/{snapshotId}", - arguments = listOf(navArgument("snapshotId") { type = NavType.StringType }) - ) { backStackEntry -> - val parentEntry = remember(backStackEntry) { - navController.getBackStackEntry("restore_graph/{snapshotId}") - } - val snapshotId = parentEntry.arguments?.getString("snapshotId") ?: "" - val viewModel: RestoreViewModel = viewModel( - viewModelStoreOwner = parentEntry, - factory = RestoreViewModelFactory( - app, - app.repositoriesRepository, - app.resticBinaryManager, - app.resticRepository, - app.appInfoRepository, - app.metadataRepository, - app.preferencesRepository, - app.operationWorkRepository, - snapshotId - ) - ) - RestoreScreen( - viewModel = viewModel, - onNavigateToOperationProgress = { navController.navigate(Screen.OperationProgress.route) { launchSingleTop = true } }, - onNavigateToAppsConfig = { navController.navigate(io.github.hddq.restoid.ui.restore.RestoreRoutes.AppsConfig) }, - onNavigateToCustomDirectoriesConfig = { navController.navigate(io.github.hddq.restoid.ui.restore.RestoreRoutes.CustomDirectoriesConfig) } - ) - } - composable(io.github.hddq.restoid.ui.restore.RestoreRoutes.AppsConfig) { backStackEntry -> - val parentEntry = remember(backStackEntry) { - navController.getBackStackEntry("restore_graph/{snapshotId}") - } - val snapshotId = parentEntry.arguments?.getString("snapshotId") ?: "" - val vm: RestoreViewModel = viewModel( - viewModelStoreOwner = parentEntry, - factory = RestoreViewModelFactory( - app, - app.repositoriesRepository, - app.resticBinaryManager, - app.resticRepository, - app.appInfoRepository, - app.metadataRepository, - app.preferencesRepository, - app.operationWorkRepository, - snapshotId - ) - ) - io.github.hddq.restoid.ui.screens.RestoreAppsConfigScreen(viewModel = vm) - } - composable(io.github.hddq.restoid.ui.restore.RestoreRoutes.CustomDirectoriesConfig) { backStackEntry -> - val parentEntry = remember(backStackEntry) { - navController.getBackStackEntry("restore_graph/{snapshotId}") - } - val snapshotId = parentEntry.arguments?.getString("snapshotId") ?: "" - val vm: RestoreViewModel = viewModel( - viewModelStoreOwner = parentEntry, - factory = RestoreViewModelFactory( - app, - app.repositoriesRepository, - app.resticBinaryManager, - app.resticRepository, - app.appInfoRepository, - app.metadataRepository, - app.preferencesRepository, - app.operationWorkRepository, - snapshotId - ) - ) - io.github.hddq.restoid.ui.screens.RestoreCustomDirectoriesConfigScreen(viewModel = vm) - } - } - composable(Screen.Licenses.route) { LicensesScreen(onNavigateUp = { navController.navigateUp() }) } - composable(Screen.OperationProgress.route) { - OperationProgressScreen(onNavigateUp = { navController.navigateUp() }) - } - } - } - } - } - } - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/RestoidApplication.kt b/app/src/main/java/io/github/hddq/restoid/RestoidApplication.kt deleted file mode 100644 index 5180f1d3..00000000 --- a/app/src/main/java/io/github/hddq/restoid/RestoidApplication.kt +++ /dev/null @@ -1,99 +0,0 @@ -package io.github.hddq.restoid - -import android.app.Application -import io.github.hddq.restoid.data.* -import io.github.hddq.restoid.work.OperationRequestStore -import io.github.hddq.restoid.work.OperationWorkRepository -import com.topjohnwu.superuser.Shell -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch - -class RestoidApplication : Application() { - - private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - - lateinit var rootRepository: RootRepository - private set - lateinit var resticBinaryManager: ResticBinaryManager - private set - lateinit var resticExecutor: ResticExecutor - private set - lateinit var resticRepository: ResticRepository - private set - lateinit var repositoriesRepository: RepositoriesRepository - private set - lateinit var notificationRepository: NotificationRepository - private set - lateinit var appInfoRepository: AppInfoRepository - private set - lateinit var passwordManager: PasswordManager - private set - lateinit var metadataRepository: MetadataRepository - private set - lateinit var scheduleRepository: ScheduleRepository - private set - lateinit var preferencesRepository: PreferencesRepository - private set - lateinit var operationCoordinator: OperationCoordinator - private set - lateinit var operationLockManager: OperationLockManager - private set - lateinit var operationRequestStore: OperationRequestStore - private set - lateinit var operationRuntimeRepository: OperationRuntimeRepository - private set - lateinit var operationWorkRepository: OperationWorkRepository - private set - - override fun onCreate() { - super.onCreate() - - Shell.enableVerboseLogging = BuildConfig.DEBUG - Shell.setDefaultBuilder( - Shell.Builder.create() - .setFlags(Shell.FLAG_MOUNT_MASTER) - ) - - // Initialize specialized components - rootRepository = RootRepository() - resticBinaryManager = ResticBinaryManager(applicationContext) - resticExecutor = ResticExecutor(applicationContext, resticBinaryManager) - resticRepository = ResticRepository(applicationContext, resticExecutor) - - passwordManager = PasswordManager(applicationContext) - // RepositoriesRepository now needs BinaryManager to check installation status - repositoriesRepository = RepositoriesRepository(applicationContext, passwordManager, resticBinaryManager) - - notificationRepository = NotificationRepository(applicationContext) - appInfoRepository = AppInfoRepository(applicationContext) - metadataRepository = MetadataRepository(applicationContext) - scheduleRepository = ScheduleRepository(applicationContext, metadataRepository, repositoriesRepository) - preferencesRepository = PreferencesRepository(applicationContext) - operationCoordinator = OperationCoordinator() - operationLockManager = OperationLockManager(applicationContext) - operationRequestStore = OperationRequestStore(applicationContext) - operationRuntimeRepository = OperationRuntimeRepository(applicationContext) - operationWorkRepository = OperationWorkRepository( - applicationContext, - operationRequestStore, - operationRuntimeRepository - ) - - passwordManager.clearTemporaryPasswords() - notificationRepository.createNotificationChannels() - - applicationScope.launch { - rootRepository.checkRootAccess() - resticBinaryManager.checkResticStatus() // New manager handles checks - repositoriesRepository.loadRepositories() - scheduleRepository.reconcileAllSchedules() - notificationRepository.checkPermissionStatus() - } - - applicationScope.launch(Dispatchers.IO) { - operationWorkRepository.reconcileStateWithWorkManager() - } - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/Screen.kt b/app/src/main/java/io/github/hddq/restoid/Screen.kt deleted file mode 100644 index 7d515973..00000000 --- a/app/src/main/java/io/github/hddq/restoid/Screen.kt +++ /dev/null @@ -1,12 +0,0 @@ -package io.github.hddq.restoid - -sealed class Screen(val route: String, val titleRes: Int) { - object Home : Screen("home", R.string.screen_home) - object Schedules : Screen("schedules", R.string.screen_schedules) - object Settings : Screen("settings", R.string.screen_settings) - object RunTasks : Screen("run_tasks", R.string.screen_tasks) - object SnapshotDetails : Screen("snapshot_details", R.string.screen_snapshot_details) - object Restore : Screen("restore", R.string.screen_restore) - object Licenses : Screen("licenses", R.string.screen_licenses) - object OperationProgress : Screen("operation_progress", R.string.screen_operation_progress) -} diff --git a/app/src/main/java/io/github/hddq/restoid/data/AppInfoCachingLogic.kt b/app/src/main/java/io/github/hddq/restoid/data/AppInfoCachingLogic.kt deleted file mode 100644 index 567bef26..00000000 --- a/app/src/main/java/io/github/hddq/restoid/data/AppInfoCachingLogic.kt +++ /dev/null @@ -1,58 +0,0 @@ -package io.github.hddq.restoid.data - -import android.content.Context -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.drawable.BitmapDrawable -import android.graphics.drawable.Drawable -import android.util.Base64 -import androidx.core.graphics.drawable.toBitmap -import io.github.hddq.restoid.model.AppInfo -import kotlinx.serialization.Serializable -import java.io.ByteArrayOutputStream - -@Serializable -data class CachedAppInfo( - val name: String, - val packageName: String, - val versionName: String, - val versionCode: Long, - val apkPaths: List, - val iconBase64: String -) - -fun CachedAppInfo.toAppInfo(context: Context): AppInfo { - return AppInfo( - name = this.name, - packageName = this.packageName, - versionName = this.versionName, - versionCode = this.versionCode, - apkPaths = this.apkPaths, - icon = this.iconBase64.base64ToDrawable(context) - ) -} - -fun AppInfo.toCachedAppInfo(): CachedAppInfo { - return CachedAppInfo( - name = this.name, - packageName = this.packageName, - versionName = this.versionName, - versionCode = this.versionCode, - apkPaths = this.apkPaths, - iconBase64 = this.icon.toBase64() - ) -} - -private fun Drawable.toBase64(): String { - val bitmap = this.toBitmap(config = Bitmap.Config.ARGB_8888) - val outputStream = ByteArrayOutputStream() - bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream) - val byteArray = outputStream.toByteArray() - return Base64.encodeToString(byteArray, Base64.DEFAULT) -} - -private fun String.base64ToDrawable(context: Context): Drawable { - val decodedString = Base64.decode(this, Base64.DEFAULT) - val bitmap = BitmapFactory.decodeByteArray(decodedString, 0, decodedString.size) - return BitmapDrawable(context.resources, bitmap) -} diff --git a/app/src/main/java/io/github/hddq/restoid/data/AppInfoRepository.kt b/app/src/main/java/io/github/hddq/restoid/data/AppInfoRepository.kt deleted file mode 100644 index 4eecbc89..00000000 --- a/app/src/main/java/io/github/hddq/restoid/data/AppInfoRepository.kt +++ /dev/null @@ -1,207 +0,0 @@ -package io.github.hddq.restoid.data - -import android.content.Context -import android.content.pm.PackageInfo -import android.content.pm.PackageManager -import android.content.pm.PermissionInfo -import android.os.Build -import io.github.hddq.restoid.model.AppInfo -import com.topjohnwu.superuser.Shell -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json -import java.io.File - -/** - * A repository for fetching information about installed applications. - * It uses a two-layer cache: an in-memory cache for speed and a persistent - * JSON file cache on disk to survive app restarts. - */ -class AppInfoRepository(private val context: Context) { - - // Hot cache: In-memory for fully inflated AppInfo objects with Drawables. - private val appInfoCache = mutableMapOf() - // Warm cache: In-memory representation of the JSON on disk. - private val diskCache = mutableMapOf() - - private val cacheFile = File(context.filesDir, "app_info_cache.json") - private val json = Json { - ignoreUnknownKeys = true - prettyPrint = false // Use false for smaller file size - } - private val cacheMutex = Mutex() - - init { - // Load the persistent cache from disk on initialization. - loadCacheFromDisk() - } - - private fun loadCacheFromDisk() { - if (!cacheFile.exists()) return - try { - val content = cacheFile.readText() - if (content.isNotBlank()) { - val cachedItems = json.decodeFromString>(content) - diskCache.putAll(cachedItems) - } - } catch (e: Exception) { - // Error reading or parsing cache, it might be corrupted or empty. - cacheFile.delete() - } - } - - private suspend fun saveCacheToDisk() = cacheMutex.withLock { - withContext(Dispatchers.IO) { - try { - val content = json.encodeToString(diskCache) - cacheFile.writeText(content) - } catch (e: Exception) { - // Handle serialization/write error, e.g., log it. - } - } - } - - /** - * Fetches AppInfo for a list of package names, utilizing caching. - */ - suspend fun getAppInfoForPackages(packageNames: List): List { - return withContext(Dispatchers.IO) { - packageNames.mapNotNull { getAppInfo(it) } - } - } - - /** - * Gets all user-installed applications, leveraging the cache. - * It refreshes the list from the package manager and fetches info for any new apps. - */ - suspend fun getInstalledUserApps(): List = withContext(Dispatchers.IO) { - val packageNamesResult = Shell.cmd("pm list packages -3").exec() - if (packageNamesResult.isSuccess) { - val packageNames = packageNamesResult.out - .map { it.removePrefix("package:").trim() } - .filter { it != context.packageName } // Exclude self - // This will fetch from cache if available, or from system for new apps. - val appInfos = getAppInfoForPackages(packageNames) - return@withContext appInfos.sortedBy { it.name.lowercase() } - } - return@withContext emptyList() - } - - - /** - * Fetches information for a single application. - * 1. Checks hot (in-memory) cache. - * 2. Checks warm (disk-backed) cache. - * 3. Fetches from the system if not found in any cache. - * It now includes a version check to invalidate stale cache entries. - */ - private suspend fun getAppInfo(packageName: String): AppInfo? { - val pm = context.packageManager - val currentPackageInfo: PackageInfo = try { - pm.getPackageInfo(packageName, PackageManager.GET_META_DATA) - } catch (e: PackageManager.NameNotFoundException) { - // App isn't installed. Clean up cache if it exists. - var wasCached = false - cacheMutex.withLock { - if (appInfoCache.remove(packageName) != null) wasCached = true - if (diskCache.remove(packageName) != null) wasCached = true - } - if (wasCached) saveCacheToDisk() - return null - } - - val currentVersionCode = - currentPackageInfo.longVersionCode - - val currentSourceDir = currentPackageInfo.applicationInfo?.sourceDir - - // 1. Check hot in-memory cache. - if (currentSourceDir != null) { - appInfoCache[packageName]?.let { - if (it.versionCode == currentVersionCode && it.apkPaths.contains(currentSourceDir)) return it - } - } - - // 2. Check warm disk-backed cache. - if (currentSourceDir != null) { - diskCache[packageName]?.let { cachedInfo -> - if (cachedInfo.versionCode == currentVersionCode && cachedInfo.apkPaths.contains(currentSourceDir)) { - val appInfo = cachedInfo.toAppInfo(context) - // Preserve selection state if available in hot cache - val finalInfo = appInfoCache[packageName]?.isSelected?.let { isSelected -> - appInfo.copy(isSelected = isSelected) - } ?: appInfo - appInfoCache[packageName] = finalInfo // Promote to hot cache. - return finalInfo - } - } - } - - // 3. Not in any cache or cache is stale, so fetch fresh info. - // We can use the PackageInfo we already retrieved. This is faster and doesn't need root. - return try { - currentPackageInfo.applicationInfo?.let { app -> - val apkPaths = mutableListOf() - apkPaths.add(app.sourceDir) - app.splitSourceDirs?.let { apkPaths.addAll(it) } - - val info = AppInfo( - name = app.loadLabel(pm).toString(), - packageName = app.packageName, - versionName = currentPackageInfo.versionName ?: "N/A", - versionCode = currentVersionCode, - icon = app.loadIcon(pm), - apkPaths = apkPaths.distinct(), - // Preserve selection state if it was in the hot cache before being found stale - isSelected = appInfoCache[packageName]?.isSelected ?: true - ) - - // Update caches and save to disk. - cacheMutex.withLock { - appInfoCache[packageName] = info - diskCache[packageName] = info.toCachedAppInfo() - } - saveCacheToDisk() - info - } - } catch (e: Exception) { - // Something went wrong while building AppInfo. - null - } - } - - /** - * Returns currently granted dangerous/runtime permissions for a package. - * These permissions can be restored later using `pm grant`. - */ - suspend fun getGrantedRuntimePermissions(packageName: String): List = withContext(Dispatchers.IO) { - val pm = context.packageManager - val packageInfo = try { - pm.getPackageInfo( - packageName, - PackageManager.PackageInfoFlags.of(PackageManager.GET_PERMISSIONS.toLong()) - ) - } catch (_: Exception) { - return@withContext emptyList() - } - - val requestedPermissions = packageInfo.requestedPermissions ?: return@withContext emptyList() - val requestedPermissionFlags = packageInfo.requestedPermissionsFlags ?: IntArray(0) - - requestedPermissions.mapIndexedNotNull { index, permission -> - val flags = requestedPermissionFlags.getOrElse(index) { 0 } - val isGranted = (flags and PackageInfo.REQUESTED_PERMISSION_GRANTED) != 0 - if (!isGranted) return@mapIndexedNotNull null - - val permissionInfo = try { - pm.getPermissionInfo(permission, 0) - } catch (_: Exception) { - return@mapIndexedNotNull null - } - - if (permissionInfo.protection == PermissionInfo.PROTECTION_DANGEROUS) permission else null - }.distinct().sorted() - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/data/MetadataRepository.kt b/app/src/main/java/io/github/hddq/restoid/data/MetadataRepository.kt deleted file mode 100644 index 39b92889..00000000 --- a/app/src/main/java/io/github/hddq/restoid/data/MetadataRepository.kt +++ /dev/null @@ -1,125 +0,0 @@ -package io.github.hddq.restoid.data - -import android.content.Context -import io.github.hddq.restoid.model.RestoidMetadata -import io.github.hddq.restoid.model.Schedule -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import java.io.File - -class MetadataRepository(private val context: Context) { - - private val metadataRoot = File(context.filesDir, "metadata") - private val json = Json { ignoreUnknownKeys = true } - - suspend fun getSchedules(repoId: String): List { - return withContext(Dispatchers.IO) { - try { - val repoDir = File(metadataRoot, repoId) - val schedulesFile = File(repoDir, "schedules.json") - if (schedulesFile.exists()) { - json.decodeFromString>(schedulesFile.readText()) - } else { - emptyList() - } - } catch (e: Exception) { - e.printStackTrace() - emptyList() - } - } - } - - suspend fun saveSchedules(repoId: String, schedules: List): Boolean { - return withContext(Dispatchers.IO) { - try { - val repoDir = File(metadataRoot, repoId) - if (!repoDir.exists()) repoDir.mkdirs() - val schedulesFile = File(repoDir, "schedules.json") - schedulesFile.writeText(json.encodeToString(schedules)) - true - } catch (e: Exception) { - e.printStackTrace() - false - } - } - } - - suspend fun getMetadataForSnapshot(repoId: String, snapshotId: String): RestoidMetadata? { - return withContext(Dispatchers.IO) { - try { - val repoDir = File(metadataRoot, repoId) - if (!repoDir.exists() || !repoDir.isDirectory) return@withContext null - - // Look for an exact match using the full snapshot ID. - val metadataFile = File(repoDir, "$snapshotId.json") - - if (metadataFile.exists()) { - val content = metadataFile.readText() - json.decodeFromString(content) - } else { - null - } - } catch (e: Exception) { - e.printStackTrace() - null - } - } - } - - suspend fun saveMetadataForSnapshot(repoId: String, snapshotId: String, metadata: RestoidMetadata): Boolean { - return withContext(Dispatchers.IO) { - try { - val repoDir = File(metadataRoot, repoId) - if (!repoDir.exists()) repoDir.mkdirs() - val metadataFile = File(repoDir, "$snapshotId.json") - metadataFile.writeText(json.encodeToString(metadata)) - true - } catch (e: Exception) { - e.printStackTrace() - false - } - } - } - - suspend fun getAllMetadata(repoId: String): Map { - return withContext(Dispatchers.IO) { - val repoDir = File(metadataRoot, repoId) - if (!repoDir.exists() || !repoDir.isDirectory) return@withContext emptyMap() - - repoDir.listFiles { _, name -> name.endsWith(".json") } - ?.associate { file -> - val snapshotId = file.nameWithoutExtension - val metadata = try { - json.decodeFromString(file.readText()) - } catch (e: Exception) { - null - } - snapshotId to metadata - } - ?.filterValues { it != null } - ?.mapValues { it.value!! } ?: emptyMap() - } - } - - suspend fun deleteMetadataForSnapshot(repoId: String, snapshotId: String): Boolean { - return withContext(Dispatchers.IO) { - try { - val repoDir = File(metadataRoot, repoId) - if (!repoDir.exists()) return@withContext true // Nothing to delete - - val metadataFile = File(repoDir, "$snapshotId.json") - if (metadataFile.exists()) { - metadataFile.delete() - } else { - true // File doesn't exist, so it's already "deleted" - } - } catch (e: Exception) { - e.printStackTrace() - false - } - } - } -} - diff --git a/app/src/main/java/io/github/hddq/restoid/data/NotificationRepository.kt b/app/src/main/java/io/github/hddq/restoid/data/NotificationRepository.kt deleted file mode 100644 index b1bd3be7..00000000 --- a/app/src/main/java/io/github/hddq/restoid/data/NotificationRepository.kt +++ /dev/null @@ -1,202 +0,0 @@ -package io.github.hddq.restoid.data - -import android.Manifest -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.text.format.Formatter -import androidx.core.app.ActivityCompat -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import androidx.core.content.ContextCompat -import io.github.hddq.restoid.MainActivity -import io.github.hddq.restoid.R -import io.github.hddq.restoid.ui.shared.OperationProgress -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow - -sealed class NotificationPermissionState { - object Granted : NotificationPermissionState() - object Denied : NotificationPermissionState() - object NotRequested : NotificationPermissionState() -} - -class NotificationRepository(private val context: Context) { - - private val _permissionState = MutableStateFlow(NotificationPermissionState.NotRequested) - val permissionState = _permissionState.asStateFlow() - - companion object { - const val EXTRA_OPEN_OPERATION_PROGRESS = "extra_open_operation_progress" - const val PROGRESS_CHANNEL_ID = "progress_channel" - const val FINISHED_CHANNEL_ID = "finished_channel" - const val PROGRESS_NOTIFICATION_ID = 1 - const val FINISHED_NOTIFICATION_ID = 2 - } - - fun createNotificationChannels() { - val notificationManager: NotificationManager = - context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - // Low importance channel for silent progress updates - val progressChannel = NotificationChannel( - PROGRESS_CHANNEL_ID, - context.getString(R.string.channel_ongoing_operations), - NotificationManager.IMPORTANCE_LOW - ).apply { - description = context.getString(R.string.channel_ongoing_description) - } - notificationManager.createNotificationChannel(progressChannel) - - // High importance channel for finished notifications to make them pop up - val finishedChannel = NotificationChannel( - FINISHED_CHANNEL_ID, - context.getString(R.string.channel_completed_operations), - NotificationManager.IMPORTANCE_HIGH - ).apply { - description = context.getString(R.string.channel_completed_description) - } - notificationManager.createNotificationChannel(finishedChannel) - } - - private fun createPendingIntent(): PendingIntent { - val intent = Intent(context, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP - putExtra(EXTRA_OPEN_OPERATION_PROGRESS, true) - } - return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) - } - - fun buildOperationProgressNotification(operationName: String, progress: OperationProgress): Notification { - val overallPercent = (progress.overallPercentage.coerceIn(0f, 1f) * 100).toInt() - val stagePercent = (progress.stagePercentage.coerceIn(0f, 1f) * 100).toInt() - - val processedSize = Formatter.formatFileSize(context, progress.bytesProcessed) - val totalSize = Formatter.formatFileSize(context, progress.totalBytes) - val filesText = context.resources.getQuantityString( - R.plurals.notification_files_progress, - progress.totalFiles, - progress.filesProcessed, - progress.totalFiles - ) - - val contentText = if (progress.totalFiles > 0) { - "$filesText | $processedSize / $totalSize" - } else { - context.getString(R.string.notification_scanning_files) - } - - val title = progress.stageTitle.let { "$it ($stagePercent%)" } - - val match = Regex("^\\[(\\d+)/(\\d+)\\]").find(progress.stageTitle) - val currentStage = match?.groupValues?.get(1)?.toIntOrNull() ?: 1 - val totalStages = match?.groupValues?.get(2)?.toIntOrNull() ?: 1 - - val progressStyle = NotificationCompat.ProgressStyle() - if (totalStages > 1) { - val segments = mutableListOf() - val points = mutableListOf() - val weight = 100 / totalStages - for (i in 0 until totalStages) { - val segmentColor = if (i < currentStage - 1) android.graphics.Color.DKGRAY - else if (i == currentStage - 1) android.graphics.Color.BLUE - else android.graphics.Color.LTGRAY - segments.add(NotificationCompat.ProgressStyle.Segment(weight).setColor(segmentColor)) - - if (i < totalStages - 1) { - val pointPos = weight * (i + 1) - val pointColor = if (i < currentStage - 1) android.graphics.Color.GREEN else android.graphics.Color.GRAY - points.add(NotificationCompat.ProgressStyle.Point(pointPos).setColor(pointColor)) - } - } - progressStyle.setProgressSegments(segments) - progressStyle.setProgressPoints(points) - } - - // Ensure the ProgressStyle itself knows the current progress - progressStyle.setProgress(overallPercent) - - return NotificationCompat.Builder(context, PROGRESS_CHANNEL_ID) - .setContentTitle(title) - .setContentText(contentText) - .setSmallIcon(R.drawable.ic_launcher_foreground) - .setPriority(NotificationCompat.PRIORITY_LOW) - .setOngoing(true) - .setOnlyAlertOnce(true) - .setProgress(100, overallPercent, false) - .setStyle(progressStyle) - .setTicker("$overallPercent%") - .setShortCriticalText("$overallPercent%") - .addExtras(android.os.Bundle().apply { - putBoolean(Notification.EXTRA_REQUEST_PROMOTED_ONGOING, true) - }) - .setContentIntent(createPendingIntent()) - .build() - } - - fun buildOperationFinishedNotification(operationName: String, success: Boolean, summary: String): Notification { - val title = if (success) { - context.getString(R.string.notification_operation_finished_success, operationName) - } else { - context.getString(R.string.notification_operation_finished_failure, operationName) - } - - return NotificationCompat.Builder(context, FINISHED_CHANNEL_ID) - .setContentTitle(title) - .setContentText(summary) - .setStyle(NotificationCompat.BigTextStyle().bigText(summary)) - .setSmallIcon(R.drawable.ic_launcher_foreground) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setAutoCancel(true) - .setContentIntent(createPendingIntent()) - .build() - } - - fun cancelProgressNotification() { - NotificationManagerCompat.from(context).cancel(PROGRESS_NOTIFICATION_ID) - } - - fun showOperationProgressNotification(operationName: String, progress: OperationProgress) { - if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { - return - } - - NotificationManagerCompat.from(context) - .notify(PROGRESS_NOTIFICATION_ID, buildOperationProgressNotification(operationName, progress)) - } - - - fun showOperationFinishedNotification(operationName: String, success: Boolean, summary: String) { - cancelProgressNotification() - - if (ActivityCompat.checkSelfPermission( - context, - Manifest.permission.POST_NOTIFICATIONS - ) != PackageManager.PERMISSION_GRANTED - ) { - return - } - - NotificationManagerCompat.from(context) - .notify(FINISHED_NOTIFICATION_ID, buildOperationFinishedNotification(operationName, success, summary)) - } - - - fun checkPermissionStatus() { - when { - ContextCompat.checkSelfPermission( - context, - Manifest.permission.POST_NOTIFICATIONS - ) == PackageManager.PERMISSION_GRANTED -> { - _permissionState.value = NotificationPermissionState.Granted - } - else -> { - _permissionState.value = NotificationPermissionState.Denied - } - } - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/data/OperationCoordinator.kt b/app/src/main/java/io/github/hddq/restoid/data/OperationCoordinator.kt deleted file mode 100644 index 346d9465..00000000 --- a/app/src/main/java/io/github/hddq/restoid/data/OperationCoordinator.kt +++ /dev/null @@ -1,40 +0,0 @@ -package io.github.hddq.restoid.data - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow - -enum class OperationType { - BACKUP, - RUN_TASKS, - RESTORE, - MAINTENANCE -} - -data class ActiveOperation( - val type: OperationType, - val backendType: RepositoryBackendType?, - val startedAtMillis: Long = System.currentTimeMillis() -) - -class OperationCoordinator { - private val monitor = Any() - private val _activeOperation = MutableStateFlow(null) - val activeOperation = _activeOperation.asStateFlow() - - fun tryStart(type: OperationType, backendType: RepositoryBackendType?): Boolean { - synchronized(monitor) { - if (_activeOperation.value != null) return false - _activeOperation.value = ActiveOperation(type = type, backendType = backendType) - return true - } - } - - fun finish(type: OperationType) { - synchronized(monitor) { - val current = _activeOperation.value ?: return - if (current.type == type) { - _activeOperation.value = null - } - } - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/data/OperationLockManager.kt b/app/src/main/java/io/github/hddq/restoid/data/OperationLockManager.kt deleted file mode 100644 index 05e9330e..00000000 --- a/app/src/main/java/io/github/hddq/restoid/data/OperationLockManager.kt +++ /dev/null @@ -1,62 +0,0 @@ -package io.github.hddq.restoid.data - -import android.content.Context -import android.net.ConnectivityManager -import android.net.NetworkCapabilities -import android.net.wifi.WifiManager -import android.os.PowerManager - -class OperationLockManager(context: Context) { - private val appContext = context.applicationContext - private val powerManager = appContext.getSystemService(Context.POWER_SERVICE) as PowerManager - private val wifiManager = appContext.getSystemService(Context.WIFI_SERVICE) as WifiManager - private val connectivityManager = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - - private var wakeLock: PowerManager.WakeLock? = null - private var wifiLock: WifiManager.WifiLock? = null - - fun acquire(backendType: RepositoryBackendType?) { - release() - - wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$LOCK_TAG_PREFIX:wake").apply { - setReferenceCounted(false) - acquire(MAX_WAKE_LOCK_DURATION_MS) - } - - if (backendType != null && isRemoteBackend(backendType) && isOnWifiTransport()) { - wifiLock = wifiManager.createWifiLock(WifiManager.WIFI_MODE_FULL_LOW_LATENCY, "$LOCK_TAG_PREFIX:wifi").apply { - setReferenceCounted(false) - acquire() - } - } - } - - fun release() { - wifiLock?.let { - if (it.isHeld) it.release() - } - wifiLock = null - - wakeLock?.let { - if (it.isHeld) it.release() - } - wakeLock = null - } - - private fun isRemoteBackend(backendType: RepositoryBackendType): Boolean { - return backendType == RepositoryBackendType.SFTP || - backendType == RepositoryBackendType.REST || - backendType == RepositoryBackendType.S3 - } - - private fun isOnWifiTransport(): Boolean { - val activeNetwork = connectivityManager.activeNetwork ?: return false - val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false - return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) - } - - private companion object { - const val LOCK_TAG_PREFIX = "restoid:operation" - const val MAX_WAKE_LOCK_DURATION_MS = 6 * 60 * 60 * 1000L - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/data/OperationRuntimeRepository.kt b/app/src/main/java/io/github/hddq/restoid/data/OperationRuntimeRepository.kt deleted file mode 100644 index ac69209c..00000000 --- a/app/src/main/java/io/github/hddq/restoid/data/OperationRuntimeRepository.kt +++ /dev/null @@ -1,157 +0,0 @@ -package io.github.hddq.restoid.data - -import android.content.Context -import io.github.hddq.restoid.R -import io.github.hddq.restoid.ui.shared.OperationProgress -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json - -data class OperationRuntimeState( - val operationType: OperationType? = null, - val isRunning: Boolean = false, - val success: Boolean? = null, - val stopRequested: Boolean = false, - val progress: OperationProgress = OperationProgress() -) - -class OperationRuntimeRepository(context: Context) { - private val appContext = context.applicationContext - private val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - private val json = Json { ignoreUnknownKeys = true } - - private val _state = MutableStateFlow(loadState()) - val state = _state.asStateFlow() - - fun markEnqueued(operationType: OperationType, initialProgress: OperationProgress) { - updateState( - OperationRuntimeState( - operationType = operationType, - isRunning = true, - success = null, - stopRequested = false, - progress = initialProgress.copy(isFinished = false) - ) - ) - } - - fun markProgress(operationType: OperationType, progress: OperationProgress) { - val current = _state.value - updateState( - OperationRuntimeState( - operationType = operationType, - isRunning = !progress.isFinished, - success = null, - stopRequested = current.stopRequested && current.operationType == operationType, - progress = progress - ) - ) - } - - fun markFinished(operationType: OperationType, success: Boolean, progress: OperationProgress) { - updateState( - OperationRuntimeState( - operationType = operationType, - isRunning = false, - success = success, - stopRequested = false, - progress = progress.copy(isFinished = true) - ) - ) - } - - fun markStopRequested(operationType: OperationType, progress: OperationProgress) { - val current = _state.value - if (!current.isRunning || current.operationType != operationType) return - updateState( - current.copy( - stopRequested = true, - progress = progress.copy(isFinished = false) - ) - ) - } - - fun clearFinished(operationType: OperationType? = null) { - val current = _state.value - if (current.isRunning) return - if (operationType != null && current.operationType != operationType) return - updateState(OperationRuntimeState()) - } - - fun clearStaleRunningState() { - val current = _state.value - if (!current.isRunning) return - val interruptedSummary = appContext.getString(R.string.operation_interrupted) - updateState( - current.copy( - isRunning = false, - success = false, - stopRequested = false, - progress = current.progress.copy( - isFinished = true, - error = current.progress.error ?: interruptedSummary, - finalSummary = if (current.progress.finalSummary.isNotBlank()) { - current.progress.finalSummary - } else { - current.progress.error ?: interruptedSummary - } - ) - ) - ) - } - - private fun loadState(): OperationRuntimeState { - val encoded = prefs.getString(KEY_STATE, null) ?: return OperationRuntimeState() - return runCatching { json.decodeFromString(encoded).toRuntimeState() } - .getOrElse { OperationRuntimeState() } - } - - private fun updateState(newState: OperationRuntimeState) { - _state.value = newState - val encoded = runCatching { json.encodeToString(PersistedState.fromRuntimeState(newState)) }.getOrNull() - if (encoded == null) { - prefs.edit().remove(KEY_STATE).apply() - return - } - prefs.edit().putString(KEY_STATE, encoded).apply() - } - - @Serializable - private data class PersistedState( - val operationType: String? = null, - val isRunning: Boolean = false, - val success: Boolean? = null, - val stopRequested: Boolean = false, - val progress: OperationProgress = OperationProgress() - ) { - fun toRuntimeState(): OperationRuntimeState { - return OperationRuntimeState( - operationType = operationType?.let { name -> runCatching { OperationType.valueOf(name) }.getOrNull() }, - isRunning = isRunning, - success = success, - stopRequested = stopRequested, - progress = progress - ) - } - - companion object { - fun fromRuntimeState(state: OperationRuntimeState): PersistedState { - return PersistedState( - operationType = state.operationType?.name, - isRunning = state.isRunning, - success = state.success, - stopRequested = state.stopRequested, - progress = state.progress - ) - } - } - } - - private companion object { - const val PREFS_NAME = "operation_runtime" - const val KEY_STATE = "state" - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/data/PasswordManager.kt b/app/src/main/java/io/github/hddq/restoid/data/PasswordManager.kt deleted file mode 100644 index 932343f8..00000000 --- a/app/src/main/java/io/github/hddq/restoid/data/PasswordManager.kt +++ /dev/null @@ -1,290 +0,0 @@ -package io.github.hddq.restoid.data - -import android.content.Context -import android.content.SharedPreferences -import io.github.hddq.restoid.util.CryptoHelper - -class PasswordManager(private val context: Context) { - - private val temporaryPasswords = mutableMapOf() - - private val prefs: SharedPreferences = context.getSharedPreferences("repository_passwords_native", Context.MODE_PRIVATE) - - init { - // Clean up the old EncryptedSharedPreferences file if it exists. - context.deleteSharedPreferences("repository_passwords") - } - - fun savePassword(repositoryPath: String, password: String) { - // Ensure temporary cache is in sync when saving permanently - temporaryPasswords[repositoryPath] = password - val encrypted = CryptoHelper.encrypt(password) - prefs.edit() - .putString(repositoryPath, encrypted) - .apply() - } - - fun savePasswordTemporary(repositoryPath: String, password: String) { - temporaryPasswords[repositoryPath] = password - } - - fun getPassword(repositoryPath: String): String? { - val temp = temporaryPasswords[repositoryPath] - if (temp != null) return temp - - val encrypted = prefs.getString(repositoryPath, null) ?: return null - return CryptoHelper.decrypt(encrypted) - } - - fun removePassword(repositoryPath: String) { - // Also remove from temporary cache if it exists there - temporaryPasswords.remove(repositoryPath) - prefs.edit() - .remove(repositoryPath) - .apply() - } - - fun hasPassword(repositoryPath: String): Boolean { - return temporaryPasswords.containsKey(repositoryPath) || prefs.contains(repositoryPath) - } - - fun hasStoredPassword(repositoryPath: String): Boolean { - return prefs.contains(repositoryPath) - } - - fun removeStoredPassword(repositoryPath: String) { - // Also remove from temporary cache if it exists there - temporaryPasswords.remove(repositoryPath) - prefs.edit() - .remove(repositoryPath) - .apply() - } - - fun clearTemporaryPasswords() { - temporaryPasswords.clear() - } - - private fun sftpPasswordKey(repositoryKey: String): String { - return "sftp_ssh_password::$repositoryKey" - } - - private fun sftpKeyKey(repositoryKey: String): String { - return "sftp_ssh_key::$repositoryKey" - } - - private fun sftpKeyPassphraseKey(repositoryKey: String): String { - return "sftp_ssh_key_passphrase::$repositoryKey" - } - - private fun restUsernameKey(repositoryKey: String): String { - return "rest_http_username::$repositoryKey" - } - - private fun restPasswordKey(repositoryKey: String): String { - return "rest_http_password::$repositoryKey" - } - - private fun s3AccessKeyIdKey(repositoryKey: String): String { - return "s3_access_key_id::$repositoryKey" - } - - private fun s3SecretAccessKeyKey(repositoryKey: String): String { - return "s3_secret_access_key::$repositoryKey" - } - - fun saveSftpPassword(repositoryKey: String, password: String) { - savePassword(sftpPasswordKey(repositoryKey), password) - } - - fun saveSftpPasswordTemporary(repositoryKey: String, password: String) { - savePasswordTemporary(sftpPasswordKey(repositoryKey), password) - } - - fun getSftpPassword(repositoryKey: String): String? { - return getPassword(sftpPasswordKey(repositoryKey)) - } - - fun hasSftpPassword(repositoryKey: String): Boolean { - return hasPassword(sftpPasswordKey(repositoryKey)) - } - - fun hasStoredSftpPassword(repositoryKey: String): Boolean { - return hasStoredPassword(sftpPasswordKey(repositoryKey)) - } - - fun removeSftpPassword(repositoryKey: String) { - removePassword(sftpPasswordKey(repositoryKey)) - } - - fun removeStoredSftpPassword(repositoryKey: String) { - removeStoredPassword(sftpPasswordKey(repositoryKey)) - } - - fun saveSftpKey(repositoryKey: String, key: String) { - savePassword(sftpKeyKey(repositoryKey), key) - } - - fun saveSftpKeyTemporary(repositoryKey: String, key: String) { - savePasswordTemporary(sftpKeyKey(repositoryKey), key) - } - - fun getSftpKey(repositoryKey: String): String? { - return getPassword(sftpKeyKey(repositoryKey)) - } - - fun hasSftpKey(repositoryKey: String): Boolean { - return hasPassword(sftpKeyKey(repositoryKey)) - } - - fun hasStoredSftpKey(repositoryKey: String): Boolean { - return hasStoredPassword(sftpKeyKey(repositoryKey)) - } - - fun removeSftpKey(repositoryKey: String) { - removePassword(sftpKeyKey(repositoryKey)) - } - - fun removeStoredSftpKey(repositoryKey: String) { - removeStoredPassword(sftpKeyKey(repositoryKey)) - } - - fun saveSftpKeyPassphrase(repositoryKey: String, passphrase: String) { - savePassword(sftpKeyPassphraseKey(repositoryKey), passphrase) - } - - fun saveSftpKeyPassphraseTemporary(repositoryKey: String, passphrase: String) { - savePasswordTemporary(sftpKeyPassphraseKey(repositoryKey), passphrase) - } - - fun getSftpKeyPassphrase(repositoryKey: String): String? { - return getPassword(sftpKeyPassphraseKey(repositoryKey)) - } - - fun hasSftpKeyPassphrase(repositoryKey: String): Boolean { - return hasPassword(sftpKeyPassphraseKey(repositoryKey)) - } - - fun hasStoredSftpKeyPassphrase(repositoryKey: String): Boolean { - return hasStoredPassword(sftpKeyPassphraseKey(repositoryKey)) - } - - fun removeSftpKeyPassphrase(repositoryKey: String) { - removePassword(sftpKeyPassphraseKey(repositoryKey)) - } - - fun removeStoredSftpKeyPassphrase(repositoryKey: String) { - removeStoredPassword(sftpKeyPassphraseKey(repositoryKey)) - } - - fun saveRestUsername(repositoryKey: String, username: String) { - savePassword(restUsernameKey(repositoryKey), username) - } - - fun saveRestUsernameTemporary(repositoryKey: String, username: String) { - savePasswordTemporary(restUsernameKey(repositoryKey), username) - } - - fun getRestUsername(repositoryKey: String): String? { - return getPassword(restUsernameKey(repositoryKey)) - } - - fun hasRestUsername(repositoryKey: String): Boolean { - return hasPassword(restUsernameKey(repositoryKey)) - } - - fun hasStoredRestUsername(repositoryKey: String): Boolean { - return hasStoredPassword(restUsernameKey(repositoryKey)) - } - - fun removeRestUsername(repositoryKey: String) { - removePassword(restUsernameKey(repositoryKey)) - } - - fun removeStoredRestUsername(repositoryKey: String) { - removeStoredPassword(restUsernameKey(repositoryKey)) - } - - fun saveRestPassword(repositoryKey: String, password: String) { - savePassword(restPasswordKey(repositoryKey), password) - } - - fun saveRestPasswordTemporary(repositoryKey: String, password: String) { - savePasswordTemporary(restPasswordKey(repositoryKey), password) - } - - fun getRestPassword(repositoryKey: String): String? { - return getPassword(restPasswordKey(repositoryKey)) - } - - fun hasRestPassword(repositoryKey: String): Boolean { - return hasPassword(restPasswordKey(repositoryKey)) - } - - fun hasStoredRestPassword(repositoryKey: String): Boolean { - return hasStoredPassword(restPasswordKey(repositoryKey)) - } - - fun removeRestPassword(repositoryKey: String) { - removePassword(restPasswordKey(repositoryKey)) - } - - fun removeStoredRestPassword(repositoryKey: String) { - removeStoredPassword(restPasswordKey(repositoryKey)) - } - - fun saveS3AccessKeyId(repositoryKey: String, accessKeyId: String) { - savePassword(s3AccessKeyIdKey(repositoryKey), accessKeyId) - } - - fun saveS3AccessKeyIdTemporary(repositoryKey: String, accessKeyId: String) { - savePasswordTemporary(s3AccessKeyIdKey(repositoryKey), accessKeyId) - } - - fun getS3AccessKeyId(repositoryKey: String): String? { - return getPassword(s3AccessKeyIdKey(repositoryKey)) - } - - fun hasS3AccessKeyId(repositoryKey: String): Boolean { - return hasPassword(s3AccessKeyIdKey(repositoryKey)) - } - - fun hasStoredS3AccessKeyId(repositoryKey: String): Boolean { - return hasStoredPassword(s3AccessKeyIdKey(repositoryKey)) - } - - fun removeS3AccessKeyId(repositoryKey: String) { - removePassword(s3AccessKeyIdKey(repositoryKey)) - } - - fun removeStoredS3AccessKeyId(repositoryKey: String) { - removeStoredPassword(s3AccessKeyIdKey(repositoryKey)) - } - - fun saveS3SecretAccessKey(repositoryKey: String, secretAccessKey: String) { - savePassword(s3SecretAccessKeyKey(repositoryKey), secretAccessKey) - } - - fun saveS3SecretAccessKeyTemporary(repositoryKey: String, secretAccessKey: String) { - savePasswordTemporary(s3SecretAccessKeyKey(repositoryKey), secretAccessKey) - } - - fun getS3SecretAccessKey(repositoryKey: String): String? { - return getPassword(s3SecretAccessKeyKey(repositoryKey)) - } - - fun hasS3SecretAccessKey(repositoryKey: String): Boolean { - return hasPassword(s3SecretAccessKeyKey(repositoryKey)) - } - - fun hasStoredS3SecretAccessKey(repositoryKey: String): Boolean { - return hasStoredPassword(s3SecretAccessKeyKey(repositoryKey)) - } - - fun removeS3SecretAccessKey(repositoryKey: String) { - removePassword(s3SecretAccessKeyKey(repositoryKey)) - } - - fun removeStoredS3SecretAccessKey(repositoryKey: String) { - removeStoredPassword(s3SecretAccessKeyKey(repositoryKey)) - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/data/PreferencesRepository.kt b/app/src/main/java/io/github/hddq/restoid/data/PreferencesRepository.kt deleted file mode 100644 index db53dbd6..00000000 --- a/app/src/main/java/io/github/hddq/restoid/data/PreferencesRepository.kt +++ /dev/null @@ -1,180 +0,0 @@ -package io.github.hddq.restoid.data - -import android.content.Context -import io.github.hddq.restoid.ui.shared.BackupTypes -import io.github.hddq.restoid.model.MaintenanceConfig -import io.github.hddq.restoid.ui.restore.RestoreTypes -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json - -class PreferencesRepository(context: Context) { - private val prefs = context.getSharedPreferences("user_preferences", Context.MODE_PRIVATE) - private val json = Json { ignoreUnknownKeys = true } - - private companion object { - const val KEY_REQUIRE_APP_UNLOCK = "require_app_unlock" - } - - // Maintenance Preferences - fun saveMaintenanceState(state: MaintenanceConfig) { - with(prefs.edit()) { - putBoolean("maintenance_checkRepo", state.checkRepo) - putBoolean("maintenance_pruneRepo", state.pruneRepo) - putBoolean("maintenance_unlockRepo", state.unlockRepo) - putBoolean("maintenance_readData", state.readData) - putBoolean("maintenance_forgetSnapshots", state.forgetSnapshots) - putInt("maintenance_keepLast", state.keepLast) - putInt("maintenance_keepDaily", state.keepDaily) - putInt("maintenance_keepWeekly", state.keepWeekly) - putInt("maintenance_keepMonthly", state.keepMonthly) - apply() - } - } - - fun loadMaintenanceState(): MaintenanceConfig { - return MaintenanceConfig( - checkRepo = prefs.getBoolean("maintenance_checkRepo", true), - pruneRepo = prefs.getBoolean("maintenance_pruneRepo", false), - unlockRepo = prefs.getBoolean("maintenance_unlockRepo", false), - readData = prefs.getBoolean("maintenance_readData", false), - forgetSnapshots = prefs.getBoolean("maintenance_forgetSnapshots", false), - keepLast = prefs.getInt("maintenance_keepLast", 5), - keepDaily = prefs.getInt("maintenance_keepDaily", 7), - keepWeekly = prefs.getInt("maintenance_keepWeekly", 4), - keepMonthly = prefs.getInt("maintenance_keepMonthly", 6) - ) - } - - // Run Tasks Backup Preferences - fun saveRunTasksBackupEnabled(enabled: Boolean) { - prefs.edit().putBoolean("runtasks_backup_enabled", enabled).apply() - } - - fun loadRunTasksBackupEnabled(): Boolean { - return prefs.getBoolean("runtasks_backup_enabled", true) - } - - fun saveRunTasksSelectedApps(selectAll: Boolean, packageNames: Set) { - with(prefs.edit()) { - putBoolean("runtasks_select_all_apps", selectAll) - putStringSet("runtasks_selected_packages", packageNames) - apply() - } - } - - fun loadRunTasksSelectAllApps(): Boolean { - return prefs.getBoolean("runtasks_select_all_apps", true) - } - - fun loadRunTasksSelectedPackages(): Set { - return prefs.getStringSet("runtasks_selected_packages", emptySet()) ?: emptySet() - } - - fun saveRunTasksAppBackupTypes(types: Map) { - prefs.edit() - .putString("runtasks_app_backup_types", json.encodeToString(types)) - .apply() - } - - fun loadRunTasksAppBackupTypes(): Map { - val serialized = prefs.getString("runtasks_app_backup_types", null) ?: return emptyMap() - return try { - json.decodeFromString>(serialized) - } catch (e: Exception) { - emptyMap() - } - } - - // Custom Directories Backup Preferences - fun saveCustomDirectoriesBackupEnabled(enabled: Boolean) { - prefs.edit().putBoolean("runtasks_custom_directories_enabled", enabled).apply() - } - - fun loadCustomDirectoriesBackupEnabled(): Boolean { - return prefs.getBoolean("runtasks_custom_directories_enabled", false) - } - - fun saveCustomDirectories(allUris: Set, selectedUris: Set) { - with(prefs.edit()) { - putStringSet("runtasks_custom_directories_all", allUris) - putStringSet("runtasks_custom_directories_selected", selectedUris) - apply() - } - } - - fun loadCustomDirectoriesAll(): Set { - return prefs.getStringSet("runtasks_custom_directories_all", emptySet()) ?: emptySet() - } - - fun loadCustomDirectoriesSelected(): Set { - return prefs.getStringSet("runtasks_custom_directories_selected", emptySet()) ?: emptySet() - } - - // Backup Preferences - fun saveBackupTypes(types: BackupTypes) { - with(prefs.edit()) { - putBoolean("backup_apk", types.apk) - putBoolean("backup_data", types.data) - putBoolean("backup_deviceProtectedData", types.deviceProtectedData) - putBoolean("backup_externalData", types.externalData) - putBoolean("backup_obb", types.obb) - putBoolean("backup_media", types.media) - putBoolean("backup_permissions", types.permissions) - apply() - } - } - - fun loadBackupTypes(): BackupTypes { - return BackupTypes( - apk = prefs.getBoolean("backup_apk", true), - data = prefs.getBoolean("backup_data", true), - deviceProtectedData = prefs.getBoolean("backup_deviceProtectedData", true), - externalData = prefs.getBoolean("backup_externalData", false), - obb = prefs.getBoolean("backup_obb", false), - media = prefs.getBoolean("backup_media", false), - permissions = prefs.getBoolean("backup_permissions", true) - ) - } - - // Restore Preferences - fun saveRestoreTypes(types: RestoreTypes) { - with(prefs.edit()) { - putBoolean("restore_apk", types.apk) - putBoolean("restore_data", types.data) - putBoolean("restore_deviceProtectedData", types.deviceProtectedData) - putBoolean("restore_externalData", types.externalData) - putBoolean("restore_obb", types.obb) - putBoolean("restore_media", types.media) - putBoolean("restore_permissions", types.permissions) - apply() - } - } - - fun loadRestoreTypes(): RestoreTypes { - return RestoreTypes( - apk = prefs.getBoolean("restore_apk", true), - data = prefs.getBoolean("restore_data", true), - deviceProtectedData = prefs.getBoolean("restore_deviceProtectedData", true), - externalData = prefs.getBoolean("restore_externalData", false), - obb = prefs.getBoolean("restore_obb", false), - media = prefs.getBoolean("restore_media", false), - permissions = prefs.getBoolean("restore_permissions", true) - ) - } - - fun saveAllowDowngrade(allow: Boolean) { - prefs.edit().putBoolean("restore_allowDowngrade", allow).apply() - } - - fun loadAllowDowngrade(): Boolean { - return prefs.getBoolean("restore_allowDowngrade", false) - } - - fun saveRequireAppUnlock(required: Boolean) { - prefs.edit().putBoolean(KEY_REQUIRE_APP_UNLOCK, required).apply() - } - - fun loadRequireAppUnlock(): Boolean { - return prefs.getBoolean(KEY_REQUIRE_APP_UNLOCK, false) - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/data/RepositoriesRepository.kt b/app/src/main/java/io/github/hddq/restoid/data/RepositoriesRepository.kt deleted file mode 100644 index 5312c658..00000000 --- a/app/src/main/java/io/github/hddq/restoid/data/RepositoriesRepository.kt +++ /dev/null @@ -1,1291 +0,0 @@ -package io.github.hddq.restoid.data - -import android.content.Context -import android.util.Log -import com.topjohnwu.superuser.Shell -import io.github.hddq.restoid.R -import io.github.hddq.restoid.util.StorageUtils -import io.github.hddq.restoid.util.buildResticOptionFlags -import io.github.hddq.restoid.util.buildShellEnvironmentPrefix -import io.github.hddq.restoid.util.isValidResticOptionName -import io.github.hddq.restoid.util.shellQuote -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json -import java.io.File -import java.security.MessageDigest -import java.util.Base64 - -// Represents the state of the "Add Repository" operation -sealed class AddRepositoryState { - object Idle : AddRepositoryState() - object Initializing : AddRepositoryState() - object Success : AddRepositoryState() - data class SuccessWithMetadataWarning(val warning: String) : AddRepositoryState() - data class Error(val message: String) : AddRepositoryState() -} - -data class SftpServerTrustInfo( - val endpoint: String, - val fingerprints: List, - val knownHostEntries: List -) - -data class RestCredentials( - val username: String, - val password: String -) - -data class S3Credentials( - val accessKeyId: String, - val secretAccessKey: String -) - -class RepositoriesRepository( - private val context: Context, - private val passwordManager: PasswordManager, - private val binaryManager: ResticBinaryManager -) { - private companion object { - private const val MISSING_S3_BUCKET_ERROR = "The specified bucket does not exist" - // Timeout for S3 repo existence check — restic retries indefinitely when bucket is missing - private const val S3_CHECK_TIMEOUT_SECONDS = 5 - private const val SSH_KEY_PASSPHRASE_ENV = "RESTOID_SSH_KEY_PASSPHRASE" - } - - private val prefs = context.getSharedPreferences("repositories", Context.MODE_PRIVATE) - private val LEGACY_REPOS_KEY = "repositories_json_v1" - private val REPOS_KEY = "repositories_json_v2" - private val LEGACY_SELECTED_REPO_PATH_KEY = "selected_repo_path" - private val SELECTED_REPO_KEY = "selected_repo_key" - - private val _repositories = MutableStateFlow>(emptyList()) - val repositories = _repositories.asStateFlow() - - private val _selectedRepository = MutableStateFlow(null) - val selectedRepository = _selectedRepository.asStateFlow() - - private val json = Json { ignoreUnknownKeys = true } - - fun loadRepositories() { - val reposV2 = prefs.getStringSet(REPOS_KEY, null) - val reposV1 = prefs.getStringSet(LEGACY_REPOS_KEY, emptySet()) ?: emptySet() - - val repoJsonSet = mutableSetOf().apply { - if (reposV2 != null) addAll(reposV2) - addAll(reposV1) - } - - _repositories.value = repoJsonSet.mapNotNull { jsonString -> - try { - json.decodeFromString(jsonString) - } catch (_: Exception) { - null - } - }.sortedBy { it.name } - - if (reposV2 == null && repoJsonSet.isNotEmpty()) { - prefs.edit().putStringSet(REPOS_KEY, repoJsonSet).apply() - } - - val selectedKey = prefs.getString(SELECTED_REPO_KEY, null) - ?: prefs.getString(LEGACY_SELECTED_REPO_PATH_KEY, null) - - if (selectedKey != null && _repositories.value.any { repositoryKey(it) == selectedKey }) { - _selectedRepository.value = selectedKey - prefs.edit().putString(SELECTED_REPO_KEY, selectedKey).remove(LEGACY_SELECTED_REPO_PATH_KEY).apply() - } else if (selectedKey != null) { - val legacySelectedRepository = _repositories.value.find { it.path == selectedKey } - if (legacySelectedRepository != null) { - val migratedKey = repositoryKey(legacySelectedRepository) - _selectedRepository.value = migratedKey - prefs.edit().putString(SELECTED_REPO_KEY, migratedKey).remove(LEGACY_SELECTED_REPO_PATH_KEY).apply() - } else { - _selectedRepository.value = null - prefs.edit().remove(SELECTED_REPO_KEY).remove(LEGACY_SELECTED_REPO_PATH_KEY).apply() - } - } else { - _selectedRepository.value = null - prefs.edit().remove(SELECTED_REPO_KEY).apply() - } - } - - fun selectRepository(key: String) { - if (_repositories.value.none { repositoryKey(it) == key }) { - prefs.edit().remove(SELECTED_REPO_KEY).apply() - _selectedRepository.value = null - return - } - - prefs.edit().putString(SELECTED_REPO_KEY, key).apply() - _selectedRepository.value = key - } - - fun getRepositoryPassword(key: String): String? = passwordManager.getPassword(key) - fun hasRepositoryPassword(key: String): Boolean = passwordManager.hasPassword(key) - fun hasStoredRepositoryPassword(key: String): Boolean = passwordManager.hasStoredPassword(key) - fun saveRepositoryPasswordTemporary(key: String, password: String) = passwordManager.savePasswordTemporary(key, password) - fun saveRepositoryPassword(key: String, password: String) = passwordManager.savePassword(key, password) - fun forgetPassword(key: String) = passwordManager.removeStoredPassword(key) - - fun getSftpPassword(key: String): String? = passwordManager.getSftpPassword(key) - fun hasSftpPassword(key: String): Boolean = passwordManager.hasSftpPassword(key) - fun hasStoredSftpPassword(key: String): Boolean = passwordManager.hasStoredSftpPassword(key) - fun saveSftpPasswordTemporary(key: String, password: String) = passwordManager.saveSftpPasswordTemporary(key, password) - fun saveSftpPassword(key: String, password: String) = passwordManager.saveSftpPassword(key, password) - fun forgetSftpPassword(key: String) = passwordManager.removeStoredSftpPassword(key) - - fun getSftpKey(key: String): String? = passwordManager.getSftpKey(key) - fun hasSftpKey(key: String): Boolean = passwordManager.hasSftpKey(key) - fun hasStoredSftpKey(key: String): Boolean = passwordManager.hasStoredSftpKey(key) - fun saveSftpKeyTemporary(key: String, keyContent: String) = passwordManager.saveSftpKeyTemporary(key, keyContent) - fun saveSftpKey(key: String, keyContent: String) = passwordManager.saveSftpKey(key, keyContent) - fun getSftpKeyPassphrase(key: String): String? = passwordManager.getSftpKeyPassphrase(key) - fun hasSftpKeyPassphrase(key: String): Boolean = passwordManager.hasSftpKeyPassphrase(key) - fun hasStoredSftpKeyPassphrase(key: String): Boolean = passwordManager.hasStoredSftpKeyPassphrase(key) - fun saveSftpKeyPassphraseTemporary(key: String, passphrase: String) = passwordManager.saveSftpKeyPassphraseTemporary(key, passphrase) - fun saveSftpKeyPassphrase(key: String, passphrase: String) = passwordManager.saveSftpKeyPassphrase(key, passphrase) - fun forgetSftpKey(key: String) { - passwordManager.removeStoredSftpKey(key) - passwordManager.removeStoredSftpKeyPassphrase(key) - } - - fun hasSftpCredentials(key: String): Boolean { - val repo = getRepositoryByKey(key) ?: return false - return if (repo.sftpKeyAuthRequired) { - hasSftpKey(key) && (!repo.sftpKeyPassphraseRequired || hasSftpKeyPassphrase(key)) - } else { - hasSftpPassword(key) - } - } - - fun hasStoredSftpCredentials(key: String): Boolean { - val repo = getRepositoryByKey(key) ?: return false - return if (repo.sftpKeyAuthRequired) { - hasStoredSftpKey(key) && (!repo.sftpKeyPassphraseRequired || hasStoredSftpKeyPassphrase(key)) - } else { - hasStoredSftpPassword(key) - } - } - - fun getRestUsername(key: String): String? = passwordManager.getRestUsername(key) - fun getRestPassword(key: String): String? = passwordManager.getRestPassword(key) - - fun getRestCredentials(key: String): RestCredentials? { - val username = getRestUsername(key) - val password = getRestPassword(key) - return if (username.isNullOrBlank() || password.isNullOrBlank()) { - null - } else { - RestCredentials(username = username, password = password) - } - } - - fun hasRestCredentials(key: String): Boolean = getRestCredentials(key) != null - - fun hasStoredRestCredentials(key: String): Boolean { - return passwordManager.hasStoredRestUsername(key) && passwordManager.hasStoredRestPassword(key) - } - - fun saveRestCredentialsTemporary(key: String, username: String, password: String) { - passwordManager.saveRestUsernameTemporary(key, username) - passwordManager.saveRestPasswordTemporary(key, password) - } - - fun saveRestCredentials(key: String, username: String, password: String) { - passwordManager.saveRestUsername(key, username) - passwordManager.saveRestPassword(key, password) - } - - fun forgetRestCredentials(key: String) { - passwordManager.removeStoredRestUsername(key) - passwordManager.removeStoredRestPassword(key) - } - - fun getS3AccessKeyId(key: String): String? = passwordManager.getS3AccessKeyId(key) - fun getS3SecretAccessKey(key: String): String? = passwordManager.getS3SecretAccessKey(key) - - fun getS3Credentials(key: String): S3Credentials? { - val accessKeyId = getS3AccessKeyId(key) - val secretAccessKey = getS3SecretAccessKey(key) - return if (accessKeyId.isNullOrBlank() || secretAccessKey.isNullOrBlank()) { - null - } else { - S3Credentials(accessKeyId = accessKeyId, secretAccessKey = secretAccessKey) - } - } - - fun hasS3Credentials(key: String): Boolean = getS3Credentials(key) != null - - fun hasStoredS3Credentials(key: String): Boolean { - return passwordManager.hasStoredS3AccessKeyId(key) && passwordManager.hasStoredS3SecretAccessKey(key) - } - - fun saveS3CredentialsTemporary(key: String, accessKeyId: String, secretAccessKey: String) { - passwordManager.saveS3AccessKeyIdTemporary(key, accessKeyId) - passwordManager.saveS3SecretAccessKeyTemporary(key, secretAccessKey) - } - - fun saveS3Credentials(key: String, accessKeyId: String, secretAccessKey: String) { - passwordManager.saveS3AccessKeyId(key, accessKeyId) - passwordManager.saveS3SecretAccessKey(key, secretAccessKey) - } - - fun forgetS3Credentials(key: String) { - passwordManager.removeStoredS3AccessKeyId(key) - passwordManager.removeStoredS3SecretAccessKey(key) - } - - fun getExecutionEnvironmentVariables(key: String): Map { - val repository = getRepositoryByKey(key) ?: return emptyMap() - val environmentWithDefaults = if (repository.backendType == RepositoryBackendType.SFTP) { - applySftpClientEnvironmentDefaults(repository.environmentVariables) - } else { - repository.environmentVariables - } - - var executionEnvironment = environmentWithDefaults - - if (repository.backendType == RepositoryBackendType.SFTP) { - executionEnvironment = when { - !repository.sftpKeyAuthRequired -> { - val sftpPassword = getSftpPassword(key) - if (sftpPassword.isNullOrBlank()) { - executionEnvironment - } else { - prepareSshAuthenticationEnvironment( - baseEnvironmentVariables = executionEnvironment, - sshPassword = sftpPassword - ).getOrDefault(executionEnvironment + mapOf("SSHPASS" to sftpPassword)) - } - } - repository.sftpKeyPassphraseRequired -> { - val sftpKeyPassphrase = getSftpKeyPassphrase(key) - if (sftpKeyPassphrase.isNullOrBlank()) { - executionEnvironment - } else { - prepareSshAuthenticationEnvironment( - baseEnvironmentVariables = executionEnvironment, - sftpKeyPassphrase = sftpKeyPassphrase - ).getOrDefault(executionEnvironment + mapOf(SSH_KEY_PASSPHRASE_ENV to sftpKeyPassphrase)) - } - } - else -> executionEnvironment - } - } - - if (repository.backendType == RepositoryBackendType.REST) { - val restCredentials = getRestCredentials(key) - if (restCredentials != null) { - executionEnvironment = executionEnvironment + mapOf( - "RESTIC_REST_USERNAME" to restCredentials.username, - "RESTIC_REST_PASSWORD" to restCredentials.password - ) - } - } - - if (repository.backendType == RepositoryBackendType.S3) { - val s3Credentials = getS3Credentials(key) - if (s3Credentials != null) { - executionEnvironment = executionEnvironment + mapOf( - "AWS_ACCESS_KEY_ID" to s3Credentials.accessKeyId, - "AWS_SECRET_ACCESS_KEY" to s3Credentials.secretAccessKey - ) - } - } - - return executionEnvironment - } - - fun getExecutionResticOptions(key: String): Map { - val repository = getRepositoryByKey(key) ?: return emptyMap() - val knownHostsFile = if (repository.backendType == RepositoryBackendType.SFTP) { - ensureSftpKnownHostsFile().getOrNull() - } else { - null - } - - val sftpKeyFilePath = if (repository.backendType == RepositoryBackendType.SFTP && repository.sftpKeyAuthRequired) { - val sftpKey = getSftpKey(key) - if (sftpKey.isNullOrBlank()) { - null - } else { - ensureSftpKeyFile(key, sftpKey) - sftpKeyFileForRepository(key).absolutePath - } - } else { - null - } - - return applySftpResticOptionDefaults( - backendType = repository.backendType, - options = repository.resticOptions, - knownHostsFile = knownHostsFile, - strictHostKeyCheckingValue = "yes", - sftpKeyFilePath = sftpKeyFilePath - ) - } - - fun getRepositoryByKey(key: String): LocalRepository? { - return repositories.value.find { repositoryKey(it) == key } - } - - fun repositoryKey(repository: LocalRepository): String { - return repository.path - } - - fun deleteRepository(key: String) { - val currentJsonSet = prefs.getStringSet(REPOS_KEY, emptySet())?.toMutableSet() ?: mutableSetOf() - val repoToRemoveJson = currentJsonSet.find { - try { - val decoded = json.decodeFromString(it) - repositoryKey(decoded) == key - } catch (_: Exception) { - false - } - } - - if (repoToRemoveJson != null && currentJsonSet.remove(repoToRemoveJson)) { - prefs.edit().putStringSet(REPOS_KEY, currentJsonSet).apply() - passwordManager.removePassword(key) - passwordManager.removeSftpPassword(key) - passwordManager.removeSftpKey(key) - passwordManager.removeSftpKeyPassphrase(key) - passwordManager.removeRestUsername(key) - passwordManager.removeRestPassword(key) - passwordManager.removeS3AccessKeyId(key) - passwordManager.removeS3SecretAccessKey(key) - deleteSftpKeyFile(key) - - if (_selectedRepository.value == key) { - prefs.edit().remove(SELECTED_REPO_KEY).apply() - _selectedRepository.value = null - } - loadRepositories() - } - } - - suspend fun probeSftpServerTrust( - path: String, - password: String, - environmentVariables: Map, - resticOptions: Map, - sftpPassword: String, - sftpKey: String, - sftpKeyAuthRequired: Boolean, - sftpKeyPassphrase: String - ): Result { - return withContext(Dispatchers.IO) { - val normalizedPath = normalizeRepositoryPath(path, RepositoryBackendType.SFTP) - val binaryState = binaryManager.resticState.value - if (binaryState !is ResticState.Installed) { - return@withContext Result.failure(IllegalStateException(context.getString(R.string.repo_error_binary_not_ready))) - } - - val tempKnownHostsFile = File.createTempFile("sftp-known-hosts", ".tmp", context.cacheDir) - val passwordFile = File.createTempFile("restic-pass", ".tmp", context.cacheDir) - val tempSftpKeyFile = if (sftpKeyAuthRequired && sftpKey.isNotBlank()) { - val keyFile = File.createTempFile("sftp-key", ".tmp", context.cacheDir) - keyFile.writeText(sftpKey) - keyFile.setReadable(false, false) - keyFile.setWritable(false, false) - keyFile.setExecutable(false, false) - keyFile.setReadable(true, true) - keyFile.setWritable(true, true) - keyFile - } else { - null - } - - try { - passwordFile.writeText(password) - - val persistedEnvironmentVariables = when { - !sftpKeyAuthRequired && sftpPassword.isNotBlank() -> { - val envResult = prepareSshAuthenticationEnvironment( - baseEnvironmentVariables = environmentVariables, - sshPassword = sftpPassword - ) - if (envResult.isFailure) { - return@withContext Result.failure( - IllegalStateException( - envResult.exceptionOrNull()?.message - ?: context.getString(R.string.repo_error_sftp_password_setup_failed) - ) - ) - } - envResult.getOrThrow() - } - sftpKeyAuthRequired && sftpKeyPassphrase.isNotBlank() -> { - val envResult = prepareSshAuthenticationEnvironment( - baseEnvironmentVariables = environmentVariables, - sftpKeyPassphrase = sftpKeyPassphrase - ) - if (envResult.isFailure) { - return@withContext Result.failure( - IllegalStateException( - envResult.exceptionOrNull()?.message - ?: context.getString(R.string.repo_error_sftp_password_setup_failed) - ) - ) - } - envResult.getOrThrow() - } - else -> applySftpClientEnvironmentDefaults(environmentVariables) - } - - val probeResticOptions = applySftpResticOptionDefaults( - backendType = RepositoryBackendType.SFTP, - options = resticOptions, - knownHostsFile = tempKnownHostsFile, - strictHostKeyCheckingValue = "accept-new", - sftpKeyFilePath = tempSftpKeyFile?.absolutePath - ) - - if (probeResticOptions.keys.any { !isValidResticOptionName(it) }) { - return@withContext Result.failure(IllegalStateException(context.getString(R.string.settings_error_invalid_restic_options))) - } - - val envPrefix = buildShellEnvironmentPrefix(persistedEnvironmentVariables) - val resticOptionFlags = buildResticOptionFlags(probeResticOptions) - - val probeResult = Shell.cmd(buildString { - if (envPrefix.isNotEmpty()) append(envPrefix).append(' ') - append("RESTIC_PASSWORD_FILE=").append(shellQuote(passwordFile.absolutePath)).append(' ') - append(shellQuote(binaryState.path)).append(' ') - if (resticOptionFlags.isNotEmpty()) append(resticOptionFlags).append(' ') - append("-r ").append(shellQuote(normalizedPath)).append(" snapshots --last 1 --json") - }).exec() - - val knownHostEntries = readKnownHostEntries(tempKnownHostsFile) - if (knownHostEntries.isEmpty()) { - val stderr = probeResult.err.joinToString("\n").trim() - val message = if (stderr.isBlank()) { - context.getString(R.string.repo_error_sftp_fingerprint_probe_failed) - } else { - stderr - } - return@withContext Result.failure(IllegalStateException(message)) - } - - val fingerprints = knownHostEntries - .mapNotNull(::parseFingerprintFromKnownHostEntry) - .distinct() - - if (fingerprints.isEmpty()) { - return@withContext Result.failure(IllegalStateException(context.getString(R.string.repo_error_sftp_fingerprint_probe_failed))) - } - - Result.success( - SftpServerTrustInfo( - endpoint = buildSftpEndpointLabel(normalizedPath), - fingerprints = fingerprints, - knownHostEntries = knownHostEntries - ) - ) - } finally { - passwordFile.delete() - tempKnownHostsFile.delete() - tempSftpKeyFile?.delete() - } - } - } - - fun trustSftpServer(knownHostEntries: List): Result { - val normalizedEntries = knownHostEntries - .map { it.trim() } - .filter { it.isNotBlank() && !it.startsWith("#") } - .distinct() - - if (normalizedEntries.isEmpty()) { - return Result.failure(IllegalStateException(context.getString(R.string.repo_error_sftp_fingerprint_probe_failed))) - } - - val knownHostsResult = ensureSftpKnownHostsFile() - if (knownHostsResult.isFailure) { - return Result.failure( - IllegalStateException( - knownHostsResult.exceptionOrNull()?.message - ?: context.getString(R.string.repo_error_sftp_known_hosts_setup_failed) - ) - ) - } - - val knownHostsFile = knownHostsResult.getOrThrow() - return runCatching { - val existingEntries = readKnownHostEntries(knownHostsFile).toMutableSet() - val newEntries = normalizedEntries.filter { !existingEntries.contains(it) } - - if (newEntries.isNotEmpty()) { - val separator = if (knownHostsFile.length() > 0L) "\n" else "" - knownHostsFile.appendText(separator + newEntries.joinToString("\n") + "\n") - } - } - } - - suspend fun addRepository( - path: String, - backendType: RepositoryBackendType, - password: String, - environmentVariables: Map, - resticOptions: Map, - sftpPassword: String, - sftpKey: String, - sftpKeyAuthRequired: Boolean, - sftpKeyPassphrase: String, - s3AccessKeyId: String, - s3SecretAccessKey: String, - restUsername: String, - restPassword: String, - resticRepository: ResticRepository, - savePassword: Boolean - ): AddRepositoryState { - return withContext(Dispatchers.IO) { - val normalizedPath = normalizeRepositoryPath(path, backendType) - val resolvedPath = if (backendType == RepositoryBackendType.LOCAL) { - StorageUtils.resolvePathForShell(normalizedPath) - } else { - normalizedPath - } - - val knownHostsFile = if (backendType == RepositoryBackendType.SFTP) { - val knownHostsResult = ensureSftpKnownHostsFile() - if (knownHostsResult.isFailure) { - return@withContext AddRepositoryState.Error( - knownHostsResult.exceptionOrNull()?.message - ?: context.getString(R.string.repo_error_sftp_known_hosts_setup_failed) - ) - } - knownHostsResult.getOrThrow() - } else { - null - } - - val newRepositoryKey = resolvedPath - val persistedSftpKeyFilePath = if (backendType == RepositoryBackendType.SFTP && sftpKeyAuthRequired) { - sftpKeyFileForRepository(newRepositoryKey).absolutePath - } else { - null - } - - val tempSftpKeyFile = if (backendType == RepositoryBackendType.SFTP && sftpKeyAuthRequired && sftpKey.isNotBlank()) { - val keyFile = File.createTempFile("sftp-key", ".tmp", context.cacheDir) - keyFile.writeText(sftpKey) - keyFile.setReadable(false, false) - keyFile.setWritable(false, false) - keyFile.setExecutable(false, false) - keyFile.setReadable(true, true) - keyFile.setWritable(true, true) - keyFile - } else { - null - } - - val hasRestAuthCredentials = backendType == RepositoryBackendType.REST && - restUsername.isNotBlank() && - restPassword.isNotBlank() - val hasS3AuthCredentials = backendType == RepositoryBackendType.S3 && - s3AccessKeyId.isNotBlank() && - s3SecretAccessKey.isNotBlank() - - val persistedResticOptions = applySftpResticOptionDefaults( - backendType = backendType, - options = resticOptions, - knownHostsFile = knownHostsFile, - strictHostKeyCheckingValue = "yes", - sftpKeyFilePath = persistedSftpKeyFilePath - ) - - val executionResticOptions = applySftpResticOptionDefaults( - backendType = backendType, - options = resticOptions, - knownHostsFile = knownHostsFile, - strictHostKeyCheckingValue = "yes", - sftpKeyFilePath = tempSftpKeyFile?.absolutePath - ) - - val persistedEnvironmentVariables = when { - backendType == RepositoryBackendType.SFTP && !sftpKeyAuthRequired && sftpPassword.isNotBlank() -> { - val envResult = prepareSshAuthenticationEnvironment( - baseEnvironmentVariables = environmentVariables, - sshPassword = sftpPassword - ) - if (envResult.isFailure) { - tempSftpKeyFile?.delete() - return@withContext AddRepositoryState.Error( - envResult.exceptionOrNull()?.message - ?: context.getString(R.string.repo_error_sftp_password_setup_failed) - ) - } - envResult.getOrThrow() - } - backendType == RepositoryBackendType.SFTP && sftpKeyAuthRequired && sftpKeyPassphrase.isNotBlank() -> { - val envResult = prepareSshAuthenticationEnvironment( - baseEnvironmentVariables = environmentVariables, - sftpKeyPassphrase = sftpKeyPassphrase - ) - if (envResult.isFailure) { - tempSftpKeyFile?.delete() - return@withContext AddRepositoryState.Error( - envResult.exceptionOrNull()?.message - ?: context.getString(R.string.repo_error_sftp_password_setup_failed) - ) - } - envResult.getOrThrow() - } - backendType == RepositoryBackendType.SFTP -> applySftpClientEnvironmentDefaults(environmentVariables) - else -> environmentVariables - } - - val executionEnvironmentVariables = if (hasRestAuthCredentials) { - persistedEnvironmentVariables + mapOf( - "RESTIC_REST_USERNAME" to restUsername, - "RESTIC_REST_PASSWORD" to restPassword - ) - } else if (hasS3AuthCredentials) { - persistedEnvironmentVariables + mapOf( - "AWS_ACCESS_KEY_ID" to s3AccessKeyId, - "AWS_SECRET_ACCESS_KEY" to s3SecretAccessKey - ) - } else { - persistedEnvironmentVariables - } - - val draftRepository = LocalRepository( - path = resolvedPath, - backendType = backendType, - restAuthRequired = hasRestAuthCredentials, - s3AuthRequired = hasS3AuthCredentials, - sftpKeyAuthRequired = sftpKeyAuthRequired, - sftpKeyPassphraseRequired = sftpKeyPassphrase.isNotBlank(), - environmentVariables = persistedEnvironmentVariables, - resticOptions = persistedResticOptions - ) - val persistedRepositoryKey = repositoryKey(draftRepository) - - if (persistedResticOptions.keys.any { !isValidResticOptionName(it) }) { - tempSftpKeyFile?.delete() - return@withContext AddRepositoryState.Error(context.getString(R.string.settings_error_invalid_restic_options)) - } - - if (repositories.value.any { repositoryKey(it) == persistedRepositoryKey }) { - tempSftpKeyFile?.delete() - return@withContext AddRepositoryState.Error(context.getString(R.string.repo_error_exists)) - } - - val binaryState = binaryManager.resticState.value - if (binaryState !is ResticState.Installed) { - tempSftpKeyFile?.delete() - return@withContext AddRepositoryState.Error(context.getString(R.string.repo_error_binary_not_ready)) - } - - val resticPath = binaryState.path - val wasEmpty = repositories.value.isEmpty() - val passwordFile = File.createTempFile("restic-pass", ".tmp", context.cacheDir) - val envPrefix = buildShellEnvironmentPrefix(executionEnvironmentVariables) - val resticOptionFlags = buildResticOptionFlags(executionResticOptions) - - try { - passwordFile.writeText(password) - - if (backendType == RepositoryBackendType.LOCAL) { - val repoExists = Shell.cmd("[ -f ${shellQuote(resolvedPath)}/config ]").exec().isSuccess - val directoryExists = Shell.cmd("[ -d ${shellQuote(resolvedPath)} ]").exec().isSuccess - - if (repoExists) { - val checkResult = Shell.cmd(buildString { - if (envPrefix.isNotEmpty()) append(envPrefix).append(' ') - append("HOME=").append(shellQuote(context.cacheDir.absolutePath)).append(' ') - append("RESTIC_CACHE_DIR=").append(shellQuote(File(context.cacheDir, "restic").absolutePath)).append(' ') - append("RESTIC_PASSWORD_FILE=").append(shellQuote(passwordFile.absolutePath)).append(' ') - append(shellQuote(resticPath)).append(' ') - if (resticOptionFlags.isNotEmpty()) append(resticOptionFlags).append(' ') - append("-r ").append(shellQuote(resolvedPath)).append(" list keys --no-lock") - }).exec() - if (checkResult.isSuccess) { - handleSuccessfulRepoAdd( - draftRepository, - password, - resticRepository, - executionEnvironmentVariables, - savePassword, - wasEmpty, - sftpPassword, - sftpKey, - sftpKeyPassphrase, - s3AccessKeyId, - s3SecretAccessKey, - restUsername, - restPassword - ) - } else { - AddRepositoryState.Error(context.getString(R.string.repo_error_invalid_password_or_corrupted)) - } - } else { - if (directoryExists) { - val directoryHasEntries = Shell.cmd("find ${shellQuote(resolvedPath)} -mindepth 1 -print -quit | grep -q .").exec().isSuccess - if (directoryHasEntries) { - return@withContext AddRepositoryState.Error(context.getString(R.string.repo_error_directory_not_repository)) - } - } else { - val mkdirResult = Shell.cmd("mkdir -p ${shellQuote(resolvedPath)}").exec() - if (!mkdirResult.isSuccess) { - return@withContext AddRepositoryState.Error(context.getString(R.string.repo_error_failed_create_directory)) - } - } - - val initResult = Shell.cmd(buildString { - if (envPrefix.isNotEmpty()) append(envPrefix).append(' ') - append("RESTIC_PASSWORD_FILE=").append(shellQuote(passwordFile.absolutePath)).append(' ') - append(shellQuote(resticPath)).append(' ') - if (resticOptionFlags.isNotEmpty()) append(resticOptionFlags).append(' ') - append("-r ").append(shellQuote(resolvedPath)).append(" init") - }).exec() - if (initResult.isSuccess) { - handleSuccessfulRepoAdd( - draftRepository, - password, - resticRepository, - executionEnvironmentVariables, - savePassword, - wasEmpty, - sftpPassword, - sftpKey, - sftpKeyPassphrase, - s3AccessKeyId, - s3SecretAccessKey, - restUsername, - restPassword - ) - } else { - if (!directoryExists) { - Shell.cmd("rm -rf ${shellQuote(resolvedPath)}").exec() - } - AddRepositoryState.Error(context.getString(R.string.repo_error_failed_initialize)) - } - } - } else { - // For S3: wrap list keys with a timeout so a missing/nonexistent bucket - // doesn't cause infinite retrying. `timeout` returns exit code 124 when it kills the process. - val checkCommand = buildString { - if (envPrefix.isNotEmpty()) append(envPrefix).append(' ') - append("HOME=").append(shellQuote(context.cacheDir.absolutePath)).append(' ') - append("RESTIC_CACHE_DIR=").append(shellQuote(File(context.cacheDir, "restic").absolutePath)).append(' ') - append("RESTIC_PASSWORD_FILE=").append(shellQuote(passwordFile.absolutePath)).append(' ') - if (backendType == RepositoryBackendType.S3) { - append("timeout ${S3_CHECK_TIMEOUT_SECONDS}s ") - } - append(shellQuote(resticPath)).append(' ') - if (resticOptionFlags.isNotEmpty()) append(resticOptionFlags).append(' ') - append("-r ").append(shellQuote(resolvedPath)).append(" list keys --no-lock") - } - val (checkResult, checkErrorOutput) = execShellWithCapturedError(checkCommand) - - if (checkResult.isSuccess) { - handleSuccessfulRepoAdd( - draftRepository, - password, - resticRepository, - executionEnvironmentVariables, - savePassword, - wasEmpty, - sftpPassword, - sftpKey, - sftpKeyPassphrase, - s3AccessKeyId, - s3SecretAccessKey, - restUsername, - restPassword - ) - } else { - // exit code 124 = killed by timeout, or stderr explicitly says bucket missing - var isMissingS3Bucket = false - if (backendType == RepositoryBackendType.S3) { - val timedOut = checkResult.code == 124 - val bucketMissing = checkErrorOutput.contains(MISSING_S3_BUCKET_ERROR, ignoreCase = true) - isMissingS3Bucket = timedOut || bucketMissing - } - - val initResult = Shell.cmd(buildString { - if (envPrefix.isNotEmpty()) append(envPrefix).append(' ') - append("RESTIC_PASSWORD_FILE=").append(shellQuote(passwordFile.absolutePath)).append(' ') - append(shellQuote(resticPath)).append(' ') - if (resticOptionFlags.isNotEmpty()) append(resticOptionFlags).append(' ') - append("-r ").append(shellQuote(resolvedPath)).append(" init") - }).exec() - - if (initResult.isSuccess) { - handleSuccessfulRepoAdd( - draftRepository, - password, - resticRepository, - executionEnvironmentVariables, - savePassword, - wasEmpty, - sftpPassword, - sftpKey, - sftpKeyPassphrase, - s3AccessKeyId, - s3SecretAccessKey, - restUsername, - restPassword - ) - } else { - if (isMissingS3Bucket) { - AddRepositoryState.Error(context.getString(R.string.repo_error_s3_bucket_not_found)) - } else { - AddRepositoryState.Error(context.getString(R.string.repo_error_failed_initialize)) - } - } - } - } - } finally { - passwordFile.delete() - tempSftpKeyFile?.delete() - } - } - } - - private suspend fun handleSuccessfulRepoAdd( - repo: LocalRepository, - password: String, - resticRepository: ResticRepository, - executionEnvironmentVariables: Map, - savePassword: Boolean, - wasEmpty: Boolean, - sftpPassword: String, - sftpKey: String, - sftpKeyPassphrase: String, - s3AccessKeyId: String, - s3SecretAccessKey: String, - restUsername: String, - restPassword: String - ): AddRepositoryState { - val configResult = resticRepository.getConfig( - repo.path, - password, - executionEnvironmentVariables, - repo.resticOptions - ) - if (configResult.isSuccess) { - val repoId = configResult.getOrNull()?.id - var metadataRestoreSuccess = true - if (repoId != null) { - val restoreResult = restoreMetadataForRepo( - repoId, - repo.path, - password, - resticRepository, - executionEnvironmentVariables, - repo.resticOptions - ) - if (restoreResult.isFailure) { - metadataRestoreSuccess = false - } - } - resticRepository.clearSnapshots() - saveNewRepository( - repo = repo.copy(id = repoId), - password = password, - sftpPassword = sftpPassword, - sftpKey = sftpKey, - sftpKeyPassphrase = sftpKeyPassphrase, - s3AccessKeyId = s3AccessKeyId, - s3SecretAccessKey = s3SecretAccessKey, - restUsername = restUsername, - restPassword = restPassword, - save = savePassword, - wasEmpty = wasEmpty - ) - return if (metadataRestoreSuccess) { - AddRepositoryState.Success - } else { - AddRepositoryState.SuccessWithMetadataWarning(context.getString(R.string.repo_warning_metadata_restore_failed)) - } - } - return AddRepositoryState.Error(context.getString(R.string.repo_error_failed_get_id)) - } - - suspend fun restoreMetadataForRepo( - repoId: String, - repoPath: String, - password: String, - resticRepository: ResticRepository, - environmentVariables: Map, - resticOptions: Map - ): Result { - val tempRestoreDir = File(context.cacheDir, "metadata_restore_${System.currentTimeMillis()}").also { it.mkdirs() } - return try { - val snapshotsResult = resticRepository.getSnapshots(repoPath, password, environmentVariables, resticOptions) - if (snapshotsResult.isFailure) { - return Result.failure(Exception("Failed to load snapshots for metadata restoration: ${snapshotsResult.exceptionOrNull()?.message}")) - } - - val snapshots = snapshotsResult.getOrNull() - val metadataSnapshot = snapshots - ?.filter { it.tags.contains("restoid") && it.tags.contains("metadata") } - ?.maxByOrNull { it.time } - - if (metadataSnapshot != null) { - val restoreResult = resticRepository.restore( - repoPath = repoPath, - password = password, - snapshotId = metadataSnapshot.id, - targetPath = tempRestoreDir.absolutePath, - pathsToRestore = emptyList(), - environmentVariables = environmentVariables, - resticOptions = resticOptions - ) - - if (restoreResult.isSuccess) { - val finalMetadataParentDir = File(context.filesDir, "metadata") - if (!finalMetadataParentDir.exists()) finalMetadataParentDir.mkdirs() - - val ownerResult = Shell.cmd("stat -c '%u:%g' ${context.filesDir.absolutePath}").exec() - val owner = if (ownerResult.isSuccess) ownerResult.out.firstOrNull()?.trim() else null - - if (owner != null) { - val chownResult = Shell.cmd("chown -R $owner ${shellQuote(tempRestoreDir.absolutePath)}").exec() - val cpResult = Shell.cmd("cp -a ${shellQuote(tempRestoreDir.absolutePath + "/.")} ${shellQuote(finalMetadataParentDir.absolutePath + "/")}").exec() - if (chownResult.isSuccess && cpResult.isSuccess) { - Result.success(Unit) - } else { - Result.failure(Exception("Failed to copy or chown metadata files")) - } - } else { - Result.failure(Exception("Failed to get owner for metadata directory")) - } - } else { - Result.failure(Exception("Restic restore command failed")) - } - } else { - Result.success(Unit) // No metadata to restore, which is fine - } - } catch (e: Exception) { - Log.e("RepoRepo", "Error during metadata restore", e) - Result.failure(e) - } finally { - tempRestoreDir.deleteRecursively() - } - } - - private fun saveNewRepository( - repo: LocalRepository, - password: String, - sftpPassword: String, - sftpKey: String, - sftpKeyPassphrase: String, - s3AccessKeyId: String, - s3SecretAccessKey: String, - restUsername: String, - restPassword: String, - save: Boolean, - wasEmpty: Boolean - ) { - saveRepository(repo) - val key = repositoryKey(repo) - if (save) passwordManager.savePassword(key, password) - else passwordManager.savePasswordTemporary(key, password) - - if (repo.backendType == RepositoryBackendType.SFTP) { - if (repo.sftpKeyAuthRequired && sftpKey.isNotBlank()) { - if (save) passwordManager.saveSftpKey(key, sftpKey) - else passwordManager.saveSftpKeyTemporary(key, sftpKey) - if (repo.sftpKeyPassphraseRequired && sftpKeyPassphrase.isNotBlank()) { - if (save) passwordManager.saveSftpKeyPassphrase(key, sftpKeyPassphrase) - else passwordManager.saveSftpKeyPassphraseTemporary(key, sftpKeyPassphrase) - } - } else if (!repo.sftpKeyAuthRequired && sftpPassword.isNotBlank()) { - if (save) passwordManager.saveSftpPassword(key, sftpPassword) - else passwordManager.saveSftpPasswordTemporary(key, sftpPassword) - } - } - - if (repo.backendType == RepositoryBackendType.S3 && s3AccessKeyId.isNotBlank() && s3SecretAccessKey.isNotBlank()) { - if (save) { - passwordManager.saveS3AccessKeyId(key, s3AccessKeyId) - passwordManager.saveS3SecretAccessKey(key, s3SecretAccessKey) - } else { - passwordManager.saveS3AccessKeyIdTemporary(key, s3AccessKeyId) - passwordManager.saveS3SecretAccessKeyTemporary(key, s3SecretAccessKey) - } - } - - if (repo.backendType == RepositoryBackendType.REST && restUsername.isNotBlank() && restPassword.isNotBlank()) { - if (save) { - passwordManager.saveRestUsername(key, restUsername) - passwordManager.saveRestPassword(key, restPassword) - } else { - passwordManager.saveRestUsernameTemporary(key, restUsername) - passwordManager.saveRestPasswordTemporary(key, restPassword) - } - } - - loadRepositories() - selectRepository(key) - } - - // Captures stderr separately so callers can inspect the error message, - // e.g. to distinguish a missing S3 bucket from a wrong password. - private fun execShellWithCapturedError(command: String): Pair { - val stdout = ArrayList() - val stderr = ArrayList() - val result = Shell.cmd(command).to(stdout, stderr).exec() - val errorOutput = stderr.joinToString("\n").trim().ifBlank { - result.err.joinToString("\n").trim() - } - return result to errorOutput - } - - private fun saveRepository(repository: LocalRepository) { - val currentJsonSet = prefs.getStringSet(REPOS_KEY, emptySet())?.toMutableSet() ?: mutableSetOf() - val repoJson = json.encodeToString(repository) - currentJsonSet.add(repoJson) - prefs.edit().putStringSet(REPOS_KEY, currentJsonSet).apply() - } - - private fun normalizeRepositoryPath(path: String, backendType: RepositoryBackendType): String { - val trimmed = path.trim() - if (trimmed.isEmpty()) return trimmed - - return when (backendType) { - RepositoryBackendType.LOCAL -> trimmed - RepositoryBackendType.SFTP -> if (trimmed.startsWith("sftp:")) trimmed else "sftp:$trimmed" - RepositoryBackendType.REST -> if (trimmed.startsWith("rest:")) trimmed else "rest:$trimmed" - RepositoryBackendType.S3 -> if (trimmed.startsWith("s3:")) trimmed else "s3:$trimmed" - } - } - - private fun prepareSshAuthenticationEnvironment( - baseEnvironmentVariables: Map, - sshPassword: String? = null, - sftpKeyPassphrase: String? = null - ): Result> { - val sshPathResult = Shell.cmd("command -v ssh").exec() - val sshPath = sshPathResult.out.firstOrNull()?.trim().orEmpty() - if (!sshPathResult.isSuccess || sshPath.isBlank()) { - return Result.failure(IllegalStateException(context.getString(R.string.repo_error_sftp_ssh_binary_not_found))) - } - - val askpassDir = File(context.filesDir, "sftp-askpass") - if (!askpassDir.exists() && !askpassDir.mkdirs()) { - return Result.failure(IllegalStateException(context.getString(R.string.repo_error_sftp_askpass_create_failed))) - } - - val askpassScript = File(askpassDir, "ssh-askpass.sh") - runCatching { - askpassScript.writeText( - "#!/system/bin/sh\n" + - "if [ -n \"\$${SSH_KEY_PASSPHRASE_ENV}\" ]; then\n" + - " printf '%s\\n' \"\$${SSH_KEY_PASSPHRASE_ENV}\"\n" + - " exit 0\n" + - "fi\n" + - "if [ -n \"\$SSHPASS\" ]; then\n" + - " printf '%s\\n' \"\$SSHPASS\"\n" + - " exit 0\n" + - "fi\n" + - "exit 1\n" - ) - askpassScript.setExecutable(true, true) - askpassDir.setExecutable(true, true) - }.onFailure { - return Result.failure(IllegalStateException(context.getString(R.string.repo_error_sftp_askpass_create_failed))) - } - - val environmentWithDefaults = applySftpClientEnvironmentDefaults(baseEnvironmentVariables) - val displayValue = environmentWithDefaults["DISPLAY"] ?: "restoid:0" - val authEnvironment = mutableMapOf() - if (!sshPassword.isNullOrBlank()) { - authEnvironment["SSHPASS"] = sshPassword - } - if (!sftpKeyPassphrase.isNullOrBlank()) { - authEnvironment[SSH_KEY_PASSPHRASE_ENV] = sftpKeyPassphrase - } - - return Result.success( - environmentWithDefaults + authEnvironment + mapOf( - "SSH_ASKPASS" to askpassScript.absolutePath, - "SSH_ASKPASS_REQUIRE" to "force", - "DISPLAY" to displayValue - ) - ) - } - - private fun applySftpClientEnvironmentDefaults(baseEnvironmentVariables: Map): Map { - val result = linkedMapOf() - result.putAll(baseEnvironmentVariables) - - if (result["HOME"].isNullOrBlank()) { - result["HOME"] = context.filesDir.absolutePath - } - if (result["TMPDIR"].isNullOrBlank()) { - result["TMPDIR"] = context.cacheDir.absolutePath - } - - return result - } - - private fun ensureSftpKnownHostsFile(): Result { - val sshDir = File(context.filesDir, "ssh") - val knownHostsFile = File(sshDir, "known_hosts") - - return runCatching { - if (!sshDir.exists() && !sshDir.mkdirs()) { - throw IllegalStateException(context.getString(R.string.repo_error_sftp_known_hosts_setup_failed)) - } - if (!knownHostsFile.exists() && !knownHostsFile.createNewFile()) { - throw IllegalStateException(context.getString(R.string.repo_error_sftp_known_hosts_setup_failed)) - } - - sshDir.setExecutable(true, true) - sshDir.setReadable(true, true) - sshDir.setWritable(true, true) - knownHostsFile.setReadable(true, true) - knownHostsFile.setWritable(true, true) - - knownHostsFile - } - } - - private fun ensureSftpKeyFile(repositoryKey: String, sftpKey: String?): Result { - if (sftpKey.isNullOrBlank()) return Result.success(null) - - val keysDir = File(context.filesDir, "sftp-keys") - val keyFile = sftpKeyFileForRepository(repositoryKey) - - return runCatching { - if (!keysDir.exists() && !keysDir.mkdirs()) { - throw IllegalStateException(context.getString(R.string.repo_error_sftp_key_storage_failed)) - } - if (!keyFile.exists() && !keyFile.createNewFile()) { - throw IllegalStateException(context.getString(R.string.repo_error_sftp_key_storage_failed)) - } - - keyFile.writeText(sftpKey) - - keysDir.setExecutable(true, true) - keysDir.setReadable(true, true) - keysDir.setWritable(true, true) - - // Owner read/write only - keyFile.setReadable(false, false) - keyFile.setWritable(false, false) - keyFile.setExecutable(false, false) - keyFile.setReadable(true, true) - keyFile.setWritable(true, true) - - keyFile - } - } - - private fun deleteSftpKeyFile(repositoryKey: String) { - val keyFile = sftpKeyFileForRepository(repositoryKey) - if (keyFile.exists()) { - keyFile.delete() - } - } - - private fun sftpKeyFileForRepository(repositoryKey: String): File { - val hash = MessageDigest.getInstance("SHA-256") - .digest(repositoryKey.toByteArray()) - .joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) } - return File(File(context.filesDir, "sftp-keys"), "${hash.take(32)}_id") - } - - private fun buildDefaultSftpSshArgs( - knownHostsFile: File, - strictHostKeyCheckingValue: String, - sftpKeyFilePath: String? - ): String { - val baseArgs = "-o BatchMode=no " + - "-o StrictHostKeyChecking=$strictHostKeyCheckingValue " + - "-o UserKnownHostsFile=${knownHostsFile.absolutePath} " + - "-o GlobalKnownHostsFile=/dev/null " + - "-o ConnectTimeout=15 " - - return if (sftpKeyFilePath != null) { - baseArgs + "-o PubkeyAuthentication=yes " + - "-o PasswordAuthentication=no " + - "-o IdentitiesOnly=yes " + - "-i $sftpKeyFilePath" - } else { - baseArgs + "-o PubkeyAuthentication=no " + - "-o KbdInteractiveAuthentication=no " + - "-o PasswordAuthentication=yes " + - "-o PreferredAuthentications=password " + - "-o NumberOfPasswordPrompts=1" - } - } - - private fun readKnownHostEntries(file: File): List { - if (!file.exists()) return emptyList() - - return runCatching { - file.readLines() - .map { it.trim() } - .filter { it.isNotBlank() && !it.startsWith("#") } - }.getOrDefault(emptyList()) - } - - private fun parseFingerprintFromKnownHostEntry(entry: String): String? { - val parts = entry.split(Regex("\\s+")) - if (parts.size < 3) return null - - val keyTypeIndex = if (parts.firstOrNull()?.startsWith("@") == true) 2 else 1 - val keyIndex = keyTypeIndex + 1 - if (parts.size <= keyIndex) return null - - val algorithm = parts[keyTypeIndex] - val keyBase64 = parts[keyIndex] - val decoded = runCatching { Base64.getDecoder().decode(keyBase64) }.getOrNull() ?: return null - val digest = MessageDigest.getInstance("SHA-256").digest(decoded) - val fingerprint = Base64.getEncoder().withoutPadding().encodeToString(digest) - - return "$algorithm SHA256:$fingerprint" - } - - private fun buildSftpEndpointLabel(path: String): String { - val withoutScheme = path.removePrefix("sftp:") - val authority = withoutScheme.substringBefore(":/") - val hostPort = authority.substringAfterLast('@', authority) - - if (hostPort.startsWith("[")) { - val closingBracket = hostPort.indexOf(']') - if (closingBracket > 0) { - return hostPort.substring(0, closingBracket + 1) - } - } - - return hostPort.substringBefore(':') - } - - private fun applySftpResticOptionDefaults( - backendType: RepositoryBackendType, - options: Map, - knownHostsFile: File?, - strictHostKeyCheckingValue: String, - sftpKeyFilePath: String? = null - ): Map { - if (backendType != RepositoryBackendType.SFTP) { - return options - } - - if (!options["sftp.command"].isNullOrBlank()) { - return options - } - - if (knownHostsFile == null) { - return options - } - - return options + mapOf( - "sftp.args" to buildDefaultSftpSshArgs( - knownHostsFile = knownHostsFile, - strictHostKeyCheckingValue = strictHostKeyCheckingValue, - sftpKeyFilePath = sftpKeyFilePath - ) - ) - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/data/Repository.kt b/app/src/main/java/io/github/hddq/restoid/data/Repository.kt deleted file mode 100644 index 3af55a31..00000000 --- a/app/src/main/java/io/github/hddq/restoid/data/Repository.kt +++ /dev/null @@ -1,47 +0,0 @@ -package io.github.hddq.restoid.data - -import kotlinx.serialization.Serializable -import java.io.File - -/** - * Represents a restic repository. - * @param path The repository specification understood by restic. - * @param name A user-friendly name, derived from the path. - * @param id The unique ID of the restic repository. - */ -@Serializable -enum class RepositoryBackendType { - LOCAL, - SFTP, - REST, - S3 -} - -@Serializable -data class LocalRepository( - val path: String, - val backendType: RepositoryBackendType = RepositoryBackendType.LOCAL, - val name: String = defaultRepositoryName(path, backendType), - val id: String? = null, - val restAuthRequired: Boolean = false, - val s3AuthRequired: Boolean = false, - val sftpKeyAuthRequired: Boolean = false, - val sftpKeyPassphraseRequired: Boolean = false, - val environmentVariables: Map = emptyMap(), - val resticOptions: Map = emptyMap() -) - -private fun defaultRepositoryName(path: String, backendType: RepositoryBackendType): String { - val trimmed = path.trim().trimEnd('/') - if (trimmed.isEmpty()) return path - - return when (backendType) { - RepositoryBackendType.LOCAL -> File(trimmed).name.ifBlank { trimmed } - RepositoryBackendType.SFTP, - RepositoryBackendType.REST, - RepositoryBackendType.S3 -> { - val segment = trimmed.substringAfterLast('/').substringAfterLast(':') - if (segment.isBlank()) trimmed else segment - } - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/data/ResticBinaryManager.kt b/app/src/main/java/io/github/hddq/restoid/data/ResticBinaryManager.kt deleted file mode 100644 index 21cc6ce2..00000000 --- a/app/src/main/java/io/github/hddq/restoid/data/ResticBinaryManager.kt +++ /dev/null @@ -1,64 +0,0 @@ -package io.github.hddq.restoid.data - -import android.content.Context -import android.os.Build -import android.util.Log -import com.topjohnwu.superuser.Shell -import io.github.hddq.restoid.R -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.withContext -import java.io.File - -/** - * Responsible solely for managing the Restic binary executable. - * - Resolves the bundled binary path - * - Checks if installed and executable - */ -class ResticBinaryManager(private val context: Context) { - - private val _resticState = MutableStateFlow(ResticState.Idle) - val resticState = _resticState.asStateFlow() - - private val resticFile: File - get() = resolveBundledBinary() - - fun getBinaryPath(): String? = - resticFile.takeIf { it.exists() && it.canExecute() }?.absolutePath - - // Check the status of the restic binary by executing it - suspend fun checkResticStatus() { - withContext(Dispatchers.IO) { - if (resticFile.exists() && resticFile.canExecute()) { - val result = Shell.cmd("${resticFile.absolutePath} version").exec() - if (result.isSuccess) { - val versionOutput = result.out.firstOrNull()?.trim() ?: context.getString(R.string.restic_unknown_version) - val version = versionOutput.split(" ").getOrNull(1) ?: context.getString(R.string.restic_unknown) - _resticState.value = ResticState.Installed(resticFile.absolutePath, version, versionOutput) - } else { - _resticState.value = ResticState.Error(context.getString(R.string.restic_error_binary_corrupted)) - } - } else { - _resticState.value = ResticState.Error(context.getString(R.string.restic_error_bundled_not_found)) - } - } - } - - private fun resolveBundledBinary(): File { - val nativeLibDir = File(context.applicationInfo.nativeLibraryDir) - val directBinary = File(nativeLibDir, "librestic.so") - if (directBinary.exists()) { - return directBinary - } - - val abi = Build.SUPPORTED_ABIS.firstOrNull().orEmpty() - val fallbackBinary = File(nativeLibDir, "../$abi/librestic.so") - if (fallbackBinary.exists()) { - return fallbackBinary - } - - Log.w("ResticBinMgr", "Bundled restic binary missing in ${nativeLibDir.absolutePath}") - return directBinary - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/data/ResticExecutor.kt b/app/src/main/java/io/github/hddq/restoid/data/ResticExecutor.kt deleted file mode 100644 index 58ea0ea1..00000000 --- a/app/src/main/java/io/github/hddq/restoid/data/ResticExecutor.kt +++ /dev/null @@ -1,114 +0,0 @@ -package io.github.hddq.restoid.data - -import android.content.Context -import com.topjohnwu.superuser.CallbackList -import com.topjohnwu.superuser.Shell -import io.github.hddq.restoid.R -import io.github.hddq.restoid.util.buildResticOptionFlags -import io.github.hddq.restoid.util.buildShellEnvironmentPrefix -import io.github.hddq.restoid.util.isValidEnvironmentVariableName -import io.github.hddq.restoid.util.isValidResticOptionName -import io.github.hddq.restoid.util.shellQuote -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.File - -/** - * Low-level execution engine for Restic commands. - * Handles: - * - Creating temporary password files - * - Constructing the shell command with the binary path - * - Executing via libsu - * - Returning Result - */ -class ResticExecutor( - private val context: Context, - private val binaryManager: ResticBinaryManager -) { - - fun binaryPath(): String? = binaryManager.getBinaryPath() - - /** - * Executes a restic command. - * @param repoPath Path to the repository. - * @param password Repository password. - * @param command The restic command arguments (e.g. "snapshots --json"). - * @param failureMessage Default message if the command fails with empty stderr. - * @param environmentVariables Optional environment variables to prepend. - * @param resticOptions Optional restic backend options passed via -o. - * @param stdoutCallback Optional callback for streaming stdout (e.g., for progress). - */ - suspend fun execute( - repoPath: String, - password: String, - command: String, - failureMessage: String = context.getString(R.string.restic_executor_failure_command), - environmentVariables: Map = emptyMap(), - resticOptions: Map = emptyMap(), - stdoutCallback: CallbackList? = null - ): Result { - return withContext(Dispatchers.IO) { - val state = binaryManager.resticState.value - if (state !is ResticState.Installed) { - return@withContext Result.failure(Exception(context.getString(R.string.restic_executor_failure_not_ready))) - } - - if (environmentVariables.keys.any { !isValidEnvironmentVariableName(it) }) { - return@withContext Result.failure(Exception(context.getString(R.string.restic_executor_failure_invalid_env_name))) - } - - if (resticOptions.keys.any { !isValidResticOptionName(it) }) { - return@withContext Result.failure(Exception(context.getString(R.string.restic_executor_failure_invalid_option_name))) - } - - val resticPath = state.path - val passwordFile = File.createTempFile("restic-pass", ".tmp", context.cacheDir) - - try { - passwordFile.writeText(password) - - val envPrefix = buildShellEnvironmentPrefix(environmentVariables) - val optionFlags = buildResticOptionFlags(resticOptions) - - // Construct the full command - // We use RESTIC_PASSWORD_FILE env var to avoid leaking password in process list - val fullCommand = buildString { - if (envPrefix.isNotEmpty()) append(envPrefix).append(' ') - append("RESTIC_PASSWORD_FILE=").append(shellQuote(passwordFile.absolutePath)).append(' ') - append("RESTIC_CACHE_DIR=").append(shellQuote(File(context.cacheDir, "restic").absolutePath)).append(' ') - append(shellQuote(resticPath)).append(' ') - append("--retry-lock 5s ") - if (optionFlags.isNotEmpty()) append(optionFlags).append(' ') - append("-r ").append(shellQuote(repoPath)).append(' ') - append(command) - } - - val shellTask = Shell.cmd(fullCommand) - - // Attach callback if provided - val stderr = mutableListOf() - if (stdoutCallback != null) { - shellTask.to(stdoutCallback, stderr) - } else { - // Otherwise just capture stderr normally - shellTask.to(ArrayList(), stderr) - } - - val result = shellTask.exec() - - if (result.isSuccess) { - Result.success(result.out.joinToString("\n")) - } else { - val errorOutput = stderr.joinToString("\n") - val errorMsg = if (errorOutput.isEmpty()) failureMessage else errorOutput - Result.failure(Exception(errorMsg)) - } - } catch (e: Exception) { - Result.failure(e) - } finally { - // Always clean up the sensitive password file - passwordFile.delete() - } - } - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/data/ResticRepository.kt b/app/src/main/java/io/github/hddq/restoid/data/ResticRepository.kt deleted file mode 100644 index 1cd39520..00000000 --- a/app/src/main/java/io/github/hddq/restoid/data/ResticRepository.kt +++ /dev/null @@ -1,449 +0,0 @@ -package io.github.hddq.restoid.data - -import android.content.Context -import android.util.Log -import com.topjohnwu.superuser.Shell -import io.github.hddq.restoid.R -import io.github.hddq.restoid.model.ResticConfig -import io.github.hddq.restoid.util.buildResticOptionFlags -import io.github.hddq.restoid.util.buildShellEnvironmentPrefix -import io.github.hddq.restoid.util.isValidEnvironmentVariableName -import io.github.hddq.restoid.util.isValidResticOptionName -import io.github.hddq.restoid.util.shellQuote -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json -import java.io.File - -/** - * High-level repository logic. - * Uses [ResticExecutor] to run commands and maps output to data models. - * Uses [ResticBinaryManager] mostly to check state availability. - */ -class ResticRepository( - private val context: Context, - private val executor: ResticExecutor -) { - - private val _snapshots = MutableStateFlow?>(null) - val snapshots = _snapshots.asStateFlow() - - private val json = Json { ignoreUnknownKeys = true } - - fun clearSnapshots() { - _snapshots.value = null - } - - suspend fun getSnapshots( - repoPath: String, - password: String, - environmentVariables: Map = emptyMap(), - resticOptions: Map = emptyMap() - ): Result> { - return withContext(Dispatchers.IO) { - val result = executor.execute( - repoPath = repoPath, - password = password, - command = "snapshots --json", - failureMessage = context.getString(R.string.restic_failure_load_snapshots), - environmentVariables = environmentVariables, - resticOptions = resticOptions - ) - - result.map { output -> - val snapshots = parseSnapshotsJson(output) - _snapshots.value = snapshots - snapshots - } - } - } - - suspend fun refreshSnapshots( - repoPath: String, - password: String, - environmentVariables: Map = emptyMap(), - resticOptions: Map = emptyMap() - ) { - getSnapshots(repoPath, password, environmentVariables, resticOptions) - } - - suspend fun check( - repoPath: String, - password: String, - readAllData: Boolean, - environmentVariables: Map = emptyMap(), - resticOptions: Map = emptyMap() - ): Result { - val args = if (readAllData) "check --read-data" else "check" - return executor.execute( - repoPath, - password, - args, - context.getString(R.string.restic_failure_check_repository), - environmentVariables = environmentVariables, - resticOptions = resticOptions - ) - } - - suspend fun prune( - repoPath: String, - password: String, - environmentVariables: Map = emptyMap(), - resticOptions: Map = emptyMap() - ): Result { - return executor.execute( - repoPath, - password, - "prune", - context.getString(R.string.restic_failure_prune_repository), - environmentVariables = environmentVariables, - resticOptions = resticOptions - ) - } - - suspend fun ls( - repoPath: String, - password: String, - snapshotId: String, - environmentVariables: Map = emptyMap(), - resticOptions: Map = emptyMap() - ): Result> { - return withContext(Dispatchers.IO) { - val result = executor.execute( - repoPath = repoPath, - password = password, - command = "ls --json $snapshotId", - failureMessage = context.getString(R.string.restic_failure_load_snapshots), - environmentVariables = environmentVariables, - resticOptions = resticOptions - ) - - result.map { output -> - output.lines().mapNotNull { line -> - if (line.trim().startsWith("{")) { - extractJsonField(line, "path") - } else { - null - } - } - } - } - } - - suspend fun dump( - repoPath: String, - password: String, - snapshotId: String, - filePath: String, - environmentVariables: Map = emptyMap(), - resticOptions: Map = emptyMap() - ): Result { - return executor.execute( - repoPath = repoPath, - password = password, - command = "dump $snapshotId ${shellQuote(filePath)}", - failureMessage = context.getString(R.string.restic_executor_failure_command), - environmentVariables = environmentVariables, - resticOptions = resticOptions - ) - } - - suspend fun unlock( - repoPath: String, - password: String, - environmentVariables: Map = emptyMap(), - resticOptions: Map = emptyMap() - ): Result { - return executor.execute( - repoPath, - password, - "unlock", - context.getString(R.string.restic_failure_unlock_repository), - environmentVariables = environmentVariables, - resticOptions = resticOptions - ) - } - - suspend fun forget( - repoPath: String, - password: String, - keepLast: Int, - keepDaily: Int, - keepWeekly: Int, - keepMonthly: Int, - environmentVariables: Map = emptyMap(), - resticOptions: Map = emptyMap() - ): Result { - val forgetOptions = buildString { - if (keepLast > 0) append(" --keep-last $keepLast") - if (keepDaily > 0) append(" --keep-daily $keepDaily") - if (keepWeekly > 0) append(" --keep-weekly $keepWeekly") - if (keepMonthly > 0) append(" --keep-monthly $keepMonthly") - } - - if (forgetOptions.isBlank()) { - return Result.failure(Exception(context.getString(R.string.restic_failure_no_forget_policy))) - } - - val groupingAndTagOptions = " --group-by host,tags --tag 'restoid' --tag 'backup'" - return executor.execute( - repoPath, - password, - "forget$forgetOptions$groupingAndTagOptions", - context.getString(R.string.restic_failure_forget_snapshots), - environmentVariables = environmentVariables, - resticOptions = resticOptions - ) - } - - suspend fun forgetSnapshot( - repoPath: String, - password: String, - snapshotId: String, - environmentVariables: Map = emptyMap(), - resticOptions: Map = emptyMap() - ): Result { - return executor.execute( - repoPath, - password, - "forget $snapshotId", - context.getString(R.string.restic_failure_delete_snapshot), - environmentVariables = environmentVariables, - resticOptions = resticOptions - ) - .map { - refreshSnapshots(repoPath, password, environmentVariables, resticOptions) - Unit - } - } - - suspend fun forgetMetadataSnapshots( - repoPath: String, - password: String, - environmentVariables: Map = emptyMap(), - resticOptions: Map = emptyMap() - ): Result { - val command = "forget --keep-last 5 --tag 'restoid' --tag 'metadata'" - return executor.execute( - repoPath, - password, - command, - context.getString(R.string.restic_failure_forget_metadata), - environmentVariables = environmentVariables, - resticOptions = resticOptions - ) - } - - suspend fun getConfig( - repoPath: String, - password: String, - environmentVariables: Map = emptyMap(), - resticOptions: Map = emptyMap() - ): Result { - return executor.execute( - repoPath, - password, - "cat config --json --no-lock", - context.getString(R.string.restic_failure_get_repo_config), - environmentVariables = environmentVariables, - resticOptions = resticOptions - ) - .mapCatching { jsonOutput -> - json.decodeFromString(jsonOutput) - } - } - - suspend fun changePassword( - repoPath: String, - oldPassword: String, - newPassword: String, - environmentVariables: Map = emptyMap(), - resticOptions: Map = emptyMap() - ): Result { - return withContext(Dispatchers.IO) { - val newPassFile = File.createTempFile("restic-new-pass", ".tmp", context.cacheDir) - try { - newPassFile.writeText(newPassword) - val command = "key passwd --new-password-file ${shellQuote(newPassFile.absolutePath)}" - executor.execute( - repoPath, - oldPassword, - command, - context.getString(R.string.restic_failure_change_password), - environmentVariables = environmentVariables, - resticOptions = resticOptions - ) - .map { Unit } - } finally { - newPassFile.delete() - } - } - } - - suspend fun restore( - repoPath: String, - password: String, - snapshotId: String, - targetPath: String, - pathsToRestore: List, - environmentVariables: Map = emptyMap(), - resticOptions: Map = emptyMap() - ): Result { - val includes = pathsToRestore.joinToString(" ") { "--include ${shellQuote(it)}" } - val command = "restore $snapshotId --target ${shellQuote(targetPath)} $includes" - return executor.execute( - repoPath, - password, - command, - context.getString(R.string.restic_failure_restore_snapshot), - environmentVariables = environmentVariables, - resticOptions = resticOptions - ) - } - - suspend fun backupMetadata( - repositoryId: String, - repoPath: String, - password: String, - environmentVariables: Map = emptyMap(), - resticOptions: Map = emptyMap() - ) { - // Backing up metadata requires specific CD commands to avoid recursive paths in the snapshot. - // We bypass the executor here to replicate the pre-refactor logic exactly. - - if (environmentVariables.keys.any { !isValidEnvironmentVariableName(it) }) return - if (resticOptions.keys.any { !isValidResticOptionName(it) }) return - - val resticPath = executor.binaryPath() ?: return - - var passwordFile: File? = null - try { - val metadataDir = File(context.filesDir, "metadata") - if (!metadataDir.exists() || !metadataDir.isDirectory) return - - passwordFile = File.createTempFile("restic-pass-meta", ".tmp", context.cacheDir) - passwordFile.writeText(password) - - val tags = listOf("restoid", "metadata") - val tagFlags = tags.joinToString(" ") { "--tag '$it'" } - - val envPrefix = buildShellEnvironmentPrefix(environmentVariables) - val optionFlags = buildResticOptionFlags(resticOptions) - - // Use 'cd' to ensure relative paths in the backup for simpler restore. - // This specific structure prevents absolute path recursion in the snapshot. - val command = buildString { - append("cd ").append(shellQuote(metadataDir.absolutePath)).append(" && ") - if (envPrefix.isNotEmpty()) append(envPrefix).append(' ') - append("RESTIC_PASSWORD_FILE=").append(shellQuote(passwordFile.absolutePath)).append(' ') - append("RESTIC_CACHE_DIR=").append(shellQuote(File(context.cacheDir, "restic").absolutePath)).append(' ') - append(shellQuote(resticPath)).append(' ') - append("--retry-lock 5s ") - if (optionFlags.isNotEmpty()) append(optionFlags).append(' ') - append("-r ").append(shellQuote(repoPath)).append(' ') - append("backup ").append(shellQuote(repositoryId)).append(" --json ").append(tagFlags) - } - - withContext(Dispatchers.IO) { - val result = Shell.cmd(command).exec() - if (!result.isSuccess) { - Log.e("ResticRepo", "Metadata backup failed: ${result.err.joinToString("\n")}") - } else { - // After a successful metadata backup, prune the old ones. - val forgetResult = forgetMetadataSnapshots(repoPath, password, environmentVariables, resticOptions) - if (forgetResult.isFailure) { - Log.e("ResticRepo", "Forgetting old metadata snapshots failed: ${forgetResult.exceptionOrNull()?.message}") - } else { - Log.d("ResticRepo", "Successfully forgot old metadata snapshots.") - } - } - } - } catch (e: Exception) { - Log.e("ResticRepo", "Exception during metadata backup", e) - } finally { - passwordFile?.delete() - } - } - - // --- JSON Parsing Helpers --- - - private fun parseSnapshotsJson(jsonOutput: String): List { - val snapshots = mutableListOf() - try { - val lines = jsonOutput.trim().lines() - if (lines.size == 1 && lines[0].trim().startsWith("[")) { - val content = lines[0].trim().removeSurrounding("[", "]") - if (content.isNotEmpty()) { - splitJsonObjects(content).forEach { parseSnapshotObject(it)?.let { s -> snapshots.add(s) } } - } - } else { - lines.forEach { line -> - if (line.trim().startsWith("{")) parseSnapshotObject(line.trim())?.let { snapshots.add(it) } - } - } - } catch (e: Exception) { - // Parsing failed - } - return snapshots - } - - private fun splitJsonObjects(content: String): List { - val objects = mutableListOf() - var braceCount = 0 - var start = 0 - var inString = false - var escapeNext = false - - for (i in content.indices) { - val char = content[i] - if (escapeNext) { - escapeNext = false; continue - } - when (char) { - '\\' -> escapeNext = true - '"' -> if (!escapeNext) inString = !inString - '{' -> if (!inString) braceCount++ - '}' -> if (!inString) { - braceCount-- - if (braceCount == 0) { - objects.add(content.substring(start, i + 1)) - start = i + 1 - while (start < content.length && (content[start] == ',' || content[start].isWhitespace())) start++ - } - } - } - } - return objects - } - - private fun parseSnapshotObject(jsonObj: String): SnapshotInfo? { - return try { - val id = extractJsonField(jsonObj, "id") ?: extractJsonField(jsonObj, "short_id") ?: return null - val time = extractJsonField(jsonObj, "time") ?: context.getString(R.string.restic_unknown) - val paths = extractJsonArrayField(jsonObj, "paths") - val tags = extractJsonArrayField(jsonObj, "tags") - SnapshotInfo(id, time, paths, tags) - } catch (e: Exception) { - null - } - } - - private fun extractJsonField(json: String, field: String): String? { - return Regex("\"$field\"\\s*:\\s*\"([^\"]*)\"").find(json)?.groupValues?.get(1) - } - - private fun extractJsonArrayField(json: String, field: String): List { - val match = Regex("\"$field\"\\s*:\\s*\\[([^\\]]*)\\]").find(json)?.groupValues?.get(1) ?: return emptyList() - if (match.isBlank()) return emptyList() - return match.split(",").map { it.trim().removeSurrounding("\"") }.filter { it.isNotEmpty() } - } -} - -data class SnapshotInfo( - val id: String, - val time: String, - val paths: List, - val tags: List -) diff --git a/app/src/main/java/io/github/hddq/restoid/data/ResticState.kt b/app/src/main/java/io/github/hddq/restoid/data/ResticState.kt deleted file mode 100644 index b3321470..00000000 --- a/app/src/main/java/io/github/hddq/restoid/data/ResticState.kt +++ /dev/null @@ -1,11 +0,0 @@ -package io.github.hddq.restoid.data - -/** - * Represents the state of the restic binary on the device. - * Extracted to a separate file to be shared across Manager and Repository. - */ -sealed class ResticState { - object Idle : ResticState() - data class Installed(val path: String, val version: String, val fullVersionOutput: String) : ResticState() - data class Error(val message: String) : ResticState() -} diff --git a/app/src/main/java/io/github/hddq/restoid/data/RootRepository.kt b/app/src/main/java/io/github/hddq/restoid/data/RootRepository.kt deleted file mode 100644 index 591eff0c..00000000 --- a/app/src/main/java/io/github/hddq/restoid/data/RootRepository.kt +++ /dev/null @@ -1,27 +0,0 @@ -package io.github.hddq.restoid.data - -import com.topjohnwu.superuser.Shell -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.withContext - -sealed class RootState { - object Checking : RootState() - object Granted : RootState() - object Denied : RootState() -} - -class RootRepository { - private val _rootState = MutableStateFlow(RootState.Checking) - val rootState = _rootState.asStateFlow() - - suspend fun checkRootAccess() { - _rootState.value = RootState.Checking - val hasRoot = withContext(Dispatchers.IO) { - runCatching { Shell.getCachedShell()?.close() } - Shell.getShell().isRoot - } - _rootState.value = if (hasRoot) RootState.Granted else RootState.Denied - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/data/ScheduleRepository.kt b/app/src/main/java/io/github/hddq/restoid/data/ScheduleRepository.kt deleted file mode 100644 index 162c5d06..00000000 --- a/app/src/main/java/io/github/hddq/restoid/data/ScheduleRepository.kt +++ /dev/null @@ -1,128 +0,0 @@ -package io.github.hddq.restoid.data - -import android.content.Context -import androidx.work.* -import io.github.hddq.restoid.model.Schedule -import io.github.hddq.restoid.work.ScheduleWorker -import java.util.concurrent.TimeUnit - -class ScheduleRepository( - private val context: Context, - private val metadataRepository: MetadataRepository, - private val repositoriesRepository: RepositoriesRepository -) { - private val workManager = WorkManager.getInstance(context) - - suspend fun getSchedules(repoId: String): List { - return metadataRepository.getSchedules(repoId) - } - - suspend fun saveSchedule(repoKey: String, repoId: String, schedule: Schedule) { - val schedules = getSchedules(repoId).toMutableList() - val index = schedules.indexOfFirst { it.id == schedule.id } - - val scheduleToSave = if (index != -1) { - schedule.copy(lastRunTimestamp = schedules[index].lastRunTimestamp) - } else { - schedule - } - - if (index != -1) { - schedules[index] = scheduleToSave - } else { - schedules.add(scheduleToSave) - } - metadataRepository.saveSchedules(repoId, schedules) - updateWorkManager(repoKey, scheduleToSave) - } - - suspend fun toggleSchedule(repoKey: String, repoId: String, scheduleId: String, isEnabled: Boolean) { - val schedules = getSchedules(repoId).toMutableList() - val index = schedules.indexOfFirst { it.id == scheduleId } - if (index != -1) { - val updatedSchedule = schedules[index].copy(isEnabled = isEnabled) - schedules[index] = updatedSchedule - metadataRepository.saveSchedules(repoId, schedules) - updateWorkManager(repoKey, updatedSchedule) - } - } - - suspend fun deleteSchedule(repoKey: String, repoId: String, scheduleId: String) { - val schedules = getSchedules(repoId).filter { it.id != scheduleId } - metadataRepository.saveSchedules(repoId, schedules) - cancelWork(scheduleId) - } - - private fun updateWorkManager(repoKey: String, schedule: Schedule) { - if (schedule.isEnabled) { - val networkType = if (schedule.triggerConditions.requireUnmeteredNetwork) - NetworkType.UNMETERED else NetworkType.NOT_REQUIRED - val constraints = Constraints.Builder() - .setRequiredNetworkType(networkType) - .setRequiresBatteryNotLow(schedule.triggerConditions.requireBatteryNotLow) - .setRequiresCharging(schedule.triggerConditions.requireCharging) - .build() - - var initialDelay = 0L - if (schedule.lastRunTimestamp != null) { - val elapsedMillis = System.currentTimeMillis() - schedule.lastRunTimestamp - val intervalMillis = TimeUnit.HOURS.toMillis(schedule.intervalHours.toLong()) - val remainingMillis = intervalMillis - elapsedMillis - if (remainingMillis > 0) { - initialDelay = remainingMillis - } - } - - val workRequest = PeriodicWorkRequestBuilder( - schedule.intervalHours.toLong(), TimeUnit.HOURS - ) - .setConstraints(constraints) - .setInitialDelay(initialDelay, TimeUnit.MILLISECONDS) - .setInputData( - workDataOf( - ScheduleWorker.KEY_REPO_KEY to repoKey, - ScheduleWorker.KEY_SCHEDULE_ID to schedule.id - ) - ) - .build() - - workManager.enqueueUniquePeriodicWork( - getWorkName(schedule.id), - ExistingPeriodicWorkPolicy.UPDATE, - workRequest - ) - } else { - cancelWork(schedule.id) - } - } - - private fun cancelWork(scheduleId: String) { - workManager.cancelUniqueWork(getWorkName(scheduleId)) - } - - private fun getWorkName(scheduleId: String): String = "schedule_$scheduleId" - - fun runNow(repoKey: String, schedule: Schedule) { - val workRequest = OneTimeWorkRequestBuilder() - .setInputData( - workDataOf( - ScheduleWorker.KEY_REPO_KEY to repoKey, - ScheduleWorker.KEY_SCHEDULE_ID to schedule.id - ) - ) - .build() - workManager.enqueue(workRequest) - } - - suspend fun reconcileAllSchedules() { - val reposList = repositoriesRepository.repositories.value - for (repository in reposList) { - val repoKey = repositoriesRepository.repositoryKey(repository) - val repoId = repository.id ?: continue - val schedules = getSchedules(repoId) - for (schedule in schedules) { - updateWorkManager(repoKey, schedule) - } - } - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/model/AppinfoDataClass.kt b/app/src/main/java/io/github/hddq/restoid/model/AppinfoDataClass.kt deleted file mode 100644 index d9d2dcd1..00000000 --- a/app/src/main/java/io/github/hddq/restoid/model/AppinfoDataClass.kt +++ /dev/null @@ -1,13 +0,0 @@ -package io.github.hddq.restoid.model - -import android.graphics.drawable.Drawable - -data class AppInfo( - val name: String, - val packageName: String, - val versionName: String, - val versionCode: Long, - val icon: Drawable, - val apkPaths: List, - val isSelected: Boolean = true -) diff --git a/app/src/main/java/io/github/hddq/restoid/model/BackupDetail.kt b/app/src/main/java/io/github/hddq/restoid/model/BackupDetail.kt deleted file mode 100644 index d8689dd8..00000000 --- a/app/src/main/java/io/github/hddq/restoid/model/BackupDetail.kt +++ /dev/null @@ -1,23 +0,0 @@ -package io.github.hddq.restoid.model - -/** - * A data class to hold the processed information about what was backed up for a specific app - * within a snapshot. - * - * @param appInfo Details about the application (name, icon, etc.). - * @param backedUpItems A list of strings representing the types of data backed up (e.g., "APK", "Data"). - * @param versionName The version of the app at the time of backup. - * @param versionCode The version code of the app at the time of backup. - * @param backupSize The total size of the backed up files for this app, in bytes. - * @param isDowngrade True if the backed up version is older than the currently installed version. - * @param isInstalled True if the app is currently installed on the device. - */ -data class BackupDetail( - val appInfo: AppInfo, - val backedUpItems: List, - val versionName: String? = null, - val versionCode: Long? = null, - val backupSize: Long? = null, - val isDowngrade: Boolean, - val isInstalled: Boolean -) diff --git a/app/src/main/java/io/github/hddq/restoid/model/BackupMetadataModel.kt b/app/src/main/java/io/github/hddq/restoid/model/BackupMetadataModel.kt deleted file mode 100644 index aa0c5263..00000000 --- a/app/src/main/java/io/github/hddq/restoid/model/BackupMetadataModel.kt +++ /dev/null @@ -1,43 +0,0 @@ -package io.github.hddq.restoid.model - -import kotlinx.serialization.Serializable - -@Serializable -data class CustomDirectoryMetadata( - val size: Long -) - -/** - * Represents the top-level structure of the `restoid.json` metadata file. - * It contains a map of applications that were included in the snapshot. - * - * @param apps A map where the key is the package name (e.g., "com.example.app") and - * the value is an [AppMetadata] object containing details about the backup for that app. - */ -@Serializable -data class RestoidMetadata( - val apps: Map, - val customDirectories: Map = emptyMap() -) - -/** - * Contains metadata for a single application within a snapshot. - * This information helps in understanding what was backed up for each app. - * - * @param size The total calculated size of the backed-up files for this app in bytes. - * @param types A list of strings indicating which parts of the app were backed up - * (e.g., "apk", "data", "user_de"). - * @param versionCode The version code of the app at the time of backup. - * @param versionName The version name of the app at the time of backup. - * @param grantedRuntimePermissions A list of dangerous/runtime permissions that were granted - * for this app at backup time (e.g., camera, location). - */ -@Serializable -data class AppMetadata( - val size: Long, - val types: List, - val versionCode: Long, - val versionName: String, - val appName: String? = null, - val grantedRuntimePermissions: List = emptyList() -) diff --git a/app/src/main/java/io/github/hddq/restoid/model/CustomDirectory.kt b/app/src/main/java/io/github/hddq/restoid/model/CustomDirectory.kt deleted file mode 100644 index 0d2500d2..00000000 --- a/app/src/main/java/io/github/hddq/restoid/model/CustomDirectory.kt +++ /dev/null @@ -1,6 +0,0 @@ -package io.github.hddq.restoid.model - -data class CustomDirectory( - val uri: String, - val isSelected: Boolean = true -) diff --git a/app/src/main/java/io/github/hddq/restoid/model/MaintenanceConfig.kt b/app/src/main/java/io/github/hddq/restoid/model/MaintenanceConfig.kt deleted file mode 100644 index fc81ef56..00000000 --- a/app/src/main/java/io/github/hddq/restoid/model/MaintenanceConfig.kt +++ /dev/null @@ -1,13 +0,0 @@ -package io.github.hddq.restoid.model - -data class MaintenanceConfig( - val unlockRepo: Boolean = false, - val forgetSnapshots: Boolean = false, - val pruneRepo: Boolean = false, - val checkRepo: Boolean = true, - val readData: Boolean = false, - val keepLast: Int = 5, - val keepDaily: Int = 7, - val keepWeekly: Int = 4, - val keepMonthly: Int = 6 -) diff --git a/app/src/main/java/io/github/hddq/restoid/model/ResticConfig.kt b/app/src/main/java/io/github/hddq/restoid/model/ResticConfig.kt deleted file mode 100644 index 54d41681..00000000 --- a/app/src/main/java/io/github/hddq/restoid/model/ResticConfig.kt +++ /dev/null @@ -1,10 +0,0 @@ -package io.github.hddq.restoid.model - -import kotlinx.serialization.Serializable - -@Serializable -data class ResticConfig( - val id: String, - val version: Int, - val chunker_polynomial: String -) diff --git a/app/src/main/java/io/github/hddq/restoid/model/Schedule.kt b/app/src/main/java/io/github/hddq/restoid/model/Schedule.kt deleted file mode 100644 index 365d0651..00000000 --- a/app/src/main/java/io/github/hddq/restoid/model/Schedule.kt +++ /dev/null @@ -1,23 +0,0 @@ -package io.github.hddq.restoid.model - -import io.github.hddq.restoid.work.RunTasksConfig -import kotlinx.serialization.Serializable -import java.util.UUID - -@Serializable -data class TriggerConditions( - val requireBatteryNotLow: Boolean = false, - val requireCharging: Boolean = false, - val requireUnmeteredNetwork: Boolean = false -) - -@Serializable -data class Schedule( - val id: String = UUID.randomUUID().toString(), - val name: String, - val intervalHours: Int, - val config: RunTasksConfig, - val triggerConditions: TriggerConditions = TriggerConditions(), - val lastRunTimestamp: Long? = null, - val isEnabled: Boolean = true -) diff --git a/app/src/main/java/io/github/hddq/restoid/ui/components/PasswordDialog.kt b/app/src/main/java/io/github/hddq/restoid/ui/components/PasswordDialog.kt deleted file mode 100644 index 4dd163d1..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/components/PasswordDialog.kt +++ /dev/null @@ -1,114 +0,0 @@ -package io.github.hddq.restoid.ui.components - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Checkbox -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.unit.dp -import io.github.hddq.restoid.R - -@Composable -fun PasswordDialog( - title: String, - message: String, - onPasswordEntered: (String, Boolean) -> Unit, - onDismiss: () -> Unit -) { - var password by remember { mutableStateOf("") } - var savePassword by remember { mutableStateOf(false) } - var isError by remember { mutableStateOf(false) } - val keyboardController = LocalSoftwareKeyboardController.current - - val submitPassword = { - if (password.isNotBlank()) { - onPasswordEntered(password, savePassword) - } else { - isError = true - } - } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(text = title) }, - text = { - Column { - Text(text = message) - Spacer(modifier = Modifier.height(16.dp)) - OutlinedTextField( - value = password, - onValueChange = { - password = it - isError = false - }, - label = { Text(stringResource(R.string.label_password)) }, - visualTransformation = PasswordVisualTransformation(), - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done - ), - keyboardActions = KeyboardActions( - onDone = { - keyboardController?.hide() - submitPassword() - } - ), - isError = isError, - supportingText = if (isError) { - { Text(stringResource(R.string.error_password_cannot_be_empty)) } - } else null, - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically - ) { - Checkbox( - checked = savePassword, - onCheckedChange = { savePassword = it } - ) - Text(text = stringResource(R.string.save_password)) - } - } - }, - confirmButton = { - Button( - onClick = { - keyboardController?.hide() - submitPassword() - } - ) { - Text(stringResource(R.string.action_ok)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) - } - } - ) -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/components/SshPrivateKeyField.kt b/app/src/main/java/io/github/hddq/restoid/ui/components/SshPrivateKeyField.kt deleted file mode 100644 index 3bf15785..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/components/SshPrivateKeyField.kt +++ /dev/null @@ -1,86 +0,0 @@ -package io.github.hddq.restoid.ui.components - -import android.content.Context -import android.net.Uri -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.FolderOpen -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import io.github.hddq.restoid.R - -@Composable -fun SshPrivateKeyField( - value: String, - onValueChange: (String) -> Unit, - onImportError: (String) -> Unit, - label: String, - modifier: Modifier = Modifier, - placeholder: String = "", - enabled: Boolean = true, - isError: Boolean = false, - supportingText: (@Composable (() -> Unit))? = null -) { - val context = LocalContext.current - val launcher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.OpenDocument(), - onResult = { uri -> - if (uri == null) return@rememberLauncherForActivityResult - - readPrivateKeyText(context, uri) - .onSuccess(onValueChange) - .onFailure { error -> - onImportError( - error.message ?: context.getString(R.string.error_sftp_key_import_failed) - ) - } - } - ) - - OutlinedTextField( - value = value, - onValueChange = onValueChange, - label = { Text(label) }, - placeholder = if (placeholder.isNotBlank()) { - { Text(placeholder) } - } else { - null - }, - minLines = 3, - maxLines = 6, - trailingIcon = { - IconButton( - onClick = { launcher.launch(arrayOf("*/*")) }, - enabled = enabled - ) { - Icon( - imageVector = Icons.Default.FolderOpen, - contentDescription = stringResource(R.string.cd_select_private_key_file) - ) - } - }, - isError = isError, - supportingText = supportingText, - modifier = modifier, - enabled = enabled - ) -} - -private fun readPrivateKeyText(context: Context, uri: Uri): Result { - return runCatching { - val imported = context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { reader -> - reader.readText() - } ?: throw IllegalStateException(context.getString(R.string.error_sftp_key_import_failed)) - - imported.trimEnd().ifBlank { - throw IllegalStateException(context.getString(R.string.error_sftp_key_file_empty)) - } - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/components/UsernamePasswordDialog.kt b/app/src/main/java/io/github/hddq/restoid/ui/components/UsernamePasswordDialog.kt deleted file mode 100644 index cf725776..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/components/UsernamePasswordDialog.kt +++ /dev/null @@ -1,168 +0,0 @@ -package io.github.hddq.restoid.ui.components - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Checkbox -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.dp -import io.github.hddq.restoid.R - -@Composable -fun UsernamePasswordDialog( - title: String, - message: String, - usernameLabel: String, - passwordLabel: String, - onCredentialsEntered: (String, String, Boolean) -> Unit, - onDismiss: () -> Unit, - saveCredentialsLabel: String = "" -) { - var username by remember { mutableStateOf("") } - var password by remember { mutableStateOf("") } - var saveCredentials by remember { mutableStateOf(false) } - var passwordVisible by remember { mutableStateOf(false) } - var isError by remember { mutableStateOf(false) } - val keyboardController = LocalSoftwareKeyboardController.current - - val submitCredentials = { - if (username.isNotBlank() && password.isNotBlank()) { - onCredentialsEntered(username, password, saveCredentials) - } else { - isError = true - } - } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(text = title) }, - text = { - Column { - Text(text = message) - Spacer(modifier = Modifier.height(16.dp)) - - OutlinedTextField( - value = username, - onValueChange = { - username = it - isError = false - }, - label = { Text(usernameLabel) }, - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Next - ), - isError = isError && username.isBlank(), - supportingText = if (isError && username.isBlank()) { - { Text(stringResource(R.string.error_username_cannot_be_empty)) } - } else { - null - }, - modifier = Modifier.fillMaxWidth() - ) - - Spacer(modifier = Modifier.height(8.dp)) - - OutlinedTextField( - value = password, - onValueChange = { - password = it - isError = false - }, - label = { Text(passwordLabel) }, - visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - val image = if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility - val description = if (passwordVisible) { - stringResource(R.string.cd_hide_password) - } else { - stringResource(R.string.cd_show_password) - } - IconButton(onClick = { passwordVisible = !passwordVisible }) { - Icon(imageVector = image, contentDescription = description) - } - }, - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done - ), - keyboardActions = KeyboardActions( - onDone = { - keyboardController?.hide() - submitCredentials() - } - ), - isError = isError && password.isBlank(), - supportingText = if (isError && password.isBlank()) { - { Text(stringResource(R.string.error_password_cannot_be_empty)) } - } else { - null - }, - modifier = Modifier.fillMaxWidth() - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically - ) { - Checkbox( - checked = saveCredentials, - onCheckedChange = { saveCredentials = it } - ) - Text( - text = if (saveCredentialsLabel.isBlank()) { - stringResource(R.string.save_credentials) - } else { - saveCredentialsLabel - } - ) - } - } - }, - confirmButton = { - Button(onClick = { - keyboardController?.hide() - submitCredentials() - }) { - Text(stringResource(R.string.action_ok)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) - } - } - ) -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/home/HomeViewModel.kt b/app/src/main/java/io/github/hddq/restoid/ui/home/HomeViewModel.kt deleted file mode 100644 index d5d9fe6c..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/home/HomeViewModel.kt +++ /dev/null @@ -1,837 +0,0 @@ -package io.github.hddq.restoid.ui.home - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import io.github.hddq.restoid.R -import io.github.hddq.restoid.data.AppInfoRepository -import io.github.hddq.restoid.data.LocalRepository -import io.github.hddq.restoid.data.MetadataRepository -import io.github.hddq.restoid.data.RepositoriesRepository -import io.github.hddq.restoid.data.RepositoryBackendType -import io.github.hddq.restoid.data.ResticBinaryManager -import io.github.hddq.restoid.data.ResticRepository -import io.github.hddq.restoid.data.ResticState -import io.github.hddq.restoid.data.SnapshotInfo -import io.github.hddq.restoid.model.AppInfo -import io.github.hddq.restoid.model.RestoidMetadata -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -data class SnapshotWithMetadata( - val snapshotInfo: SnapshotInfo, - val metadata: RestoidMetadata? -) - -enum class HomeAuthFailure { - REPOSITORY_PASSWORD, - SFTP_PASSWORD, - REST_CREDENTIALS, - S3_CREDENTIALS -} - -enum class HomeCredentialPrompt { - REPOSITORY_PASSWORD, - SFTP_PASSWORD, - REST_CREDENTIALS, - S3_CREDENTIALS -} - -data class HomeUiState( - val snapshotsWithMetadata: List = emptyList(), - val appInfoMap: Map> = emptyMap(), - val isLoading: Boolean = false, - val isRefreshing: Boolean = false, - val error: String? = null, - val authFailure: HomeAuthFailure? = null, - val openPrompt: HomeCredentialPrompt? = null, - val selectedRepo: String? = null, - val resticState: ResticState = ResticState.Idle, - val showPasswordDialogFor: String? = null, - val showSftpPasswordDialogFor: String? = null, - val isSftpKeyAuthRequired: Boolean = false, - val isSftpKeyPassphraseRequired: Boolean = false, - val showRestCredentialsDialogFor: String? = null, - val showS3CredentialsDialogFor: String? = null, - val isRepoReady: Boolean = false -) - -class HomeViewModel( - private val context: android.content.Context, - private val repositoriesRepository: RepositoriesRepository, - private val resticBinaryManager: ResticBinaryManager, // Use Manager for state - private val resticRepository: ResticRepository, - private val appInfoRepository: AppInfoRepository, - private val metadataRepository: MetadataRepository -) : ViewModel() { - - private val _uiState = MutableStateFlow(HomeUiState()) - val uiState = _uiState.asStateFlow() - private val refreshTrigger = MutableStateFlow(0) - private var lastObservedRepoKey: String? = null - - init { - viewModelScope.launch { - repositoriesRepository.loadRepositories() - resticBinaryManager.checkResticStatus() - } - - combine( - repositoriesRepository.selectedRepository, - resticBinaryManager.resticState, // Observe state from Manager - resticRepository.snapshots, - repositoriesRepository.repositories, - refreshTrigger - ) { repoKey, restic, snapshots, repos, _ -> - - val selectedRepository = repoKey?.let { repositoriesRepository.getRepositoryByKey(it) } - val repoPath = selectedRepository?.path - val env = repoKey?.let { repositoriesRepository.getExecutionEnvironmentVariables(it) }.orEmpty() - val resticOptions = repoKey?.let { repositoriesRepository.getExecutionResticOptions(it) }.orEmpty() - val isRepoSwitch = repoKey != lastObservedRepoKey - - val hasRepositoryPassword = repoKey?.let { repositoriesRepository.hasRepositoryPassword(it) } ?: false - val hasSftpCredentials = if (selectedRepository?.backendType == RepositoryBackendType.SFTP) { - repositoriesRepository.hasSftpCredentials(repositoriesRepository.repositoryKey(selectedRepository)) - } else { - true - } - val hasRestCredentials = if ( - selectedRepository?.backendType == RepositoryBackendType.REST && - selectedRepository.restAuthRequired - ) { - repositoriesRepository.hasRestCredentials(repositoriesRepository.repositoryKey(selectedRepository)) - } else { - true - } - val hasS3Credentials = if ( - selectedRepository?.backendType == RepositoryBackendType.S3 && - selectedRepository.s3AuthRequired - ) { - repositoriesRepository.hasS3Credentials(repositoriesRepository.repositoryKey(selectedRepository)) - } else { - true - } - - val isRepoReady = - repoKey != null && - restic is ResticState.Installed && - hasRepositoryPassword && - hasSftpCredentials && - hasRestCredentials && - hasS3Credentials - - _uiState.update { - it.copy( - selectedRepo = repoKey, - resticState = restic, - isRepoReady = isRepoReady - ) - } - - if (isRepoSwitch) { - lastObservedRepoKey = repoKey - val canLoadSelectedRepo = repoPath != null && restic is ResticState.Installed - - if (snapshots != null) { - resticRepository.clearSnapshots() - } - - _uiState.update { - it.copy( - snapshotsWithMetadata = emptyList(), - appInfoMap = emptyMap(), - isLoading = canLoadSelectedRepo, - isRefreshing = false, - error = null, - authFailure = null, - openPrompt = null, - showPasswordDialogFor = null, - showSftpPasswordDialogFor = null, - showRestCredentialsDialogFor = null, - showS3CredentialsDialogFor = null - ) - } - - if (canLoadSelectedRepo && snapshots == null) { - loadSnapshots(repoPath, env, resticOptions, repoKey, restic) - } - - return@combine - } - - if (repoPath == null || restic !is ResticState.Installed) { - resticRepository.clearSnapshots() - _uiState.update { - it.copy( - snapshotsWithMetadata = emptyList(), - appInfoMap = emptyMap(), - isLoading = false, - error = null, - authFailure = null, - openPrompt = null, - showPasswordDialogFor = null, - showSftpPasswordDialogFor = null, - showRestCredentialsDialogFor = null, - showS3CredentialsDialogFor = null - ) - } - return@combine - } - - if (snapshots == null) { - if (!_uiState.value.isRefreshing) { - _uiState.update { it.copy(isLoading = true, error = null, openPrompt = null) } - } - loadSnapshots(repoPath, env, resticOptions, repoKey, restic) - return@combine - } - - _uiState.update { - it.copy( - isLoading = false, - error = null, - authFailure = null, - openPrompt = null, - showPasswordDialogFor = null, - showSftpPasswordDialogFor = null, - showRestCredentialsDialogFor = null, - showS3CredentialsDialogFor = null - ) - } - - val repo = repos.find { repositoriesRepository.repositoryKey(it) == repoKey } - if (repo?.id == null) { - // repoPath is guaranteed not null here because of the check above - val errorMsg = context.getString(R.string.home_error_repository_id_not_found) - _uiState.update { - it.copy( - snapshotsWithMetadata = emptyList(), - appInfoMap = emptyMap(), - error = errorMsg, - authFailure = null, - openPrompt = null - ) - } - return@combine - } - - val filteredSnapshots = snapshots.filter { it.tags.contains("restoid") && it.tags.contains("backup") } - - val snapshotsWithMetadata = filteredSnapshots.map { snapshot -> - val metadata = metadataRepository.getMetadataForSnapshot(repo.id, snapshot.id) - SnapshotWithMetadata(snapshot, metadata) - } - - _uiState.update { it.copy(snapshotsWithMetadata = snapshotsWithMetadata) } - loadAppInfoForSnapshots(snapshotsWithMetadata) - - }.launchIn(viewModelScope) - } - - private fun loadSnapshots( - repoPath: String?, - environmentVariables: Map, - resticOptions: Map, - repoKey: String?, - resticState: ResticState - ) { - if (repoPath == null || repoKey == null || resticState !is ResticState.Installed) { - _uiState.update { - it.copy( - snapshotsWithMetadata = emptyList(), - appInfoMap = emptyMap(), - isLoading = false, - authFailure = null, - openPrompt = null - ) - } - return - } - - val repository = repositoriesRepository.getRepositoryByKey(repoKey) - ?: run { - _uiState.update { - it.copy( - snapshotsWithMetadata = emptyList(), - appInfoMap = emptyMap(), - isLoading = false, - authFailure = null, - openPrompt = null - ) - } - return - } - - viewModelScope.launch { - if (repository.backendType == RepositoryBackendType.SFTP && !repositoriesRepository.hasSftpCredentials(repoKey)) { - _uiState.update { - it.copy( - isLoading = false, - error = null, - authFailure = null, - openPrompt = null, - showPasswordDialogFor = null, - showSftpPasswordDialogFor = repoKey, - isSftpKeyAuthRequired = repository.sftpKeyAuthRequired, - isSftpKeyPassphraseRequired = repository.sftpKeyPassphraseRequired, - showRestCredentialsDialogFor = null, - showS3CredentialsDialogFor = null - ) - } - return@launch - } - - if ( - repository.backendType == RepositoryBackendType.REST && - repository.restAuthRequired && - !repositoriesRepository.hasRestCredentials(repoKey) - ) { - _uiState.update { - it.copy( - isLoading = false, - error = null, - authFailure = null, - openPrompt = null, - showPasswordDialogFor = null, - showSftpPasswordDialogFor = null, - showRestCredentialsDialogFor = repoKey, - showS3CredentialsDialogFor = null - ) - } - return@launch - } - - if ( - repository.backendType == RepositoryBackendType.S3 && - repository.s3AuthRequired && - !repositoriesRepository.hasS3Credentials(repoKey) - ) { - _uiState.update { - it.copy( - isLoading = false, - error = null, - authFailure = null, - openPrompt = null, - showPasswordDialogFor = null, - showSftpPasswordDialogFor = null, - showRestCredentialsDialogFor = null, - showS3CredentialsDialogFor = repoKey - ) - } - return@launch - } - - if (!repositoriesRepository.hasRepositoryPassword(repoKey)) { - _uiState.update { - it.copy( - isLoading = false, - error = null, - authFailure = null, - openPrompt = null, - showPasswordDialogFor = repoKey, - showSftpPasswordDialogFor = null, - showRestCredentialsDialogFor = null, - showS3CredentialsDialogFor = null - ) - } - return@launch - } - - val password = repositoriesRepository.getRepositoryPassword(repoKey)!! - val result = resticRepository.getSnapshots(repoPath, password, environmentVariables, resticOptions) - if (result.isFailure) { - val rawMessage = result.exceptionOrNull()?.message ?: context.getString(R.string.error_unknown) - val authFailure = classifyAuthenticationFailure(repository, rawMessage) - _uiState.update { - it.copy( - error = when (authFailure) { - HomeAuthFailure.REPOSITORY_PASSWORD -> context.getString(R.string.home_error_repository_password_incorrect) - HomeAuthFailure.SFTP_PASSWORD -> context.getString(R.string.home_error_sftp_password_incorrect) - HomeAuthFailure.REST_CREDENTIALS -> context.getString(R.string.home_error_rest_credentials_incorrect) - HomeAuthFailure.S3_CREDENTIALS -> context.getString(R.string.home_error_s3_credentials_incorrect) - null -> rawMessage - }, - authFailure = authFailure, - openPrompt = null, - isLoading = false, - showPasswordDialogFor = null, - showSftpPasswordDialogFor = null, - showRestCredentialsDialogFor = null, - showS3CredentialsDialogFor = null - ) - } - } - } - } - - fun refreshSnapshots() { - val repoKey = _uiState.value.selectedRepo - val resticState = _uiState.value.resticState - if (repoKey == null || resticState !is ResticState.Installed) return - - val repository = repositoriesRepository.getRepositoryByKey(repoKey) ?: return - val repoPath = repository.path - val environmentVariables = repositoriesRepository.getExecutionEnvironmentVariables(repoKey) - val resticOptions = repositoriesRepository.getExecutionResticOptions(repoKey) - - if (repository.backendType == RepositoryBackendType.SFTP && !repositoriesRepository.hasSftpCredentials(repoKey)) { - _uiState.update { - it.copy( - isRefreshing = false, - error = null, - authFailure = null, - openPrompt = null, - showPasswordDialogFor = null, - showSftpPasswordDialogFor = repoKey, - isSftpKeyAuthRequired = repository.sftpKeyAuthRequired, - isSftpKeyPassphraseRequired = repository.sftpKeyPassphraseRequired, - showRestCredentialsDialogFor = null, - showS3CredentialsDialogFor = null - ) - } - return - } - - if ( - repository.backendType == RepositoryBackendType.REST && - repository.restAuthRequired && - !repositoriesRepository.hasRestCredentials(repoKey) - ) { - _uiState.update { - it.copy( - isRefreshing = false, - error = null, - authFailure = null, - openPrompt = null, - showPasswordDialogFor = null, - showSftpPasswordDialogFor = null, - showRestCredentialsDialogFor = repoKey, - showS3CredentialsDialogFor = null - ) - } - return - } - - if ( - repository.backendType == RepositoryBackendType.S3 && - repository.s3AuthRequired && - !repositoriesRepository.hasS3Credentials(repoKey) - ) { - _uiState.update { - it.copy( - isRefreshing = false, - error = null, - authFailure = null, - openPrompt = null, - showPasswordDialogFor = null, - showSftpPasswordDialogFor = null, - showRestCredentialsDialogFor = null, - showS3CredentialsDialogFor = repoKey - ) - } - return - } - - if (!repositoriesRepository.hasRepositoryPassword(repoKey)) { - _uiState.update { - it.copy( - isRefreshing = false, - error = null, - authFailure = null, - openPrompt = null, - showPasswordDialogFor = repoKey, - showSftpPasswordDialogFor = null, - showRestCredentialsDialogFor = null, - showS3CredentialsDialogFor = null - ) - } - return - } - - viewModelScope.launch { - _uiState.update { it.copy(isRefreshing = true, error = null, authFailure = null, openPrompt = null) } - try { - val password = repositoriesRepository.getRepositoryPassword(repoKey)!! - val result = resticRepository.getSnapshots(repoPath, password, environmentVariables, resticOptions) - if (result.isFailure) { - val rawMessage = result.exceptionOrNull()?.message ?: context.getString(R.string.error_unknown) - val authFailure = classifyAuthenticationFailure(repository, rawMessage) - _uiState.update { - it.copy( - error = when (authFailure) { - HomeAuthFailure.REPOSITORY_PASSWORD -> context.getString(R.string.home_error_repository_password_incorrect) - HomeAuthFailure.SFTP_PASSWORD -> context.getString(R.string.home_error_sftp_password_incorrect) - HomeAuthFailure.REST_CREDENTIALS -> context.getString(R.string.home_error_rest_credentials_incorrect) - HomeAuthFailure.S3_CREDENTIALS -> context.getString(R.string.home_error_s3_credentials_incorrect) - null -> rawMessage - }, - authFailure = authFailure, - openPrompt = null - ) - } - } else { - refreshTrigger.update { it + 1 } - } - } catch (e: Exception) { - _uiState.update { it.copy(error = e.message, authFailure = null, openPrompt = null) } - } finally { - _uiState.update { it.copy(isRefreshing = false) } - } - } - } - - private fun classifyAuthenticationFailure( - repository: LocalRepository, - message: String - ): HomeAuthFailure? { - val normalized = message.lowercase() - val isRepositoryPasswordError = - normalized.contains("wrong password or no key found") || - normalized.contains("wrong password") || - normalized.contains("no key found") - - if (isRepositoryPasswordError) { - return HomeAuthFailure.REPOSITORY_PASSWORD - } - - if (repository.backendType == RepositoryBackendType.SFTP) { - val isSftpAuthError = - normalized.contains("permission denied (") || - normalized.contains("authentication failed") || - normalized.contains("unable to authenticate") || - normalized.contains("too many authentication failures") || - normalized.contains("ssh: handshake failed") || - normalized.contains("publickey,password") || - normalized.contains("keyboard-interactive") - - if (isSftpAuthError) { - return HomeAuthFailure.SFTP_PASSWORD - } - } - - if (repository.backendType == RepositoryBackendType.REST && repository.restAuthRequired) { - val isRestAuthError = - normalized.contains("401") || - normalized.contains("403") || - normalized.contains("unauthorized") || - normalized.contains("forbidden") || - normalized.contains("authentication required") || - normalized.contains("authentication failed") || - normalized.contains("authorization failed") || - normalized.contains("access denied") - - if (isRestAuthError) { - return HomeAuthFailure.REST_CREDENTIALS - } - } - - if (repository.backendType == RepositoryBackendType.S3 && repository.s3AuthRequired) { - val isS3AuthError = - normalized.contains("access key") || - normalized.contains("secret key") || - normalized.contains("invalidaccesskeyid") || - normalized.contains("signaturedoesnotmatch") || - normalized.contains("invalidtoken") || - normalized.contains("invalid argument") && normalized.contains("credentials") || - normalized.contains("the access key id") || - normalized.contains("request signature") - - if (isS3AuthError) { - return HomeAuthFailure.S3_CREDENTIALS - } - } - - return null - } - - private fun loadAppInfoForSnapshots(snapshotsWithMetadata: List) { - viewModelScope.launch { - val appInfoMap = withContext(Dispatchers.Default) { - val map = mutableMapOf>() - snapshotsWithMetadata.forEach { item -> - val appsMetadata = item.metadata?.apps - val packageNames = appsMetadata?.keys?.toList() ?: emptyList() - if (packageNames.isNotEmpty()) { - val appInfos = appInfoRepository.getAppInfoForPackages(packageNames) - val sortedApps = appInfos.sortedByDescending { app -> - appsMetadata?.get(app.packageName)?.size ?: 0L - } - map[item.snapshotInfo.id] = sortedApps - } - } - map - } - _uiState.update { it.copy(appInfoMap = appInfoMap) } - } - } - - fun onPasswordEntered(password: String, save: Boolean) { - val repoKey = _uiState.value.showPasswordDialogFor ?: return - val repository = repositoriesRepository.getRepositoryByKey(repoKey) ?: return - _uiState.update { - it.copy( - showPasswordDialogFor = null, - showSftpPasswordDialogFor = null, - showRestCredentialsDialogFor = null, - showS3CredentialsDialogFor = null, - isLoading = true, - error = null, - authFailure = null, - openPrompt = null - ) - } - - if (save) repositoriesRepository.saveRepositoryPassword(repoKey, password) - else repositoriesRepository.saveRepositoryPasswordTemporary(repoKey, password) - - loadSnapshots( - repository.path, - repositoriesRepository.getExecutionEnvironmentVariables(repoKey), - repositoriesRepository.getExecutionResticOptions(repoKey), - repoKey, - uiState.value.resticState - ) - } - - fun onDismissPasswordDialog() { - _uiState.update { - it.copy( - showPasswordDialogFor = null, - showSftpPasswordDialogFor = null, - showRestCredentialsDialogFor = null, - showS3CredentialsDialogFor = null, - snapshotsWithMetadata = emptyList(), - appInfoMap = emptyMap(), - isLoading = false, - error = null, - authFailure = null, - openPrompt = HomeCredentialPrompt.REPOSITORY_PASSWORD - ) - } - } - - fun onSftpPasswordEntered(credentials: String, passphrase: String, save: Boolean) { - val repoKey = _uiState.value.showSftpPasswordDialogFor ?: return - val repository = repositoriesRepository.getRepositoryByKey(repoKey) ?: return - _uiState.update { - it.copy( - showSftpPasswordDialogFor = null, - showPasswordDialogFor = null, - showRestCredentialsDialogFor = null, - showS3CredentialsDialogFor = null, - isLoading = true, - error = null, - authFailure = null, - openPrompt = null - ) - } - - if (repository.sftpKeyAuthRequired) { - if (save) repositoriesRepository.saveSftpKey(repoKey, credentials) - else repositoriesRepository.saveSftpKeyTemporary(repoKey, credentials) - if (repository.sftpKeyPassphraseRequired) { - if (save) repositoriesRepository.saveSftpKeyPassphrase(repoKey, passphrase) - else repositoriesRepository.saveSftpKeyPassphraseTemporary(repoKey, passphrase) - } - } else { - if (save) repositoriesRepository.saveSftpPassword(repoKey, credentials) - else repositoriesRepository.saveSftpPasswordTemporary(repoKey, credentials) - } - - loadSnapshots( - repository.path, - repositoriesRepository.getExecutionEnvironmentVariables(repoKey), - repositoriesRepository.getExecutionResticOptions(repoKey), - repoKey, - uiState.value.resticState - ) - } - - fun onDismissSftpPasswordDialog() { - _uiState.update { - it.copy( - showSftpPasswordDialogFor = null, - showPasswordDialogFor = null, - showRestCredentialsDialogFor = null, - showS3CredentialsDialogFor = null, - snapshotsWithMetadata = emptyList(), - appInfoMap = emptyMap(), - isLoading = false, - error = null, - authFailure = null, - openPrompt = HomeCredentialPrompt.SFTP_PASSWORD - ) - } - } - - fun onRestCredentialsEntered(username: String, password: String, save: Boolean) { - val repoKey = _uiState.value.showRestCredentialsDialogFor ?: return - val repository = repositoriesRepository.getRepositoryByKey(repoKey) ?: return - _uiState.update { - it.copy( - showRestCredentialsDialogFor = null, - showSftpPasswordDialogFor = null, - showPasswordDialogFor = null, - showS3CredentialsDialogFor = null, - isLoading = true, - error = null, - authFailure = null, - openPrompt = null - ) - } - - if (save) repositoriesRepository.saveRestCredentials(repoKey, username, password) - else repositoriesRepository.saveRestCredentialsTemporary(repoKey, username, password) - - loadSnapshots( - repository.path, - repositoriesRepository.getExecutionEnvironmentVariables(repoKey), - repositoriesRepository.getExecutionResticOptions(repoKey), - repoKey, - uiState.value.resticState - ) - } - - fun onS3CredentialsEntered(accessKeyId: String, secretAccessKey: String, save: Boolean) { - val repoKey = _uiState.value.showS3CredentialsDialogFor ?: return - val repository = repositoriesRepository.getRepositoryByKey(repoKey) ?: return - _uiState.update { - it.copy( - showS3CredentialsDialogFor = null, - showRestCredentialsDialogFor = null, - showSftpPasswordDialogFor = null, - showPasswordDialogFor = null, - isLoading = true, - error = null, - authFailure = null, - openPrompt = null - ) - } - - if (save) repositoriesRepository.saveS3Credentials(repoKey, accessKeyId, secretAccessKey) - else repositoriesRepository.saveS3CredentialsTemporary(repoKey, accessKeyId, secretAccessKey) - - loadSnapshots( - repository.path, - repositoriesRepository.getExecutionEnvironmentVariables(repoKey), - repositoriesRepository.getExecutionResticOptions(repoKey), - repoKey, - uiState.value.resticState - ) - } - - fun onDismissRestCredentialsDialog() { - _uiState.update { - it.copy( - showRestCredentialsDialogFor = null, - showSftpPasswordDialogFor = null, - showPasswordDialogFor = null, - showS3CredentialsDialogFor = null, - snapshotsWithMetadata = emptyList(), - appInfoMap = emptyMap(), - isLoading = false, - error = null, - authFailure = null, - openPrompt = HomeCredentialPrompt.REST_CREDENTIALS - ) - } - } - - fun onDismissS3CredentialsDialog() { - _uiState.update { - it.copy( - showS3CredentialsDialogFor = null, - showRestCredentialsDialogFor = null, - showSftpPasswordDialogFor = null, - showPasswordDialogFor = null, - snapshotsWithMetadata = emptyList(), - appInfoMap = emptyMap(), - isLoading = false, - error = null, - authFailure = null, - openPrompt = HomeCredentialPrompt.S3_CREDENTIALS - ) - } - } - - fun onRetryRepositoryPasswordEntry() { - val repoKey = _uiState.value.selectedRepo ?: return - _uiState.update { - it.copy( - showPasswordDialogFor = repoKey, - showSftpPasswordDialogFor = null, - showRestCredentialsDialogFor = null, - showS3CredentialsDialogFor = null, - isLoading = false, - error = null, - authFailure = null, - openPrompt = null - ) - } - } - - fun onRetrySftpPasswordEntry() { - val repoKey = _uiState.value.selectedRepo ?: return - val repository = repositoriesRepository.getRepositoryByKey(repoKey) ?: return - if (repository.backendType != RepositoryBackendType.SFTP) return - - _uiState.update { - it.copy( - showSftpPasswordDialogFor = repoKey, - isSftpKeyAuthRequired = repository.sftpKeyAuthRequired, - isSftpKeyPassphraseRequired = repository.sftpKeyPassphraseRequired, - showPasswordDialogFor = null, - showRestCredentialsDialogFor = null, - showS3CredentialsDialogFor = null, - isLoading = false, - error = null, - authFailure = null, - openPrompt = null - ) - } - } - - fun onRetryRestCredentialsEntry() { - val repoKey = _uiState.value.selectedRepo ?: return - val repository = repositoriesRepository.getRepositoryByKey(repoKey) ?: return - if (repository.backendType != RepositoryBackendType.REST || !repository.restAuthRequired) return - - _uiState.update { - it.copy( - showRestCredentialsDialogFor = repoKey, - showSftpPasswordDialogFor = null, - showPasswordDialogFor = null, - showS3CredentialsDialogFor = null, - isLoading = false, - error = null, - authFailure = null, - openPrompt = null - ) - } - } - - fun onRetryS3CredentialsEntry() { - val repoKey = _uiState.value.selectedRepo ?: return - val repository = repositoriesRepository.getRepositoryByKey(repoKey) ?: return - if (repository.backendType != RepositoryBackendType.S3 || !repository.s3AuthRequired) return - - _uiState.update { - it.copy( - showS3CredentialsDialogFor = repoKey, - showRestCredentialsDialogFor = null, - showSftpPasswordDialogFor = null, - showPasswordDialogFor = null, - isLoading = false, - error = null, - authFailure = null, - openPrompt = null - ) - } - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/home/HomeViewModelFactory.kt b/app/src/main/java/io/github/hddq/restoid/ui/home/HomeViewModelFactory.kt deleted file mode 100644 index 251c9d19..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/home/HomeViewModelFactory.kt +++ /dev/null @@ -1,27 +0,0 @@ -package io.github.hddq.restoid.ui.home - -import android.content.Context -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import io.github.hddq.restoid.data.AppInfoRepository -import io.github.hddq.restoid.data.MetadataRepository -import io.github.hddq.restoid.data.RepositoriesRepository -import io.github.hddq.restoid.data.ResticBinaryManager -import io.github.hddq.restoid.data.ResticRepository - -class HomeViewModelFactory( - private val context: Context, - private val repositoriesRepository: RepositoriesRepository, - private val resticBinaryManager: ResticBinaryManager, // Injected - private val resticRepository: ResticRepository, - private val appInfoRepository: AppInfoRepository, - private val metadataRepository: MetadataRepository -) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(HomeViewModel::class.java)) { - @Suppress("UNCHECKED_CAST") - return HomeViewModel(context, repositoriesRepository, resticBinaryManager, resticRepository, appInfoRepository, metadataRepository) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/operation/OperationProgressViewModel.kt b/app/src/main/java/io/github/hddq/restoid/ui/operation/OperationProgressViewModel.kt deleted file mode 100644 index 38c3bdfc..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/operation/OperationProgressViewModel.kt +++ /dev/null @@ -1,44 +0,0 @@ -package io.github.hddq.restoid.ui.operation - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import io.github.hddq.restoid.data.OperationRuntimeState -import io.github.hddq.restoid.work.OperationWorkRepository -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch - -sealed interface OperationProgressUiEvent { - data object NavigateUp : OperationProgressUiEvent -} - -class OperationProgressViewModel( - private val operationWorkRepository: OperationWorkRepository -) : ViewModel() { - - private val _state = MutableStateFlow(OperationRuntimeState()) - val state = _state.asStateFlow() - private val _uiEvents = MutableSharedFlow(extraBufferCapacity = 1) - val uiEvents: SharedFlow = _uiEvents.asSharedFlow() - - init { - viewModelScope.launch { - operationWorkRepository.operationState.collect { runtimeState -> - _state.value = runtimeState - } - } - } - - fun onDone() { - operationWorkRepository.clearFinished(_state.value.operationType) - _state.value = OperationRuntimeState() - } - - fun onStopConfirmed() { - operationWorkRepository.cancelCurrentOperation() - _uiEvents.tryEmit(OperationProgressUiEvent.NavigateUp) - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/operation/OperationProgressViewModelFactory.kt b/app/src/main/java/io/github/hddq/restoid/ui/operation/OperationProgressViewModelFactory.kt deleted file mode 100644 index 1a5b6cb5..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/operation/OperationProgressViewModelFactory.kt +++ /dev/null @@ -1,17 +0,0 @@ -package io.github.hddq.restoid.ui.operation - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import io.github.hddq.restoid.work.OperationWorkRepository - -class OperationProgressViewModelFactory( - private val operationWorkRepository: OperationWorkRepository -) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(OperationProgressViewModel::class.java)) { - @Suppress("UNCHECKED_CAST") - return OperationProgressViewModel(operationWorkRepository) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/restore/RestoreRoutes.kt b/app/src/main/java/io/github/hddq/restoid/ui/restore/RestoreRoutes.kt deleted file mode 100644 index 8d69cdc0..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/restore/RestoreRoutes.kt +++ /dev/null @@ -1,6 +0,0 @@ -package io.github.hddq.restoid.ui.restore - -object RestoreRoutes { - const val AppsConfig = "restore_apps_config" - const val CustomDirectoriesConfig = "restore_custom_directories_config" -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/restore/RestoreViewModel.kt b/app/src/main/java/io/github/hddq/restoid/ui/restore/RestoreViewModel.kt deleted file mode 100644 index 82c50a23..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/restore/RestoreViewModel.kt +++ /dev/null @@ -1,526 +0,0 @@ -package io.github.hddq.restoid.ui.restore - -import android.app.Application -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import io.github.hddq.restoid.R -import io.github.hddq.restoid.data.AppInfoRepository -import io.github.hddq.restoid.data.MetadataRepository -import io.github.hddq.restoid.data.OperationType -import io.github.hddq.restoid.data.RepositoriesRepository -import io.github.hddq.restoid.data.RepositoryBackendType -import io.github.hddq.restoid.data.ResticBinaryManager -import io.github.hddq.restoid.data.ResticRepository -import io.github.hddq.restoid.data.ResticState -import io.github.hddq.restoid.data.SnapshotInfo -import io.github.hddq.restoid.model.AppInfo -import io.github.hddq.restoid.model.BackupDetail -import io.github.hddq.restoid.model.RestoidMetadata -import io.github.hddq.restoid.ui.shared.OperationProgress -import io.github.hddq.restoid.work.OperationWorkRepository -import io.github.hddq.restoid.work.RestoreAppSelection -import io.github.hddq.restoid.work.RestoreTypeSelection -import io.github.hddq.restoid.work.RestoreWorkRequest -import kotlinx.serialization.Serializable -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -@Serializable -data class RestoreTypes( - val apk: Boolean = true, - val data: Boolean = true, - val deviceProtectedData: Boolean = true, - val externalData: Boolean = false, - val obb: Boolean = false, - val media: Boolean = false, - val permissions: Boolean = true -) { - fun toSelection(): RestoreTypeSelection { - return RestoreTypeSelection( - apk = apk, - data = data, - deviceProtectedData = deviceProtectedData, - externalData = externalData, - obb = obb, - media = media, - permissions = permissions - ) - } - - fun anyEnabled(): Boolean { - return apk || data || deviceProtectedData || externalData || obb || media || permissions - } -} - -fun RestoreTypeSelection.toUiModel(): RestoreTypes { - return RestoreTypes( - apk = apk, - data = data, - deviceProtectedData = deviceProtectedData, - externalData = externalData, - obb = obb, - media = media, - permissions = permissions - ) -} - -sealed interface RestoreUiEvent { - data object NavigateToOperationProgress : RestoreUiEvent -} - -class RestoreViewModel( - private val application: Application, - private val repositoriesRepository: RepositoriesRepository, - private val resticBinaryManager: ResticBinaryManager, - private val resticRepository: ResticRepository, - private val appInfoRepository: AppInfoRepository, - private val metadataRepository: MetadataRepository, - private val preferencesRepository: io.github.hddq.restoid.data.PreferencesRepository, - private val operationWorkRepository: OperationWorkRepository, - val snapshotId: String -) : ViewModel() { - - private var snapshotMetadata: RestoidMetadata? = null - - private val _snapshot = MutableStateFlow(null) - val snapshot = _snapshot.asStateFlow() - - private val _backupDetails = MutableStateFlow>(emptyList()) - val backupDetails = _backupDetails.asStateFlow() - - private val _customDirectories = MutableStateFlow>(emptyMap()) - val customDirectories = _customDirectories.asStateFlow() - - private val _isLoading = MutableStateFlow(true) - val isLoading = _isLoading.asStateFlow() - - private val _error = MutableStateFlow(null) - val error = _error.asStateFlow() - - private val _restoreTypes = MutableStateFlow(RestoreTypes()) - val restoreTypes = _restoreTypes.asStateFlow() - - private val _appRestoreTypes = MutableStateFlow>(emptyMap()) - val appRestoreTypes = _appRestoreTypes.asStateFlow() - - private val _allowDowngrade = MutableStateFlow(false) - val allowDowngrade = _allowDowngrade.asStateFlow() - - private val _restoreAppsEnabled = MutableStateFlow(true) - val restoreAppsEnabled = _restoreAppsEnabled.asStateFlow() - - private val _restoreCustomDirectoriesEnabled = MutableStateFlow(false) - val restoreCustomDirectoriesEnabled = _restoreCustomDirectoriesEnabled.asStateFlow() - - private val _isRestoring = MutableStateFlow(false) - val isRestoring = _isRestoring.asStateFlow() - - private val _restoreProgress = MutableStateFlow(OperationProgress()) - val restoreProgress = _restoreProgress.asStateFlow() - - private val _operationBlocked = MutableStateFlow(false) - val operationBlocked = _operationBlocked.asStateFlow() - private val _uiEvents = MutableSharedFlow(extraBufferCapacity = 1) - val uiEvents: SharedFlow = _uiEvents.asSharedFlow() - - init { - _restoreTypes.value = preferencesRepository.loadRestoreTypes() - _allowDowngrade.value = preferencesRepository.loadAllowDowngrade() - observeOperationState() - if (snapshotId.isNotBlank()) { - loadSnapshotDetails(snapshotId) - } - } - - private fun observeOperationState() { - viewModelScope.launch { - operationWorkRepository.operationState.collect { state -> - if (state.operationType == OperationType.RESTORE) { - _isRestoring.value = state.isRunning - _restoreProgress.value = state.progress - } else { - _isRestoring.value = false - _restoreProgress.value = OperationProgress() - } - } - } - } - - private fun loadSnapshotDetails(snapshotId: String) { - viewModelScope.launch { - _isLoading.value = true - _error.value = null - _backupDetails.value = emptyList() - try { - val selectedRepoKey = repositoriesRepository.selectedRepository.first() - val repo = selectedRepoKey?.let { repositoriesRepository.getRepositoryByKey(it) } - val repoPath = repo?.path - val password = selectedRepoKey?.let { repositoriesRepository.getRepositoryPassword(it) } - val repoId = repo?.id - - if (repoPath != null && password != null && repoId != null) { - val executionEnvironment = repositoriesRepository.getExecutionEnvironmentVariables(selectedRepoKey) - val resticOptions = repositoriesRepository.getExecutionResticOptions(selectedRepoKey) - - val result = resticRepository.getSnapshots( - repoPath, - password, - executionEnvironment, - resticOptions - ) - result.fold( - onSuccess = { snapshots -> - val foundSnapshot = snapshots.find { it.id.startsWith(snapshotId) } - _snapshot.value = foundSnapshot - if (foundSnapshot != null) { - var metadata = metadataRepository.getMetadataForSnapshot(repoId, foundSnapshot.id) - - if (metadata == null) { - // Attempt to recover metadata from the repository - val lsResult = resticRepository.ls(repoPath, password, foundSnapshot.id, executionEnvironment, resticOptions) - lsResult.fold( - onSuccess = { paths -> - val metadataPath = paths.find { it.endsWith("/restoid.json") } - if (metadataPath != null) { - val dumpResult = resticRepository.dump(repoPath, password, foundSnapshot.id, metadataPath, executionEnvironment, resticOptions) - dumpResult.fold( - onSuccess = { content -> - try { - val json = kotlinx.serialization.json.Json { ignoreUnknownKeys = true } - val recoveredMetadata = json.decodeFromString(content) - metadataRepository.saveMetadataForSnapshot(repoId, foundSnapshot.id, recoveredMetadata) - metadata = recoveredMetadata - } catch (e: Exception) { - android.util.Log.e("RestoreViewModel", "Failed to parse recovered metadata", e) - } - }, - onFailure = { android.util.Log.e("RestoreViewModel", "Failed to dump metadata: ${it.message}") } - ) - } - }, - onFailure = { android.util.Log.e("RestoreViewModel", "Failed to ls snapshot: ${it.message}") } - ) - } - - snapshotMetadata = metadata - processSnapshot(foundSnapshot, metadata) - } else { - _error.value = application.getString(R.string.error_snapshot_not_found) - } - }, - onFailure = { _error.value = it.message } - ) - } else { - _error.value = application.getString(R.string.error_repository_password_or_id_not_found) - } - } catch (e: Exception) { - _error.value = e.message - } finally { - _isLoading.value = false - } - } - } - - private suspend fun processSnapshot(snapshot: SnapshotInfo, metadata: RestoidMetadata?) { - val appMetadataMap = metadata?.apps ?: emptyMap() - val packageNames = appMetadataMap.keys.toList().filter { it != application.packageName } - - if (packageNames.isEmpty()) { - _backupDetails.value = emptyList() - return - } - - val appInfos = appInfoRepository.getAppInfoForPackages(packageNames) - val appInfoMap = appInfos.associateBy { it.packageName } - - val details = appMetadataMap.filterKeys { it != application.packageName }.map { (packageName, appMeta) -> - val appInfo = appInfoMap[packageName] - val items = findBackedUpItems(snapshot, packageName, appMeta.grantedRuntimePermissions.isNotEmpty()) - - val isInstalled = appInfo != null - val isDowngrade = if (isInstalled) { - appMeta.versionCode < appInfo.versionCode - } else { - false - } - - val finalAppInfo = appInfo ?: AppInfo( - name = appMeta.appName ?: packageName, - packageName = packageName, - versionName = appMeta.versionName, - versionCode = appMeta.versionCode, - icon = application.packageManager.defaultActivityIcon, - apkPaths = emptyList(), - isSelected = true - ) - - BackupDetail(finalAppInfo, items, appMeta.versionName, appMeta.versionCode, appMeta.size, isDowngrade, isInstalled) - } - - _backupDetails.value = details.sortedWith( - compareByDescending { it.backupSize ?: 0L } - .thenBy { it.appInfo.name.lowercase() } - ) - _appRestoreTypes.value = details.associate { detail -> - detail.appInfo.packageName to (_appRestoreTypes.value[detail.appInfo.packageName] ?: _restoreTypes.value) - } - _customDirectories.value = metadata?.customDirectories?.mapValues { true } ?: emptyMap() - if (_customDirectories.value.isNotEmpty()) { - _restoreCustomDirectoriesEnabled.value = true - } - } - - fun setRestoreAppsEnabled(enabled: Boolean) { - _restoreAppsEnabled.value = enabled - } - - fun setRestoreCustomDirectoriesEnabled(enabled: Boolean) { - _restoreCustomDirectoriesEnabled.value = enabled - } - - private fun findBackedUpItems(snapshot: SnapshotInfo, pkg: String, hasPermissionBackup: Boolean): List { - val items = mutableListOf() - snapshot.paths.forEach { path -> - when { - (path.startsWith("/data/app/") && path.contains("/${pkg}-")) -> if (!items.contains(application.getString(R.string.backup_type_apk))) items.add(application.getString(R.string.backup_type_apk)) - path == "/data/data/$pkg" || path.matches(Regex("^/data/user/\\d+/${Regex.escape(pkg)}$")) -> if (!items.contains(application.getString(R.string.backup_type_data))) items.add(application.getString(R.string.backup_type_data)) - path.matches(Regex("^/data/user_de/\\d+/${Regex.escape(pkg)}$")) -> if (!items.contains(application.getString(R.string.backup_type_device_protected_data))) items.add(application.getString(R.string.backup_type_device_protected_data)) - path.matches(Regex("^/storage/emulated/\\d+/Android/data/${Regex.escape(pkg)}$")) -> if (!items.contains(application.getString(R.string.backup_type_external_data))) items.add(application.getString(R.string.backup_type_external_data)) - path.matches(Regex("^/storage/emulated/\\d+/Android/obb/${Regex.escape(pkg)}$")) -> if (!items.contains(application.getString(R.string.backup_item_obb))) items.add(application.getString(R.string.backup_item_obb)) - path.matches(Regex("^/storage/emulated/\\d+/Android/media/${Regex.escape(pkg)}$")) -> if (!items.contains(application.getString(R.string.backup_item_media))) items.add(application.getString(R.string.backup_item_media)) - } - } - if (hasPermissionBackup && !items.contains(application.getString(R.string.backup_item_permissions))) { - items.add(application.getString(R.string.backup_item_permissions)) - } - return if (items.isNotEmpty()) items else listOf(application.getString(R.string.backup_item_unknown)) - } - - fun startRestore() { - if (_isRestoring.value) return - preferencesRepository.saveRestoreTypes(_restoreTypes.value) - preferencesRepository.saveAllowDowngrade(_allowDowngrade.value) - - val resticState = resticBinaryManager.resticState.value - val selectedRepoKey = repositoriesRepository.selectedRepository.value - val selectedRepository = selectedRepoKey?.let { repositoriesRepository.getRepositoryByKey(it) } - val selectedRepoPath = selectedRepository?.path - val currentSnapshot = _snapshot.value - val selectedApps = if (_restoreAppsEnabled.value) { - _backupDetails.value.filter { it.appInfo.isSelected && (_allowDowngrade.value || !it.isDowngrade) } - } else { - emptyList() - } - - val selectedCustomDirectories = if (_restoreCustomDirectoriesEnabled.value) { - _customDirectories.value.filter { it.value }.keys.toList() - } else { - emptyList() - } - - if (resticState !is ResticState.Installed || selectedRepoPath == null || currentSnapshot == null) { - _restoreProgress.value = OperationProgress( - isFinished = true, - error = application.getString(R.string.restore_error_preflight_failed), - finalSummary = application.getString(R.string.restore_error_preflight_failed) - ) - return - } - - val selectedCustomDirs = _customDirectories.value.filterValues { it }.keys.toList() - - if (selectedApps.isEmpty() && selectedCustomDirs.isEmpty()) { - _restoreProgress.value = OperationProgress( - isFinished = true, - error = application.getString(R.string.restore_error_no_items_selected), - finalSummary = application.getString(R.string.restore_error_no_items_selected) - ) - return - } - - if (selectedApps.isNotEmpty() && selectedApps.none { effectiveRestoreTypes(it.appInfo.packageName).anyEnabled() }) { - _restoreProgress.value = OperationProgress( - isFinished = true, - error = application.getString(R.string.restore_error_no_restore_types_selected), - finalSummary = application.getString(R.string.restore_error_no_restore_types_selected) - ) - return - } - - if (selectedRepository.backendType == RepositoryBackendType.SFTP && !repositoriesRepository.hasSftpCredentials(selectedRepoKey)) { - _restoreProgress.value = OperationProgress( - isFinished = true, - error = application.getString(R.string.error_sftp_password_not_found_for_repository), - finalSummary = application.getString(R.string.summary_sftp_password_not_found) - ) - return - } - - if ( - selectedRepository.backendType == RepositoryBackendType.REST && - selectedRepository.restAuthRequired && - !repositoriesRepository.hasRestCredentials(selectedRepoKey) - ) { - _restoreProgress.value = OperationProgress( - isFinished = true, - error = application.getString(R.string.error_rest_credentials_not_found_for_repository), - finalSummary = application.getString(R.string.summary_rest_credentials_not_found) - ) - return - } - - if ( - selectedRepository.backendType == RepositoryBackendType.S3 && - selectedRepository.s3AuthRequired && - !repositoriesRepository.hasS3Credentials(selectedRepoKey) - ) { - _restoreProgress.value = OperationProgress( - isFinished = true, - error = application.getString(R.string.error_s3_credentials_not_found_for_repository), - finalSummary = application.getString(R.string.summary_s3_credentials_not_found) - ) - return - } - - val password = repositoriesRepository.getRepositoryPassword(selectedRepoKey) - if (password == null) { - _restoreProgress.value = OperationProgress( - isFinished = true, - error = application.getString(R.string.restore_error_password_not_found), - finalSummary = application.getString(R.string.summary_password_not_found) - ) - return - } - - val request = RestoreWorkRequest( - repositoryKey = selectedRepoKey, - snapshotId = currentSnapshot.id, - restoreTypes = _restoreTypes.value.toSelection(), - allowDowngrade = _allowDowngrade.value, - selectedApps = selectedApps.map { - RestoreAppSelection( - packageName = it.appInfo.packageName, - appName = it.appInfo.name - ) - }, - appRestoreTypes = selectedApps.associate { it.appInfo.packageName to effectiveRestoreTypes(it.appInfo.packageName).toSelection() }, - selectedCustomDirectories = selectedCustomDirs - ) - - viewModelScope.launch(Dispatchers.IO) { - val enqueued = operationWorkRepository.enqueueRestore(request) - if (enqueued) { - _uiEvents.tryEmit(RestoreUiEvent.NavigateToOperationProgress) - } else { - _operationBlocked.value = true - _restoreProgress.value = OperationProgress( - isFinished = true, - error = application.getString(R.string.error_operation_already_running), - finalSummary = application.getString(R.string.summary_operation_already_running) - ) - } - } - } - - fun onDone() { - operationWorkRepository.clearFinished(OperationType.RESTORE) - _restoreProgress.value = OperationProgress() - } - - fun consumeOperationBlocked() { - _operationBlocked.value = false - } - - fun toggleRestoreAppSelection(packageName: String) { - _appRestoreTypes.update { it.ensurePackage(packageName, _restoreTypes.value) } - _backupDetails.update { currentDetails -> - currentDetails.map { detail -> - if (detail.appInfo.packageName == packageName) { - if (_allowDowngrade.value || !detail.isDowngrade) { - detail.copy(appInfo = detail.appInfo.copy(isSelected = !detail.appInfo.isSelected)) - } else { - detail - } - } else { - detail - } - } - } - } - - fun toggleAllRestoreSelection() { - _backupDetails.update { currentDetails -> - val selectableDetails = currentDetails.filter { _allowDowngrade.value || !it.isDowngrade } - val shouldSelectAll = selectableDetails.any { !it.appInfo.isSelected } - _appRestoreTypes.update { currentTypes -> - selectableDetails.fold(currentTypes) { acc, detail -> - acc.ensurePackage(detail.appInfo.packageName, _restoreTypes.value) - } - } - currentDetails.map { detail -> - val canBeSelected = _allowDowngrade.value || !detail.isDowngrade - detail.copy(appInfo = detail.appInfo.copy(isSelected = shouldSelectAll && canBeSelected)) - } - } - } - - fun toggleCustomDirectory(path: String) { - _customDirectories.update { current -> - current.toMutableMap().apply { - this[path] = !(this[path] ?: true) - } - } - } - - fun toggleAllCustomDirectoriesSelection() { - _customDirectories.update { current -> - val shouldSelectAll = current.values.any { !it } - current.mapValues { shouldSelectAll } - } - } - - fun setAllowDowngrade(value: Boolean) { - _allowDowngrade.value = value - if (!value) { - _backupDetails.update { currentDetails -> - currentDetails.map { detail -> - if (detail.isDowngrade) detail.copy(appInfo = detail.appInfo.copy(isSelected = false)) else detail - } - } - } - } - - fun setRestoreApk(value: Boolean) = _restoreTypes.update { it.copy(apk = value) } - fun setRestoreData(value: Boolean) = _restoreTypes.update { it.copy(data = value) } - fun setRestoreDeviceProtectedData(value: Boolean) = _restoreTypes.update { it.copy(deviceProtectedData = value) } - fun setRestoreExternalData(value: Boolean) = _restoreTypes.update { it.copy(externalData = value) } - fun setRestoreObb(value: Boolean) = _restoreTypes.update { it.copy(obb = value) } - fun setRestoreMedia(value: Boolean) = _restoreTypes.update { it.copy(media = value) } - - fun setAppRestoreTypes(packageName: String, restoreTypes: RestoreTypes) { - _appRestoreTypes.update { it + (packageName to restoreTypes) } - } - - fun setSelectedAppsRestoreTypes(restoreTypes: RestoreTypes) { - val selectedPackageNames = _backupDetails.value - .filter { it.appInfo.isSelected && (_allowDowngrade.value || !it.isDowngrade) } - .map { it.appInfo.packageName } - _restoreTypes.value = restoreTypes - _appRestoreTypes.update { it + selectedPackageNames.associateWith { restoreTypes } } - } - - private fun effectiveRestoreTypes(packageName: String): RestoreTypes { - return _appRestoreTypes.value[packageName] ?: _restoreTypes.value - } - - private fun Map.ensurePackage(packageName: String, defaultTypes: RestoreTypes): Map { - return if (containsKey(packageName)) this else this + (packageName to defaultTypes) - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/restore/RestoreViewModelFactory.kt b/app/src/main/java/io/github/hddq/restoid/ui/restore/RestoreViewModelFactory.kt deleted file mode 100644 index 31a66b8c..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/restore/RestoreViewModelFactory.kt +++ /dev/null @@ -1,37 +0,0 @@ -package io.github.hddq.restoid.ui.restore - -import android.app.Application -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import io.github.hddq.restoid.data.* -import io.github.hddq.restoid.work.OperationWorkRepository - -class RestoreViewModelFactory( - private val application: Application, - private val repositoriesRepository: RepositoriesRepository, - private val resticBinaryManager: ResticBinaryManager, - private val resticRepository: ResticRepository, - private val appInfoRepository: AppInfoRepository, - private val metadataRepository: MetadataRepository, - private val preferencesRepository: PreferencesRepository, - private val operationWorkRepository: OperationWorkRepository, - private val snapshotId: String -) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(RestoreViewModel::class.java)) { - @Suppress("UNCHECKED_CAST") - return RestoreViewModel( - application, - repositoriesRepository, - resticBinaryManager, - resticRepository, - appInfoRepository, - metadataRepository, - preferencesRepository, - operationWorkRepository, - snapshotId - ) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/runtasks/RunTasksRoutes.kt b/app/src/main/java/io/github/hddq/restoid/ui/runtasks/RunTasksRoutes.kt deleted file mode 100644 index 60287fbc..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/runtasks/RunTasksRoutes.kt +++ /dev/null @@ -1,9 +0,0 @@ -package io.github.hddq.restoid.ui.runtasks - -object RunTasksRoutes { - const val Main = "run_tasks/main" - const val BackupConfig = "run_tasks/backup_config" - const val ForgetConfig = "run_tasks/forget_config" - const val CheckConfig = "run_tasks/check_config" - const val CustomDirectoriesConfig = "run_tasks/custom_directories_config" -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/runtasks/RunTasksScreens.kt b/app/src/main/java/io/github/hddq/restoid/ui/runtasks/RunTasksScreens.kt deleted file mode 100644 index 8ddbb0f0..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/runtasks/RunTasksScreens.kt +++ /dev/null @@ -1,229 +0,0 @@ -package io.github.hddq.restoid.ui.runtasks - -import android.widget.Toast -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.Switch -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.clickable -import android.content.Context -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Row -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.SwitchDefaults -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Icon -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import io.github.hddq.restoid.R -import io.github.hddq.restoid.model.AppInfo -import io.github.hddq.restoid.ui.shared.* - -@Composable -fun RunTasksScreen( - viewModel: RunTasksViewModel, - onNavigateToOperationProgress: () -> Unit, - onNavigateToBackupConfig: () -> Unit, - onNavigateToCustomDirectoriesConfig: () -> Unit, - onNavigateToForgetConfig: () -> Unit, - onNavigateToCheckConfig: () -> Unit, - modifier: Modifier = Modifier -) { - val context = LocalContext.current - val uiState by viewModel.uiState.collectAsState() - val operationBlocked by viewModel.operationBlocked.collectAsState() - - LaunchedEffect(operationBlocked) { - if (operationBlocked) { - Toast.makeText(context, context.getString(R.string.error_operation_already_running), Toast.LENGTH_SHORT).show() - viewModel.consumeOperationBlocked() - } - } - - LaunchedEffect(viewModel) { - viewModel.uiEvents.collect { event -> - when (event) { - RunTasksUiEvent.NavigateToOperationProgress -> onNavigateToOperationProgress() - } - } - } - - LazyColumn( - modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 88.dp, top = 8.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - item { - Column { - Text( - text = context.getString(R.string.operation_backup), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 8.dp) - ) - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) - ) { - TaskRow( - title = context.getString(R.string.run_tasks_applications), - subtitle = buildBackupSubtitle(uiState.apps, uiState.appBackupTypes, uiState.backupTypes, context), - checked = uiState.backupEnabled, - onCheckedChange = viewModel::setBackupEnabled, - onNavigate = onNavigateToBackupConfig - ) - HorizontalDivider(color = MaterialTheme.colorScheme.background) - TaskRow( - title = context.getString(R.string.run_tasks_custom_directories), - subtitle = buildCustomDirectoriesSubtitle(uiState.customDirectories, context), - checked = uiState.customDirectoriesBackupEnabled, - onCheckedChange = viewModel::setCustomDirectoriesBackupEnabled, - onNavigate = onNavigateToCustomDirectoriesConfig - ) - } - } - } - - item { - Column { - Text( - text = context.getString(R.string.operation_maintenance), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 8.dp) - ) - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) - ) { - Column { - TaskRow( - title = context.getString(R.string.maintenance_task_unlock_repository), - checked = uiState.maintenance.unlockRepo, - onCheckedChange = viewModel::setUnlockRepo - ) - HorizontalDivider(color = MaterialTheme.colorScheme.background) - TaskRow( - title = context.getString(R.string.run_tasks_forget_snapshots), - subtitle = buildForgetSubtitle(uiState.maintenance, context), - checked = uiState.maintenance.forgetSnapshots, - onCheckedChange = viewModel::setForgetSnapshots, - onNavigate = onNavigateToForgetConfig - ) - HorizontalDivider(color = MaterialTheme.colorScheme.background) - TaskRow( - title = context.getString(R.string.maintenance_task_prune_repository), - checked = uiState.maintenance.pruneRepo, - onCheckedChange = viewModel::setPruneRepo - ) - HorizontalDivider(color = MaterialTheme.colorScheme.background) - TaskRow( - title = context.getString(R.string.run_tasks_check_integrity), - subtitle = buildCheckSubtitle(uiState.maintenance, context), - checked = uiState.maintenance.checkRepo, - onCheckedChange = viewModel::setCheckRepo, - onNavigate = onNavigateToCheckConfig - ) - } - } - } - } - } -} - -@Composable -fun BackupConfigScreen( - viewModel: RunTasksViewModel, - modifier: Modifier = Modifier -) { - val uiState by viewModel.uiState.collectAsState() - io.github.hddq.restoid.ui.shared.BackupConfigScreen( - isLoadingApps = uiState.isLoadingApps, - apps = uiState.apps, - appBackupTypes = uiState.appBackupTypes, - backupTypes = uiState.backupTypes, - onRefreshApps = viewModel::refreshAppsList, - onToggleAllApps = viewModel::toggleAllApps, - onToggleAppSelection = viewModel::toggleAppSelection, - onSetSelectedAppsBackupTypes = viewModel::setSelectedAppsBackupTypes, - onSetAppBackupTypes = viewModel::setAppBackupTypes, - modifier = modifier - ) -} - -@Composable -fun ForgetConfigScreen( - viewModel: RunTasksViewModel, - modifier: Modifier = Modifier -) { - val uiState by viewModel.uiState.collectAsState() - io.github.hddq.restoid.ui.shared.ForgetConfigScreen( - maintenance = uiState.maintenance, - onKeepLastChange = viewModel::setKeepLast, - onKeepDailyChange = viewModel::setKeepDaily, - onKeepWeeklyChange = viewModel::setKeepWeekly, - onKeepMonthlyChange = viewModel::setKeepMonthly, - modifier = modifier - ) -} - -@Composable -fun CheckConfigScreen( - viewModel: RunTasksViewModel, - modifier: Modifier = Modifier -) { - val uiState by viewModel.uiState.collectAsState() - io.github.hddq.restoid.ui.shared.CheckConfigScreen( - maintenance = uiState.maintenance, - onReadDataChange = viewModel::setReadData, - modifier = modifier - ) -} - - - -@Composable -fun CustomDirectoriesConfigScreen( - viewModel: RunTasksViewModel, - modifier: Modifier = Modifier -) { - val uiState by viewModel.uiState.collectAsState() - io.github.hddq.restoid.ui.shared.CustomDirectoriesConfigScreen( - customDirectories = uiState.customDirectories, - onAddDirectory = viewModel::addCustomDirectory, - onToggleDirectory = viewModel::toggleCustomDirectory, - modifier = modifier - ) -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/runtasks/RunTasksViewModel.kt b/app/src/main/java/io/github/hddq/restoid/ui/runtasks/RunTasksViewModel.kt deleted file mode 100644 index 320f9bf1..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/runtasks/RunTasksViewModel.kt +++ /dev/null @@ -1,427 +0,0 @@ -package io.github.hddq.restoid.ui.runtasks - -import android.app.Application -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import io.github.hddq.restoid.R -import io.github.hddq.restoid.data.OperationType -import io.github.hddq.restoid.data.RepositoriesRepository -import io.github.hddq.restoid.data.RepositoryBackendType -import io.github.hddq.restoid.data.ResticBinaryManager -import io.github.hddq.restoid.data.ResticState -import io.github.hddq.restoid.model.AppInfo -import io.github.hddq.restoid.ui.shared.BackupTypes -import io.github.hddq.restoid.ui.shared.OperationProgress -import io.github.hddq.restoid.work.OperationWorkRepository -import io.github.hddq.restoid.work.RunTasksWorkRequest -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import io.github.hddq.restoid.model.MaintenanceConfig - -import io.github.hddq.restoid.model.CustomDirectory - -data class RunTasksUiState( - val apps: List = emptyList(), - val isLoadingApps: Boolean = true, - val backupEnabled: Boolean = true, - val backupTypes: BackupTypes = BackupTypes(), - val appBackupTypes: Map = emptyMap(), - val maintenance: MaintenanceConfig = MaintenanceConfig(), - val isRunning: Boolean = false, - val progress: OperationProgress = OperationProgress(), - val customDirectoriesBackupEnabled: Boolean = false, - val customDirectories: List = emptyList() -) - -sealed interface RunTasksUiEvent { - data object NavigateToOperationProgress : RunTasksUiEvent -} - -class RunTasksViewModel( - private val application: Application, - private val repositoriesRepository: RepositoriesRepository, - private val resticBinaryManager: ResticBinaryManager, - private val appInfoRepository: io.github.hddq.restoid.data.AppInfoRepository, - private val preferencesRepository: io.github.hddq.restoid.data.PreferencesRepository, - private val operationWorkRepository: OperationWorkRepository -) : ViewModel() { - - private val _uiState = MutableStateFlow( - RunTasksUiState( - backupEnabled = preferencesRepository.loadRunTasksBackupEnabled(), - backupTypes = preferencesRepository.loadBackupTypes(), - maintenance = preferencesRepository.loadMaintenanceState(), - customDirectoriesBackupEnabled = preferencesRepository.loadCustomDirectoriesBackupEnabled(), - customDirectories = preferencesRepository.loadCustomDirectoriesAll().map { uri -> - CustomDirectory( - uri = uri, - isSelected = preferencesRepository.loadCustomDirectoriesSelected().contains(uri) - ) - } - ) - ) - val uiState = _uiState.asStateFlow() - - private val _operationBlocked = MutableStateFlow(false) - val operationBlocked = _operationBlocked.asStateFlow() - - private val _uiEvents = MutableSharedFlow(extraBufferCapacity = 1) - val uiEvents: SharedFlow = _uiEvents.asSharedFlow() - - init { - loadInstalledApps() - observeOperationState() - } - - fun refreshAppsList() { - loadInstalledApps() - } - - private fun loadInstalledApps() { - viewModelScope.launch { - _uiState.update { it.copy(isLoadingApps = true) } - val selectAll = preferencesRepository.loadRunTasksSelectAllApps() - val selectedPackages = preferencesRepository.loadRunTasksSelectedPackages() - val savedAppBackupTypes = preferencesRepository.loadRunTasksAppBackupTypes() - val currentSelection = _uiState.value.apps.associate { it.packageName to it.isSelected } - val currentBackupTypes = _uiState.value.appBackupTypes - val defaultBackupTypes = _uiState.value.backupTypes - val apps = appInfoRepository.getInstalledUserApps().map { app -> - val selected = if (_uiState.value.apps.isEmpty()) { - // Initial load: restore from preferences - if (selectAll) true else selectedPackages.contains(app.packageName) - } else { - // Refresh: keep current selection, fallback to default (true) - currentSelection[app.packageName] ?: app.isSelected - } - app.copy(isSelected = selected) - } - val appBackupTypes = apps.associate { app -> - app.packageName to ( - currentBackupTypes[app.packageName] - ?: savedAppBackupTypes[app.packageName] - ?: defaultBackupTypes - ) - } - _uiState.update { it.copy(apps = apps, appBackupTypes = appBackupTypes, isLoadingApps = false) } - } - } - - private fun observeOperationState() { - viewModelScope.launch { - operationWorkRepository.operationState.collect { state -> - if (state.operationType == OperationType.RUN_TASKS) { - _uiState.update { it.copy(isRunning = state.isRunning, progress = state.progress) } - } else { - _uiState.update { it.copy(isRunning = false, progress = OperationProgress()) } - } - } - } - } - - fun run() { - if (_uiState.value.isRunning) return - - preferencesRepository.saveBackupTypes(_uiState.value.backupTypes) - preferencesRepository.saveRunTasksAppBackupTypes(_uiState.value.appBackupTypes) - preferencesRepository.saveMaintenanceState(_uiState.value.maintenance) - preferencesRepository.saveRunTasksBackupEnabled(_uiState.value.backupEnabled) - - val allAppsSelected = _uiState.value.apps.isNotEmpty() && _uiState.value.apps.all { it.isSelected } - val selectedPackages = _uiState.value.apps.filter { it.isSelected }.map { it.packageName }.toSet() - preferencesRepository.saveRunTasksSelectedApps(allAppsSelected, selectedPackages) - - preferencesRepository.saveCustomDirectoriesBackupEnabled(_uiState.value.customDirectoriesBackupEnabled) - val allCustomDirs = _uiState.value.customDirectories.map { it.uri }.toSet() - val selectedCustomDirs = _uiState.value.customDirectories.filter { it.isSelected }.map { it.uri }.toSet() - preferencesRepository.saveCustomDirectories(allCustomDirs, selectedCustomDirs) - - val errorState = preflightChecks() - if (errorState != null) { - _uiState.update { it.copy(isRunning = false, progress = errorState) } - return - } - - val selectedRepoKey = repositoriesRepository.selectedRepository.value ?: run { - _uiState.update { - it.copy( - isRunning = false, - progress = OperationProgress( - isFinished = true, - error = application.getString(R.string.error_no_backup_repository_selected), - finalSummary = application.getString(R.string.summary_no_backup_repository_selected) - ) - ) - } - return - } - - val maintenance = _uiState.value.maintenance - val request = RunTasksWorkRequest( - repositoryKey = selectedRepoKey, - backupEnabled = _uiState.value.backupEnabled, - backupTypes = _uiState.value.backupTypes.toSelection(), - selectedPackageNames = _uiState.value.apps.filter { it.isSelected }.map { it.packageName }, - appBackupTypes = selectedAppBackupTypes().mapValues { it.value.toSelection() }, - customDirectories = if (_uiState.value.customDirectoriesBackupEnabled) selectedCustomDirs.toList() else emptyList(), - unlockRepo = maintenance.unlockRepo, - forgetSnapshots = maintenance.forgetSnapshots, - pruneRepo = maintenance.pruneRepo, - checkRepo = maintenance.checkRepo, - readData = maintenance.readData, - keepLast = maintenance.keepLast, - keepDaily = maintenance.keepDaily, - keepWeekly = maintenance.keepWeekly, - keepMonthly = maintenance.keepMonthly - ) - - viewModelScope.launch(Dispatchers.IO) { - val enqueued = operationWorkRepository.enqueueRunTasks(request) - if (enqueued) { - _uiEvents.tryEmit(RunTasksUiEvent.NavigateToOperationProgress) - } else { - _operationBlocked.value = true - _uiState.update { - it.copy( - isRunning = false, - progress = OperationProgress( - isFinished = true, - error = application.getString(R.string.error_operation_already_running), - finalSummary = application.getString(R.string.summary_operation_already_running) - ) - ) - } - } - } - } - - private fun preflightChecks(): OperationProgress? { - val state = _uiState.value - val maintenance = state.maintenance - val hasMaintenanceTask = maintenance.unlockRepo || maintenance.forgetSnapshots || maintenance.pruneRepo || maintenance.checkRepo - if (!state.backupEnabled && !hasMaintenanceTask) { - return OperationProgress( - isFinished = true, - error = application.getString(R.string.maintenance_error_no_tasks), - finalSummary = application.getString(R.string.maintenance_summary_no_tasks) - ) - } - - if (state.backupEnabled) { - val hasCustomDirs = state.customDirectoriesBackupEnabled && state.customDirectories.any { it.isSelected } - if (state.apps.none { it.isSelected } && !hasCustomDirs) { - return OperationProgress( - isFinished = true, - error = application.getString(R.string.error_no_items_selected), - finalSummary = application.getString(R.string.summary_no_items_selected) - ) - } - val hasSelectedBackupType = state.apps - .filter { it.isSelected } - .any { app -> effectiveBackupTypes(state, app.packageName).anyEnabled() } || hasCustomDirs - if (!hasSelectedBackupType) { - return OperationProgress( - isFinished = true, - error = application.getString(R.string.error_no_backup_types_selected), - finalSummary = application.getString(R.string.summary_no_backup_types_selected) - ) - } - } - - if (resticBinaryManager.resticState.value !is ResticState.Installed) { - return OperationProgress( - isFinished = true, - error = application.getString(R.string.error_restic_not_installed), - finalSummary = application.getString(R.string.summary_restic_binary_not_installed) - ) - } - - val selectedRepoKey = repositoriesRepository.selectedRepository.value - val repository = selectedRepoKey?.let { repositoriesRepository.getRepositoryByKey(it) } - if (selectedRepoKey == null || repository == null) { - return OperationProgress( - isFinished = true, - error = application.getString(R.string.error_no_backup_repository_selected), - finalSummary = application.getString(R.string.summary_no_backup_repository_selected) - ) - } - - if (repositoriesRepository.getRepositoryPassword(selectedRepoKey) == null) { - return OperationProgress( - isFinished = true, - error = application.getString(R.string.error_password_not_found_for_repository), - finalSummary = application.getString(R.string.summary_password_not_found) - ) - } - - if (repository.backendType == RepositoryBackendType.SFTP && !repositoriesRepository.hasSftpCredentials(selectedRepoKey)) { - return OperationProgress( - isFinished = true, - error = application.getString(R.string.error_sftp_password_not_found_for_repository), - finalSummary = application.getString(R.string.summary_sftp_password_not_found) - ) - } - - if ( - repository.backendType == RepositoryBackendType.REST && - repository.restAuthRequired && - !repositoriesRepository.hasRestCredentials(selectedRepoKey) - ) { - return OperationProgress( - isFinished = true, - error = application.getString(R.string.error_rest_credentials_not_found_for_repository), - finalSummary = application.getString(R.string.summary_rest_credentials_not_found) - ) - } - - if ( - repository.backendType == RepositoryBackendType.S3 && - repository.s3AuthRequired && - !repositoriesRepository.hasS3Credentials(selectedRepoKey) - ) { - return OperationProgress( - isFinished = true, - error = application.getString(R.string.error_s3_credentials_not_found_for_repository), - finalSummary = application.getString(R.string.summary_s3_credentials_not_found) - ) - } - - return null - } - - fun consumeOperationBlocked() { - _operationBlocked.value = false - } - - fun setBackupEnabled(enabled: Boolean) { - _uiState.update { it.copy(backupEnabled = enabled) } - } - - fun setCustomDirectoriesBackupEnabled(enabled: Boolean) { - _uiState.update { it.copy(customDirectoriesBackupEnabled = enabled) } - } - - fun addCustomDirectory(uriString: String) { - val repoKey = repositoriesRepository.selectedRepository.value - val repository = repoKey?.let { repositoriesRepository.getRepositoryByKey(it) } - - if (repository != null && repository.backendType == io.github.hddq.restoid.data.RepositoryBackendType.LOCAL) { - val repoUri = android.net.Uri.parse(repository.path) - val customDirUri = android.net.Uri.parse(uriString) - val repoPath = io.github.hddq.restoid.util.StorageUtils.getPathFromTreeUri(repoUri) ?: repository.path - val customDirPath = io.github.hddq.restoid.util.StorageUtils.getPathFromTreeUri(customDirUri) ?: uriString - - if (repoPath == customDirPath) { - android.widget.Toast.makeText( - application, - application.getString(io.github.hddq.restoid.R.string.error_cannot_backup_repo_to_itself), - android.widget.Toast.LENGTH_LONG - ).show() - return - } - } - - _uiState.update { state -> - if (state.customDirectories.none { it.uri == uriString }) { - state.copy(customDirectories = state.customDirectories + CustomDirectory(uriString)) - } else { - state - } - } - } - - fun toggleCustomDirectory(uriString: String) { - _uiState.update { state -> - state.copy( - customDirectories = state.customDirectories.map { - if (it.uri == uriString) it.copy(isSelected = !it.isSelected) else it - } - ) - } - } - - fun removeCustomDirectory(uriString: String) { - _uiState.update { state -> - state.copy(customDirectories = state.customDirectories.filter { it.uri != uriString }) - } - } - - fun setUnlockRepo(value: Boolean) = _uiState.update { it.copy(maintenance = it.maintenance.copy(unlockRepo = value)) } - fun setForgetSnapshots(value: Boolean) = _uiState.update { it.copy(maintenance = it.maintenance.copy(forgetSnapshots = value)) } - fun setPruneRepo(value: Boolean) = _uiState.update { it.copy(maintenance = it.maintenance.copy(pruneRepo = value)) } - fun setCheckRepo(value: Boolean) = _uiState.update { it.copy(maintenance = it.maintenance.copy(checkRepo = value)) } - fun setReadData(value: Boolean) = _uiState.update { it.copy(maintenance = it.maintenance.copy(readData = value)) } - fun setKeepLast(value: Int) = _uiState.update { it.copy(maintenance = it.maintenance.copy(keepLast = value)) } - fun setKeepDaily(value: Int) = _uiState.update { it.copy(maintenance = it.maintenance.copy(keepDaily = value)) } - fun setKeepWeekly(value: Int) = _uiState.update { it.copy(maintenance = it.maintenance.copy(keepWeekly = value)) } - fun setKeepMonthly(value: Int) = _uiState.update { it.copy(maintenance = it.maintenance.copy(keepMonthly = value)) } - - fun toggleAppSelection(packageName: String) { - _uiState.update { state -> - state.copy( - apps = state.apps.map { app -> - if (app.packageName == packageName) app.copy(isSelected = !app.isSelected) else app - }, - appBackupTypes = state.appBackupTypes.ensurePackage(packageName, state.backupTypes) - ) - } - } - - fun toggleAllApps() { - _uiState.update { state -> - val shouldSelectAll = state.apps.any { !it.isSelected } - val apps = state.apps.map { it.copy(isSelected = shouldSelectAll) } - state.copy( - apps = apps, - appBackupTypes = apps.fold(state.appBackupTypes) { acc, app -> - acc.ensurePackage(app.packageName, state.backupTypes) - } - ) - } - } - - fun setAppBackupTypes(packageName: String, backupTypes: BackupTypes) { - _uiState.update { state -> - state.copy(appBackupTypes = state.appBackupTypes + (packageName to backupTypes)) - } - } - - fun setSelectedAppsBackupTypes(backupTypes: BackupTypes) { - _uiState.update { state -> - val selectedPackageNames = state.apps.filter { it.isSelected }.map { it.packageName } - state.copy( - backupTypes = backupTypes, - appBackupTypes = state.appBackupTypes + selectedPackageNames.associateWith { backupTypes } - ) - } - } - - fun setBackupApk(value: Boolean) = _uiState.update { it.copy(backupTypes = it.backupTypes.copy(apk = value)) } - fun setBackupData(value: Boolean) = _uiState.update { it.copy(backupTypes = it.backupTypes.copy(data = value)) } - fun setBackupDeviceProtectedData(value: Boolean) = _uiState.update { it.copy(backupTypes = it.backupTypes.copy(deviceProtectedData = value)) } - fun setBackupExternalData(value: Boolean) = _uiState.update { it.copy(backupTypes = it.backupTypes.copy(externalData = value)) } - fun setBackupObb(value: Boolean) = _uiState.update { it.copy(backupTypes = it.backupTypes.copy(obb = value)) } - fun setBackupMedia(value: Boolean) = _uiState.update { it.copy(backupTypes = it.backupTypes.copy(media = value)) } - fun setBackupPermissions(value: Boolean) = _uiState.update { it.copy(backupTypes = it.backupTypes.copy(permissions = value)) } - - private fun selectedAppBackupTypes(): Map { - val state = _uiState.value - return state.apps - .filter { it.isSelected } - .associate { app -> app.packageName to effectiveBackupTypes(state, app.packageName) } - } - - private fun effectiveBackupTypes(state: RunTasksUiState, packageName: String): BackupTypes { - return state.appBackupTypes[packageName] ?: state.backupTypes - } - - private fun Map.ensurePackage(packageName: String, defaultTypes: BackupTypes): Map { - return if (containsKey(packageName)) this else this + (packageName to defaultTypes) - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/runtasks/RunTasksViewModelFactory.kt b/app/src/main/java/io/github/hddq/restoid/ui/runtasks/RunTasksViewModelFactory.kt deleted file mode 100644 index a8a0579a..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/runtasks/RunTasksViewModelFactory.kt +++ /dev/null @@ -1,34 +0,0 @@ -package io.github.hddq.restoid.ui.runtasks - -import android.app.Application -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import io.github.hddq.restoid.data.AppInfoRepository -import io.github.hddq.restoid.data.PreferencesRepository -import io.github.hddq.restoid.data.RepositoriesRepository -import io.github.hddq.restoid.data.ResticBinaryManager -import io.github.hddq.restoid.work.OperationWorkRepository - -class RunTasksViewModelFactory( - private val application: Application, - private val repositoriesRepository: RepositoriesRepository, - private val resticBinaryManager: ResticBinaryManager, - private val appInfoRepository: AppInfoRepository, - private val preferencesRepository: PreferencesRepository, - private val operationWorkRepository: OperationWorkRepository -) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(RunTasksViewModel::class.java)) { - @Suppress("UNCHECKED_CAST") - return RunTasksViewModel( - application = application, - repositoriesRepository = repositoriesRepository, - resticBinaryManager = resticBinaryManager, - appInfoRepository = appInfoRepository, - preferencesRepository = preferencesRepository, - operationWorkRepository = operationWorkRepository - ) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/schedules/SchedulesRoutes.kt b/app/src/main/java/io/github/hddq/restoid/ui/schedules/SchedulesRoutes.kt deleted file mode 100644 index 1d03aa14..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/schedules/SchedulesRoutes.kt +++ /dev/null @@ -1,10 +0,0 @@ -package io.github.hddq.restoid.ui.schedules - -object SchedulesRoutes { - const val Main = "schedules/main" - const val AddEdit = "schedules/add_edit" - const val BackupConfig = "schedules/backup_config" - const val CustomDirectoriesConfig = "schedules/custom_directories_config" - const val ForgetConfig = "schedules/forget_config" - const val CheckConfig = "schedules/check_config" -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/schedules/SchedulesScreens.kt b/app/src/main/java/io/github/hddq/restoid/ui/schedules/SchedulesScreens.kt deleted file mode 100644 index f02c4249..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/schedules/SchedulesScreens.kt +++ /dev/null @@ -1,621 +0,0 @@ -package io.github.hddq.restoid.ui.schedules - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.ChevronRight -import androidx.compose.material.icons.filled.Delete -import io.github.hddq.restoid.LocalAppBarActions -import androidx.compose.material3.IconButton -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.FilterChip -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import io.github.hddq.restoid.R -import io.github.hddq.restoid.model.Schedule -import io.github.hddq.restoid.ui.shared.* -import io.github.hddq.restoid.ui.shared.AppListItem -import io.github.hddq.restoid.ui.shared.BackupTypesBottomSheet -import io.github.hddq.restoid.ui.shared.BackupTypeToggle -import io.github.hddq.restoid.ui.shared.BackupTypes -import io.github.hddq.restoid.ui.shared.PolicySlider -import io.github.hddq.restoid.ui.shared.SelectAllListItem -import io.github.hddq.restoid.ui.shared.TaskRow -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -@Composable -fun SchedulesScreen( - viewModel: SchedulesViewModel, - onNavigateToAddEditSchedule: () -> Unit, - modifier: Modifier = Modifier -) { - val uiState by viewModel.uiState.collectAsState() - var scheduleToDelete by remember { mutableStateOf(null) } - val lifecycleOwner = LocalLifecycleOwner.current - - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - viewModel.loadSchedules() - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - } - } - - Box(modifier = modifier.fillMaxSize()) { - if (uiState.isLoading) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } else if (uiState.schedules.isEmpty()) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text( - text = stringResource(R.string.no_schedules_configured), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } else { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(16.dp) - ) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) - ) { - Column { - val schedules = uiState.schedules - schedules.forEachIndexed { index, schedule -> - ScheduleItem( - schedule = schedule, - onEdit = { - viewModel.startEditSchedule(schedule) - onNavigateToAddEditSchedule() - }, - onDelete = { scheduleToDelete = schedule }, - onToggleEnabled = { viewModel.toggleScheduleEnabled(schedule) }, - onRunNow = { viewModel.runNow(schedule) } - ) - if (index < schedules.size - 1) { - HorizontalDivider(color = MaterialTheme.colorScheme.background) - } - } - } - } - Spacer(Modifier.height(88.dp)) - } - } - - val isRepoSelected = uiState.selectedRepoKey != null - - ExtendedFloatingActionButton( - onClick = { - if (isRepoSelected) { - viewModel.startAddSchedule() - onNavigateToAddEditSchedule() - } - }, - icon = { Icon(Icons.Default.Add, contentDescription = null) }, - text = { Text(stringResource(R.string.fab_add_schedule)) }, - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(16.dp), - containerColor = if (isRepoSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, - contentColor = if (isRepoSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) - ) - } - - scheduleToDelete?.let { schedule -> - AlertDialog( - onDismissRequest = { scheduleToDelete = null }, - title = { Text(stringResource(R.string.dialog_delete_schedule_title)) }, - text = { Text(stringResource(R.string.dialog_delete_schedule_message)) }, - confirmButton = { - TextButton( - onClick = { - viewModel.deleteSchedule(schedule.id) - scheduleToDelete = null - } - ) { - Text(stringResource(R.string.action_delete)) - } - }, - dismissButton = { - TextButton(onClick = { scheduleToDelete = null }) { - Text(stringResource(R.string.action_cancel)) - } - } - ) - } -} -@Composable -fun AddEditScheduleScreen( - viewModel: SchedulesViewModel, - onNavigateBack: () -> Unit, - onNavigateToBackupConfig: () -> Unit, - onNavigateToCustomDirectoriesConfig: () -> Unit, - onNavigateToForgetConfig: () -> Unit, - onNavigateToCheckConfig: () -> Unit, - modifier: Modifier = Modifier -) { - val state by viewModel.addEditState.collectAsState() - val context = LocalContext.current - var intervalText by remember(state.intervalHours) { mutableStateOf(state.intervalHours.toString()) } - val appBarActions = LocalAppBarActions.current - - BackHandler { - viewModel.onBackPress() - } - - DisposableEffect(state.id) { - if (state.id != null) { - appBarActions.value = { - IconButton(onClick = { viewModel.onDeleteScheduleClick() }) { - Icon( - Icons.Default.Delete, - contentDescription = stringResource(R.string.action_delete), - tint = MaterialTheme.colorScheme.error - ) - } - } - } - onDispose { appBarActions.value = null } - } - - LaunchedEffect(viewModel) { - viewModel.uiEvents.collect { event -> - when (event) { - SchedulesUiEvent.NavigateBack -> onNavigateBack() - } - } - } - - LazyColumn( - modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 88.dp, top = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - - item { - Column { - Text( - text = stringResource(R.string.section_general), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 8.dp) - ) - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) - ) { - Column { - OutlinedTextField( - value = state.name, - onValueChange = viewModel::setName, - label = { Text(stringResource(R.string.schedule_name_label)) }, - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp) - ) - - HorizontalDivider(color = MaterialTheme.colorScheme.background) - - Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { - val isError = intervalText.toIntOrNull()?.let { it !in 1..720 } ?: true - Text( - text = stringResource(R.string.schedule_interval_label), - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(bottom = 8.dp) - ) - OutlinedTextField( - value = intervalText, - onValueChange = { input -> - intervalText = input.filter { it.isDigit() } - intervalText.toIntOrNull()?.let { parsed -> - if (parsed in 1..720) viewModel.setIntervalHours(parsed) - } - }, - suffix = { Text(stringResource(R.string.unit_hours)) }, - isError = isError, - supportingText = if (isError) { - { Text(stringResource(R.string.schedule_interval_range_hint)) } - } else null, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - Spacer(Modifier.height(8.dp)) - FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - listOf(6, 12, 24, 48, 168, 720).forEach { preset -> - FilterChip( - selected = state.intervalHours == preset, - onClick = { - viewModel.setIntervalHours(preset) - intervalText = preset.toString() - }, - label = { - Text( - when (preset) { - 168 -> stringResource(R.string.preset_1_week) - 720 -> stringResource(R.string.preset_1_month) - else -> stringResource(R.string.preset_hours, preset) - } - ) - } - ) - } - } - - if (state.id != null) { - Spacer(Modifier.height(16.dp)) - val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) } - val currentLastRun = state.lastRunTimestamp - val lastRunText = if (currentLastRun != null) { - val relativeTime = android.text.format.DateUtils.getRelativeTimeSpanString( - currentLastRun, - System.currentTimeMillis(), - android.text.format.DateUtils.MINUTE_IN_MILLIS - ).toString() - val exactTime = dateFormat.format(Date(currentLastRun)) - stringResource(R.string.schedule_last_run, relativeTime, exactTime) - } else { - stringResource(R.string.schedule_never_run) - } - Text( - text = lastRunText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - } - } - - item { - Column { - Text( - text = stringResource(R.string.section_conditions), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 8.dp) - ) - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) - ) { - Column { - TaskRow( - title = stringResource(R.string.condition_battery_not_low), - checked = state.triggerConditions.requireBatteryNotLow, - onCheckedChange = viewModel::setRequireBatteryNotLow - ) - HorizontalDivider(color = MaterialTheme.colorScheme.background) - TaskRow( - title = stringResource(R.string.condition_charging), - checked = state.triggerConditions.requireCharging, - onCheckedChange = viewModel::setRequireCharging - ) - HorizontalDivider(color = MaterialTheme.colorScheme.background) - TaskRow( - title = stringResource(R.string.condition_unmetered_network), - checked = state.triggerConditions.requireUnmeteredNetwork, - onCheckedChange = viewModel::setRequireUnmeteredNetwork - ) - } - } - } - } - - item { - Column { - Text( - text = stringResource(R.string.operation_backup), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 8.dp) - ) - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) - ) { - TaskRow( - title = stringResource(R.string.run_tasks_applications), - subtitle = buildBackupSubtitle(state.apps, state.appBackupTypes, state.backupTypes, context), - checked = state.backupEnabled, - onCheckedChange = viewModel::setBackupEnabled, - onNavigate = onNavigateToBackupConfig - ) - HorizontalDivider(color = MaterialTheme.colorScheme.background) - TaskRow( - title = stringResource(R.string.run_tasks_custom_directories), - subtitle = buildCustomDirectoriesSubtitle(state.customDirectories, context), - checked = state.customDirectoriesBackupEnabled, - onCheckedChange = viewModel::setCustomDirectoriesBackupEnabled, - onNavigate = onNavigateToCustomDirectoriesConfig - ) - } - } - } - - item { - Column { - Text( - text = stringResource(R.string.operation_maintenance), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 8.dp) - ) - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) - ) { - Column { - TaskRow( - title = stringResource(R.string.maintenance_task_unlock_repository), - checked = state.maintenance.unlockRepo, - onCheckedChange = viewModel::setUnlockRepo - ) - HorizontalDivider(color = MaterialTheme.colorScheme.background) - TaskRow( - title = stringResource(R.string.run_tasks_forget_snapshots), - subtitle = buildForgetSubtitle(state.maintenance, context), - checked = state.maintenance.forgetSnapshots, - onCheckedChange = viewModel::setForgetSnapshots, - onNavigate = onNavigateToForgetConfig - ) - HorizontalDivider(color = MaterialTheme.colorScheme.background) - TaskRow( - title = stringResource(R.string.maintenance_task_prune_repository), - checked = state.maintenance.pruneRepo, - onCheckedChange = viewModel::setPruneRepo - ) - HorizontalDivider(color = MaterialTheme.colorScheme.background) - TaskRow( - title = stringResource(R.string.run_tasks_check_integrity), - subtitle = buildCheckSubtitle(state.maintenance, context), - checked = state.maintenance.checkRepo, - onCheckedChange = viewModel::setCheckRepo, - onNavigate = onNavigateToCheckConfig - ) - } - } - } - } - } - - if (state.showConfirmDeleteDialog) { - AlertDialog( - onDismissRequest = { viewModel.onCancelDeleteSchedule() }, - title = { Text(stringResource(R.string.dialog_delete_schedule_title)) }, - text = { Text(stringResource(R.string.dialog_delete_schedule_message)) }, - confirmButton = { - TextButton(onClick = { viewModel.confirmDeleteSchedule() }) { - Text(stringResource(R.string.action_delete)) - } - }, - dismissButton = { - TextButton(onClick = { viewModel.onCancelDeleteSchedule() }) { - Text(stringResource(R.string.action_cancel)) - } - } - ) - } - - if (state.showDiscardChangesDialog) { - AlertDialog( - onDismissRequest = { viewModel.onDismissDiscard() }, - title = { Text(stringResource(R.string.dialog_discard_changes_title)) }, - text = { Text(stringResource(R.string.dialog_discard_changes_message)) }, - confirmButton = { - TextButton( - onClick = { viewModel.onConfirmDiscard() }, - colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) - ) { - Text(stringResource(R.string.action_discard)) - } - }, - dismissButton = { - TextButton(onClick = { viewModel.onDismissDiscard() }) { - Text(stringResource(R.string.action_cancel)) - } - } - ) - } -} - -@Composable -fun ScheduleBackupConfigScreen( - viewModel: SchedulesViewModel, - modifier: Modifier = Modifier -) { - val state by viewModel.addEditState.collectAsState() - io.github.hddq.restoid.ui.shared.BackupConfigScreen( - isLoadingApps = state.isLoadingApps, - apps = state.apps, - appBackupTypes = state.appBackupTypes, - backupTypes = state.backupTypes, - onRefreshApps = viewModel::refreshAppsList, - onToggleAllApps = viewModel::toggleAllApps, - onToggleAppSelection = viewModel::toggleAppSelection, - onSetSelectedAppsBackupTypes = viewModel::setSelectedAppsBackupTypes, - onSetAppBackupTypes = viewModel::setAppBackupTypes, - modifier = modifier - ) -} - -@Composable -fun ScheduleForgetConfigScreen(viewModel: SchedulesViewModel, modifier: Modifier = Modifier) { - val state by viewModel.addEditState.collectAsState() - io.github.hddq.restoid.ui.shared.ForgetConfigScreen( - maintenance = state.maintenance, - onKeepLastChange = viewModel::setKeepLast, - onKeepDailyChange = viewModel::setKeepDaily, - onKeepWeeklyChange = viewModel::setKeepWeekly, - onKeepMonthlyChange = viewModel::setKeepMonthly, - modifier = modifier - ) -} - -@Composable -fun ScheduleCheckConfigScreen(viewModel: SchedulesViewModel, modifier: Modifier = Modifier) { - val state by viewModel.addEditState.collectAsState() - io.github.hddq.restoid.ui.shared.CheckConfigScreen( - maintenance = state.maintenance, - onReadDataChange = viewModel::setReadData, - modifier = modifier - ) -} - -@Composable -private fun ScheduleItem( - schedule: Schedule, - onEdit: () -> Unit, - onDelete: () -> Unit, - onToggleEnabled: () -> Unit, - onRunNow: () -> Unit -) { - val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) } - - Row( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - .clickable(onClick = onEdit) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = schedule.name, - style = MaterialTheme.typography.bodyLarge - ) - - val lastRunText = if (schedule.lastRunTimestamp != null) { - val relativeTime = android.text.format.DateUtils.getRelativeTimeSpanString( - schedule.lastRunTimestamp, - System.currentTimeMillis(), - android.text.format.DateUtils.MINUTE_IN_MILLIS - ).toString() - val exactTime = dateFormat.format(Date(schedule.lastRunTimestamp)) - stringResource(R.string.schedule_last_run, relativeTime, exactTime) - } else { - stringResource(R.string.schedule_never_run) - } - - Text( - text = stringResource(R.string.schedule_interval_label) + ": ${schedule.intervalHours}h\n" + lastRunText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - - Spacer(Modifier.width(12.dp)) - Icon( - imageVector = Icons.Default.ChevronRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Spacer(Modifier.width(12.dp)) - Box( - modifier = Modifier - .fillMaxHeight(0.5f) - .width(1.dp) - .background(MaterialTheme.colorScheme.outlineVariant) - ) - Spacer(Modifier.width(16.dp)) - - Switch( - checked = schedule.isEnabled, - onCheckedChange = { onToggleEnabled() }, - thumbContent = if (schedule.isEnabled) { - { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - modifier = Modifier.size(SwitchDefaults.IconSize) - ) - } - } else { - null - } - ) - } -} - - -@Composable -fun ScheduleCustomDirectoriesConfigScreen( - viewModel: SchedulesViewModel, - modifier: Modifier = Modifier -) { - val state by viewModel.addEditState.collectAsState() - io.github.hddq.restoid.ui.shared.CustomDirectoriesConfigScreen( - customDirectories = state.customDirectories, - onAddDirectory = viewModel::addCustomDirectory, - onToggleDirectory = viewModel::toggleCustomDirectory, - modifier = modifier - ) -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/schedules/SchedulesViewModel.kt b/app/src/main/java/io/github/hddq/restoid/ui/schedules/SchedulesViewModel.kt deleted file mode 100644 index 1e8b4a0f..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/schedules/SchedulesViewModel.kt +++ /dev/null @@ -1,404 +0,0 @@ -package io.github.hddq.restoid.ui.schedules - -import android.app.Application -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import io.github.hddq.restoid.data.AppInfoRepository -import io.github.hddq.restoid.data.RepositoriesRepository -import io.github.hddq.restoid.data.ScheduleRepository -import io.github.hddq.restoid.model.AppInfo -import io.github.hddq.restoid.model.Schedule -import io.github.hddq.restoid.model.TriggerConditions -import io.github.hddq.restoid.model.MaintenanceConfig -import io.github.hddq.restoid.ui.shared.BackupTypes -import io.github.hddq.restoid.ui.shared.toUiModel -import io.github.hddq.restoid.work.RunTasksConfig -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import java.util.UUID - -data class SchedulesUiState( - val schedules: List = emptyList(), - val selectedRepoKey: String? = null, - val isLoading: Boolean = false -) - -data class AddEditScheduleUiState( - val id: String? = null, - val name: String = "", - val intervalHours: Int = 24, - val isEnabled: Boolean = true, - val backupEnabled: Boolean = true, - val backupTypes: BackupTypes = BackupTypes(), - val appBackupTypes: Map = emptyMap(), - val apps: List = emptyList(), - val maintenance: MaintenanceConfig = MaintenanceConfig(), - val triggerConditions: TriggerConditions = TriggerConditions(), - val lastRunTimestamp: Long? = null, - val isLoadingApps: Boolean = false, - val isSaving: Boolean = false, - val showConfirmDeleteDialog: Boolean = false, - val showDiscardChangesDialog: Boolean = false, - val customDirectoriesBackupEnabled: Boolean = false, - val customDirectories: List = emptyList() -) - -sealed interface SchedulesUiEvent { - data object NavigateBack : SchedulesUiEvent -} - -class SchedulesViewModel( - private val application: Application, - private val scheduleRepository: ScheduleRepository, - private val repositoriesRepository: RepositoriesRepository, - private val appInfoRepository: AppInfoRepository -) : ViewModel() { - - private val _uiState = MutableStateFlow(SchedulesUiState()) - val uiState = _uiState.asStateFlow() - - private val _addEditState = MutableStateFlow(AddEditScheduleUiState()) - val addEditState = _addEditState.asStateFlow() - - private var initialState: AddEditScheduleUiState? = null - - private val _uiEvents = MutableSharedFlow(extraBufferCapacity = 1) - val uiEvents: SharedFlow = _uiEvents.asSharedFlow() - - init { - viewModelScope.launch { - repositoriesRepository.selectedRepository.collect { repoKey -> - _uiState.update { it.copy(selectedRepoKey = repoKey) } - loadSchedules() - } - } - } - - fun loadSchedules() { - val repoKey = _uiState.value.selectedRepoKey ?: return - val repository = repositoriesRepository.getRepositoryByKey(repoKey) ?: return - val repoId = repository.id ?: return - - viewModelScope.launch { - _uiState.update { it.copy(isLoading = true) } - val schedules = scheduleRepository.getSchedules(repoId) - _uiState.update { it.copy(schedules = schedules, isLoading = false) } - } - } - - fun deleteSchedule(scheduleId: String) { - val repoKey = _uiState.value.selectedRepoKey ?: return - val repository = repositoriesRepository.getRepositoryByKey(repoKey) ?: return - val repoId = repository.id ?: return - - viewModelScope.launch { - scheduleRepository.deleteSchedule(repoKey, repoId, scheduleId) - loadSchedules() - } - } - - fun toggleScheduleEnabled(schedule: Schedule) { - val repoKey = _uiState.value.selectedRepoKey ?: return - val repository = repositoriesRepository.getRepositoryByKey(repoKey) ?: return - val repoId = repository.id ?: return - - viewModelScope.launch { - scheduleRepository.toggleSchedule(repoKey, repoId, schedule.id, !schedule.isEnabled) - loadSchedules() - } - } - - fun runNow(schedule: Schedule) { - val repoKey = _uiState.value.selectedRepoKey ?: return - scheduleRepository.runNow(repoKey, schedule) - } - - // Add/Edit Screen methods - fun startAddSchedule() { - val newState = AddEditScheduleUiState() - _addEditState.value = newState - initialState = newState - loadAppsForAddEdit() - } - fun startEditSchedule(schedule: Schedule) { - val newState = AddEditScheduleUiState( - id = schedule.id, - name = schedule.name, - intervalHours = schedule.intervalHours, - isEnabled = schedule.isEnabled, - backupEnabled = schedule.config.backupEnabled, - backupTypes = schedule.config.backupTypes.toUiModel(), - appBackupTypes = schedule.config.appBackupTypes.mapValues { it.value.toUiModel() }, - maintenance = MaintenanceConfig( - unlockRepo = schedule.config.unlockRepo, - forgetSnapshots = schedule.config.forgetSnapshots, - pruneRepo = schedule.config.pruneRepo, - checkRepo = schedule.config.checkRepo, - readData = schedule.config.readData, - keepLast = schedule.config.keepLast, - keepDaily = schedule.config.keepDaily, - keepWeekly = schedule.config.keepWeekly, - keepMonthly = schedule.config.keepMonthly - ), - triggerConditions = schedule.triggerConditions, - lastRunTimestamp = schedule.lastRunTimestamp, - customDirectoriesBackupEnabled = schedule.config.customDirectories.isNotEmpty(), - customDirectories = schedule.config.customDirectories.map { io.github.hddq.restoid.model.CustomDirectory(it, true) } - ) - _addEditState.value = newState - initialState = newState - loadAppsForAddEdit(schedule.config.selectedPackageNames) - } - - private fun loadAppsForAddEdit(selectedPackageNames: List? = null) { - viewModelScope.launch { - _addEditState.update { it.copy(isLoadingApps = true) } - val allApps = appInfoRepository.getInstalledUserApps() - val currentTypes = _addEditState.value.appBackupTypes - val defaultTypes = _addEditState.value.backupTypes - val apps = if (selectedPackageNames != null) { - allApps.map { it.copy(isSelected = selectedPackageNames.contains(it.packageName)) } - } else { - allApps // Default selection from AppInfo might be true, we might want to default to true for new schedules too - } - val appBackupTypes = apps.associate { app -> - app.packageName to (currentTypes[app.packageName] ?: defaultTypes) - } - _addEditState.update { - val updated = it.copy(apps = apps, appBackupTypes = appBackupTypes, isLoadingApps = false) - // Update initialState if it was just set (apps are loaded asynchronously) - if (initialState?.apps?.isEmpty() == true && apps.isNotEmpty()) { - initialState = updated - } - updated - } - } - } - - fun hasChanges(): Boolean { - val current = _addEditState.value - val initial = initialState ?: return false - - return current.name != initial.name || - current.intervalHours != initial.intervalHours || - current.isEnabled != initial.isEnabled || - current.backupEnabled != initial.backupEnabled || - current.backupTypes != initial.backupTypes || - current.appBackupTypes != initial.appBackupTypes || - current.apps.map { it.packageName to it.isSelected } != initial.apps.map { it.packageName to it.isSelected } || - current.maintenance != initial.maintenance || - current.triggerConditions != initial.triggerConditions || - current.customDirectoriesBackupEnabled != initial.customDirectoriesBackupEnabled || - current.customDirectories != initial.customDirectories - } - - fun onBackPress() { - if (hasChanges()) { - _addEditState.update { it.copy(showDiscardChangesDialog = true) } - } else { - _uiEvents.tryEmit(SchedulesUiEvent.NavigateBack) - } - } - - fun onConfirmDiscard() { - _addEditState.update { it.copy(showDiscardChangesDialog = false) } - _uiEvents.tryEmit(SchedulesUiEvent.NavigateBack) - } - - fun onDismissDiscard() { - _addEditState.update { it.copy(showDiscardChangesDialog = false) } - } - - fun saveSchedule() { - val repoKey = _uiState.value.selectedRepoKey ?: return - val repository = repositoriesRepository.getRepositoryByKey(repoKey) ?: return - val repoId = repository.id ?: return - - val state = _addEditState.value - val schedule = Schedule( - id = state.id ?: UUID.randomUUID().toString(), - name = state.name.ifBlank { "Schedule" }, - intervalHours = state.intervalHours, - isEnabled = state.isEnabled, - config = RunTasksConfig( - backupEnabled = state.backupEnabled, - backupTypes = state.backupTypes.toSelection(), - selectedPackageNames = state.apps.filter { it.isSelected }.map { it.packageName }, - appBackupTypes = selectedAppBackupTypes(state).mapValues { it.value.toSelection() }, - customDirectories = if (state.customDirectoriesBackupEnabled) state.customDirectories.filter { it.isSelected }.map { it.uri } else emptyList(), - unlockRepo = state.maintenance.unlockRepo, - forgetSnapshots = state.maintenance.forgetSnapshots, - pruneRepo = state.maintenance.pruneRepo, - checkRepo = state.maintenance.checkRepo, - readData = state.maintenance.readData, - keepLast = state.maintenance.keepLast, - keepDaily = state.maintenance.keepDaily, - keepWeekly = state.maintenance.keepWeekly, - keepMonthly = state.maintenance.keepMonthly - ), - triggerConditions = state.triggerConditions - ) - - viewModelScope.launch { - _addEditState.update { it.copy(isSaving = true) } - scheduleRepository.saveSchedule(repoKey, repoId, schedule) - _addEditState.update { it.copy(isSaving = false) } - loadSchedules() - _uiEvents.emit(SchedulesUiEvent.NavigateBack) - } - } - - fun setName(name: String) = _addEditState.update { it.copy(name = name) } - fun setIntervalHours(hours: Int) = _addEditState.update { it.copy(intervalHours = hours) } - fun setBackupEnabled(enabled: Boolean) = _addEditState.update { it.copy(backupEnabled = enabled) } - fun setBackupApk(value: Boolean) = _addEditState.update { it.copy(backupTypes = it.backupTypes.copy(apk = value)) } - fun setBackupData(value: Boolean) = _addEditState.update { it.copy(backupTypes = it.backupTypes.copy(data = value)) } - fun setBackupDeviceProtectedData(value: Boolean) = _addEditState.update { it.copy(backupTypes = it.backupTypes.copy(deviceProtectedData = value)) } - fun setBackupExternalData(value: Boolean) = _addEditState.update { it.copy(backupTypes = it.backupTypes.copy(externalData = value)) } - fun setBackupObb(value: Boolean) = _addEditState.update { it.copy(backupTypes = it.backupTypes.copy(obb = value)) } - fun setBackupMedia(value: Boolean) = _addEditState.update { it.copy(backupTypes = it.backupTypes.copy(media = value)) } - fun setBackupPermissions(value: Boolean) = _addEditState.update { it.copy(backupTypes = it.backupTypes.copy(permissions = value)) } - - fun setCustomDirectoriesBackupEnabled(enabled: Boolean) { - _addEditState.update { it.copy(customDirectoriesBackupEnabled = enabled) } - } - - fun addCustomDirectory(uriString: String) { - val repoKey = _uiState.value.selectedRepoKey - val repository = repoKey?.let { repositoriesRepository.getRepositoryByKey(it) } - - if (repository != null && repository.backendType == io.github.hddq.restoid.data.RepositoryBackendType.LOCAL) { - val repoUri = android.net.Uri.parse(repository.path) - val customDirUri = android.net.Uri.parse(uriString) - val repoPath = io.github.hddq.restoid.util.StorageUtils.getPathFromTreeUri(repoUri) ?: repository.path - val customDirPath = io.github.hddq.restoid.util.StorageUtils.getPathFromTreeUri(customDirUri) ?: uriString - - if (repoPath == customDirPath) { - android.widget.Toast.makeText( - application, - application.getString(io.github.hddq.restoid.R.string.error_cannot_backup_repo_to_itself), - android.widget.Toast.LENGTH_LONG - ).show() - return - } - } - - _addEditState.update { state -> - if (state.customDirectories.none { it.uri == uriString }) { - state.copy(customDirectories = state.customDirectories + io.github.hddq.restoid.model.CustomDirectory(uriString)) - } else { - state - } - } - } - - fun toggleCustomDirectory(uriString: String) { - _addEditState.update { state -> - state.copy( - customDirectories = state.customDirectories.map { - if (it.uri == uriString) it.copy(isSelected = !it.isSelected) else it - } - ) - } - } - - fun removeCustomDirectory(uriString: String) { - _addEditState.update { state -> - state.copy(customDirectories = state.customDirectories.filter { it.uri != uriString }) - } - } - - fun toggleAppSelection(packageName: String) { - _addEditState.update { state -> - state.copy( - apps = state.apps.map { - if (it.packageName == packageName) it.copy(isSelected = !it.isSelected) else it - }, - appBackupTypes = state.appBackupTypes.ensurePackage(packageName, state.backupTypes) - ) - } - } - - fun toggleAllApps() { - _addEditState.update { state -> - val shouldSelectAll = state.apps.any { !it.isSelected } - val apps = state.apps.map { it.copy(isSelected = shouldSelectAll) } - state.copy( - apps = apps, - appBackupTypes = apps.fold(state.appBackupTypes) { acc, app -> - acc.ensurePackage(app.packageName, state.backupTypes) - } - ) - } - } - - fun setAppBackupTypes(packageName: String, backupTypes: BackupTypes) { - _addEditState.update { state -> - state.copy(appBackupTypes = state.appBackupTypes + (packageName to backupTypes)) - } - } - - fun setSelectedAppsBackupTypes(backupTypes: BackupTypes) { - _addEditState.update { state -> - val selectedPackageNames = state.apps.filter { it.isSelected }.map { it.packageName } - state.copy( - backupTypes = backupTypes, - appBackupTypes = state.appBackupTypes + selectedPackageNames.associateWith { backupTypes } - ) - } - } - - fun setUnlockRepo(value: Boolean) = _addEditState.update { it.copy(maintenance = it.maintenance.copy(unlockRepo = value)) } - fun setForgetSnapshots(value: Boolean) = _addEditState.update { it.copy(maintenance = it.maintenance.copy(forgetSnapshots = value)) } - fun setPruneRepo(value: Boolean) = _addEditState.update { it.copy(maintenance = it.maintenance.copy(pruneRepo = value)) } - fun setCheckRepo(value: Boolean) = _addEditState.update { it.copy(maintenance = it.maintenance.copy(checkRepo = value)) } - fun setReadData(value: Boolean) = _addEditState.update { it.copy(maintenance = it.maintenance.copy(readData = value)) } - fun setKeepLast(value: Int) = _addEditState.update { it.copy(maintenance = it.maintenance.copy(keepLast = value)) } - fun setKeepDaily(value: Int) = _addEditState.update { it.copy(maintenance = it.maintenance.copy(keepDaily = value)) } - fun setKeepWeekly(value: Int) = _addEditState.update { it.copy(maintenance = it.maintenance.copy(keepWeekly = value)) } - fun setKeepMonthly(value: Int) = _addEditState.update { it.copy(maintenance = it.maintenance.copy(keepMonthly = value)) } - - fun setRequireBatteryNotLow(value: Boolean) = _addEditState.update { it.copy(triggerConditions = it.triggerConditions.copy(requireBatteryNotLow = value)) } - fun setRequireCharging(value: Boolean) = _addEditState.update { it.copy(triggerConditions = it.triggerConditions.copy(requireCharging = value)) } - fun setRequireUnmeteredNetwork(value: Boolean) = _addEditState.update { it.copy(triggerConditions = it.triggerConditions.copy(requireUnmeteredNetwork = value)) } - - fun refreshAppsList() { - loadAppsForAddEdit(_addEditState.value.apps.filter { it.isSelected }.map { it.packageName }) - } - - fun onDeleteScheduleClick() = _addEditState.update { it.copy(showConfirmDeleteDialog = true) } - - fun onCancelDeleteSchedule() = _addEditState.update { it.copy(showConfirmDeleteDialog = false) } - - fun confirmDeleteSchedule() { - val scheduleId = _addEditState.value.id ?: return - _addEditState.update { it.copy(showConfirmDeleteDialog = false) } - - val repoKey = _uiState.value.selectedRepoKey ?: return - val repository = repositoriesRepository.getRepositoryByKey(repoKey) ?: return - val repoId = repository.id ?: return - - viewModelScope.launch { - scheduleRepository.deleteSchedule(repoKey, repoId, scheduleId) - loadSchedules() - _uiEvents.emit(SchedulesUiEvent.NavigateBack) - } - } - - private fun selectedAppBackupTypes(state: AddEditScheduleUiState): Map { - return state.apps - .filter { it.isSelected } - .associate { app -> - app.packageName to (state.appBackupTypes[app.packageName] ?: state.backupTypes) - } - } - - private fun Map.ensurePackage(packageName: String, defaultTypes: BackupTypes): Map { - return if (containsKey(packageName)) this else this + (packageName to defaultTypes) - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/schedules/SchedulesViewModelFactory.kt b/app/src/main/java/io/github/hddq/restoid/ui/schedules/SchedulesViewModelFactory.kt deleted file mode 100644 index c6e4c139..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/schedules/SchedulesViewModelFactory.kt +++ /dev/null @@ -1,23 +0,0 @@ -package io.github.hddq.restoid.ui.schedules - -import android.app.Application -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import io.github.hddq.restoid.data.AppInfoRepository -import io.github.hddq.restoid.data.RepositoriesRepository -import io.github.hddq.restoid.data.ScheduleRepository - -class SchedulesViewModelFactory( - private val application: Application, - private val scheduleRepository: ScheduleRepository, - private val repositoriesRepository: RepositoriesRepository, - private val appInfoRepository: AppInfoRepository -) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(SchedulesViewModel::class.java)) { - return SchedulesViewModel(application, scheduleRepository, repositoriesRepository, appInfoRepository) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/screens/HomeScreen.kt b/app/src/main/java/io/github/hddq/restoid/ui/screens/HomeScreen.kt deleted file mode 100644 index 4132104e..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/screens/HomeScreen.kt +++ /dev/null @@ -1,498 +0,0 @@ -package io.github.hddq.restoid.ui.screens - -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material.icons.filled.Folder -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Checkbox -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.pulltorefresh.PullToRefreshBox -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.pluralStringResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import coil.compose.rememberAsyncImagePainter -import io.github.hddq.restoid.R -import io.github.hddq.restoid.data.ResticState -import io.github.hddq.restoid.model.AppInfo -import io.github.hddq.restoid.ui.components.PasswordDialog -import io.github.hddq.restoid.ui.components.SshPrivateKeyField -import io.github.hddq.restoid.ui.components.UsernamePasswordDialog -import io.github.hddq.restoid.ui.home.HomeAuthFailure -import io.github.hddq.restoid.ui.home.HomeCredentialPrompt -import io.github.hddq.restoid.ui.home.HomeUiState -import io.github.hddq.restoid.ui.home.SnapshotWithMetadata - -private fun homeCredentialsSaveLabel(prompt: HomeCredentialPrompt): Int { - return if (prompt == HomeCredentialPrompt.S3_CREDENTIALS) R.string.action_save_passwords else R.string.save_credentials -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun HomeScreen( - onSnapshotClick: (String) -> Unit, - uiState: HomeUiState, - onRefresh: () -> Unit, - onPasswordEntered: (String, Boolean) -> Unit, - onSftpPasswordEntered: (String, String, Boolean) -> Unit, - onRestCredentialsEntered: (String, String, Boolean) -> Unit, - onS3CredentialsEntered: (String, String, Boolean) -> Unit, - onRetryRepositoryPasswordEntry: () -> Unit, - onRetrySftpPasswordEntry: () -> Unit, - onRetryRestCredentialsEntry: () -> Unit, - onRetryS3CredentialsEntry: () -> Unit, - onDismissPasswordDialog: () -> Unit, - onDismissSftpPasswordDialog: () -> Unit, - onDismissRestCredentialsDialog: () -> Unit, - onDismissS3CredentialsDialog: () -> Unit, - modifier: Modifier = Modifier -) { - Column( - modifier = modifier - .fillMaxSize() - .padding(horizontal = 16.dp, vertical = 16.dp) - ) { - // Header Section - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.home_title), - style = MaterialTheme.typography.headlineLarge, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - ) - } - Spacer(Modifier.height(24.dp)) - - // Refreshable Content Area - PullToRefreshBox( - isRefreshing = uiState.isRefreshing, - onRefresh = onRefresh, - modifier = Modifier.fillMaxSize() - ) { - Box(modifier = Modifier.fillMaxSize()) { - when { - uiState.selectedRepo == null -> { - Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { - Text( - stringResource(R.string.no_repository_selected), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - uiState.resticState !is ResticState.Installed -> { - Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { - Text( - stringResource(R.string.restic_not_available_check_settings), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.error - ) - } - } - uiState.isLoading -> { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } - } - uiState.error != null -> { - val errorMessage = uiState.error - Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { - Text( - text = stringResource(R.string.error_with_message, errorMessage), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.error - ) - - when (uiState.authFailure) { - HomeAuthFailure.REPOSITORY_PASSWORD -> { - Spacer(Modifier.height(12.dp)) - Button(onClick = onRetryRepositoryPasswordEntry) { - Text(stringResource(R.string.action_enter_password_again)) - } - } - - HomeAuthFailure.SFTP_PASSWORD -> { - Spacer(Modifier.height(12.dp)) - Button(onClick = onRetrySftpPasswordEntry) { - Text(stringResource(R.string.action_enter_sftp_password_again)) - } - } - - HomeAuthFailure.REST_CREDENTIALS -> { - Spacer(Modifier.height(12.dp)) - Button(onClick = onRetryRestCredentialsEntry) { - Text(stringResource(R.string.action_enter_rest_credentials_again)) - } - } - - HomeAuthFailure.S3_CREDENTIALS -> { - Spacer(Modifier.height(12.dp)) - Button(onClick = onRetryS3CredentialsEntry) { - Text(stringResource(R.string.action_enter_s3_credentials_again)) - } - } - - null -> Unit - } - } - } - uiState.openPrompt != null -> { - Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { - Text( - text = stringResource(R.string.home_open_needed_message), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(Modifier.height(12.dp)) - when (uiState.openPrompt) { - HomeCredentialPrompt.REPOSITORY_PASSWORD -> { - Button(onClick = onRetryRepositoryPasswordEntry) { - Text(stringResource(R.string.action_open_repository)) - } - } - - HomeCredentialPrompt.SFTP_PASSWORD -> { - Button(onClick = onRetrySftpPasswordEntry) { - Text(stringResource(R.string.action_open_sftp)) - } - } - - HomeCredentialPrompt.REST_CREDENTIALS -> { - Button(onClick = onRetryRestCredentialsEntry) { - Text(stringResource(R.string.action_open_rest)) - } - } - - HomeCredentialPrompt.S3_CREDENTIALS -> { - Button(onClick = onRetryS3CredentialsEntry) { - Text(stringResource(R.string.action_open_s3)) - } - } - } - } - } - uiState.snapshotsWithMetadata.isEmpty() -> { - Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { - Text( - text = stringResource(R.string.no_snapshots_found_for_repository), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - else -> { - Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { - Text( - text = pluralStringResource( - R.plurals.snapshots_count, - uiState.snapshotsWithMetadata.size, - uiState.snapshotsWithMetadata.size - ), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 8.dp) - ) - Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)) { - Column { - val snapshots = uiState.snapshotsWithMetadata.sortedByDescending { it.snapshotInfo.time } - snapshots.forEachIndexed { index, item -> - SnapshotItem(snapshotWithMetadata = item, apps = uiState.appInfoMap[item.snapshotInfo.id], onClick = { onSnapshotClick(item.snapshotInfo.id) }) - if (index < snapshots.size - 1) HorizontalDivider(color = MaterialTheme.colorScheme.background) - } - } - } - - Spacer(Modifier.height(88.dp)) - } - } - } - } - } - } - - if (uiState.showPasswordDialogFor != null) { - val repositoryKey = uiState.showPasswordDialogFor - PasswordDialog( - title = stringResource(R.string.repository_password_required), - message = stringResource(R.string.enter_password_for_repository, repositoryKey), - onPasswordEntered = onPasswordEntered, - onDismiss = onDismissPasswordDialog - ) - } - - if (uiState.showSftpPasswordDialogFor != null) { - val repositoryKey = uiState.showSftpPasswordDialogFor - val isKeyAuth = uiState.isSftpKeyAuthRequired - - if (isKeyAuth) { - var credentials by remember { mutableStateOf("") } - var passphrase by remember { mutableStateOf("") } - var passphraseVisible by remember { mutableStateOf(false) } - var saveCredentials by remember { mutableStateOf(false) } - var importErrorMessage by remember { mutableStateOf(null) } - - AlertDialog( - onDismissRequest = onDismissSftpPasswordDialog, - title = { Text(stringResource(R.string.sftp_key_required)) }, - text = { - Column { - Text(stringResource(R.string.enter_sftp_credentials_for_repository, repositoryKey)) - Spacer(modifier = Modifier.height(16.dp)) - SshPrivateKeyField( - value = credentials, - onValueChange = { - credentials = it - importErrorMessage = null - }, - onImportError = { importErrorMessage = it }, - label = stringResource(R.string.label_sftp_private_key), - placeholder = stringResource(R.string.placeholder_sftp_private_key), - isError = importErrorMessage != null, - supportingText = importErrorMessage?.let { message -> { Text(message) } }, - modifier = Modifier.fillMaxWidth() - ) - - if (uiState.isSftpKeyPassphraseRequired) { - Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - value = passphrase, - onValueChange = { passphrase = it }, - label = { Text(stringResource(R.string.label_sftp_key_passphrase)) }, - singleLine = true, - visualTransformation = if (passphraseVisible) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - val image = if (passphraseVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility - val description = if (passphraseVisible) stringResource(R.string.cd_hide_password) else stringResource(R.string.cd_show_password) - IconButton(onClick = { passphraseVisible = !passphraseVisible }) { - Icon(imageVector = image, contentDescription = description) - } - }, - modifier = Modifier.fillMaxWidth() - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Checkbox( - checked = saveCredentials, - onCheckedChange = { saveCredentials = it } - ) - Text(stringResource(R.string.save_credentials)) - } - } - }, - confirmButton = { - Button( - onClick = { - if (credentials.isNotBlank() && (!uiState.isSftpKeyPassphraseRequired || passphrase.isNotBlank())) { - onSftpPasswordEntered(credentials, passphrase, saveCredentials) - } - }, - enabled = credentials.isNotBlank() && (!uiState.isSftpKeyPassphraseRequired || passphrase.isNotBlank()) - ) { - Text(stringResource(R.string.action_save)) - } - }, - dismissButton = { - TextButton(onClick = onDismissSftpPasswordDialog) { - Text(stringResource(R.string.action_cancel)) - } - } - ) - } else { - PasswordDialog( - title = stringResource(R.string.sftp_password_required), - message = stringResource(R.string.enter_sftp_password_for_repository, repositoryKey), - onPasswordEntered = { password, save -> onSftpPasswordEntered(password, "", save) }, - onDismiss = onDismissSftpPasswordDialog - ) - } - } - - if (uiState.showRestCredentialsDialogFor != null) { - val repositoryKey = uiState.showRestCredentialsDialogFor - UsernamePasswordDialog( - title = stringResource(R.string.rest_credentials_required), - message = stringResource(R.string.enter_rest_credentials_for_repository, repositoryKey), - usernameLabel = stringResource(R.string.label_rest_username), - passwordLabel = stringResource(R.string.label_rest_password), - onCredentialsEntered = onRestCredentialsEntered, - onDismiss = onDismissRestCredentialsDialog, - saveCredentialsLabel = stringResource(homeCredentialsSaveLabel(HomeCredentialPrompt.REST_CREDENTIALS)) - ) - } - - if (uiState.showS3CredentialsDialogFor != null) { - val repositoryKey = uiState.showS3CredentialsDialogFor - UsernamePasswordDialog( - title = stringResource(R.string.s3_credentials_required), - message = stringResource(R.string.enter_s3_credentials_for_repository, repositoryKey), - usernameLabel = stringResource(R.string.label_s3_access_key_id), - passwordLabel = stringResource(R.string.label_s3_secret_access_key), - onCredentialsEntered = onS3CredentialsEntered, - onDismiss = onDismissS3CredentialsDialog, - saveCredentialsLabel = stringResource(homeCredentialsSaveLabel(HomeCredentialPrompt.S3_CREDENTIALS)) - ) - } -} - -@Composable -private fun SnapshotItem(snapshotWithMetadata: SnapshotWithMetadata, apps: List?, onClick: () -> Unit) { - val snapshot = snapshotWithMetadata.snapshotInfo - val context = androidx.compose.ui.platform.LocalContext.current - - val timeText = remember(snapshot.time, context) { - try { - val instant = java.time.Instant.parse(snapshot.time) - val now = java.time.Instant.now() - val duration = java.time.Duration.between(instant, now) - - val relativeTime = when { - duration.toMinutes() < 1 -> context.getString(R.string.time_just_now) - duration.toHours() < 1 -> { - val mins = duration.toMinutes().toInt() - context.resources.getQuantityString(R.plurals.time_mins_ago, mins, mins) - } - duration.toDays() < 1 -> { - val hours = duration.toHours().toInt() - context.resources.getQuantityString(R.plurals.time_hours_ago, hours, hours) - } - duration.toDays() < 30 -> { - val days = duration.toDays().toInt() - context.resources.getQuantityString(R.plurals.time_days_ago, days, days) - } - duration.toDays() < 365 -> { - val months = (duration.toDays() / 30).toInt() - context.resources.getQuantityString(R.plurals.time_months_ago, months, months) - } - else -> { - val years = (duration.toDays() / 365).toInt() - context.resources.getQuantityString(R.plurals.time_years_ago, years, years) - } - } - "$relativeTime (${snapshot.time.take(10)})" - } catch (e: Exception) { - snapshot.time.take(10) - } - } - - Column( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { - Text(text = snapshot.id.take(8), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace) - Text(text = timeText, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - - val appCount = snapshotWithMetadata.metadata?.apps?.size ?: 0 - val customDirCount = snapshotWithMetadata.metadata?.customDirectories?.size ?: 0 - val totalCount = appCount + customDirCount - - if (totalCount > 0) { - Text( - pluralStringResource(R.plurals.backed_up_items_count, totalCount, totalCount), - style = MaterialTheme.typography.labelMedium - ) - BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { - val iconSize = 32.dp - val spacing = 8.dp - val itemWidthWithSpacing = iconSize + spacing - val maxIconsPossible = ((maxWidth + spacing) / itemWidthWithSpacing).toInt().coerceAtLeast(1) - - val customDirSlot = if (customDirCount > 0) 1 else 0 - val availableSlotsForApps = (maxIconsPossible - customDirSlot).coerceAtLeast(0) - val showCounter = appCount > availableSlotsForApps - val limit = if (showCounter) (availableSlotsForApps - 1).coerceAtLeast(0) else availableSlotsForApps - - Row(horizontalArrangement = Arrangement.spacedBy(spacing), verticalAlignment = Alignment.CenterVertically) { - if (apps != null && apps.isNotEmpty() && appCount > 0) { - apps.take(limit).forEach { app -> - Image(painter = rememberAsyncImagePainter(model = app.icon), contentDescription = app.name, modifier = Modifier.size(iconSize)) - } - val realMoreCount = appCount - limit - if(realMoreCount > 0) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.size(iconSize).clip(CircleShape).background(MaterialTheme.colorScheme.secondaryContainer)) { - Text(text = "+$realMoreCount", style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), color = MaterialTheme.colorScheme.onSecondaryContainer, fontWeight = FontWeight.Bold) - } - } - } - if (customDirCount > 0) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.size(iconSize)) { - Icon( - imageVector = Icons.Default.Folder, - contentDescription = null, - modifier = Modifier.fillMaxSize(), - tint = MaterialTheme.colorScheme.secondaryContainer - ) - Text( - text = customDirCount.toString(), - style = MaterialTheme.typography.labelSmall.copy(fontSize = 12.sp), - color = MaterialTheme.colorScheme.onSecondaryContainer, - fontWeight = FontWeight.Bold - ) - } - } - } - } - } else if (snapshotWithMetadata.metadata == null) { - Text( - text = stringResource(R.string.metadata_missing_or_corrupted), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } else { - Text( - text = stringResource(R.string.no_app_info_for_snapshot), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/screens/Licenses.kt b/app/src/main/java/io/github/hddq/restoid/ui/screens/Licenses.kt deleted file mode 100644 index 8b2d4eb9..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/screens/Licenses.kt +++ /dev/null @@ -1,708 +0,0 @@ -package io.github.hddq.restoid.ui.screens - -object Licenses { - const val RESTIC_LICENSE = """ -BSD 2-Clause License - -Copyright (c) 2014, Alexander Neumann -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -* Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -* Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - """ - - const val RESTOID_LICENSE = """ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. - """ -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/screens/LicensesScreen.kt b/app/src/main/java/io/github/hddq/restoid/ui/screens/LicensesScreen.kt deleted file mode 100644 index cd95f188..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/screens/LicensesScreen.kt +++ /dev/null @@ -1,238 +0,0 @@ -package io.github.hddq.restoid.ui.screens - -import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.OpenInNew -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import android.content.ClipData -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ClipEntry -import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.mikepenz.aboutlibraries.Libs -import io.github.hddq.restoid.R -import kotlinx.coroutines.launch - -@Composable -fun LicensesScreen(onNavigateUp: () -> Unit, modifier: Modifier = Modifier) { - val context = LocalContext.current - - // Load the libraries generated by the Gradle plugin - val libs = remember { - try { - val jsonString = context.resources.openRawResource(R.raw.aboutlibraries) - .bufferedReader() - .use { it.readText() } - Libs.Builder().withJson(jsonString).build() - } catch (e: Exception) { - Libs.Builder().build() - } - } - - val libraries = remember(libs) { libs.libraries.sortedBy { it.name } } - - LazyColumn( - modifier = modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(16.dp) - ) { - // 1. Custom Items (Restoid & Restic) - item { - LicenseCard( - name = stringResource(R.string.app_name), - author = stringResource(R.string.license_restoid_author), - licenseName = stringResource(R.string.license_restoid_name), - licenseText = Licenses.RESTOID_LICENSE, - url = stringResource(R.string.license_restoid_url) - ) - } - item { - LicenseCard( - name = stringResource(R.string.license_restic_name), - author = stringResource(R.string.license_restic_author), - licenseName = stringResource(R.string.license_restic_license), - licenseText = Licenses.RESTIC_LICENSE, - url = stringResource(R.string.license_restic_url) - ) - } - - // 2. Auto-generated Gradle dependencies - items(libraries) { library -> - val firstLicense = library.licenses.firstOrNull() - val licenseUrl = firstLicense?.url ?: "" - val website = library.website ?: "" - - // Prioritize License URL, fallback to website - val primaryUrl = if (licenseUrl.isNotBlank()) licenseUrl else website - - LicenseCard( - name = library.name, - author = library.developers.firstOrNull()?.name ?: library.organization?.name ?: stringResource(R.string.license_unknown_author), - licenseName = firstLicense?.name ?: stringResource(R.string.license_unknown_name), - licenseText = if (licenseUrl.isNotBlank()) { - stringResource(R.string.license_url_label, licenseUrl) - } else if (website.isNotBlank()) { - stringResource(R.string.license_project_website_label, website) - } else { - stringResource(R.string.license_no_link_available) - }, - version = library.artifactVersion, - url = primaryUrl - ) - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun LicenseCard( - name: String, - author: String = "", - licenseName: String = "", - licenseText: String, - version: String? = null, - url: String = "" -) { - var expanded by remember { mutableStateOf(false) } - val uriHandler = LocalUriHandler.current - val clipboard = LocalClipboard.current - val scope = rememberCoroutineScope() - val tapToExpand = stringResource(R.string.license_tap_to_expand) - - // Optimize text for preview - val licensePreview = remember(licenseText, tapToExpand) { - if (licenseText.length > 200) { - licenseText.trimIndent().take(200) + tapToExpand - } else { - licenseText.trimIndent() - } - } - - Card( - modifier = Modifier - .fillMaxWidth() - .combinedClickable( - onClick = { expanded = !expanded }, - onLongClick = { - // Copy to clipboard on long press - scope.launch { - clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, "$name\n$url\n\n$licenseText"))) - } - } - ), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer - ) - ) { - Column( - modifier = Modifier - .padding(16.dp) - .fillMaxWidth() - .animateContentSize() - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top - ) { - Column(modifier = Modifier.weight(1f)) { - Text(name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - if (author.isNotBlank()) { - Text(author, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary) - } - if (version != null) { - Text(version, style = MaterialTheme.typography.labelSmall, fontFamily = FontFamily.Monospace) - } - } - - // Action buttons - Row { - if (url.isNotBlank()) { - IconButton( - onClick = { uriHandler.openUri(url) }, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.OpenInNew, - contentDescription = stringResource(R.string.cd_open_link), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(16.dp) - ) - } - } - } - } - - if (licenseName.isNotBlank()) { - Spacer(Modifier.height(8.dp)) - Text(licenseName, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - - if (expanded || licenseText.isNotBlank()) { - Spacer(Modifier.height(8.dp)) - Text( - text = if (expanded) licenseText.trimIndent() else licensePreview, - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface - ) - - if (expanded) { - Row( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - horizontalArrangement = Arrangement.End - ) { - IconButton( - onClick = { - scope.launch { - clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, "$name\n$url\n\n$licenseText"))) - } - }, - modifier = Modifier.size(32.dp) - ) { - Icon( - imageVector = Icons.Default.ContentCopy, - contentDescription = stringResource(R.string.cd_copy_license), - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp) - ) - } - } - } - } - } - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/screens/OperationProgressScreen.kt b/app/src/main/java/io/github/hddq/restoid/ui/screens/OperationProgressScreen.kt deleted file mode 100644 index 03f9a642..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/screens/OperationProgressScreen.kt +++ /dev/null @@ -1,109 +0,0 @@ -package io.github.hddq.restoid.ui.screens - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.activity.compose.BackHandler -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.lifecycle.viewmodel.compose.viewModel -import io.github.hddq.restoid.R -import io.github.hddq.restoid.RestoidApplication -import io.github.hddq.restoid.data.OperationType -import io.github.hddq.restoid.ui.operation.OperationProgressUiEvent -import io.github.hddq.restoid.ui.operation.OperationProgressViewModel -import io.github.hddq.restoid.ui.operation.OperationProgressViewModelFactory -import io.github.hddq.restoid.ui.shared.ProgressScreenContent - -@Composable -fun OperationProgressScreen( - onNavigateUp: () -> Unit, - modifier: Modifier = Modifier -) { - val application = LocalContext.current.applicationContext as RestoidApplication - val viewModel: OperationProgressViewModel = viewModel( - factory = OperationProgressViewModelFactory(application.operationWorkRepository) - ) - val state by viewModel.state.collectAsState() - var showStopConfirmation by remember { mutableStateOf(false) } - - LaunchedEffect(viewModel) { - viewModel.uiEvents.collect { event -> - when (event) { - OperationProgressUiEvent.NavigateUp -> onNavigateUp() - } - } - } - - val operationTypeLabel = when (state.operationType) { - OperationType.BACKUP -> stringResource(R.string.operation_backup) - OperationType.RUN_TASKS -> stringResource(R.string.operation_run_tasks) - OperationType.RESTORE -> stringResource(R.string.operation_restore) - OperationType.MAINTENANCE -> stringResource(R.string.operation_maintenance) - null -> "" - } - - val showProgress = state.isRunning || state.progress.isFinished - - BackHandler(enabled = state.isRunning) { - showStopConfirmation = true - } - - if (showStopConfirmation) { - AlertDialog( - onDismissRequest = { showStopConfirmation = false }, - title = { Text(text = stringResource(R.string.dialog_stop_operation_title)) }, - text = { Text(text = stringResource(R.string.dialog_stop_operation_message)) }, - confirmButton = { - TextButton( - onClick = { - showStopConfirmation = false - viewModel.onStopConfirmed() - } - ) { - Text(text = stringResource(R.string.action_stop)) - } - }, - dismissButton = { - TextButton(onClick = { showStopConfirmation = false }) { - Text(text = stringResource(R.string.action_cancel)) - } - } - ) - } - - if (showProgress && operationTypeLabel.isNotBlank()) { - ProgressScreenContent( - progress = state.progress, - operationType = operationTypeLabel, - onDone = { - viewModel.onDone() - onNavigateUp() - }, - modifier = modifier.fillMaxSize() - ) - } else { - Box( - modifier = modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - text = stringResource(R.string.operation_progress_no_active_operation), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/screens/RestoreScreen.kt b/app/src/main/java/io/github/hddq/restoid/ui/screens/RestoreScreen.kt deleted file mode 100644 index d0d1d0fa..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/screens/RestoreScreen.kt +++ /dev/null @@ -1,623 +0,0 @@ -package io.github.hddq.restoid.ui.screens - -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.ChevronRight -import androidx.compose.material.icons.filled.Folder -import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation.NavController -import coil.compose.rememberAsyncImagePainter -import android.widget.Toast -import io.github.hddq.restoid.R -import io.github.hddq.restoid.RestoidApplication -import io.github.hddq.restoid.Screen -import io.github.hddq.restoid.model.BackupDetail -import io.github.hddq.restoid.ui.restore.RestoreUiEvent -import io.github.hddq.restoid.ui.restore.RestoreTypes -import io.github.hddq.restoid.ui.restore.RestoreViewModel -import io.github.hddq.restoid.ui.restore.RestoreViewModelFactory -import io.github.hddq.restoid.ui.shared.TaskRow - -@Composable -fun RestoreScreen( - viewModel: RestoreViewModel, - onNavigateToOperationProgress: () -> Unit, - onNavigateToAppsConfig: () -> Unit, - onNavigateToCustomDirectoriesConfig: () -> Unit, - modifier: Modifier = Modifier -) { - val application = LocalContext.current.applicationContext as RestoidApplication - val backupDetails by viewModel.backupDetails.collectAsState() - val isLoading by viewModel.isLoading.collectAsState() - val allowDowngrade by viewModel.allowDowngrade.collectAsState() - val operationBlocked by viewModel.operationBlocked.collectAsState() - val customDirectories by viewModel.customDirectories.collectAsState() - - val restoreAppsEnabled by viewModel.restoreAppsEnabled.collectAsState() - val restoreCustomDirectoriesEnabled by viewModel.restoreCustomDirectoriesEnabled.collectAsState() - val restoreTypes by viewModel.restoreTypes.collectAsState() - val appRestoreTypes by viewModel.appRestoreTypes.collectAsState() - - val context = LocalContext.current - - LaunchedEffect(operationBlocked) { - if (operationBlocked) { - Toast.makeText(application, application.getString(R.string.error_operation_already_running), Toast.LENGTH_SHORT).show() - viewModel.consumeOperationBlocked() - } - } - - LaunchedEffect(viewModel) { - viewModel.uiEvents.collect { event -> - when (event) { - RestoreUiEvent.NavigateToOperationProgress -> onNavigateToOperationProgress() - } - } - } - - LazyColumn( - modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 88.dp, top = 8.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - item { - Column { - Text( - text = stringResource(R.string.operation_restore), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 8.dp) - ) - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) - ) { - val selectableBackupDetails = backupDetails.filter { allowDowngrade || !it.isDowngrade } - TaskRow( - title = stringResource(R.string.run_tasks_applications), - subtitle = buildSelectedRestoreTypesSummary(selectableBackupDetails, appRestoreTypes, restoreTypes, context), - checked = restoreAppsEnabled, - onCheckedChange = viewModel::setRestoreAppsEnabled, - onNavigate = onNavigateToAppsConfig - ) - HorizontalDivider(color = MaterialTheme.colorScheme.background) - - val customDirsList = customDirectories.map { io.github.hddq.restoid.model.CustomDirectory(it.key, it.value) } - TaskRow( - title = stringResource(R.string.run_tasks_custom_directories), - subtitle = io.github.hddq.restoid.ui.shared.buildCustomDirectoriesSubtitle(customDirsList, context), - checked = restoreCustomDirectoriesEnabled, - onCheckedChange = viewModel::setRestoreCustomDirectoriesEnabled, - onNavigate = onNavigateToCustomDirectoriesConfig - ) - } - } - } - - if (isLoading) { - item { - Box( - modifier = Modifier - .fillMaxWidth() - .height(200.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - } - } - } -} - -@Composable -fun RestoreAppsConfigScreen( - viewModel: RestoreViewModel, - modifier: Modifier = Modifier -) { - val backupDetails by viewModel.backupDetails.collectAsState() - val isLoading by viewModel.isLoading.collectAsState() - val allowDowngrade by viewModel.allowDowngrade.collectAsState() - val restoreTypes by viewModel.restoreTypes.collectAsState() - val appRestoreTypes by viewModel.appRestoreTypes.collectAsState() - - val selectableBackupDetails = backupDetails.filter { allowDowngrade || !it.isDowngrade } - val isAllSelected = selectableBackupDetails.isNotEmpty() && selectableBackupDetails.all { it.appInfo.isSelected } - var selectedAppPackageName by remember { mutableStateOf(null) } - var showBulkRestoreTypesSheet by remember { mutableStateOf(false) } - val context = LocalContext.current - - LazyColumn( - modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 88.dp, top = 8.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - item { - Column { - Text( - text = stringResource(R.string.restore_options_title), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 8.dp) - ) - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer - ) - ) { - TaskRow( - title = stringResource(R.string.allow_downgrade), - checked = allowDowngrade, - onCheckedChange = viewModel::setAllowDowngrade - ) - } - } - } - - if (backupDetails.isEmpty() && !isLoading) { - item { - Text( - stringResource(R.string.no_app_data_found_in_snapshot), - modifier = Modifier.padding(16.dp) - ) - } - } else { - item { - Column { - Text( - text = stringResource(R.string.apps_to_restore_title), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 8.dp) - ) - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) - ) { - Column { - io.github.hddq.restoid.ui.shared.SelectAllListItem( - isChecked = isAllSelected, - subtitle = buildSelectedRestoreTypesSummary(selectableBackupDetails, appRestoreTypes, restoreTypes, context), - onClick = { showBulkRestoreTypesSheet = true }, - onToggle = viewModel::toggleAllRestoreSelection - ) - - HorizontalDivider(color = MaterialTheme.colorScheme.background) - - backupDetails.forEachIndexed { index, detail -> - RestoreAppListItem( - detail = detail, - allowDowngrade = allowDowngrade, - restoreTypes = appRestoreTypes[detail.appInfo.packageName] ?: restoreTypes, - onClick = { selectedAppPackageName = detail.appInfo.packageName }, - onToggle = { viewModel.toggleRestoreAppSelection(detail.appInfo.packageName) } - ) - if (index < backupDetails.size - 1) { - HorizontalDivider(color = MaterialTheme.colorScheme.background) - } - } - } - } - } - } - } - } - - if (showBulkRestoreTypesSheet) { - RestoreTypesBottomSheet( - title = stringResource(R.string.toggle_all), - restoreTypes = selectedBulkRestoreTypes(selectableBackupDetails, appRestoreTypes, restoreTypes), - onRestoreTypesChange = viewModel::setSelectedAppsRestoreTypes, - onDismissRequest = { showBulkRestoreTypesSheet = false } - ) - } - - selectedAppPackageName?.let { packageName -> - val detail = backupDetails.firstOrNull { it.appInfo.packageName == packageName } - if (detail != null) { - RestoreTypesBottomSheet( - title = detail.appInfo.name, - restoreTypes = appRestoreTypes[packageName] ?: restoreTypes, - onRestoreTypesChange = { viewModel.setAppRestoreTypes(packageName, it) }, - onDismissRequest = { selectedAppPackageName = null } - ) - } - } -} - -@Composable -fun RestoreCustomDirectoriesConfigScreen( - viewModel: RestoreViewModel, - modifier: Modifier = Modifier -) { - val customDirectories by viewModel.customDirectories.collectAsState() - val customDirectoriesList = customDirectories.toList() - - LazyColumn( - modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 88.dp, top = 8.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - if (customDirectoriesList.isEmpty()) { - item { - Text( - stringResource(R.string.run_tasks_custom_directories_subtitle_none), - modifier = Modifier.padding(16.dp) - ) - } - } else { - item { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) - ) { - Column { - val isAllCustomDirsSelected = customDirectoriesList.all { it.second } - val selectedCount = customDirectoriesList.count { it.second } - - io.github.hddq.restoid.ui.shared.SelectAllListItem( - isChecked = isAllCustomDirsSelected, - subtitle = if (selectedCount > 0) "$selectedCount selected" else "None selected", - onClick = null, - onToggle = viewModel::toggleAllCustomDirectoriesSelection - ) - - HorizontalDivider(color = MaterialTheme.colorScheme.background) - - customDirectoriesList.forEachIndexed { index, (path, isSelected) -> - RestoreCustomDirListItem( - path = path, - isSelected = isSelected, - onToggle = { viewModel.toggleCustomDirectory(path) } - ) - if (index < customDirectoriesList.size - 1) { - HorizontalDivider(color = MaterialTheme.colorScheme.background) - } - } - } - } - } - } - } -} - -@Composable -fun RestoreTypeToggle(label: String, description: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onCheckedChange(!checked) } - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = label, - style = MaterialTheme.typography.bodyLarge - ) - Text( - text = description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch( - checked = checked, - onCheckedChange = onCheckedChange, - thumbContent = if (checked) { - { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - modifier = Modifier.size(SwitchDefaults.IconSize), - ) - } - } else { - null - } - ) - } -} - -@Composable -private fun RestoreAppListItem( - detail: BackupDetail, - allowDowngrade: Boolean, - restoreTypes: RestoreTypes, - onClick: () -> Unit, - onToggle: () -> Unit -) { - val app = detail.appInfo - val isEnabled = allowDowngrade || !detail.isDowngrade - val context = LocalContext.current - - Row( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - .clickable(enabled = isEnabled, onClick = onClick) - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Image( - painter = rememberAsyncImagePainter(model = app.icon), - contentDescription = app.name, - modifier = Modifier.size(48.dp) - ) - Spacer(Modifier.width(16.dp)) - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.Center - ) { - Text( - text = app.name, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodyLarge, - color = if (isEnabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - - val versionColor = if (detail.isDowngrade) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant - val versionText = if (detail.isInstalled) { - stringResource( - R.string.restore_backup_version_with_installed, - detail.versionName ?: stringResource(R.string.not_available), - app.versionName - ) - } else { - stringResource( - R.string.restore_backup_version_only, - detail.versionName ?: stringResource(R.string.not_available) - ) - } - - Text( - text = versionText, - style = MaterialTheme.typography.bodySmall, - color = if (isEnabled) versionColor else versionColor.copy(alpha = 0.38f), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = buildRestoreTypesSummary(restoreTypes, context), - style = MaterialTheme.typography.bodySmall, - color = if (isEnabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - Spacer(Modifier.width(12.dp)) - Icon( - imageVector = Icons.Default.ChevronRight, - contentDescription = null, - tint = if (isEnabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) - ) - Spacer(Modifier.width(12.dp)) - Box( - modifier = Modifier - .fillMaxHeight(0.5f) - .width(1.dp) - .background(MaterialTheme.colorScheme.outlineVariant) - ) - Spacer(Modifier.width(16.dp)) - Switch( - checked = app.isSelected, - onCheckedChange = { onToggle() }, - enabled = isEnabled, - thumbContent = if (app.isSelected) { - { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - modifier = Modifier.size(SwitchDefaults.IconSize), - ) - } - } else { - null - } - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun RestoreTypesBottomSheet( - title: String, - restoreTypes: RestoreTypes, - onRestoreTypesChange: (RestoreTypes) -> Unit, - onDismissRequest: () -> Unit -) { - ModalBottomSheet(onDismissRequest = onDismissRequest) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(start = 16.dp, end = 16.dp, bottom = 32.dp) - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 12.dp) - ) - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) - ) { - Column { - RestoreTypeToggle( - label = stringResource(R.string.backup_type_apk), - description = stringResource(R.string.backup_type_apk_desc), - checked = restoreTypes.apk - ) { - onRestoreTypesChange(restoreTypes.copy(apk = it)) - } - HorizontalDivider(color = MaterialTheme.colorScheme.background) - RestoreTypeToggle( - label = stringResource(R.string.backup_type_data), - description = stringResource(R.string.backup_type_data_desc), - checked = restoreTypes.data - ) { - onRestoreTypesChange(restoreTypes.copy(data = it)) - } - HorizontalDivider(color = MaterialTheme.colorScheme.background) - RestoreTypeToggle( - label = stringResource(R.string.backup_type_device_protected_data), - description = stringResource(R.string.backup_type_device_protected_data_desc), - checked = restoreTypes.deviceProtectedData - ) { - onRestoreTypesChange(restoreTypes.copy(deviceProtectedData = it)) - } - HorizontalDivider(color = MaterialTheme.colorScheme.background) - RestoreTypeToggle( - label = stringResource(R.string.backup_type_external_data), - description = stringResource(R.string.backup_type_external_data_desc), - checked = restoreTypes.externalData - ) { - onRestoreTypesChange(restoreTypes.copy(externalData = it)) - } - HorizontalDivider(color = MaterialTheme.colorScheme.background) - RestoreTypeToggle( - label = stringResource(R.string.backup_type_obb_data), - description = stringResource(R.string.backup_type_obb_data_desc), - checked = restoreTypes.obb - ) { - onRestoreTypesChange(restoreTypes.copy(obb = it)) - } - HorizontalDivider(color = MaterialTheme.colorScheme.background) - RestoreTypeToggle( - label = stringResource(R.string.backup_type_media_data), - description = stringResource(R.string.backup_type_media_data_desc), - checked = restoreTypes.media - ) { - onRestoreTypesChange(restoreTypes.copy(media = it)) - } - HorizontalDivider(color = MaterialTheme.colorScheme.background) - RestoreTypeToggle( - label = stringResource(R.string.backup_type_permissions), - description = stringResource(R.string.backup_type_permissions_desc), - checked = restoreTypes.permissions - ) { - onRestoreTypesChange(restoreTypes.copy(permissions = it)) - } - } - } - } - } -} - -private fun buildRestoreTypesSummary(restoreTypes: RestoreTypes, context: android.content.Context): String { - val types = buildList { - if (restoreTypes.apk) add(context.getString(R.string.backup_type_apk)) - if (restoreTypes.data) add(context.getString(R.string.backup_type_data)) - if (restoreTypes.deviceProtectedData) add(context.getString(R.string.backup_type_device_protected_data)) - if (restoreTypes.externalData) add(context.getString(R.string.backup_type_external_data)) - if (restoreTypes.obb) add(context.getString(R.string.backup_type_obb_data)) - if (restoreTypes.media) add(context.getString(R.string.backup_type_media_data)) - if (restoreTypes.permissions) add(context.getString(R.string.backup_type_permissions)) - }.joinToString(", ") - - return types.ifBlank { context.getString(R.string.backup_types_none) } -} - -private fun buildSelectedRestoreTypesSummary( - details: List, - appRestoreTypes: Map, - defaultRestoreTypes: RestoreTypes, - context: android.content.Context -): String { - val selectedTypes = details - .filter { it.appInfo.isSelected } - .map { appRestoreTypes[it.appInfo.packageName] ?: defaultRestoreTypes } - .distinct() - - return when (selectedTypes.size) { - 0 -> buildRestoreTypesSummary(defaultRestoreTypes, context) - 1 -> buildRestoreTypesSummary(selectedTypes.first(), context) - else -> context.getString(R.string.backup_types_mixed) - } -} - -private fun selectedBulkRestoreTypes( - details: List, - appRestoreTypes: Map, - defaultRestoreTypes: RestoreTypes -): RestoreTypes { - return details - .firstOrNull { it.appInfo.isSelected } - ?.let { appRestoreTypes[it.appInfo.packageName] ?: defaultRestoreTypes } - ?: defaultRestoreTypes -} - -@Composable -private fun RestoreCustomDirListItem( - path: String, - isSelected: Boolean, - onToggle: () -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onToggle) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Default.Folder, - contentDescription = null, - modifier = Modifier.size(40.dp), - tint = MaterialTheme.colorScheme.primary - ) - Spacer(Modifier.width(16.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = path.substringAfterLast("/").ifEmpty { path }, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = path, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - Spacer(Modifier.width(16.dp)) - Switch( - checked = isSelected, - onCheckedChange = { onToggle() }, - thumbContent = if (isSelected) { - { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - modifier = Modifier.size(SwitchDefaults.IconSize), - ) - } - } else null - ) - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/screens/SettingsScreen.kt b/app/src/main/java/io/github/hddq/restoid/ui/screens/SettingsScreen.kt deleted file mode 100644 index d9197796..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/screens/SettingsScreen.kt +++ /dev/null @@ -1,151 +0,0 @@ -package io.github.hddq.restoid.ui.screens - -import android.Manifest -import android.content.Intent -import android.provider.Settings -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.compose.ui.res.stringResource -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import android.widget.Toast -import io.github.hddq.restoid.R -import io.github.hddq.restoid.ui.screens.settings.* -import io.github.hddq.restoid.ui.settings.SettingsViewModel -import io.github.hddq.restoid.util.StorageUtils - -@Composable -fun SettingsScreen( - viewModel: SettingsViewModel, // Passed directly - onNavigateToLicenses: () -> Unit, - modifier: Modifier = Modifier -) { - val addRepoUiState by viewModel.addRepoUiState.collectAsStateWithLifecycle() - val showMetadataWarning by viewModel.showMetadataWarning.collectAsStateWithLifecycle() - val metadataRestoreSuccess by viewModel.metadataRestoreSuccess.collectAsStateWithLifecycle() - val context = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current - - LaunchedEffect(metadataRestoreSuccess) { - val message = metadataRestoreSuccess ?: return@LaunchedEffect - Toast.makeText(context, message, Toast.LENGTH_SHORT).show() - viewModel.consumeMetadataRestoreSuccess() - } - - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - viewModel.refreshSystemState() - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - } - } - - val directoryPickerLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.OpenDocumentTree(), - onResult = { uri -> - uri?.let { - try { - val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - context.contentResolver.takePersistableUriPermission(it, takeFlags) - StorageUtils.getPathFromTreeUri(it)?.let { path -> - viewModel.onNewRepoPathChanged(path) - } - } catch (e: SecurityException) { - e.printStackTrace() - } - } - } - ) - - val notificationPermissionLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission(), - onResult = { viewModel.checkNotificationPermission() } - ) - - if (showMetadataWarning != null) { - AlertDialog( - onDismissRequest = { viewModel.dismissMetadataWarning() }, - title = { Text(stringResource(R.string.repo_warning_metadata_restore_failed_title)) }, - text = { Text(showMetadataWarning ?: stringResource(R.string.repo_warning_metadata_restore_failed)) }, - confirmButton = { - TextButton(onClick = { viewModel.dismissMetadataWarning() }) { - Text(stringResource(R.string.action_ok)) - } - } - ) - } - - if (addRepoUiState.showDialog) { - AddRepositoryDialog( - uiState = addRepoUiState, - onDismiss = { viewModel.onNewRepoDialogDismiss() }, - onBackendTypeChange = { viewModel.onNewRepoBackendTypeChanged(it) }, - onPathChange = { viewModel.onNewRepoPathChanged(it) }, - onPasswordChange = { viewModel.onNewRepoPasswordChanged(it) }, - onSftpPasswordChange = { viewModel.onNewRepoSftpPasswordChanged(it) }, - onSftpKeyChange = { viewModel.onNewRepoSftpKeyChanged(it) }, - onSftpKeyImportError = { viewModel.onNewRepoSftpKeyImportError(it) }, - onSftpKeyPassphraseChange = { viewModel.onNewRepoSftpKeyPassphraseChanged(it) }, - onSftpAuthMethodChange = { viewModel.onNewRepoSftpAuthMethodChanged(it) }, - onS3AccessKeyIdChange = { viewModel.onNewRepoS3AccessKeyIdChanged(it) }, - onS3SecretAccessKeyChange = { viewModel.onNewRepoS3SecretAccessKeyChanged(it) }, - onRestUsernameChange = { viewModel.onNewRepoRestUsernameChanged(it) }, - onRestPasswordChange = { viewModel.onNewRepoRestPasswordChanged(it) }, - onEnvironmentVariablesChange = { viewModel.onNewRepoEnvironmentVariablesChanged(it) }, - onSavePasswordChange = { viewModel.onSavePasswordChanged(it) }, - onConfirm = { viewModel.addRepository() }, - onSelectPath = { directoryPickerLauncher.launch(null) } - ) - } - - addRepoUiState.sftpServerTrustInfo?.let { trustInfo -> - SftpServerTrustDialog( - trustInfo = trustInfo, - onConfirm = { viewModel.onSftpTrustDialogConfirm() }, - onDismiss = { viewModel.onSftpTrustDialogDismiss() } - ) - } - - LazyColumn( - modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - item { - SystemSettings( - viewModel = viewModel, - notificationPermissionLauncher = { notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) }, - onOpenSettings = { - val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { - putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) - } - context.startActivity(intent) - } - ) - } - item { DependencySettings(viewModel = viewModel) } - item { RepositorySettings(viewModel = viewModel) } - item { OptionsSettings(viewModel = viewModel) } - item { AboutSettings(onNavigateToLicenses = onNavigateToLicenses) } - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/screens/SnapshotDetailsScreen.kt b/app/src/main/java/io/github/hddq/restoid/ui/screens/SnapshotDetailsScreen.kt deleted file mode 100644 index 84699426..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/screens/SnapshotDetailsScreen.kt +++ /dev/null @@ -1,295 +0,0 @@ -package io.github.hddq.restoid.ui.screens - -import android.text.format.Formatter -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Folder -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.pluralStringResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation.NavController -import coil.compose.rememberAsyncImagePainter -import io.github.hddq.restoid.R -import io.github.hddq.restoid.RestoidApplication -import io.github.hddq.restoid.data.SnapshotInfo -import io.github.hddq.restoid.model.BackupDetail -import io.github.hddq.restoid.ui.snapshot.ForgetResult -import io.github.hddq.restoid.ui.snapshot.SnapshotDetailsViewModel -import io.github.hddq.restoid.ui.snapshot.SnapshotDetailsViewModelFactory - -import io.github.hddq.restoid.LocalAppBarActions - -@Composable -fun SnapshotDetailsScreen( - navController: NavController, - snapshotId: String?, - modifier: Modifier = Modifier -) { - val application = LocalContext.current.applicationContext as RestoidApplication - val viewModel: SnapshotDetailsViewModel = viewModel( - factory = SnapshotDetailsViewModelFactory( - application, - application.repositoriesRepository, - application.resticRepository, - application.appInfoRepository, - application.metadataRepository - ) - ) - - val snapshot by viewModel.snapshot.collectAsState() - val metadata by viewModel.metadata.collectAsState() - val backupDetails by viewModel.backupDetails.collectAsState() - val isLoading by viewModel.isLoading.collectAsState() - val error by viewModel.error.collectAsState() - val showConfirmDialog by viewModel.showConfirmForgetDialog.collectAsState() - val isForgetting by viewModel.isForgetting.collectAsState() - val forgetResult by viewModel.forgetResult.collectAsState() - - val appBarActions = LocalAppBarActions.current - DisposableEffect(snapshotId) { - appBarActions.value = { - IconButton(onClick = { viewModel.onForgetSnapshot() }) { - Icon( - Icons.Default.Delete, - contentDescription = stringResource(R.string.cd_forget_snapshot), - tint = MaterialTheme.colorScheme.error - ) - } - } - onDispose { appBarActions.value = null } - } - - LaunchedEffect(forgetResult) { - if (forgetResult is ForgetResult.Success) { - navController.popBackStack() - } - } - - LaunchedEffect(snapshotId) { - if (snapshotId != null) { - viewModel.loadSnapshotDetails(snapshotId) - } - } - - if (showConfirmDialog) { - ConfirmForgetDialog( - onConfirm = { - viewModel.confirmForgetSnapshot() - }, - onDismiss = { viewModel.cancelForgetSnapshot() } - ) - } - - Column( - modifier = modifier - .fillMaxSize() - .padding(horizontal = 16.dp) - ) { - when { - isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } - error != null -> Text(stringResource(R.string.error_with_message, error ?: ""), color = MaterialTheme.colorScheme.error) - snapshot != null -> { - LazyColumn( - modifier = Modifier.weight(1f), - contentPadding = PaddingValues(bottom = 88.dp, top = 8.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - item { - SnapshotDetailsHeader(snapshot = snapshot!!) - } - val customDirs = metadata?.customDirectories ?: emptyMap() - if (backupDetails.isNotEmpty()) { - item { - Text( - pluralStringResource( - R.plurals.snapshot_backed_up_apps_count, - backupDetails.size, - backupDetails.size - ), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 8.dp) - ) - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer - ) - ) { - Column { - backupDetails.forEachIndexed { index, detail -> - BackedUpAppItem(detail = detail) - if (index < backupDetails.size - 1) { - HorizontalDivider(color = MaterialTheme.colorScheme.background) - } - } - } - } - } - } - if (customDirs.isNotEmpty()) { - item { - Text( - pluralStringResource( - R.plurals.snapshot_backed_up_custom_directories_count, - customDirs.size, - customDirs.size - ), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = if (backupDetails.isNotEmpty()) 16.dp else 0.dp, bottom = 8.dp) - ) - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer - ) - ) { - Column { - val dirsList = customDirs.toList() - dirsList.forEachIndexed { index, (path, dirMetadata) -> - BackedUpCustomDirItem(path = path, metadata = dirMetadata) - if (index < dirsList.size - 1) { - HorizontalDivider(color = MaterialTheme.colorScheme.background) - } - } - } - } - } - } - - if (backupDetails.isEmpty() && customDirs.isEmpty() && !isLoading) { - item { - if (metadata == null) { - Text( - stringResource(R.string.metadata_missing_or_corrupted), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.error - ) - } else { - Text( - stringResource(R.string.no_app_info_for_snapshot), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - } - else -> if (!isLoading) Text(stringResource(R.string.snapshot_not_found)) - } - - if (isForgetting) { - Spacer(Modifier.height(16.dp)) - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - Text(stringResource(R.string.snapshot_forgetting), modifier = Modifier.align(Alignment.CenterHorizontally)) - Spacer(Modifier.height(16.dp)) - } - } -} - -@Composable -fun SnapshotDetailsHeader(snapshot: SnapshotInfo) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer - ) - ) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(stringResource(R.string.snapshot_id, snapshot.id), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace) - Text(stringResource(R.string.snapshot_time, snapshot.time), style = MaterialTheme.typography.bodyMedium) - } - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun BackedUpAppItem(detail: BackupDetail) { - val context = LocalContext.current - val notAvailable = stringResource(R.string.not_available) - Row(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.Top) { - Image( - painter = rememberAsyncImagePainter(model = detail.appInfo.icon), - contentDescription = null, - modifier = Modifier.size(48.dp) - ) - Spacer(Modifier.width(16.dp)) - Column { - Text(detail.appInfo.name, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium) - - val versionInfo = detail.versionName ?: notAvailable - val sizeInfo = detail.backupSize?.let { Formatter.formatShortFileSize(context, it) } ?: notAvailable - Text( - stringResource(R.string.snapshot_version_size, versionInfo, sizeInfo), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(Modifier.height(8.dp)) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - detail.backedUpItems.forEach { item -> - SuggestionChip(onClick = {}, label = { Text(item, style = MaterialTheme.typography.labelSmall) }) - } - } - } - } -} - -@Composable -fun ConfirmForgetDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.dialog_forget_snapshot_title)) }, - text = { Text(stringResource(R.string.dialog_forget_snapshot_message)) }, - confirmButton = { - Button( - onClick = onConfirm, - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) - ) { - Text(stringResource(R.string.action_forget)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) - } - } - ) -} - -@Composable -fun BackedUpCustomDirItem(path: String, metadata: io.github.hddq.restoid.model.CustomDirectoryMetadata) { - val context = LocalContext.current - Row(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Default.Folder, - contentDescription = null, - modifier = Modifier.size(40.dp), - tint = MaterialTheme.colorScheme.primary - ) - Spacer(Modifier.width(16.dp)) - Column { - Text(path.substringAfterLast("/").ifEmpty { path }, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium) - val sizeInfo = metadata.size?.let { Formatter.formatShortFileSize(context, it) } ?: stringResource(R.string.not_available) - Text( - stringResource(R.string.progress_size) + ": " + sizeInfo + " • " + path, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/AboutSettings.kt b/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/AboutSettings.kt deleted file mode 100644 index 3de2c72f..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/AboutSettings.kt +++ /dev/null @@ -1,119 +0,0 @@ -package io.github.hddq.restoid.ui.screens.settings - -import android.content.Intent -import android.net.Uri -import android.os.Build -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Notes -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.Translate -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import io.github.hddq.restoid.R - -@Composable -fun AboutSettings(onNavigateToLicenses: () -> Unit) { - val context = LocalContext.current - val versionText = remember { - try { - val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) - val versionName = packageInfo.versionName ?: context.getString(R.string.not_available) - val versionCode = packageInfo.longVersionCode - "$versionName ($versionCode)" - } catch (e: Exception) { - context.getString(R.string.not_available) - } - } - val githubIntent = remember { Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/hddq/restoid")) } - val weblateIntent = remember { Intent(Intent.ACTION_VIEW, Uri.parse("https://hosted.weblate.org/projects/restoid/")) } - - - Column { - Text( - text = stringResource(R.string.about_title), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 8.dp) - ) - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer - ) - ) { - Column { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onNavigateToLicenses() } - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Notes, - contentDescription = null, - modifier = Modifier.padding(end = 16.dp) - ) - Text(stringResource(R.string.open_source_licenses), style = MaterialTheme.typography.bodyLarge) - } - - HorizontalDivider(color = MaterialTheme.colorScheme.background) - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { context.startActivity(weblateIntent) } - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Translate, - contentDescription = null, - modifier = Modifier.padding(end = 16.dp) - ) - Text(stringResource(R.string.translate), style = MaterialTheme.typography.bodyLarge) - } - - HorizontalDivider(color = MaterialTheme.colorScheme.background) - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { context.startActivity(githubIntent) } - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Info, - contentDescription = null, - modifier = Modifier.padding(end = 16.dp) - ) - Column { - Text(stringResource(R.string.version_label), style = MaterialTheme.typography.bodyLarge) - Text( - versionText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/AddRepositoryDialog.kt b/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/AddRepositoryDialog.kt deleted file mode 100644 index c40bd184..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/AddRepositoryDialog.kt +++ /dev/null @@ -1,381 +0,0 @@ -package io.github.hddq.restoid.ui.screens.settings - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.FolderOpen -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material3.* -import androidx.compose.material3.ExposedDropdownMenuAnchorType -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.dp -import io.github.hddq.restoid.R -import io.github.hddq.restoid.data.AddRepositoryState -import io.github.hddq.restoid.data.RepositoryBackendType -import io.github.hddq.restoid.ui.components.SshPrivateKeyField -import io.github.hddq.restoid.ui.settings.AddRepoUiState -import io.github.hddq.restoid.ui.settings.SftpAuthMethod - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AddRepositoryDialog( - uiState: AddRepoUiState, - onDismiss: () -> Unit, - onBackendTypeChange: (RepositoryBackendType) -> Unit, - onPathChange: (String) -> Unit, - onPasswordChange: (String) -> Unit, - onSftpPasswordChange: (String) -> Unit, - onSftpKeyChange: (String) -> Unit, - onSftpKeyImportError: (String) -> Unit, - onSftpKeyPassphraseChange: (String) -> Unit, - onSftpAuthMethodChange: (SftpAuthMethod) -> Unit, - onS3AccessKeyIdChange: (String) -> Unit, - onS3SecretAccessKeyChange: (String) -> Unit, - onRestUsernameChange: (String) -> Unit, - onRestPasswordChange: (String) -> Unit, - onEnvironmentVariablesChange: (String) -> Unit, - onSavePasswordChange: (Boolean) -> Unit, - onConfirm: () -> Unit, - onSelectPath: () -> Unit -) { - var repositoryPasswordVisible by remember { mutableStateOf(false) } - var sftpPasswordVisible by remember { mutableStateOf(false) } - var s3SecretAccessKeyVisible by remember { mutableStateOf(false) } - var restPasswordVisible by remember { mutableStateOf(false) } - var backendExpanded by remember { mutableStateOf(false) } - val isBusy = uiState.state is AddRepositoryState.Initializing - val hasIncompleteRestCredentials = - uiState.backendType == RepositoryBackendType.REST && - (uiState.restUsername.isBlank() != uiState.restPassword.isBlank()) - val hasIncompleteS3Credentials = - uiState.backendType == RepositoryBackendType.S3 && - (uiState.s3AccessKeyId.isBlank() != uiState.s3SecretAccessKey.isBlank()) - val hasIncompleteSftpCredentials = - uiState.backendType == RepositoryBackendType.SFTP && - uiState.sftpAuthMethod == SftpAuthMethod.KEY && uiState.sftpKey.isBlank() - - val canConfirm = !isBusy && - uiState.path.isNotBlank() && - uiState.password.isNotBlank() && - !hasIncompleteRestCredentials && - !hasIncompleteS3Credentials && - !hasIncompleteSftpCredentials - - val backendOptions = listOf( - RepositoryBackendType.LOCAL, - RepositoryBackendType.SFTP, - RepositoryBackendType.REST, - RepositoryBackendType.S3 - ) - - val selectedBackendLabel = when (uiState.backendType) { - RepositoryBackendType.LOCAL -> stringResource(R.string.repo_backend_local) - RepositoryBackendType.SFTP -> stringResource(R.string.repo_backend_sftp) - RepositoryBackendType.REST -> stringResource(R.string.repo_backend_rest) - RepositoryBackendType.S3 -> stringResource(R.string.repo_backend_s3) - } - - val pathPlaceholder = when (uiState.backendType) { - RepositoryBackendType.LOCAL -> stringResource(R.string.placeholder_select_directory) - RepositoryBackendType.SFTP -> stringResource(R.string.repo_hint_sftp) - RepositoryBackendType.REST -> stringResource(R.string.repo_hint_rest) - RepositoryBackendType.S3 -> stringResource(R.string.repo_hint_s3) - } - - val savePasswordLabel = when (uiState.backendType) { - RepositoryBackendType.SFTP, - RepositoryBackendType.REST, - RepositoryBackendType.S3 -> stringResource(R.string.action_save_passwords) - else -> stringResource(R.string.action_save_password) - } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.dialog_add_repository_title)) }, - text = { - Column( - modifier = Modifier.verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - ExposedDropdownMenuBox( - expanded = backendExpanded, - onExpandedChange = { if (!isBusy) backendExpanded = !backendExpanded } - ) { - OutlinedTextField( - value = selectedBackendLabel, - onValueChange = {}, - readOnly = true, - label = { Text(stringResource(R.string.label_repository_type)) }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = backendExpanded) }, - modifier = Modifier - .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) - .fillMaxWidth(), - enabled = !isBusy - ) - - ExposedDropdownMenu( - expanded = backendExpanded, - onDismissRequest = { backendExpanded = false } - ) { - backendOptions.forEach { backend -> - val label = when (backend) { - RepositoryBackendType.LOCAL -> stringResource(R.string.repo_backend_local) - RepositoryBackendType.SFTP -> stringResource(R.string.repo_backend_sftp) - RepositoryBackendType.REST -> stringResource(R.string.repo_backend_rest) - RepositoryBackendType.S3 -> stringResource(R.string.repo_backend_s3) - } - - DropdownMenuItem( - text = { Text(label) }, - onClick = { - onBackendTypeChange(backend) - backendExpanded = false - } - ) - } - } - } - - OutlinedTextField( - value = uiState.path, - onValueChange = onPathChange, - label = { - Text( - if (uiState.backendType == RepositoryBackendType.LOCAL) { - stringResource(R.string.label_path) - } else { - stringResource(R.string.label_repository_specification) - } - ) - }, - placeholder = { Text(pathPlaceholder) }, - singleLine = true, - readOnly = uiState.backendType == RepositoryBackendType.LOCAL, - isError = uiState.state is AddRepositoryState.Error, - trailingIcon = { - if (uiState.backendType == RepositoryBackendType.LOCAL) { - IconButton(onClick = onSelectPath, enabled = !isBusy) { - Icon(Icons.Default.FolderOpen, contentDescription = stringResource(R.string.cd_select_folder)) - } - } - }, - modifier = Modifier.fillMaxWidth(), - enabled = !isBusy - ) - - OutlinedTextField( - value = uiState.password, - onValueChange = onPasswordChange, - label = { Text(stringResource(R.string.label_repository_password)) }, - singleLine = true, - visualTransformation = if (repositoryPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - val image = if (repositoryPasswordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility - val description = if (repositoryPasswordVisible) stringResource(R.string.cd_hide_password) else stringResource(R.string.cd_show_password) - IconButton(onClick = { repositoryPasswordVisible = !repositoryPasswordVisible }, enabled = !isBusy) { - Icon(imageVector = image, contentDescription = description) - } - }, - isError = uiState.state is AddRepositoryState.Error, - modifier = Modifier.fillMaxWidth(), - enabled = !isBusy - ) - - if (uiState.backendType == RepositoryBackendType.SFTP) { - Text( - text = stringResource(R.string.label_sftp_authentication_method), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { if (!isBusy) onSftpAuthMethodChange(SftpAuthMethod.PASSWORD) }) { - RadioButton( - selected = uiState.sftpAuthMethod == SftpAuthMethod.PASSWORD, - onClick = { onSftpAuthMethodChange(SftpAuthMethod.PASSWORD) }, - enabled = !isBusy - ) - Text(stringResource(R.string.label_password)) - } - Spacer(modifier = Modifier.width(16.dp)) - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { if (!isBusy) onSftpAuthMethodChange(SftpAuthMethod.KEY) }) { - RadioButton( - selected = uiState.sftpAuthMethod == SftpAuthMethod.KEY, - onClick = { onSftpAuthMethodChange(SftpAuthMethod.KEY) }, - enabled = !isBusy - ) - Text(stringResource(R.string.label_sftp_ssh_key)) - } - } - - if (uiState.sftpAuthMethod == SftpAuthMethod.PASSWORD) { - OutlinedTextField( - value = uiState.sftpPassword, - onValueChange = onSftpPasswordChange, - label = { Text(stringResource(R.string.label_sftp_password)) }, - singleLine = true, - visualTransformation = if (sftpPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - val image = if (sftpPasswordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility - val description = if (sftpPasswordVisible) stringResource(R.string.cd_hide_password) else stringResource(R.string.cd_show_password) - IconButton(onClick = { sftpPasswordVisible = !sftpPasswordVisible }, enabled = !isBusy) { - Icon(imageVector = image, contentDescription = description) - } - }, - isError = uiState.state is AddRepositoryState.Error, - modifier = Modifier.fillMaxWidth(), - enabled = !isBusy - ) - } else { - SshPrivateKeyField( - value = uiState.sftpKey, - onValueChange = onSftpKeyChange, - onImportError = onSftpKeyImportError, - label = stringResource(R.string.label_sftp_private_key), - placeholder = stringResource(R.string.placeholder_sftp_private_key), - isError = uiState.state is AddRepositoryState.Error && uiState.sftpKey.isBlank(), - supportingText = if (uiState.state is AddRepositoryState.Error && uiState.sftpKey.isBlank()) { - { Text(uiState.state.message) } - } else { null - }, - modifier = Modifier.fillMaxWidth(), - enabled = !isBusy - ) - OutlinedTextField( - value = uiState.sftpKeyPassphrase, - onValueChange = onSftpKeyPassphraseChange, - label = { Text(stringResource(R.string.label_sftp_key_passphrase_optional)) }, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth(), - enabled = !isBusy - ) - } - } - - if (uiState.backendType == RepositoryBackendType.REST) { - OutlinedTextField( - value = uiState.restUsername, - onValueChange = onRestUsernameChange, - label = { Text(stringResource(R.string.label_rest_username)) }, - singleLine = true, - isError = uiState.state is AddRepositoryState.Error, - modifier = Modifier.fillMaxWidth(), - enabled = !isBusy - ) - - OutlinedTextField( - value = uiState.restPassword, - onValueChange = onRestPasswordChange, - label = { Text(stringResource(R.string.label_rest_password)) }, - singleLine = true, - visualTransformation = if (restPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - val image = if (restPasswordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility - val description = if (restPasswordVisible) stringResource(R.string.cd_hide_password) else stringResource(R.string.cd_show_password) - IconButton(onClick = { restPasswordVisible = !restPasswordVisible }, enabled = !isBusy) { - Icon(imageVector = image, contentDescription = description) - } - }, - isError = uiState.state is AddRepositoryState.Error, - modifier = Modifier.fillMaxWidth(), - enabled = !isBusy - ) - } - - if (uiState.backendType == RepositoryBackendType.S3) { - OutlinedTextField( - value = uiState.s3AccessKeyId, - onValueChange = onS3AccessKeyIdChange, - label = { Text(stringResource(R.string.label_s3_access_key_id)) }, - singleLine = true, - isError = uiState.state is AddRepositoryState.Error, - modifier = Modifier.fillMaxWidth(), - enabled = !isBusy - ) - - OutlinedTextField( - value = uiState.s3SecretAccessKey, - onValueChange = onS3SecretAccessKeyChange, - label = { Text(stringResource(R.string.label_s3_secret_access_key)) }, - singleLine = true, - visualTransformation = if (s3SecretAccessKeyVisible) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - val image = if (s3SecretAccessKeyVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility - val description = if (s3SecretAccessKeyVisible) stringResource(R.string.cd_hide_password) else stringResource(R.string.cd_show_password) - IconButton(onClick = { s3SecretAccessKeyVisible = !s3SecretAccessKeyVisible }, enabled = !isBusy) { - Icon(imageVector = image, contentDescription = description) - } - }, - isError = uiState.state is AddRepositoryState.Error, - modifier = Modifier.fillMaxWidth(), - enabled = !isBusy - ) - } - - if (uiState.backendType != RepositoryBackendType.LOCAL) { - OutlinedTextField( - value = uiState.environmentVariablesRaw, - onValueChange = onEnvironmentVariablesChange, - label = { Text(stringResource(R.string.label_environment_variables)) }, - placeholder = { Text(stringResource(R.string.placeholder_environment_variables)) }, - minLines = 3, - maxLines = 6, - modifier = Modifier.fillMaxWidth(), - enabled = !isBusy - ) - } - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Checkbox( - checked = uiState.savePassword, - onCheckedChange = onSavePasswordChange, - enabled = !isBusy - ) - Text( - text = savePasswordLabel, - modifier = Modifier.clickable(enabled = !isBusy, onClick = { onSavePasswordChange(!uiState.savePassword) }) - ) - } - - if (uiState.state is AddRepositoryState.Error) { - Text( - text = uiState.state.message, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall - ) - } - } - }, - confirmButton = { - Button( - onClick = onConfirm, - enabled = canConfirm - ) { - if (isBusy) { - CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) - } else { - Text(stringResource(R.string.action_add)) - } - } - }, - dismissButton = { - TextButton(onClick = onDismiss, enabled = !isBusy) { - Text(stringResource(R.string.action_cancel)) - } - } - ) -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/DependencySettings.kt b/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/DependencySettings.kt deleted file mode 100644 index a20acbdf..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/DependencySettings.kt +++ /dev/null @@ -1,44 +0,0 @@ -package io.github.hddq.restoid.ui.screens.settings - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import io.github.hddq.restoid.R -import io.github.hddq.restoid.ui.screens.settings.components.ResticDependencyRow -import io.github.hddq.restoid.ui.settings.SettingsViewModel - -@Composable -fun DependencySettings(viewModel: SettingsViewModel) { - val resticState by viewModel.resticState.collectAsStateWithLifecycle() - - Column { - Text( - text = stringResource(R.string.dependencies_title), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 8.dp) - ) - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer - ) - ) { - Column { - ResticDependencyRow( - state = resticState - ) - } - } - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/OptionsSettings.kt b/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/OptionsSettings.kt deleted file mode 100644 index 54840d90..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/OptionsSettings.kt +++ /dev/null @@ -1,43 +0,0 @@ -package io.github.hddq.restoid.ui.screens.settings - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.ui.res.stringResource -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import io.github.hddq.restoid.R -import io.github.hddq.restoid.ui.screens.settings.components.AppUnlockOnStartRow -import io.github.hddq.restoid.ui.settings.SettingsViewModel - -@Composable -fun OptionsSettings(viewModel: SettingsViewModel) { - val requireAppUnlock by viewModel.requireAppUnlock.collectAsStateWithLifecycle() - - Column { - Text( - text = stringResource(R.string.options_title), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 8.dp) - ) - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) - ) { - Column { - AppUnlockOnStartRow( - enabled = requireAppUnlock, - onCheckedChange = viewModel::onRequireAppUnlockChanged - ) - } - } - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/RepositorySettings.kt b/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/RepositorySettings.kt deleted file mode 100644 index b458df12..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/RepositorySettings.kt +++ /dev/null @@ -1,111 +0,0 @@ -package io.github.hddq.restoid.ui.screens.settings - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Info -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.Icon -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import io.github.hddq.restoid.R -import io.github.hddq.restoid.data.ResticState -import io.github.hddq.restoid.ui.screens.settings.components.SelectableRepositoryRow -import io.github.hddq.restoid.ui.settings.SettingsViewModel - -@Composable -fun RepositorySettings(viewModel: SettingsViewModel) { - val resticState by viewModel.resticState.collectAsStateWithLifecycle() - val repositories by viewModel.repositories.collectAsStateWithLifecycle() - val selectedRepository by viewModel.selectedRepository.collectAsStateWithLifecycle() - - Column { - Text( - text = stringResource(R.string.backup_repositories_title), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp) - ) - - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer - ) - ) { - if (resticState is ResticState.Installed) { - if (repositories.isEmpty()) { - Text( - stringResource(R.string.no_repositories_configured), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) - ) - } else { - Column { - repositories.forEachIndexed { index, repo -> - val repoKey = viewModel.repositoryKey(repo) - SelectableRepositoryRow( - repo = repo, - isSelected = repoKey == selectedRepository, - onSelected = { viewModel.selectRepository(repoKey) }, - viewModel = viewModel - ) - if (index < repositories.size - 1) { - HorizontalDivider(color = MaterialTheme.colorScheme.background) - } - } - } - } - } else { - Row( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Default.Info, - contentDescription = null, - modifier = Modifier.padding(end = 8.dp), - tint = LocalContentColor.current.copy(alpha = 0.6f) - ) - Text( - stringResource(R.string.install_restic_manage_repositories), - style = MaterialTheme.typography.bodyMedium, - ) - } - } - } - - if (resticState is ResticState.Installed) { - FilledTonalButton( - onClick = { viewModel.onShowAddRepoDialog() }, - modifier = Modifier.padding(top = 12.dp), - shape = MaterialTheme.shapes.extraLarge - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = null - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(text = stringResource(R.string.cd_add_repository)) - } - } - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/SftpServerTrustDialog.kt b/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/SftpServerTrustDialog.kt deleted file mode 100644 index cc9640e8..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/SftpServerTrustDialog.kt +++ /dev/null @@ -1,60 +0,0 @@ -package io.github.hddq.restoid.ui.screens.settings - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.unit.dp -import io.github.hddq.restoid.R -import io.github.hddq.restoid.data.SftpServerTrustInfo - -@Composable -fun SftpServerTrustDialog( - trustInfo: SftpServerTrustInfo, - onConfirm: () -> Unit, - onDismiss: () -> Unit -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.dialog_sftp_trust_title)) }, - text = { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = stringResource(R.string.dialog_sftp_trust_message, trustInfo.endpoint), - style = MaterialTheme.typography.bodyMedium - ) - Text( - text = stringResource(R.string.dialog_sftp_trust_fingerprints), - style = MaterialTheme.typography.titleSmall - ) - trustInfo.fingerprints.forEach { fingerprint -> - Text( - text = fingerprint, - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace - ) - } - } - }, - confirmButton = { - TextButton(onClick = onConfirm) { - Text(stringResource(R.string.action_trust)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) - } - } - ) -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/SystemSettings.kt b/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/SystemSettings.kt deleted file mode 100644 index 95b0d83b..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/SystemSettings.kt +++ /dev/null @@ -1,212 +0,0 @@ -package io.github.hddq.restoid.ui.screens.settings - -import android.content.Intent -import android.net.Uri -import android.provider.Settings -import androidx.compose.animation.AnimatedContent -import androidx.compose.foundation.layout.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Error -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import io.github.hddq.restoid.R -import io.github.hddq.restoid.data.RootState -import io.github.hddq.restoid.ui.screens.settings.components.NotificationPermissionRow -import io.github.hddq.restoid.ui.screens.settings.components.RootRequestRow -import io.github.hddq.restoid.ui.screens.settings.components.RootStatusRow -import io.github.hddq.restoid.ui.settings.SettingsViewModel - -@Composable -fun SystemSettings( - viewModel: SettingsViewModel, - notificationPermissionLauncher: () -> Unit, - onOpenSettings: () -> Unit -) { - val rootState by viewModel.rootState.collectAsStateWithLifecycle() - val notificationPermissionState by viewModel.notificationPermissionState.collectAsStateWithLifecycle() - val batteryOptimizationDisabled by viewModel.isIgnoringBatteryOptimizations.collectAsStateWithLifecycle() - val hasUsageStatsPermission by viewModel.hasUsageStatsPermission.collectAsStateWithLifecycle() - val context = androidx.compose.ui.platform.LocalContext.current - - Column { - Text( - text = stringResource(R.string.system_title), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 8.dp) - ) - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer - ) - ) { - Column { - AnimatedContent(targetState = rootState, label = "RootStatusAnimation") { state -> - when (state) { - RootState.Denied -> RootRequestRow( - text = stringResource(R.string.root_access_denied), - buttonText = stringResource(R.string.action_try_again), - icon = Icons.Default.Error, - onClick = { viewModel.requestRootAccess() } - ) - - RootState.Checking -> { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - Spacer(Modifier.width(8.dp)) - Text(stringResource(R.string.checking_for_root)) - } - } - - RootState.Granted -> RootStatusRow( - text = stringResource(R.string.root_access_granted), - icon = Icons.Default.CheckCircle - ) - } - } - HorizontalDivider(color = MaterialTheme.colorScheme.background) - NotificationPermissionRow( - state = notificationPermissionState, - onRequestPermission = notificationPermissionLauncher, - onOpenSettings = onOpenSettings - ) - HorizontalDivider(color = MaterialTheme.colorScheme.background) - BatteryOptimizationRow( - disabled = batteryOptimizationDisabled, - onRequestDisable = { - val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { - data = Uri.parse("package:${context.packageName}") - } - runCatching { context.startActivity(intent) } - .onFailure { - context.startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) - } - } - ) - HorizontalDivider(color = MaterialTheme.colorScheme.background) - UsageStatsPermissionRow( - granted = hasUsageStatsPermission, - onRequestGrant = { - val intent = Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS) - runCatching { context.startActivity(intent) } - } - ) - // App unlock moved to separate Options card - } - } - } -} - -@Composable -private fun UsageStatsPermissionRow( - granted: Boolean, - onRequestGrant: () -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.weight(1f) - ) { - Icon( - imageVector = if (granted) Icons.Default.CheckCircle else Icons.Default.Error, - contentDescription = null, - modifier = Modifier.padding(end = 16.dp), - tint = if (granted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error - ) - Column { - Text( - text = if (granted) { - stringResource(R.string.usage_stats_permission_enabled) - } else { - stringResource(R.string.usage_stats_permission_disabled) - } - ) - if (!granted) { - Text( - text = stringResource(R.string.usage_stats_permission_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - if (!granted) { - Button(onClick = onRequestGrant) { - Text(stringResource(R.string.action_enable)) - } - } - } -} - -@Composable -private fun BatteryOptimizationRow( - disabled: Boolean, - onRequestDisable: () -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.weight(1f) - ) { - Icon( - imageVector = if (disabled) Icons.Default.CheckCircle else Icons.Default.Error, - contentDescription = null, - modifier = Modifier.padding(end = 16.dp), - tint = if (disabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error - ) - Column { - Text( - text = if (disabled) { - stringResource(R.string.battery_optimization_disabled) - } else { - stringResource(R.string.battery_optimization_enabled) - } - ) - if (!disabled) { - Text( - text = stringResource(R.string.battery_optimization_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - if (!disabled) { - Button(onClick = onRequestDisable) { - Text(stringResource(R.string.action_disable)) - } - } - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/components/SettingsComponents.kt b/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/components/SettingsComponents.kt deleted file mode 100644 index 79fc8706..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/components/SettingsComponents.kt +++ /dev/null @@ -1,477 +0,0 @@ -package io.github.hddq.restoid.ui.screens.settings.components - -import androidx.compose.animation.AnimatedContent -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.selection.selectable -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Error -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Notifications -import androidx.compose.material3.Button -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import io.github.hddq.restoid.R -import io.github.hddq.restoid.data.LocalRepository -import io.github.hddq.restoid.data.NotificationPermissionState -import io.github.hddq.restoid.data.RepositoryBackendType -import io.github.hddq.restoid.data.ResticState -import io.github.hddq.restoid.ui.screens.settings.dialogs.ChangePasswordDialog -import io.github.hddq.restoid.ui.screens.settings.dialogs.SavePasswordDialog -import io.github.hddq.restoid.ui.screens.settings.dialogs.SaveRestCredentialsDialog -import io.github.hddq.restoid.ui.screens.settings.dialogs.SaveS3CredentialsDialog -import io.github.hddq.restoid.ui.screens.settings.dialogs.SaveSftpCredentialsDialog -import io.github.hddq.restoid.ui.settings.SettingsViewModel - -@Composable -fun NotificationPermissionRow( - state: NotificationPermissionState, - onRequestPermission: () -> Unit, - onOpenSettings: () -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.weight(1f) - ) { - val icon = when (state) { - NotificationPermissionState.Granted -> Icons.Default.CheckCircle - NotificationPermissionState.Denied -> Icons.Default.Error - NotificationPermissionState.NotRequested -> Icons.Default.Notifications - } - val iconColor = when (state) { - NotificationPermissionState.Granted -> MaterialTheme.colorScheme.primary - NotificationPermissionState.Denied -> MaterialTheme.colorScheme.error - NotificationPermissionState.NotRequested -> LocalContentColor.current - } - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.padding(end = 16.dp), - tint = iconColor - ) - Text( - text = when (state) { - NotificationPermissionState.Granted -> stringResource(R.string.notifications_enabled) - NotificationPermissionState.Denied -> stringResource(R.string.notifications_disabled) - NotificationPermissionState.NotRequested -> stringResource(R.string.notification_permission) - } - ) - } - if (state != NotificationPermissionState.Granted) { - Button(onClick = if (state == NotificationPermissionState.NotRequested) onRequestPermission else onOpenSettings) { - Text(if (state == NotificationPermissionState.Denied) stringResource(R.string.action_settings) else stringResource(R.string.action_grant)) - } - } - } -} - -@Composable -fun AppUnlockOnStartRow( - enabled: Boolean, - onCheckedChange: (Boolean) -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onCheckedChange(!enabled) } - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.weight(1f) - ) { - Icon( - imageVector = Icons.Default.Lock, - contentDescription = null, - modifier = Modifier.padding(end = 16.dp) - ) - Text(text = stringResource(R.string.require_app_unlock_on_start)) - } - Switch( - checked = enabled, - onCheckedChange = onCheckedChange, - thumbContent = if (enabled) { - { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - modifier = Modifier.size(SwitchDefaults.IconSize) - ) - } - } else { - null - } - ) - } -} - -@Composable -fun SelectableRepositoryRow( - repo: LocalRepository, - isSelected: Boolean, - onSelected: () -> Unit, - viewModel: SettingsViewModel -) { - var showMenu by remember { mutableStateOf(false) } - val repoKey = viewModel.repositoryKey(repo) - - Row( - modifier = Modifier - .fillMaxWidth() - .selectable( - selected = isSelected, - onClick = onSelected, - role = Role.RadioButton - ) - .padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton( - selected = isSelected, - onClick = null - ) - Spacer(Modifier.width(16.dp)) - Column(modifier = Modifier.weight(1f)) { - Text(repo.name, style = MaterialTheme.typography.bodyLarge) - Text( - repo.path, - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = when (repo.backendType) { - RepositoryBackendType.LOCAL -> stringResource(R.string.repo_backend_local) - RepositoryBackendType.SFTP -> stringResource(R.string.repo_backend_sftp) - RepositoryBackendType.REST -> stringResource(R.string.repo_backend_rest) - RepositoryBackendType.S3 -> stringResource(R.string.repo_backend_s3) - }, - style = MaterialTheme.typography.bodySmall, - color = LocalContentColor.current.copy(alpha = 0.75f) - ) - repo.id?.let { - Text( - stringResource(R.string.repository_id_short, it.take(12)), - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - color = LocalContentColor.current.copy(alpha = 0.7f) - ) - } - } - var showChangePasswordDialog by remember { mutableStateOf(false) } - var showSavePasswordDialog by remember { mutableStateOf(false) } - var showSaveSftpCredentialsDialog by remember { mutableStateOf(false) } - var showSaveRestCredentialsDialog by remember { mutableStateOf(false) } - var showSaveS3CredentialsDialog by remember { mutableStateOf(false) } - - if (showChangePasswordDialog) { - ChangePasswordDialog( - viewModel = viewModel, - repositoryKey = repoKey, - onDismiss = { showChangePasswordDialog = false } - ) - } - - if (showSavePasswordDialog) { - SavePasswordDialog( - viewModel = viewModel, - repositoryKey = repoKey, - onDismiss = { showSavePasswordDialog = false } - ) - } - - if (showSaveSftpCredentialsDialog) { - SaveSftpCredentialsDialog( - viewModel = viewModel, - repositoryKey = repoKey, - onDismiss = { showSaveSftpCredentialsDialog = false } - ) - } - - if (showSaveRestCredentialsDialog) { - SaveRestCredentialsDialog( - viewModel = viewModel, - repositoryKey = repoKey, - onDismiss = { showSaveRestCredentialsDialog = false } - ) - } - - if (showSaveS3CredentialsDialog) { - SaveS3CredentialsDialog( - viewModel = viewModel, - repositoryKey = repoKey, - onDismiss = { showSaveS3CredentialsDialog = false } - ) - } - - Box { - IconButton(onClick = { showMenu = true }) { - Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.cd_more_options)) - } - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false } - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.action_delete)) }, - onClick = { - viewModel.deleteRepository(repoKey) - showMenu = false - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.action_restore_metadata)) }, - onClick = { - viewModel.restoreMetadata(repoKey) - showMenu = false - } - ) - if (repo.backendType == RepositoryBackendType.SFTP) { - val hasCredentials = viewModel.hasStoredSftpCredentials(repoKey) - if (hasCredentials) { - DropdownMenuItem( - text = { - Text( - if (repo.sftpKeyAuthRequired) { - stringResource(R.string.action_forget_sftp_key_credentials) - } else { - stringResource(R.string.action_forget_sftp_password) - } - ) - }, - onClick = { - if (repo.sftpKeyAuthRequired) viewModel.forgetSftpKey(repoKey) else viewModel.forgetSftpPassword(repoKey) - showMenu = false - } - ) - } else { - DropdownMenuItem( - text = { - Text( - if (repo.sftpKeyAuthRequired) { - stringResource(R.string.action_save_sftp_key_credentials) - } else { - stringResource(R.string.action_save_sftp_password) - } - ) - }, - onClick = { - showSaveSftpCredentialsDialog = true - showMenu = false - } - ) - } - } - if (repo.backendType == RepositoryBackendType.REST) { - if (viewModel.hasStoredRestCredentials(repoKey)) { - DropdownMenuItem( - text = { Text(stringResource(R.string.action_forget_rest_credentials)) }, - onClick = { - viewModel.forgetRestCredentials(repoKey) - showMenu = false - } - ) - } else { - DropdownMenuItem( - text = { Text(stringResource(R.string.action_save_rest_credentials)) }, - onClick = { - showSaveRestCredentialsDialog = true - showMenu = false - } - ) - } - } - if (repo.backendType == RepositoryBackendType.S3) { - if (viewModel.hasStoredS3Credentials(repoKey)) { - DropdownMenuItem( - text = { Text(stringResource(R.string.action_forget_s3_credentials)) }, - onClick = { - viewModel.forgetS3Credentials(repoKey) - showMenu = false - } - ) - } else { - DropdownMenuItem( - text = { Text(stringResource(R.string.action_save_s3_credentials)) }, - onClick = { - showSaveS3CredentialsDialog = true - showMenu = false - } - ) - } - } - if (viewModel.hasStoredRepositoryPassword(repoKey)) { - DropdownMenuItem( - text = { Text(stringResource(R.string.action_forget_password)) }, - onClick = { - viewModel.forgetPassword(repoKey) - showMenu = false - } - ) - } else { - DropdownMenuItem( - text = { Text(stringResource(R.string.action_save_password)) }, - onClick = { - showSavePasswordDialog = true - showMenu = false - } - ) - } - DropdownMenuItem( - text = { Text(stringResource(R.string.action_change_password)) }, - onClick = { - showChangePasswordDialog = true - showMenu = false - } - ) - } - } - } -} - -@Composable -fun ResticDependencyRow(state: ResticState) { - AnimatedContent(targetState = state, label = "ResticStatusAnimation") { targetState -> - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.weight(1f) - ) { - val icon: ImageVector - val iconColor: Color - - when (targetState) { - is ResticState.Installed -> { - icon = Icons.Default.CheckCircle - iconColor = MaterialTheme.colorScheme.primary - } - is ResticState.Error -> { - icon = Icons.Default.Error - iconColor = MaterialTheme.colorScheme.error - } - ResticState.Idle -> { - icon = Icons.Default.CheckCircle - iconColor = LocalContentColor.current - } - } - - Icon(imageVector = icon, contentDescription = null, modifier = Modifier.padding(end = 16.dp), tint = iconColor) - - Column(modifier = Modifier.weight(1f)) { - Text(stringResource(R.string.restic_binary), style = MaterialTheme.typography.bodyLarge) - val supportingText = when (targetState) { - ResticState.Idle -> stringResource(R.string.restic_checking_status) - is ResticState.Installed -> targetState.fullVersionOutput - is ResticState.Error -> stringResource(R.string.restic_error_with_message, targetState.message) - } - Text( - text = supportingText, - style = MaterialTheme.typography.bodySmall, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - } - } - } -} - -@Composable -fun RootRequestRow( - text: String, - buttonText: String, - icon: ImageVector?, - onClick: () -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.weight(1f) - ) { - if (icon != null) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.padding(end = 16.dp), - tint = MaterialTheme.colorScheme.error - ) - } - Text(text = text) - } - Button(onClick = onClick) { - Text(buttonText) - } - } -} - -@Composable -fun RootStatusRow(text: String, icon: ImageVector) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(end = 16.dp) - ) - Text(text = text, style = MaterialTheme.typography.bodyLarge) - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/dialogs/ChangePasswordDialog.kt b/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/dialogs/ChangePasswordDialog.kt deleted file mode 100644 index 25eeeec2..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/dialogs/ChangePasswordDialog.kt +++ /dev/null @@ -1,144 +0,0 @@ -package io.github.hddq.restoid.ui.screens.settings.dialogs - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import io.github.hddq.restoid.R -import io.github.hddq.restoid.ui.settings.ChangePasswordState -import io.github.hddq.restoid.ui.settings.SettingsViewModel - -@Composable -fun ChangePasswordDialog( - viewModel: SettingsViewModel, - repositoryKey: String, - onDismiss: () -> Unit -) { - var oldPassword by remember { mutableStateOf("") } - var newPassword by remember { mutableStateOf("") } - var confirmPassword by remember { mutableStateOf("") } - var passwordVisible by remember { mutableStateOf(false) } - var error by remember { mutableStateOf(null) } - val failedChangePasswordMessage = stringResource(R.string.error_failed_change_password) - - val changePasswordState by viewModel.changePasswordState.collectAsStateWithLifecycle() - - LaunchedEffect(changePasswordState) { - when (changePasswordState) { - ChangePasswordState.Success -> { - viewModel.resetChangePasswordState() - onDismiss() - } - ChangePasswordState.Error -> { - error = failedChangePasswordMessage - viewModel.resetChangePasswordState() - } - else -> {} - } - } - - AlertDialog( - onDismissRequest = { - if (changePasswordState != ChangePasswordState.InProgress) { - onDismiss() - } - }, - title = { Text(stringResource(R.string.dialog_change_password)) }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = oldPassword, - onValueChange = { oldPassword = it }, - label = { Text(stringResource(R.string.label_old_password)) }, - singleLine = true, - visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - val image = if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility - IconButton(onClick = { passwordVisible = !passwordVisible }) { - Icon(imageVector = image, contentDescription = if (passwordVisible) stringResource(R.string.cd_hide) else stringResource(R.string.cd_show)) - } - }, - modifier = Modifier.fillMaxWidth(), - enabled = changePasswordState != ChangePasswordState.InProgress - ) - OutlinedTextField( - value = newPassword, - onValueChange = { newPassword = it }, - label = { Text(stringResource(R.string.label_new_password)) }, - singleLine = true, - visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - val image = if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility - IconButton(onClick = { passwordVisible = !passwordVisible }) { - Icon(imageVector = image, contentDescription = if (passwordVisible) stringResource(R.string.cd_hide) else stringResource(R.string.cd_show)) - } - }, - modifier = Modifier.fillMaxWidth(), - enabled = changePasswordState != ChangePasswordState.InProgress - ) - OutlinedTextField( - value = confirmPassword, - onValueChange = { confirmPassword = it }, - label = { Text(stringResource(R.string.label_confirm_new_password)) }, - singleLine = true, - visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - val image = if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility - IconButton(onClick = { passwordVisible = !passwordVisible }) { - Icon(imageVector = image, contentDescription = if (passwordVisible) stringResource(R.string.cd_hide) else stringResource(R.string.cd_show)) - } - }, - modifier = Modifier.fillMaxWidth(), - enabled = changePasswordState != ChangePasswordState.InProgress, - isError = newPassword != confirmPassword - ) - if (newPassword != confirmPassword) { - Text(stringResource(R.string.error_passwords_do_not_match), color = MaterialTheme.colorScheme.error) - } - error?.let { - Text(it, color = MaterialTheme.colorScheme.error) - } - } - }, - confirmButton = { - Button( - onClick = { - error = null - if (newPassword == confirmPassword) { - viewModel.changePassword(repositoryKey, oldPassword, newPassword) - } - }, - enabled = oldPassword.isNotEmpty() && - newPassword.isNotEmpty() && - confirmPassword.isNotEmpty() && - newPassword == confirmPassword && - changePasswordState != ChangePasswordState.InProgress - ) { - if (changePasswordState == ChangePasswordState.InProgress) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - } else { - Text(stringResource(R.string.action_change)) - } - } - }, - dismissButton = { - TextButton( - onClick = onDismiss, - enabled = changePasswordState != ChangePasswordState.InProgress - ) { - Text(stringResource(R.string.action_cancel)) - } - } - ) -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/dialogs/SavePasswordDialog.kt b/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/dialogs/SavePasswordDialog.kt deleted file mode 100644 index 54bb2910..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/dialogs/SavePasswordDialog.kt +++ /dev/null @@ -1,62 +0,0 @@ -package io.github.hddq.restoid.ui.screens.settings.dialogs - -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import io.github.hddq.restoid.R -import io.github.hddq.restoid.ui.settings.SettingsViewModel - -@Composable -fun SavePasswordDialog( - viewModel: SettingsViewModel, - repositoryKey: String, - onDismiss: () -> Unit -) { - var password by remember { mutableStateOf("") } - var passwordVisible by remember { mutableStateOf(false) } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.dialog_save_password)) }, - text = { - OutlinedTextField( - value = password, - onValueChange = { password = it }, - label = { Text(stringResource(R.string.label_password)) }, - singleLine = true, - visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - val image = if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility - val description = if (passwordVisible) stringResource(R.string.cd_hide_password) else stringResource(R.string.cd_show_password) - IconButton(onClick = { passwordVisible = !passwordVisible }) { - Icon(imageVector = image, contentDescription = description) - } - }, - modifier = Modifier.fillMaxWidth() - ) - }, - confirmButton = { - Button( - onClick = { - viewModel.savePassword(repositoryKey, password) - onDismiss() - }, - enabled = password.isNotEmpty() - ) { - Text(stringResource(R.string.action_save)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) - } - } - ) -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/dialogs/SaveRestCredentialsDialog.kt b/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/dialogs/SaveRestCredentialsDialog.kt deleted file mode 100644 index bb725a48..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/dialogs/SaveRestCredentialsDialog.kt +++ /dev/null @@ -1,95 +0,0 @@ -package io.github.hddq.restoid.ui.screens.settings.dialogs - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.dp -import io.github.hddq.restoid.R -import io.github.hddq.restoid.ui.settings.SettingsViewModel - -@Composable -fun SaveRestCredentialsDialog( - viewModel: SettingsViewModel, - repositoryKey: String, - onDismiss: () -> Unit -) { - var username by remember { mutableStateOf("") } - var password by remember { mutableStateOf("") } - var passwordVisible by remember { mutableStateOf(false) } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.dialog_save_rest_credentials)) }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = username, - onValueChange = { username = it }, - label = { Text(stringResource(R.string.label_rest_username)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - - OutlinedTextField( - value = password, - onValueChange = { password = it }, - label = { Text(stringResource(R.string.label_rest_password)) }, - singleLine = true, - visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - val image = if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility - val description = if (passwordVisible) stringResource(R.string.cd_hide_password) else stringResource(R.string.cd_show_password) - IconButton(onClick = { passwordVisible = !passwordVisible }) { - Icon(imageVector = image, contentDescription = description) - } - }, - modifier = Modifier.fillMaxWidth() - ) - - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.rest_credentials_hint), - style = androidx.compose.material3.MaterialTheme.typography.bodySmall, - color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant - ) - } - }, - confirmButton = { - Button( - onClick = { - viewModel.saveRestCredentials(repositoryKey, username, password) - onDismiss() - }, - enabled = username.isNotBlank() && password.isNotBlank() - ) { - Text(stringResource(R.string.action_save)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) - } - } - ) -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/dialogs/SaveS3CredentialsDialog.kt b/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/dialogs/SaveS3CredentialsDialog.kt deleted file mode 100644 index c5358b70..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/dialogs/SaveS3CredentialsDialog.kt +++ /dev/null @@ -1,96 +0,0 @@ -package io.github.hddq.restoid.ui.screens.settings.dialogs - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.dp -import io.github.hddq.restoid.R -import io.github.hddq.restoid.ui.settings.SettingsViewModel - -@Composable -fun SaveS3CredentialsDialog( - viewModel: SettingsViewModel, - repositoryKey: String, - onDismiss: () -> Unit -) { - var accessKeyId by remember { mutableStateOf("") } - var secretAccessKey by remember { mutableStateOf("") } - var secretVisible by remember { mutableStateOf(false) } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.dialog_save_s3_credentials)) }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = accessKeyId, - onValueChange = { accessKeyId = it }, - label = { Text(stringResource(R.string.label_s3_access_key_id)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - - OutlinedTextField( - value = secretAccessKey, - onValueChange = { secretAccessKey = it }, - label = { Text(stringResource(R.string.label_s3_secret_access_key)) }, - singleLine = true, - visualTransformation = if (secretVisible) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - val image = if (secretVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility - val description = if (secretVisible) stringResource(R.string.cd_hide_password) else stringResource(R.string.cd_show_password) - IconButton(onClick = { secretVisible = !secretVisible }) { - Icon(imageVector = image, contentDescription = description) - } - }, - modifier = Modifier.fillMaxWidth() - ) - - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.s3_credentials_hint), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - }, - confirmButton = { - Button( - onClick = { - viewModel.saveS3Credentials(repositoryKey, accessKeyId, secretAccessKey) - onDismiss() - }, - enabled = accessKeyId.isNotBlank() && secretAccessKey.isNotBlank() - ) { - Text(stringResource(R.string.action_save)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) - } - } - ) -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/dialogs/SaveSftpCredentialsDialog.kt b/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/dialogs/SaveSftpCredentialsDialog.kt deleted file mode 100644 index d0057105..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/screens/settings/dialogs/SaveSftpCredentialsDialog.kt +++ /dev/null @@ -1,144 +0,0 @@ -package io.github.hddq.restoid.ui.screens.settings.dialogs - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import io.github.hddq.restoid.R -import io.github.hddq.restoid.ui.components.SshPrivateKeyField -import io.github.hddq.restoid.ui.settings.SettingsViewModel - -@Composable -fun SaveSftpCredentialsDialog( - viewModel: SettingsViewModel, - repositoryKey: String, - onDismiss: () -> Unit -) { - val repositories by viewModel.repositories.collectAsStateWithLifecycle() - val repository = repositories.find { viewModel.repositoryKey(it) == repositoryKey } - - if (repository == null) { - onDismiss() - return - } - - val isKeyAuth = repository.sftpKeyAuthRequired - val requiresPassphrase = repository.sftpKeyPassphraseRequired - - var credentials by remember { mutableStateOf("") } - var passphrase by remember { mutableStateOf("") } - var passwordVisible by remember { mutableStateOf(false) } - var passphraseVisible by remember { mutableStateOf(false) } - var importErrorMessage by remember { mutableStateOf(null) } - - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text( - if (isKeyAuth) { - stringResource(R.string.dialog_save_sftp_key_credentials) - } else { - stringResource(R.string.dialog_save_sftp_password) - } - ) - }, - text = { - Column { - if (isKeyAuth) { - SshPrivateKeyField( - value = credentials, - onValueChange = { - credentials = it - importErrorMessage = null - }, - onImportError = { importErrorMessage = it }, - label = stringResource(R.string.label_sftp_private_key), - placeholder = stringResource(R.string.placeholder_sftp_private_key), - isError = importErrorMessage != null, - supportingText = importErrorMessage?.let { message -> { Text(message) } }, - modifier = Modifier.fillMaxWidth() - ) - - if (requiresPassphrase) { - Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - value = passphrase, - onValueChange = { passphrase = it }, - label = { Text(stringResource(R.string.label_sftp_key_passphrase)) }, - singleLine = true, - visualTransformation = if (passphraseVisible) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - val image = if (passphraseVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility - val description = if (passphraseVisible) stringResource(R.string.cd_hide_password) else stringResource(R.string.cd_show_password) - IconButton(onClick = { passphraseVisible = !passphraseVisible }) { - Icon(imageVector = image, contentDescription = description) - } - }, - modifier = Modifier.fillMaxWidth() - ) - } - } else { - OutlinedTextField( - value = credentials, - onValueChange = { credentials = it }, - label = { Text(stringResource(R.string.label_sftp_password)) }, - singleLine = true, - visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - val image = if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility - val description = if (passwordVisible) stringResource(R.string.cd_hide_password) else stringResource(R.string.cd_show_password) - IconButton(onClick = { passwordVisible = !passwordVisible }) { - Icon(imageVector = image, contentDescription = description) - } - }, - modifier = Modifier.fillMaxWidth() - ) - } - } - }, - confirmButton = { - Button( - onClick = { - if (isKeyAuth) { - viewModel.saveSftpKey(repositoryKey, credentials) - if (requiresPassphrase) { - viewModel.saveSftpKeyPassphrase(repositoryKey, passphrase) - } - } else { - viewModel.saveSftpPassword(repositoryKey, credentials) - } - onDismiss() - }, - enabled = credentials.isNotEmpty() && (!requiresPassphrase || passphrase.isNotEmpty()) - ) { - Text(stringResource(R.string.action_save)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) - } - } - ) -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/settings/SettingsViewModel.kt b/app/src/main/java/io/github/hddq/restoid/ui/settings/SettingsViewModel.kt deleted file mode 100644 index 25404ebd..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/settings/SettingsViewModel.kt +++ /dev/null @@ -1,602 +0,0 @@ -package io.github.hddq.restoid.ui.settings - -import android.os.PowerManager -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import io.github.hddq.restoid.data.* -import io.github.hddq.restoid.R -import io.github.hddq.restoid.util.isValidEnvironmentVariableName -import kotlinx.coroutines.delay -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -enum class SftpAuthMethod { PASSWORD, KEY } - -data class AddRepoUiState( - val backendType: RepositoryBackendType = RepositoryBackendType.LOCAL, - val path: String = "", - val password: String = "", - val sftpPassword: String = "", - val sftpKey: String = "", - val sftpKeyPassphrase: String = "", - val sftpAuthMethod: SftpAuthMethod = SftpAuthMethod.PASSWORD, - val s3AccessKeyId: String = "", - val s3SecretAccessKey: String = "", - val restUsername: String = "", - val restPassword: String = "", - val sftpServerTrustInfo: SftpServerTrustInfo? = null, - val environmentVariablesRaw: String = "", - val savePassword: Boolean = true, - val showDialog: Boolean = false, - val state: AddRepositoryState = AddRepositoryState.Idle -) - -enum class ChangePasswordState { Idle, InProgress, Success, Error } - -private data class AddRepositoryInput( - val path: String, - val backendType: RepositoryBackendType, - val password: String, - val sftpPassword: String, - val sftpKey: String, - val sftpKeyPassphrase: String, - val sftpAuthMethod: SftpAuthMethod, - val s3AccessKeyId: String, - val s3SecretAccessKey: String, - val restUsername: String, - val restPassword: String, - val environmentVariables: Map, - val savePassword: Boolean -) - -private data class PendingSftpTrustRequest( - val input: AddRepositoryInput, - val knownHostEntries: List -) - -class SettingsViewModel( - private val context: android.content.Context, - private val rootRepository: RootRepository, - private val resticBinaryManager: ResticBinaryManager, // Injected Manager - private val resticRepository: ResticRepository, - private val repositoriesRepository: RepositoriesRepository, - private val notificationRepository: NotificationRepository, - private val preferencesRepository: PreferencesRepository -) : ViewModel() { - - val rootState = rootRepository.rootState - val resticState = resticBinaryManager.resticState - - val repositories = repositoriesRepository.repositories - val selectedRepository = repositoriesRepository.selectedRepository - val notificationPermissionState = notificationRepository.permissionState - - private val _addRepoUiState = MutableStateFlow(AddRepoUiState()) - val addRepoUiState = _addRepoUiState.asStateFlow() - - private val _showMetadataWarning = MutableStateFlow(null) - val showMetadataWarning = _showMetadataWarning.asStateFlow() - - private val _metadataRestoreSuccess = MutableStateFlow(null) - val metadataRestoreSuccess = _metadataRestoreSuccess.asStateFlow() - - fun dismissMetadataWarning() { - _showMetadataWarning.value = null - } - - fun consumeMetadataRestoreSuccess() { - _metadataRestoreSuccess.value = null - } - - private val _changePasswordState = MutableStateFlow(ChangePasswordState.Idle) - val changePasswordState = _changePasswordState.asStateFlow() - - private val _requireAppUnlock = MutableStateFlow(preferencesRepository.loadRequireAppUnlock()) - val requireAppUnlock = _requireAppUnlock.asStateFlow() - - private val _isIgnoringBatteryOptimizations = MutableStateFlow(false) - val isIgnoringBatteryOptimizations = _isIgnoringBatteryOptimizations.asStateFlow() - - private val _hasUsageStatsPermission = MutableStateFlow(false) - val hasUsageStatsPermission = _hasUsageStatsPermission.asStateFlow() - - private var pendingSftpTrustRequest: PendingSftpTrustRequest? = null - - init { - viewModelScope.launch { - refreshSystemState() - } - } - - fun requestRootAccess() { - viewModelScope.launch { - refreshRootDependentState() - } - } - - fun refreshSystemState() { - viewModelScope.launch { - refreshRootDependentState() - refreshBatteryOptimizationStatus() - checkNotificationPermission() - refreshUsageStatsPermissionStatus() - } - } - - private fun refreshUsageStatsPermissionStatus() { - val appOps = context.getSystemService(android.content.Context.APP_OPS_SERVICE) as android.app.AppOpsManager - val mode = appOps.unsafeCheckOpNoThrow( - android.app.AppOpsManager.OPSTR_GET_USAGE_STATS, - android.os.Process.myUid(), - context.packageName - ) - _hasUsageStatsPermission.value = mode == android.app.AppOpsManager.MODE_ALLOWED - } - - private suspend fun refreshRootDependentState() { - rootRepository.checkRootAccess() - withContext(Dispatchers.IO) { - resticBinaryManager.checkResticStatus() - repositoriesRepository.loadRepositories() - } - } - - fun repositoryKey(repository: LocalRepository): String = repositoriesRepository.repositoryKey(repository) - - fun hasRepositoryPassword(key: String) = repositoriesRepository.hasRepositoryPassword(key) - fun hasStoredRepositoryPassword(key: String) = repositoriesRepository.hasStoredRepositoryPassword(key) - fun forgetPassword(key: String) = repositoriesRepository.forgetPassword(key) - fun savePassword(key: String, password: String) = repositoriesRepository.saveRepositoryPassword(key, password) - fun hasSftpPassword(key: String) = repositoriesRepository.hasSftpPassword(key) - fun hasStoredSftpPassword(key: String) = repositoriesRepository.hasStoredSftpPassword(key) - fun forgetSftpPassword(key: String) = repositoriesRepository.forgetSftpPassword(key) - fun saveSftpPassword(key: String, password: String) = repositoriesRepository.saveSftpPassword(key, password) - fun hasSftpKey(key: String) = repositoriesRepository.hasSftpKey(key) - fun hasStoredSftpKey(key: String) = repositoriesRepository.hasStoredSftpKey(key) - fun hasStoredSftpCredentials(key: String) = repositoriesRepository.hasStoredSftpCredentials(key) - fun forgetSftpKey(key: String) = repositoriesRepository.forgetSftpKey(key) - fun saveSftpKey(key: String, sftpKey: String) = repositoriesRepository.saveSftpKey(key, sftpKey) - fun hasSftpKeyPassphrase(key: String) = repositoriesRepository.hasSftpKeyPassphrase(key) - fun hasStoredSftpKeyPassphrase(key: String) = repositoriesRepository.hasStoredSftpKeyPassphrase(key) - fun saveSftpKeyPassphrase(key: String, passphrase: String) = repositoriesRepository.saveSftpKeyPassphrase(key, passphrase) - fun hasSftpCredentials(key: String) = repositoriesRepository.hasSftpCredentials(key) - fun hasRestCredentials(key: String) = repositoriesRepository.hasRestCredentials(key) - fun hasStoredRestCredentials(key: String) = repositoriesRepository.hasStoredRestCredentials(key) - fun forgetRestCredentials(key: String) = repositoriesRepository.forgetRestCredentials(key) - fun saveRestCredentials(key: String, username: String, password: String) { - repositoriesRepository.saveRestCredentials(key, username, password) - } - fun hasS3Credentials(key: String) = repositoriesRepository.hasS3Credentials(key) - fun hasStoredS3Credentials(key: String) = repositoriesRepository.hasStoredS3Credentials(key) - fun forgetS3Credentials(key: String) = repositoriesRepository.forgetS3Credentials(key) - fun saveS3Credentials(key: String, accessKeyId: String, secretAccessKey: String) { - repositoriesRepository.saveS3Credentials(key, accessKeyId, secretAccessKey) - } - - fun deleteRepository(key: String) { - viewModelScope.launch { repositoriesRepository.deleteRepository(key) } - } - - fun restoreMetadata(key: String) { - viewModelScope.launch { - if (resticState.value !is ResticState.Installed) { - _showMetadataWarning.value = context.getString(R.string.error_restic_not_installed) - return@launch - } - - val repository = repositoriesRepository.getRepositoryByKey(key) - val password = repositoriesRepository.getRepositoryPassword(key) - if (repository == null || password.isNullOrBlank()) { - _showMetadataWarning.value = context.getString(R.string.error_repository_password_or_id_not_found) - return@launch - } - - val repoId = repository.id ?: run { - val configResult = resticRepository.getConfig( - repository.path, - password, - repositoriesRepository.getExecutionEnvironmentVariables(key), - repositoriesRepository.getExecutionResticOptions(key) - ) - if (configResult.isFailure) { - _showMetadataWarning.value = context.getString(R.string.repo_error_failed_get_id) - return@launch - } - configResult.getOrNull()?.id - } - - if (repoId.isNullOrBlank()) { - _showMetadataWarning.value = context.getString(R.string.repo_error_failed_get_id) - return@launch - } - - val restoreResult = repositoriesRepository.restoreMetadataForRepo( - repoId = repoId, - repoPath = repository.path, - password = password, - resticRepository = resticRepository, - environmentVariables = repositoriesRepository.getExecutionEnvironmentVariables(key), - resticOptions = repositoriesRepository.getExecutionResticOptions(key) - ) - - if (restoreResult.isFailure) { - _showMetadataWarning.value = restoreResult.exceptionOrNull()?.message - ?: context.getString(R.string.repo_warning_metadata_restore_failed) - } else { - _metadataRestoreSuccess.value = context.getString(R.string.metadata_restore_success) - } - } - } - - fun changePassword(key: String, oldPassword: String, newPassword: String) { - viewModelScope.launch { - _changePasswordState.value = ChangePasswordState.InProgress - val repository = repositoriesRepository.getRepositoryByKey(key) - if (repository == null) { - _changePasswordState.value = ChangePasswordState.Error - return@launch - } - - val result = resticRepository.changePassword( - repository.path, - oldPassword, - newPassword, - repositoriesRepository.getExecutionEnvironmentVariables(key), - repositoriesRepository.getExecutionResticOptions(key) - ) - if (result.isSuccess) { - if (repositoriesRepository.hasStoredRepositoryPassword(key)) { - repositoriesRepository.saveRepositoryPassword(key, newPassword) - } - _changePasswordState.value = ChangePasswordState.Success - } else { - _changePasswordState.value = ChangePasswordState.Error - } - } - } - - fun resetChangePasswordState() { _changePasswordState.value = ChangePasswordState.Idle } - fun checkNotificationPermission() = notificationRepository.checkPermissionStatus() - - fun onRequireAppUnlockChanged(required: Boolean) { - _requireAppUnlock.value = required - preferencesRepository.saveRequireAppUnlock(required) - } - - fun refreshBatteryOptimizationStatus() { - val powerManager = context.getSystemService(android.content.Context.POWER_SERVICE) as PowerManager - _isIgnoringBatteryOptimizations.value = powerManager.isIgnoringBatteryOptimizations(context.packageName) - } - - fun selectRepository(key: String) = repositoriesRepository.selectRepository(key) - - // UI handlers - fun onNewRepoBackendTypeChanged(backendType: RepositoryBackendType) { - pendingSftpTrustRequest = null - _addRepoUiState.update { - if (it.backendType == backendType) it else it.copy( - backendType = backendType, - path = "", - sftpPassword = "", - sftpKey = "", - sftpKeyPassphrase = "", - sftpAuthMethod = SftpAuthMethod.PASSWORD, - s3AccessKeyId = "", - s3SecretAccessKey = "", - restUsername = "", - restPassword = "", - sftpServerTrustInfo = null, - environmentVariablesRaw = "", - state = AddRepositoryState.Idle - ) - } - } - - fun onNewRepoPathChanged(path: String) { - pendingSftpTrustRequest = null - _addRepoUiState.update { it.copy(path = path, sftpServerTrustInfo = null) } - } - - fun onNewRepoPasswordChanged(password: String) { - pendingSftpTrustRequest = null - _addRepoUiState.update { it.copy(password = password, sftpServerTrustInfo = null) } - } - - fun onNewRepoSftpPasswordChanged(password: String) { - pendingSftpTrustRequest = null - _addRepoUiState.update { it.copy(sftpPassword = password, sftpServerTrustInfo = null) } - } - - fun onNewRepoSftpKeyChanged(key: String) { - pendingSftpTrustRequest = null - _addRepoUiState.update { it.copy(sftpKey = key, sftpServerTrustInfo = null, state = AddRepositoryState.Idle) } - } - - fun onNewRepoSftpKeyImportError(message: String) { - pendingSftpTrustRequest = null - _addRepoUiState.update { it.copy(state = AddRepositoryState.Error(message), sftpServerTrustInfo = null) } - } - - fun onNewRepoSftpKeyPassphraseChanged(passphrase: String) { - pendingSftpTrustRequest = null - _addRepoUiState.update { it.copy(sftpKeyPassphrase = passphrase, sftpServerTrustInfo = null, state = AddRepositoryState.Idle) } - } - - fun onNewRepoSftpAuthMethodChanged(method: SftpAuthMethod) { - pendingSftpTrustRequest = null - _addRepoUiState.update { it.copy(sftpAuthMethod = method, sftpServerTrustInfo = null, state = AddRepositoryState.Idle) } - } - - fun onNewRepoS3AccessKeyIdChanged(accessKeyId: String) { - pendingSftpTrustRequest = null - _addRepoUiState.update { it.copy(s3AccessKeyId = accessKeyId, sftpServerTrustInfo = null) } - } - - fun onNewRepoS3SecretAccessKeyChanged(secretAccessKey: String) { - pendingSftpTrustRequest = null - _addRepoUiState.update { it.copy(s3SecretAccessKey = secretAccessKey, sftpServerTrustInfo = null) } - } - - fun onNewRepoRestUsernameChanged(username: String) { - pendingSftpTrustRequest = null - _addRepoUiState.update { it.copy(restUsername = username, sftpServerTrustInfo = null) } - } - - fun onNewRepoRestPasswordChanged(password: String) { - pendingSftpTrustRequest = null - _addRepoUiState.update { it.copy(restPassword = password, sftpServerTrustInfo = null) } - } - - fun onNewRepoEnvironmentVariablesChanged(raw: String) { - pendingSftpTrustRequest = null - _addRepoUiState.update { it.copy(environmentVariablesRaw = raw, sftpServerTrustInfo = null) } - } - - fun onSavePasswordChanged(save: Boolean) = _addRepoUiState.update { it.copy(savePassword = save) } - fun onNewRepoDialogDismiss() { - pendingSftpTrustRequest = null - _addRepoUiState.value = AddRepoUiState() - } - - fun onSftpTrustDialogDismiss() { - pendingSftpTrustRequest = null - _addRepoUiState.update { it.copy(sftpServerTrustInfo = null, state = AddRepositoryState.Idle) } - } - - fun onSftpTrustDialogConfirm() { - val pendingRequest = pendingSftpTrustRequest ?: run { - onSftpTrustDialogDismiss() - return - } - - viewModelScope.launch { - _addRepoUiState.update { it.copy(state = AddRepositoryState.Initializing) } - - val trustResult = repositoriesRepository.trustSftpServer(pendingRequest.knownHostEntries) - if (trustResult.isFailure) { - val message = trustResult.exceptionOrNull()?.message - ?: context.getString(R.string.repo_error_sftp_known_hosts_setup_failed) - pendingSftpTrustRequest = null - _addRepoUiState.update { - it.copy( - sftpServerTrustInfo = null, - state = AddRepositoryState.Error(message) - ) - } - return@launch - } - - pendingSftpTrustRequest = null - _addRepoUiState.update { it.copy(sftpServerTrustInfo = null) } - executeAddRepository(pendingRequest.input) - } - } - - fun onShowAddRepoDialog() { - if (resticState.value is ResticState.Installed) { - _addRepoUiState.update { it.copy(showDialog = true) } - } - } - - fun addRepository() { - if (resticState.value !is ResticState.Installed) { - _addRepoUiState.update { it.copy(state = AddRepositoryState.Error(context.getString(R.string.error_restic_not_installed))) } - return - } - - val path = addRepoUiState.value.path.trim() - val password = addRepoUiState.value.password - val sftpPassword = addRepoUiState.value.sftpPassword - val sftpKey = addRepoUiState.value.sftpKey - val sftpKeyPassphrase = addRepoUiState.value.sftpKeyPassphrase - val sftpAuthMethod = addRepoUiState.value.sftpAuthMethod - val s3AccessKeyId = addRepoUiState.value.s3AccessKeyId - val s3SecretAccessKey = addRepoUiState.value.s3SecretAccessKey - val restUsername = addRepoUiState.value.restUsername - val restPassword = addRepoUiState.value.restPassword - val savePassword = addRepoUiState.value.savePassword - val backendType = addRepoUiState.value.backendType - val envParseResult = parseEnvironmentVariables(addRepoUiState.value.environmentVariablesRaw) - - if (path.isBlank() || password.isBlank()) { - _addRepoUiState.update { it.copy(state = AddRepositoryState.Error(context.getString(R.string.settings_error_path_password_empty))) } - return - } - - if (backendType == RepositoryBackendType.SFTP) { - if (sftpAuthMethod == SftpAuthMethod.KEY && sftpKey.isBlank()) { - _addRepoUiState.update { it.copy(state = AddRepositoryState.Error(context.getString(R.string.error_sftp_key_cannot_be_empty))) } - return - } - } - - if (backendType == RepositoryBackendType.REST) { - val hasRestUsername = restUsername.isNotBlank() - val hasRestPassword = restPassword.isNotBlank() - if (hasRestUsername != hasRestPassword) { - _addRepoUiState.update { - it.copy(state = AddRepositoryState.Error(context.getString(R.string.settings_error_rest_credentials_incomplete))) - } - return - } - } - - if (backendType == RepositoryBackendType.S3) { - val hasS3AccessKeyId = s3AccessKeyId.isNotBlank() - val hasS3SecretAccessKey = s3SecretAccessKey.isNotBlank() - if (hasS3AccessKeyId != hasS3SecretAccessKey) { - _addRepoUiState.update { - it.copy(state = AddRepositoryState.Error(context.getString(R.string.settings_error_s3_credentials_incomplete))) - } - return - } - } - - if (envParseResult.isFailure) { - _addRepoUiState.update { - it.copy( - state = AddRepositoryState.Error( - envParseResult.exceptionOrNull()?.message - ?: context.getString(R.string.settings_error_invalid_environment_variables) - ) - ) - } - return - } - - val environmentVariables = envParseResult.getOrNull().orEmpty() - val input = AddRepositoryInput( - path = path, - backendType = backendType, - password = password, - sftpPassword = sftpPassword, - sftpKey = sftpKey, - sftpKeyPassphrase = sftpKeyPassphrase, - sftpAuthMethod = sftpAuthMethod, - s3AccessKeyId = s3AccessKeyId, - s3SecretAccessKey = s3SecretAccessKey, - restUsername = restUsername, - restPassword = restPassword, - environmentVariables = environmentVariables, - savePassword = savePassword - ) - - viewModelScope.launch { - if (input.backendType == RepositoryBackendType.SFTP) { - _addRepoUiState.update { it.copy(state = AddRepositoryState.Initializing, sftpServerTrustInfo = null) } - - val trustInfoResult = repositoriesRepository.probeSftpServerTrust( - path = input.path, - password = input.password, - environmentVariables = input.environmentVariables, - resticOptions = emptyMap(), - sftpPassword = input.sftpPassword, - sftpKey = input.sftpKey, - sftpKeyAuthRequired = input.sftpAuthMethod == SftpAuthMethod.KEY, - sftpKeyPassphrase = input.sftpKeyPassphrase - ) - - if (trustInfoResult.isFailure) { - _addRepoUiState.update { - it.copy( - state = AddRepositoryState.Error( - trustInfoResult.exceptionOrNull()?.message - ?: context.getString(R.string.repo_error_sftp_fingerprint_probe_failed) - ) - ) - } - return@launch - } - - val trustInfo = trustInfoResult.getOrThrow() - pendingSftpTrustRequest = PendingSftpTrustRequest( - input = input, - knownHostEntries = trustInfo.knownHostEntries - ) - - _addRepoUiState.update { - it.copy( - state = AddRepositoryState.Idle, - sftpServerTrustInfo = trustInfo - ) - } - return@launch - } - - executeAddRepository(input) - } - } - - private suspend fun executeAddRepository(input: AddRepositoryInput) { - _addRepoUiState.update { it.copy(state = AddRepositoryState.Initializing) } - - val result = repositoriesRepository.addRepository( - path = input.path, - backendType = input.backendType, - password = input.password, - environmentVariables = input.environmentVariables, - resticOptions = emptyMap(), - sftpPassword = input.sftpPassword, - sftpKey = input.sftpKey, - sftpKeyPassphrase = input.sftpKeyPassphrase, - sftpKeyAuthRequired = input.sftpAuthMethod == SftpAuthMethod.KEY, - s3AccessKeyId = input.s3AccessKeyId, - s3SecretAccessKey = input.s3SecretAccessKey, - restUsername = input.restUsername, - restPassword = input.restPassword, - resticRepository = resticRepository, - savePassword = input.savePassword - ) - - _addRepoUiState.update { it.copy(state = result, sftpServerTrustInfo = null) } - - if (result is AddRepositoryState.Success || result is AddRepositoryState.SuccessWithMetadataWarning) { - delay(1000) - if (result is AddRepositoryState.SuccessWithMetadataWarning) { - _showMetadataWarning.value = result.warning - } - onNewRepoDialogDismiss() - } - } - - private fun parseEnvironmentVariables(raw: String): Result> { - if (raw.isBlank()) return Result.success(emptyMap()) - - val parsed = linkedMapOf() - val lines = raw.lines() - - lines.forEachIndexed { index, originalLine -> - val line = originalLine.trim() - if (line.isEmpty()) return@forEachIndexed - - val normalized = if (line.startsWith("export ")) line.removePrefix("export ").trim() else line - val separatorIndex = normalized.indexOf('=') - if (separatorIndex <= 0) { - val message = context.getString(R.string.settings_error_invalid_environment_line, index + 1) - return Result.failure(IllegalArgumentException(message)) - } - - val key = normalized.substring(0, separatorIndex).trim() - var value = normalized.substring(separatorIndex + 1).trim() - - if (!isValidEnvironmentVariableName(key)) { - val message = context.getString(R.string.settings_error_invalid_environment_name, key) - return Result.failure(IllegalArgumentException(message)) - } - - if (value.length >= 2 && - ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) - ) { - value = value.substring(1, value.length - 1) - } - - parsed[key] = value - } - - return Result.success(parsed) - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/settings/SettingsViewModelFactory.kt b/app/src/main/java/io/github/hddq/restoid/ui/settings/SettingsViewModelFactory.kt deleted file mode 100644 index 611ad8b3..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/settings/SettingsViewModelFactory.kt +++ /dev/null @@ -1,37 +0,0 @@ -package io.github.hddq.restoid.ui.settings - -import android.content.Context -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import io.github.hddq.restoid.data.NotificationRepository -import io.github.hddq.restoid.data.PreferencesRepository -import io.github.hddq.restoid.data.RepositoriesRepository -import io.github.hddq.restoid.data.ResticBinaryManager -import io.github.hddq.restoid.data.ResticRepository -import io.github.hddq.restoid.data.RootRepository - -class SettingsViewModelFactory( - private val context: Context, - private val rootRepository: RootRepository, - private val resticBinaryManager: ResticBinaryManager, // New dependency - private val resticRepository: ResticRepository, - private val repositoriesRepository: RepositoriesRepository, - private val notificationRepository: NotificationRepository, - private val preferencesRepository: PreferencesRepository -) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(SettingsViewModel::class.java)) { - @Suppress("UNCHECKED_CAST") - return SettingsViewModel( - context, - rootRepository, - resticBinaryManager, - resticRepository, - repositoriesRepository, - notificationRepository, - preferencesRepository - ) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/shared/BackupTypes.kt b/app/src/main/java/io/github/hddq/restoid/ui/shared/BackupTypes.kt deleted file mode 100644 index e12c0544..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/shared/BackupTypes.kt +++ /dev/null @@ -1,43 +0,0 @@ -package io.github.hddq.restoid.ui.shared - -import io.github.hddq.restoid.work.BackupTypeSelection -import kotlinx.serialization.Serializable - -@Serializable -data class BackupTypes( - val apk: Boolean = true, - val data: Boolean = true, - val deviceProtectedData: Boolean = true, - val externalData: Boolean = false, - val obb: Boolean = false, - val media: Boolean = false, - val permissions: Boolean = true -) { - fun toSelection(): BackupTypeSelection { - return BackupTypeSelection( - apk = apk, - data = data, - deviceProtectedData = deviceProtectedData, - externalData = externalData, - obb = obb, - media = media, - permissions = permissions - ) - } - - fun anyEnabled(): Boolean { - return apk || data || deviceProtectedData || externalData || obb || media || permissions - } -} - -fun BackupTypeSelection.toUiModel(): BackupTypes { - return BackupTypes( - apk = apk, - data = data, - deviceProtectedData = deviceProtectedData, - externalData = externalData, - obb = obb, - media = media, - permissions = permissions - ) -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/shared/OperationProgress.kt b/app/src/main/java/io/github/hddq/restoid/ui/shared/OperationProgress.kt deleted file mode 100644 index f0d3d8ac..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/shared/OperationProgress.kt +++ /dev/null @@ -1,295 +0,0 @@ -package io.github.hddq.restoid.ui.shared - -import android.text.format.Formatter -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Error -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import io.github.hddq.restoid.R -import java.util.concurrent.TimeUnit -import kotlinx.coroutines.delay -import kotlinx.serialization.Serializable - -/** - * Shared data class for representing the progress of a long-running operation - * like backup or restore. It now supports multi-stage operations. - */ -@Serializable -data class OperationProgress( - val stageTitle: String = "", // e.g., "Stage 1/2: Restoring Files" - val stagePercentage: Float = 0f, // progress of the current stage (0.0 to 1.0) - val overallPercentage: Float = 0f, // overall progress (0.0 to 1.0) - - // Details for the current stage - val totalFiles: Int = 0, - val filesProcessed: Int = 0, - val totalBytes: Long = 0, - val bytesProcessed: Long = 0, - val currentFile: String = "", - - // General Info - val elapsedTime: Long = 0, // in seconds - val error: String? = null, - val isFinished: Boolean = false, - val finalSummary: String = "", - val snapshotId: String? = null, - - // Detailed summary fields (mostly for backup) - val filesNew: Int = 0, - val filesChanged: Int = 0, - val dataAdded: Long = 0, - val totalDuration: Double = 0.0 -) - - -/** - * A generalized composable to display the progress and final summary of - * a backup or restore operation. - */ -@Composable -fun ProgressScreenContent( - progress: OperationProgress, - operationType: String, // e.g., "Backup", "Restore" - onDone: () -> Unit, - modifier: Modifier = Modifier -) { - val context = LocalContext.current - val operationComplete = stringResource(R.string.progress_operation_complete, operationType) - val operationFailed = stringResource(R.string.progress_operation_failed, operationType) - val stageTitle = progress.stageTitle.ifBlank { stringResource(R.string.progress_initializing) } - - Column( - modifier = modifier - .fillMaxSize() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - AnimatedContent( - targetState = progress.isFinished, - label = "ProgressOrFinish", - transitionSpec = { - fadeIn(animationSpec = tween(300, 150)) togetherWith - fadeOut(animationSpec = tween(150)) - } - ) { isFinished -> - if (isFinished) { - // Finished State - var startAnimation by remember { mutableStateOf(false) } - - LaunchedEffect(Unit) { - delay(200) // A small delay to let the screen transition in - startAnimation = true - } - - val icon = if (progress.error == null) Icons.Default.CheckCircle else Icons.Default.Error - val iconColor = if (progress.error == null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() - ) { - AnimatedVisibility( - visible = startAnimation, - enter = scaleIn(animationSpec = spring(dampingRatio = 0.5f, stiffness = 100f)) - ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(100.dp), - tint = iconColor - ) - } - Spacer(Modifier.height(24.dp)) - Text( - text = if (progress.error == null) operationComplete else operationFailed, - style = MaterialTheme.typography.headlineMedium, - textAlign = TextAlign.Center - ) - Spacer(Modifier.height(12.dp)) - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer - ) - ) { - Text( - text = progress.finalSummary, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(16.dp), - textAlign = TextAlign.Center - ) - } - Spacer(Modifier.height(32.dp)) - Button( - onClick = onDone, - modifier = Modifier - .fillMaxWidth() - .height(50.dp) - ) { - Text(stringResource(R.string.action_done)) - } - } - - } else { - // In-Progress State - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = stageTitle, style = MaterialTheme.typography.titleLarge) - Spacer(Modifier.height(24.dp)) - - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer - ) - ) { - Column(Modifier.padding(16.dp)) { - // Overall Progress - Text(stringResource(R.string.progress_overall), style = MaterialTheme.typography.labelMedium) - Spacer(Modifier.height(8.dp)) - LinearProgressIndicator( - progress = { progress.overallPercentage }, - modifier = Modifier - .fillMaxWidth() - .height(8.dp), - strokeCap = StrokeCap.Round - ) - Spacer(Modifier.height(20.dp)) - - // Stage Progress - Text(stringResource(R.string.progress_stage), style = MaterialTheme.typography.labelMedium) - Spacer(Modifier.height(8.dp)) - LinearProgressIndicator( - progress = { progress.stagePercentage }, - modifier = Modifier - .fillMaxWidth() - .height(8.dp), - strokeCap = StrokeCap.Round - ) - Spacer(Modifier.height(24.dp)) - - - // Stats Row - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceAround - ) { - ProgressStat(label = stringResource(R.string.progress_elapsed), value = formatElapsedTime(progress.elapsedTime)) - ProgressStat( - label = stringResource(R.string.progress_items), - value = "${progress.filesProcessed}/${progress.totalFiles}" - ) - // Only show size if it's relevant (not 0) - if (progress.totalBytes > 0) { - ProgressStat( - label = stringResource(R.string.progress_size), - value = "${Formatter.formatFileSize(context, progress.bytesProcessed)} / ${ - Formatter.formatFileSize( - context, - progress.totalBytes - ) - }" - ) - } - } - Spacer(Modifier.height(16.dp)) - - // Current File Text (only show for backup as restore is app-based) - if (operationType == stringResource(R.string.operation_backup) && progress.currentFile.isNotBlank()) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - stringResource(R.string.progress_processing), - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Bold - ) - AnimatedContent( - targetState = progress.currentFile, - label = "CurrentFileAnimation", - transitionSpec = { - slideInVertically { height -> height } togetherWith - slideOutVertically { height -> -height } - } - ) { targetFile -> - Text( - text = targetFile.ifEmpty { stringResource(R.string.progress_placeholder) }, - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - } - } - } - } - } - } - } -} - -@Composable -fun ProgressStat(label: String, value: String) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text(text = label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - Spacer(Modifier.height(2.dp)) - Text(text = value, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold) - } -} - -private fun formatElapsedTime(seconds: Long): String { - val hours = TimeUnit.SECONDS.toHours(seconds) - val minutes = TimeUnit.SECONDS.toMinutes(seconds) % 60 - val secs = seconds % 60 - return if (hours > 0) { - String.format("%02d:%02d:%02d", hours, minutes, secs) - } else { - String.format("%02d:%02d", minutes, secs) - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/shared/TaskConfigComponents.kt b/app/src/main/java/io/github/hddq/restoid/ui/shared/TaskConfigComponents.kt deleted file mode 100644 index cb94aae0..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/shared/TaskConfigComponents.kt +++ /dev/null @@ -1,794 +0,0 @@ -package io.github.hddq.restoid.ui.shared - -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material.icons.filled.Add -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.ChevronRight -import androidx.compose.material.icons.filled.SelectAll -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Slider -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import coil.compose.rememberAsyncImagePainter -import io.github.hddq.restoid.R -import io.github.hddq.restoid.model.AppInfo -import kotlin.math.roundToInt -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -@Composable -fun TaskRow( - title: String, - subtitle: String? = null, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, - onNavigate: (() -> Unit)? = null -) { - val rowClick = onNavigate ?: { onCheckedChange(!checked) } - - Row( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - .clickable(onClick = rowClick) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = title, - style = MaterialTheme.typography.bodyLarge - ) - if (subtitle != null) { - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - } - - if (onNavigate != null) { - Spacer(Modifier.width(12.dp)) - Icon( - imageVector = Icons.Default.ChevronRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Spacer(Modifier.width(12.dp)) - Box( - modifier = Modifier - .fillMaxHeight(0.5f) - .width(1.dp) - .background(MaterialTheme.colorScheme.outlineVariant) - ) - Spacer(Modifier.width(16.dp)) - } - - Switch( - checked = checked, - onCheckedChange = onCheckedChange, - thumbContent = if (checked) { - { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - modifier = Modifier.size(SwitchDefaults.IconSize) - ) - } - } else { - null - } - ) - } -} - -@Composable -fun PolicySlider( - label: String, - value: Int, - range: IntRange, - onValueChange: (Int) -> Unit -) { - val isDiscrete = (range.last - range.first) <= 30 - - Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(label, style = MaterialTheme.typography.bodyLarge) - Text( - text = value.toString(), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary - ) - } - Slider( - value = value.toFloat(), - onValueChange = { onValueChange(it.roundToInt()) }, - valueRange = range.first.toFloat()..range.last.toFloat(), - steps = if (isDiscrete) (range.last - range.first - 1).coerceAtLeast(0) else 0 - ) - } -} - -@Composable -fun BackupTypeToggle(label: String, description: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onCheckedChange(!checked) } - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = label, - style = MaterialTheme.typography.bodyLarge - ) - Text( - text = description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch( - checked = checked, - onCheckedChange = onCheckedChange, - thumbContent = if (checked) { - { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - modifier = Modifier.size(SwitchDefaults.IconSize) - ) - } - } else { - null - } - ) - } -} - -@Composable -fun SelectAllListItem( - isChecked: Boolean, - subtitle: String? = null, - onClick: (() -> Unit)? = null, - onToggle: () -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - .clickable(onClick = onClick ?: onToggle) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.SelectAll, - contentDescription = null, - modifier = Modifier - .size(48.dp) - .padding(8.dp), - tint = MaterialTheme.colorScheme.primary - ) - Spacer(Modifier.width(16.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.toggle_all), - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary - ) - if (subtitle != null) { - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - } - if (onClick != null) { - Spacer(Modifier.width(12.dp)) - Icon( - imageVector = Icons.Default.ChevronRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(Modifier.width(12.dp)) - Box( - modifier = Modifier - .fillMaxHeight(0.5f) - .width(1.dp) - .background(MaterialTheme.colorScheme.outlineVariant) - ) - } - Spacer(Modifier.width(16.dp)) - Switch( - checked = isChecked, - onCheckedChange = { onToggle() }, - thumbContent = if (isChecked) { - { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - modifier = Modifier.size(SwitchDefaults.IconSize) - ) - } - } else { - null - } - ) - } -} - -@Composable -fun AppListItem( - app: AppInfo, - subtitle: String? = null, - onClick: () -> Unit, - onToggle: () -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Image( - painter = rememberAsyncImagePainter(model = app.icon), - contentDescription = app.name, - modifier = Modifier.size(48.dp) - ) - Spacer(Modifier.width(16.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = app.name, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface - ) - if (subtitle != null) { - Text( - text = subtitle, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - Spacer(Modifier.width(12.dp)) - Icon( - imageVector = Icons.Default.ChevronRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(Modifier.width(12.dp)) - Box( - modifier = Modifier - .fillMaxHeight(0.5f) - .width(1.dp) - .background(MaterialTheme.colorScheme.outlineVariant) - ) - Spacer(Modifier.width(16.dp)) - Switch( - checked = app.isSelected, - onCheckedChange = { onToggle() }, - thumbContent = if (app.isSelected) { - { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - modifier = Modifier.size(SwitchDefaults.IconSize) - ) - } - } else { - null - } - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun BackupTypesBottomSheet( - title: String, - backupTypes: BackupTypes, - onBackupTypesChange: (BackupTypes) -> Unit, - onDismissRequest: () -> Unit -) { - ModalBottomSheet(onDismissRequest = onDismissRequest) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(start = 16.dp, end = 16.dp, bottom = 32.dp) - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 12.dp) - ) - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) - ) { - Column { - BackupTypeToggle( - label = stringResource(R.string.backup_type_apk), - description = stringResource(R.string.backup_type_apk_desc), - checked = backupTypes.apk - ) { - onBackupTypesChange(backupTypes.copy(apk = it)) - } - HorizontalDivider(color = MaterialTheme.colorScheme.background) - BackupTypeToggle( - label = stringResource(R.string.backup_type_data), - description = stringResource(R.string.backup_type_data_desc), - checked = backupTypes.data - ) { - onBackupTypesChange(backupTypes.copy(data = it)) - } - HorizontalDivider(color = MaterialTheme.colorScheme.background) - BackupTypeToggle( - label = stringResource(R.string.backup_type_device_protected_data), - description = stringResource(R.string.backup_type_device_protected_data_desc), - checked = backupTypes.deviceProtectedData - ) { - onBackupTypesChange(backupTypes.copy(deviceProtectedData = it)) - } - HorizontalDivider(color = MaterialTheme.colorScheme.background) - BackupTypeToggle( - label = stringResource(R.string.backup_type_external_data), - description = stringResource(R.string.backup_type_external_data_desc), - checked = backupTypes.externalData - ) { - onBackupTypesChange(backupTypes.copy(externalData = it)) - } - HorizontalDivider(color = MaterialTheme.colorScheme.background) - BackupTypeToggle( - label = stringResource(R.string.backup_type_obb_data), - description = stringResource(R.string.backup_type_obb_data_desc), - checked = backupTypes.obb - ) { - onBackupTypesChange(backupTypes.copy(obb = it)) - } - HorizontalDivider(color = MaterialTheme.colorScheme.background) - BackupTypeToggle( - label = stringResource(R.string.backup_type_media_data), - description = stringResource(R.string.backup_type_media_data_desc), - checked = backupTypes.media - ) { - onBackupTypesChange(backupTypes.copy(media = it)) - } - HorizontalDivider(color = MaterialTheme.colorScheme.background) - BackupTypeToggle( - label = stringResource(R.string.backup_type_permissions), - description = stringResource(R.string.backup_type_permissions_desc), - checked = backupTypes.permissions - ) { - onBackupTypesChange(backupTypes.copy(permissions = it)) - } - } - } - } - } -} - -@Composable -fun BackupConfigScreen( - isLoadingApps: Boolean, - apps: List, - appBackupTypes: Map, - backupTypes: BackupTypes, - onRefreshApps: () -> Unit, - onToggleAllApps: () -> Unit, - onToggleAppSelection: (String) -> Unit, - onSetSelectedAppsBackupTypes: (BackupTypes) -> Unit, - onSetAppBackupTypes: (String, BackupTypes) -> Unit, - modifier: Modifier = Modifier -) { - val lifecycleOwner = LocalLifecycleOwner.current - var selectedAppPackageName by remember { mutableStateOf(null) } - var showBulkBackupTypesSheet by remember { mutableStateOf(false) } - - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - onRefreshApps() - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - } - } - - LazyColumn( - modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp, top = 8.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - if (isLoadingApps) { - item { - Box( - modifier = Modifier - .fillMaxWidth() - .height(200.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - } - } else { - item { - Column { - Text( - text = stringResource(R.string.apps_title), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 8.dp) - ) - } - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) - ) { - Column { - val isAllSelected = apps.isNotEmpty() && apps.all { it.isSelected } - SelectAllListItem( - isChecked = isAllSelected, - subtitle = buildSelectedBackupTypesSummary(apps, appBackupTypes, backupTypes, LocalContext.current), - onClick = { showBulkBackupTypesSheet = true }, - onToggle = onToggleAllApps - ) - HorizontalDivider(color = MaterialTheme.colorScheme.background) - apps.forEachIndexed { index, app -> - val currentAppBackupTypes = appBackupTypes[app.packageName] ?: backupTypes - AppListItem( - app = app, - subtitle = buildBackupTypesSummary(currentAppBackupTypes, LocalContext.current), - onClick = { selectedAppPackageName = app.packageName }, - onToggle = { onToggleAppSelection(app.packageName) } - ) - if (index < apps.size - 1) { - HorizontalDivider(color = MaterialTheme.colorScheme.background) - } - } - } - } - } - } - } - - if (showBulkBackupTypesSheet) { - BackupTypesBottomSheet( - title = stringResource(R.string.backup_types_for_selected_apps), - backupTypes = selectedBulkBackupTypes(apps, appBackupTypes, backupTypes), - onBackupTypesChange = onSetSelectedAppsBackupTypes, - onDismissRequest = { showBulkBackupTypesSheet = false } - ) - } - - selectedAppPackageName?.let { packageName -> - val app = apps.firstOrNull { it.packageName == packageName } - if (app != null) { - BackupTypesBottomSheet( - title = app.name, - backupTypes = appBackupTypes[packageName] ?: backupTypes, - onBackupTypesChange = { onSetAppBackupTypes(packageName, it) }, - onDismissRequest = { selectedAppPackageName = null } - ) - } - } -} - -@Composable -fun ForgetConfigScreen( - maintenance: io.github.hddq.restoid.model.MaintenanceConfig, - onKeepLastChange: (Int) -> Unit, - onKeepDailyChange: (Int) -> Unit, - onKeepWeeklyChange: (Int) -> Unit, - onKeepMonthlyChange: (Int) -> Unit, - modifier: Modifier = Modifier -) { - LazyColumn( - modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp, top = 8.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - item { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) - ) { - Column { - PolicySlider(stringResource(R.string.maintenance_keep_last), maintenance.keepLast, 0..20, onKeepLastChange) - HorizontalDivider(color = MaterialTheme.colorScheme.background) - PolicySlider(stringResource(R.string.maintenance_keep_daily), maintenance.keepDaily, 0..30, onKeepDailyChange) - HorizontalDivider(color = MaterialTheme.colorScheme.background) - PolicySlider(stringResource(R.string.maintenance_keep_weekly), maintenance.keepWeekly, 0..12, onKeepWeeklyChange) - HorizontalDivider(color = MaterialTheme.colorScheme.background) - PolicySlider(stringResource(R.string.maintenance_keep_monthly), maintenance.keepMonthly, 0..24, onKeepMonthlyChange) - } - } - } - } -} - -@Composable -fun CheckConfigScreen( - maintenance: io.github.hddq.restoid.model.MaintenanceConfig, - onReadDataChange: (Boolean) -> Unit, - modifier: Modifier = Modifier -) { - LazyColumn( - modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp, top = 8.dp) - ) { - item { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) - ) { - TaskRow( - title = stringResource(R.string.maintenance_read_all_data), - subtitle = stringResource(R.string.run_tasks_check_read_all_data_description), - checked = maintenance.readData, - onCheckedChange = onReadDataChange - ) - } - } - } -} - -fun buildBackupSubtitle( - apps: List, - appBackupTypes: Map, - defaultBackupTypes: BackupTypes, - context: android.content.Context -): String { - val selectedCount = apps.count { it.isSelected } - return context.getString( - R.string.run_tasks_backup_subtitle, - selectedCount, - buildSelectedBackupTypesSummary(apps, appBackupTypes, defaultBackupTypes, context) - ) -} - -fun buildBackupTypesSummary(backupTypes: BackupTypes, context: android.content.Context): String { - val types = buildList { - if (backupTypes.apk) add(context.getString(R.string.backup_type_apk)) - if (backupTypes.data) add(context.getString(R.string.backup_type_data)) - if (backupTypes.deviceProtectedData) add(context.getString(R.string.backup_type_device_protected_data)) - if (backupTypes.externalData) add(context.getString(R.string.backup_type_external_data)) - if (backupTypes.obb) add(context.getString(R.string.backup_type_obb_data)) - if (backupTypes.media) add(context.getString(R.string.backup_type_media_data)) - if (backupTypes.permissions) add(context.getString(R.string.backup_type_permissions)) - }.joinToString(", ") - - return types.ifBlank { context.getString(R.string.backup_types_none) } -} - -fun buildSelectedBackupTypesSummary( - apps: List, - appBackupTypes: Map, - defaultBackupTypes: BackupTypes, - context: android.content.Context -): String { - val selectedTypes = apps - .filter { it.isSelected } - .map { appBackupTypes[it.packageName] ?: defaultBackupTypes } - .distinct() - - return when (selectedTypes.size) { - 0 -> buildBackupTypesSummary(defaultBackupTypes, context) - 1 -> buildBackupTypesSummary(selectedTypes.first(), context) - else -> context.getString(R.string.backup_types_mixed) - } -} - -fun selectedBulkBackupTypes( - apps: List, - appBackupTypes: Map, - defaultBackupTypes: BackupTypes -): BackupTypes { - return apps - .firstOrNull { it.isSelected } - ?.let { appBackupTypes[it.packageName] ?: defaultBackupTypes } - ?: defaultBackupTypes -} - -fun buildForgetSubtitle(config: io.github.hddq.restoid.model.MaintenanceConfig, context: android.content.Context): String { - return context.getString( - R.string.run_tasks_forget_subtitle, - config.keepLast, - config.keepDaily, - config.keepWeekly, - config.keepMonthly - ) -} - -fun buildCheckSubtitle(config: io.github.hddq.restoid.model.MaintenanceConfig, context: android.content.Context): String { - return if (config.readData) { - context.getString(R.string.maintenance_read_all_data) - } else { - context.getString(R.string.run_tasks_check_metadata_only) - } -} - -fun buildCustomDirectoriesSubtitle(directories: List, context: android.content.Context): String { - val selectedCount = directories.count { it.isSelected } - return if (selectedCount == 0) { - context.getString(io.github.hddq.restoid.R.string.run_tasks_custom_directories_subtitle_none) - } else { - context.getString(io.github.hddq.restoid.R.string.run_tasks_custom_directories_subtitle_count, selectedCount) - } -} - -@Composable -fun CustomDirectoriesConfigScreen( - customDirectories: List, - onAddDirectory: (String) -> Unit, - onToggleDirectory: (String) -> Unit, - modifier: Modifier = Modifier -) { - val context = androidx.compose.ui.platform.LocalContext.current - val launcher = androidx.activity.compose.rememberLauncherForActivityResult( - androidx.activity.result.contract.ActivityResultContracts.OpenDocumentTree() - ) { uri -> - if (uri != null) { - context.contentResolver.takePersistableUriPermission( - uri, - android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION - ) - onAddDirectory(uri.toString()) - } - } - - androidx.compose.foundation.lazy.LazyColumn( - modifier = modifier.fillMaxSize(), - contentPadding = androidx.compose.foundation.layout.PaddingValues(start = 16.dp, end = 16.dp, bottom = 80.dp, top = 8.dp), - verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(16.dp) - ) { - item { - androidx.compose.material3.Text( - text = androidx.compose.ui.res.stringResource(io.github.hddq.restoid.R.string.custom_directories_description), - style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, - color = androidx.compose.material3.MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 8.dp) - ) - } - - if (customDirectories.isNotEmpty()) { - item { - androidx.compose.material3.Card( - modifier = Modifier.fillMaxWidth(), - colors = androidx.compose.material3.CardDefaults.cardColors(containerColor = androidx.compose.material3.MaterialTheme.colorScheme.surfaceContainer) - ) { - androidx.compose.foundation.layout.Column { - customDirectories.forEachIndexed { index, customDir -> - val uri = android.net.Uri.parse(customDir.uri) - val realPath = io.github.hddq.restoid.util.StorageUtils.getPathFromTreeUri(uri) - val displayPath = realPath ?: (uri.lastPathSegment ?: customDir.uri) - androidx.compose.foundation.layout.Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onToggleDirectory(customDir.uri) } - .padding(vertical = 12.dp, horizontal = 16.dp), - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically - ) { - androidx.compose.foundation.layout.Column(modifier = Modifier.weight(1f)) { - val title = displayPath.substringAfterLast("/") - androidx.compose.material3.Text( - text = title.ifEmpty { displayPath }, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = androidx.compose.material3.MaterialTheme.typography.bodyLarge - ) - androidx.compose.material3.Text( - text = displayPath, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = androidx.compose.material3.MaterialTheme.typography.bodySmall, - color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant - ) - } - androidx.compose.foundation.layout.Spacer(modifier = Modifier.width(16.dp)) - androidx.compose.material3.Switch( - checked = customDir.isSelected, - onCheckedChange = { onToggleDirectory(customDir.uri) }, - thumbContent = if (customDir.isSelected) { - { - androidx.compose.material3.Icon( - imageVector = androidx.compose.material.icons.Icons.Filled.Check, - contentDescription = null, - modifier = Modifier.size(androidx.compose.material3.SwitchDefaults.IconSize) - ) - } - } else { - null - } - ) - } - if (index < customDirectories.size - 1) { - androidx.compose.material3.HorizontalDivider(color = androidx.compose.material3.MaterialTheme.colorScheme.background) - } - } - } - } - } - } - - item { - FilledTonalButton( - onClick = { launcher.launch(null) }, - shape = androidx.compose.material3.MaterialTheme.shapes.extraLarge, - modifier = Modifier.padding(top = 4.dp) - ) { - androidx.compose.material3.Icon( - imageVector = androidx.compose.material.icons.Icons.Default.Add, - contentDescription = null - ) - androidx.compose.foundation.layout.Spacer(modifier = Modifier.width(8.dp)) - androidx.compose.material3.Text(androidx.compose.ui.res.stringResource(io.github.hddq.restoid.R.string.action_add_directory)) - } - } - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/snapshot/SnapshotDetailViewModel.kt b/app/src/main/java/io/github/hddq/restoid/ui/snapshot/SnapshotDetailViewModel.kt deleted file mode 100644 index b52fcded..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/snapshot/SnapshotDetailViewModel.kt +++ /dev/null @@ -1,297 +0,0 @@ -package io.github.hddq.restoid.ui.snapshot - -import android.app.Application -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import io.github.hddq.restoid.R -import io.github.hddq.restoid.data.AppInfoRepository -import io.github.hddq.restoid.data.MetadataRepository -import io.github.hddq.restoid.data.RepositoriesRepository -import io.github.hddq.restoid.data.ResticRepository -import io.github.hddq.restoid.data.SnapshotInfo -import io.github.hddq.restoid.model.AppInfo -import io.github.hddq.restoid.model.BackupDetail -import io.github.hddq.restoid.model.RestoidMetadata -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -sealed class ForgetResult { - object Idle : ForgetResult() - object Success : ForgetResult() - data class Error(val message: String) : ForgetResult() -} - -// Data class to hold the state of restore types -data class RestoreTypes( - val apk: Boolean = true, - val data: Boolean = true, - val deviceProtectedData: Boolean = true, - val externalData: Boolean = false, - val obb: Boolean = false, - val media: Boolean = false -) - -class SnapshotDetailsViewModel( - private val application: Application, - private val repositoriesRepository: RepositoriesRepository, - private val resticRepository: ResticRepository, - private val appInfoRepository: AppInfoRepository, - private val metadataRepository: MetadataRepository -) : ViewModel() { - - private val _snapshot = MutableStateFlow(null) - val snapshot = _snapshot.asStateFlow() - - private val _metadata = MutableStateFlow(null) - val metadata = _metadata.asStateFlow() - - private val _backupDetails = MutableStateFlow>(emptyList()) - val backupDetails = _backupDetails.asStateFlow() - - private val _isLoading = MutableStateFlow(false) - val isLoading = _isLoading.asStateFlow() - - private val _error = MutableStateFlow(null) - val error = _error.asStateFlow() - - private val _showConfirmForgetDialog = MutableStateFlow(false) - val showConfirmForgetDialog = _showConfirmForgetDialog.asStateFlow() - - private val _isForgetting = MutableStateFlow(false) - val isForgetting = _isForgetting.asStateFlow() - - private val _forgetResult = MutableStateFlow(ForgetResult.Idle) - val forgetResult = _forgetResult.asStateFlow() - - private val _restoreTypes = MutableStateFlow(RestoreTypes()) - val restoreTypes = _restoreTypes.asStateFlow() - - - fun loadSnapshotDetails(snapshotId: String) { - viewModelScope.launch { - _isLoading.value = true - _error.value = null - _backupDetails.value = emptyList() // Clear previous details - try { - val selectedRepoKey = repositoriesRepository.selectedRepository.first() - val repo = selectedRepoKey?.let { repositoriesRepository.getRepositoryByKey(it) } - val repoPath = repo?.path - val password = selectedRepoKey?.let { repositoriesRepository.getRepositoryPassword(it) } - val repoId = repo?.id - - - if (repoPath != null && password != null && repoId != null) { - var loadedMetadata = metadataRepository.getMetadataForSnapshot(repoId, snapshotId) - - val executionEnvironment = repositoriesRepository.getExecutionEnvironmentVariables(selectedRepoKey) - val resticOptions = repositoriesRepository.getExecutionResticOptions(selectedRepoKey) - - if (loadedMetadata == null) { - // Attempt to recover metadata from the repository - val lsResult = resticRepository.ls(repoPath, password, snapshotId, executionEnvironment, resticOptions) - lsResult.fold( - onSuccess = { paths -> - val metadataPath = paths.find { it.endsWith("/restoid.json") } - if (metadataPath != null) { - val dumpResult = resticRepository.dump(repoPath, password, snapshotId, metadataPath, executionEnvironment, resticOptions) - dumpResult.fold( - onSuccess = { content -> - try { - val json = kotlinx.serialization.json.Json { ignoreUnknownKeys = true } - val recoveredMetadata = json.decodeFromString(content) - metadataRepository.saveMetadataForSnapshot(repoId, snapshotId, recoveredMetadata) - loadedMetadata = recoveredMetadata - } catch (e: Exception) { - android.util.Log.e("SnapshotDetailsVM", "Failed to parse recovered metadata", e) - } - }, - onFailure = { android.util.Log.e("SnapshotDetailsVM", "Failed to dump metadata: ${it.message}") } - ) - } - }, - onFailure = { android.util.Log.e("SnapshotDetailsVM", "Failed to ls snapshot: ${it.message}") } - ) - } - - _metadata.value = loadedMetadata - - val result = resticRepository.getSnapshots(repoPath, password, executionEnvironment, resticOptions) - result.fold( - onSuccess = { snapshots -> - val foundSnapshot = snapshots.find { it.id.startsWith(snapshotId) } - _snapshot.value = foundSnapshot - foundSnapshot?.let { processSnapshot(it, loadedMetadata) } - }, - onFailure = { _error.value = it.message } - ) - } else { - _error.value = application.getString(R.string.error_repository_password_or_id_not_found) - } - } catch (e: Exception) { - _error.value = e.message - } finally { - _isLoading.value = false - } - } - } - - private suspend fun processSnapshot(snapshot: SnapshotInfo, metadata: RestoidMetadata?) { - val appMetadataMap = metadata?.apps ?: emptyMap() - val packageNames = appMetadataMap.keys.toList() - - if (packageNames.isEmpty()) { - _backupDetails.value = emptyList() - return - } - - val appInfos = appInfoRepository.getAppInfoForPackages(packageNames) - val appInfoMap = appInfos.associateBy { it.packageName } - - val details = appMetadataMap.map { (packageName, appMeta) -> - val appInfo = appInfoMap[packageName] - val items = findBackedUpItems(snapshot, packageName, appMeta.grantedRuntimePermissions.isNotEmpty()) - - val isInstalled = appInfo != null - val isDowngrade = if (isInstalled) { - appMeta.versionCode < appInfo.versionCode - } else { - false - } - - val finalAppInfo = appInfo ?: AppInfo( - name = appMeta.appName ?: packageName, - packageName = packageName, - versionName = appMeta.versionName, - versionCode = appMeta.versionCode, - icon = application.packageManager.defaultActivityIcon, - apkPaths = emptyList(), - isSelected = true - ) - - BackupDetail(finalAppInfo, items, appMeta.versionName, appMeta.versionCode, appMeta.size, isDowngrade, isInstalled) - } - - // SORTING: Size (High -> Low), then Name (A -> Z) - _backupDetails.value = details.sortedWith( - compareByDescending { it.backupSize ?: 0L } - .thenBy { it.appInfo.name.lowercase() } - ) - } - - private fun findBackedUpItems(snapshot: SnapshotInfo, pkg: String, hasPermissionBackup: Boolean): List { - val items = mutableListOf() - snapshot.paths.forEach { path -> - when { - (path.startsWith("/data/app/") && path.contains("/${pkg}-")) -> if (!items.contains(application.getString(R.string.backup_type_apk))) items.add(application.getString(R.string.backup_type_apk)) - path == "/data/data/$pkg" || path.matches(Regex("^/data/user/\\d+/${Regex.escape(pkg)}$")) -> if (!items.contains(application.getString(R.string.backup_type_data))) items.add(application.getString(R.string.backup_type_data)) - path.matches(Regex("^/data/user_de/\\d+/${Regex.escape(pkg)}$")) -> if (!items.contains(application.getString(R.string.backup_type_device_protected_data))) items.add(application.getString(R.string.backup_type_device_protected_data)) - path.matches(Regex("^/storage/emulated/\\d+/Android/data/${Regex.escape(pkg)}$")) -> if (!items.contains(application.getString(R.string.backup_type_external_data))) items.add(application.getString(R.string.backup_type_external_data)) - path.matches(Regex("^/storage/emulated/\\d+/Android/obb/${Regex.escape(pkg)}$")) -> if (!items.contains(application.getString(R.string.backup_item_obb))) items.add(application.getString(R.string.backup_item_obb)) - path.matches(Regex("^/storage/emulated/\\d+/Android/media/${Regex.escape(pkg)}$")) -> if (!items.contains(application.getString(R.string.backup_item_media))) items.add(application.getString(R.string.backup_item_media)) - } - } - if (hasPermissionBackup && !items.contains(application.getString(R.string.backup_item_permissions))) { - items.add(application.getString(R.string.backup_item_permissions)) - } - return if (items.isNotEmpty()) items else listOf(application.getString(R.string.backup_item_unknown)) - } - - - fun toggleRestoreAppSelection(packageName: String) { - _backupDetails.update { currentDetails -> - currentDetails.map { detail -> - if (detail.appInfo.packageName == packageName) { - detail.copy(appInfo = detail.appInfo.copy(isSelected = !detail.appInfo.isSelected)) - } else { - detail - } - } - } - } - - fun toggleAllRestoreSelection() { - _backupDetails.update { currentDetails -> - // If any app is not selected, select all. Otherwise, deselect all. - val shouldSelectAll = currentDetails.any { !it.appInfo.isSelected } - currentDetails.map { detail -> - detail.copy(appInfo = detail.appInfo.copy(isSelected = shouldSelectAll)) - } - } - } - - fun onForgetSnapshot() { - _showConfirmForgetDialog.value = true - } - - fun confirmForgetSnapshot() { - _showConfirmForgetDialog.value = false - val snapshotToForget = _snapshot.value ?: return - - viewModelScope.launch { - _isForgetting.value = true - _error.value = null - try { - val selectedRepoKey = repositoriesRepository.selectedRepository.first() - val repo = selectedRepoKey?.let { repositoriesRepository.getRepositoryByKey(it) } - val repoPath = repo?.path - val password = selectedRepoKey?.let { repositoriesRepository.getRepositoryPassword(it) } - val repoId = repo?.id - - if (repoPath != null && password != null && repoId != null) { - val executionEnvironment = repositoriesRepository.getExecutionEnvironmentVariables(selectedRepoKey) - val resticOptions = repositoriesRepository.getExecutionResticOptions(selectedRepoKey) - - val result = resticRepository.forgetSnapshot( - repoPath, - password, - snapshotToForget.id, - executionEnvironment, - resticOptions - ) - result.fold( - onSuccess = { - // Also delete the metadata file - val deleted = metadataRepository.deleteMetadataForSnapshot(repoId, snapshotToForget.id) - if (deleted) { - // After deleting, back up the changes to the metadata folder - resticRepository.backupMetadata( - repoId, - repoPath, - password, - executionEnvironment, - resticOptions - ) - } - _forgetResult.value = ForgetResult.Success - }, - onFailure = { - _error.value = it.message - _forgetResult.value = ForgetResult.Error(it.message ?: application.getString(R.string.error_unknown)) - } - ) - } else { - _error.value = application.getString(R.string.error_repository_password_or_id_not_found) - } - } catch (e: Exception) { - _error.value = e.message - } finally { - _isForgetting.value = false - } - } - } - - fun cancelForgetSnapshot() { - _showConfirmForgetDialog.value = false - } - - // --- Restore Type Toggles --- - fun setRestoreApk(value: Boolean) = _restoreTypes.update { it.copy(apk = value) } - fun setRestoreData(value: Boolean) = _restoreTypes.update { it.copy(data = value) } - fun setRestoreDeviceProtectedData(value: Boolean) = _restoreTypes.update { it.copy(deviceProtectedData = value) } - fun setRestoreExternalData(value: Boolean) = _restoreTypes.update { it.copy(externalData = value) } - fun setRestoreObb(value: Boolean) = _restoreTypes.update { it.copy(obb = value) } - fun setRestoreMedia(value: Boolean) = _restoreTypes.update { it.copy(media = value) } -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/snapshot/SnapshotDetailViewModelFactory.kt b/app/src/main/java/io/github/hddq/restoid/ui/snapshot/SnapshotDetailViewModelFactory.kt deleted file mode 100644 index 8c210862..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/snapshot/SnapshotDetailViewModelFactory.kt +++ /dev/null @@ -1,25 +0,0 @@ -package io.github.hddq.restoid.ui.snapshot - -import android.app.Application -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import io.github.hddq.restoid.data.AppInfoRepository -import io.github.hddq.restoid.data.MetadataRepository -import io.github.hddq.restoid.data.RepositoriesRepository -import io.github.hddq.restoid.data.ResticRepository - -class SnapshotDetailsViewModelFactory( - private val application: Application, - private val repositoriesRepository: RepositoriesRepository, - private val resticRepository: ResticRepository, - private val appInfoRepository: AppInfoRepository, - private val metadataRepository: MetadataRepository -) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(SnapshotDetailsViewModel::class.java)) { - @Suppress("UNCHECKED_CAST") - return SnapshotDetailsViewModel(application, repositoriesRepository, resticRepository, appInfoRepository, metadataRepository) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/ui/theme/Color.kt b/app/src/main/java/io/github/hddq/restoid/ui/theme/Color.kt deleted file mode 100644 index bde63182..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/theme/Color.kt +++ /dev/null @@ -1 +0,0 @@ -package io.github.hddq.restoid.ui.theme diff --git a/app/src/main/java/io/github/hddq/restoid/ui/theme/Theme.kt b/app/src/main/java/io/github/hddq/restoid/ui/theme/Theme.kt deleted file mode 100644 index 7a006376..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/theme/Theme.kt +++ /dev/null @@ -1,27 +0,0 @@ -package io.github.hddq.restoid.ui.theme - -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext - -@Composable -fun RestoidTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - content: @Composable () -> Unit -) { - val context = LocalContext.current - val colorScheme = if (darkTheme) { - dynamicDarkColorScheme(context) - } else { - dynamicLightColorScheme(context) - } - - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content - ) -} \ No newline at end of file diff --git a/app/src/main/java/io/github/hddq/restoid/ui/theme/Type.kt b/app/src/main/java/io/github/hddq/restoid/ui/theme/Type.kt deleted file mode 100644 index b268332b..00000000 --- a/app/src/main/java/io/github/hddq/restoid/ui/theme/Type.kt +++ /dev/null @@ -1,115 +0,0 @@ -package io.github.hddq.restoid.ui.theme - -import androidx.compose.material3.Typography -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.sp - -val Typography = Typography( - displayLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 57.sp, - lineHeight = 64.sp, - letterSpacing = (-0.25).sp - ), - displayMedium = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 45.sp, - lineHeight = 52.sp, - letterSpacing = 0.sp - ), - displaySmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 36.sp, - lineHeight = 44.sp, - letterSpacing = 0.sp - ), - headlineLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 32.sp, - lineHeight = 40.sp, - letterSpacing = 0.sp - ), - headlineMedium = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 28.sp, - lineHeight = 36.sp, - letterSpacing = 0.sp - ), - headlineSmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 24.sp, - lineHeight = 32.sp, - letterSpacing = 0.sp - ), - titleLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 22.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp - ), - titleMedium = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.15.sp - ), - titleSmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 0.1.sp - ), - bodyLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp - ), - bodyMedium = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 0.25.sp - ), - bodySmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 12.sp, - lineHeight = 16.sp, - letterSpacing = 0.4.sp - ), - labelLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 0.1.sp - ), - labelMedium = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 12.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp - ), - labelSmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 11.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp - ) -) \ No newline at end of file diff --git a/app/src/main/java/io/github/hddq/restoid/util/CryptoHelper.kt b/app/src/main/java/io/github/hddq/restoid/util/CryptoHelper.kt deleted file mode 100644 index 952debaf..00000000 --- a/app/src/main/java/io/github/hddq/restoid/util/CryptoHelper.kt +++ /dev/null @@ -1,73 +0,0 @@ -package io.github.hddq.restoid.util - -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyProperties -import android.util.Base64 -import java.security.KeyStore -import javax.crypto.Cipher -import javax.crypto.KeyGenerator -import javax.crypto.SecretKey -import javax.crypto.spec.GCMParameterSpec - -object CryptoHelper { - private const val KEY_ALIAS = "restoid_passwords_key" - private const val ANDROID_KEYSTORE = "AndroidKeyStore" - private const val TRANSFORMATION = "AES/GCM/NoPadding" - private const val IV_SEPARATOR = "]" - - private val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } - - private fun getSecretKey(): SecretKey { - return (keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.secretKey ?: generateSecretKey() - } - - private fun generateSecretKey(): SecretKey { - val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) - val keyGenParameterSpec = KeyGenParameterSpec.Builder( - KEY_ALIAS, - KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT - ) - .setBlockModes(KeyProperties.BLOCK_MODE_GCM) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .build() - keyGenerator.init(keyGenParameterSpec) - return keyGenerator.generateKey() - } - - /** - * Encrypts the [plainText] using AES-GCM and returns a Base64 encoded string containing - * the IV and the ciphertext separated by [IV_SEPARATOR]. - */ - fun encrypt(plainText: String): String { - val cipher = Cipher.getInstance(TRANSFORMATION) - cipher.init(Cipher.ENCRYPT_MODE, getSecretKey()) - val iv = cipher.iv - val encrypted = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8)) - - val ivString = Base64.encodeToString(iv, Base64.NO_WRAP) - val encryptedString = Base64.encodeToString(encrypted, Base64.NO_WRAP) - return "$ivString$IV_SEPARATOR$encryptedString" - } - - /** - * Decrypts the [cipherText] (expected to be in the format "IV]Ciphertext"). - * Returns null if decryption fails. - */ - fun decrypt(cipherText: String): String? { - return try { - val parts = cipherText.split(IV_SEPARATOR) - if (parts.size != 2) return null - - val iv = Base64.decode(parts[0], Base64.NO_WRAP) - val encrypted = Base64.decode(parts[1], Base64.NO_WRAP) - - val cipher = Cipher.getInstance(TRANSFORMATION) - val spec = GCMParameterSpec(128, iv) - cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec) - - String(cipher.doFinal(encrypted), Charsets.UTF_8) - } catch (e: Exception) { - null - } - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/util/MaintenanceOutputParser.kt b/app/src/main/java/io/github/hddq/restoid/util/MaintenanceOutputParser.kt deleted file mode 100644 index 042c20d5..00000000 --- a/app/src/main/java/io/github/hddq/restoid/util/MaintenanceOutputParser.kt +++ /dev/null @@ -1,105 +0,0 @@ -package io.github.hddq.restoid.util - -import android.content.Context -import io.github.hddq.restoid.R - -/** - * A utility object to parse the human-readable output of restic maintenance commands - * and extract a concise summary. - */ -object MaintenanceOutputParser { - - /** - * Parses the output of a given maintenance task. - * @param taskName The name of the task (e.g., "Prune", "Forget"). - * @param output The full string output from the restic command. - * @return A concise summary string. - */ - fun parse(taskName: String, output: String, context: Context): String { - return when (taskName.lowercase()) { - context.getString(R.string.maintenance_task_prune).lowercase() -> parsePrune(output, context) - context.getString(R.string.maintenance_task_forget).lowercase() -> parseForget(output, context) - context.getString(R.string.maintenance_task_check).lowercase() -> parseCheck(output, context) - context.getString(R.string.maintenance_task_unlock).lowercase() -> parseUnlock(output, context) - else -> output // Fallback for unknown tasks, return original output - } - } - - /** - * Extracts a summary from the 'prune' command output. - * It typically just returns the last few lines which contain the result. - */ - private fun parsePrune(output: String, context: Context): String { - val summary = output.lines().filter { it.isNotBlank() }.takeLast(3).joinToString("\n") - return summary.ifBlank { context.getString(R.string.maintenance_summary_prune_completed) } - } - - /** - * Extracts a summary from the 'forget' command output. - * It looks for a summary line or counts the number of removed snapshots. - */ - private fun parseForget(output: String, context: Context): String { - val lines = output.lines().map { it.trim() } - - // Best case: Look for the "remove X snapshots:" summary line from the logs. - val removeSummaryRegex = """^remove\s+(\d+)\s+snapshots?:?$""".toRegex(RegexOption.IGNORE_CASE) - for (line in lines) { - val match = removeSummaryRegex.find(line) - if (match != null) { - val count = match.groupValues[1].toIntOrNull() ?: 0 - return context.resources.getQuantityString( - R.plurals.maintenance_summary_removed_snapshots, - count, - count - ) - } - } - - // Fallback 1: Look for an explicit summary line like "2 snapshots have been removed." - val summaryLine = lines.lastOrNull { - (it.contains("snapshots have been removed", ignoreCase = true) || - it.startsWith("removed", ignoreCase = true)) && - it.contains("snapshots", ignoreCase = true) - } - if (summaryLine != null && summaryLine.isNotBlank()) { - return summaryLine - } - - // Fallback 2: If no summary line, count the individual 'remove snapshot' lines. - val removedCount = lines.count { it.startsWith("remove snapshot", ignoreCase = true) } - if (removedCount > 0) { - return context.resources.getQuantityString( - R.plurals.maintenance_summary_removed_snapshots, - removedCount, - removedCount - ) - } - - // Fallback 3: Look for an explicit "no snapshots were removed" message. - val noRemovalLine = lines.firstOrNull { it.contains("no snapshots were removed", ignoreCase = true) } - if (noRemovalLine != null) { - return context.getString(R.string.maintenance_summary_no_snapshots_removed) - } - - // Final Fallback: If none of the above match, it's highly likely nothing was changed. - return context.getString(R.string.maintenance_summary_no_snapshots_removed) - } - - /** - * Extracts a summary from the 'check' command output. - * It looks for the "no errors found" message or returns the last line. - */ - private fun parseCheck(output: String, context: Context): String { - val lines = output.lines().filter { it.isNotBlank() } - val noErrorsLine = lines.lastOrNull { it.contains("no errors found", ignoreCase = true) } - return noErrorsLine ?: lines.lastOrNull() ?: context.getString(R.string.maintenance_summary_check_completed) - } - - /** - * Extracts a summary from the 'unlock' command output. - */ - private fun parseUnlock(output: String, context: Context): String { - return output.lines().find { it.contains("successfully removed", ignoreCase = true) } - ?: context.getString(R.string.maintenance_summary_unlock_finished) - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/util/ProgressNotificationHelper.kt b/app/src/main/java/io/github/hddq/restoid/util/ProgressNotificationHelper.kt deleted file mode 100644 index 3c5eaaa1..00000000 --- a/app/src/main/java/io/github/hddq/restoid/util/ProgressNotificationHelper.kt +++ /dev/null @@ -1,90 +0,0 @@ -package io.github.hddq.restoid.util - -import android.Manifest -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.content.Context -import android.content.pm.PackageManager -import android.os.Build -import android.os.Bundle -import androidx.core.app.ActivityCompat -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import kotlinx.coroutines.delay - -class ProgressNotificationHelper(private val context: Context) { - - private val channelId = "task_progress_channel" - private val notificationId = 2137 - private val notificationManager = NotificationManagerCompat.from(context) - - init { - // You must create a notification channel on Android 8.0+ - createNotificationChannel() - } - - private fun createNotificationChannel() { - val name = "Task Progress" - val descriptionText = "Shows background task progress" - // IMPORTANCE_LOW is crucial so it doesn't pop up and make sounds continuously - val importance = NotificationManager.IMPORTANCE_LOW - val channel = NotificationChannel(channelId, name, importance).apply { - description = descriptionText - } - - val manager: NotificationManager = - context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - manager.createNotificationChannel(channel) - } - - suspend fun startFakeDownload() { - // Fail-safe check for POST_NOTIFICATIONS permission - if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { - // If you don't have permission, just abort. - // Handle permission requests in your Activity/Fragment, not here. - return - } - - val maxProgress = 100 - - val builder = NotificationCompat.Builder(context, channelId).apply { - setContentTitle("Doing some heavy work") - setContentText("Copying files...") - setSmallIcon(android.R.drawable.stat_sys_download) - setPriority(NotificationCompat.PRIORITY_LOW) - // Prevent sound/vibration on every single progress update - setOnlyAlertOnce(true) - // Prevent user from swiping it away while in progress - setOngoing(true) - // Request Live Update promotion for API 36 - addExtras(Bundle().apply { - putBoolean(Notification.EXTRA_REQUEST_PROMOTED_ONGOING, true) - }) - } - - // Send initial notification with 0% progress - builder.setProgress(maxProgress, 0, false) - notificationManager.notify(notificationId, builder.build()) - - // Simulate a background task looping (e.g., writing chunks of data) - for (progress in 1..maxProgress) { - delay(50) // Simulating work taking some time - - // Update the progress value - builder.setProgress(maxProgress, progress, false) - // Re-issue the notification with the exact SAME ID to update it - notificationManager.notify(notificationId, builder.build()) - } - - // Task is finished - // Remove the progress bar by setting both values to 0 and false - builder.setContentText("Task completed successfully!") - .setProgress(0, 0, false) - .setOngoing(false) // Allow user to dismiss it now - // Change icon to a checkmark or something similar - .setSmallIcon(android.R.drawable.stat_sys_download_done) - - notificationManager.notify(notificationId, builder.build()) - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/util/ResticOutputParser.kt b/app/src/main/java/io/github/hddq/restoid/util/ResticOutputParser.kt deleted file mode 100644 index 453d4fc7..00000000 --- a/app/src/main/java/io/github/hddq/restoid/util/ResticOutputParser.kt +++ /dev/null @@ -1,108 +0,0 @@ -package io.github.hddq.restoid.util - -import android.content.Context -import io.github.hddq.restoid.R -import io.github.hddq.restoid.ui.shared.OperationProgress -import org.json.JSONObject -import java.util.concurrent.TimeUnit - -object ResticOutputParser { - - fun parse(jsonLine: String, context: Context): OperationProgress? { - return try { - val json = JSONObject(jsonLine) - when (json.optString("message_type")) { - "status" -> parseStatus(json, context) - "summary" -> parseSummary(json, context) - else -> null - } - } catch (e: Exception) { - null - } - } - - private fun parseStatus(json: JSONObject, context: Context): OperationProgress { - // Restore status has "bytes_restored", backup has "bytes_done" - val isRestore = json.has("bytes_restored") - val percentDone = json.optDouble("percent_done", 0.0).toFloat() - - return if (isRestore) { - OperationProgress( - stagePercentage = percentDone, - totalFiles = json.optInt("total_files", 0), - filesProcessed = json.optInt("files_restored", 0), - totalBytes = json.optLong("total_bytes", 0), - bytesProcessed = json.optLong("bytes_restored", 0), - currentFile = "", // Not available in restore status - stageTitle = context.getString(R.string.restic_stage_restoring_files) - ) - } else { - // Assume backup - OperationProgress( - stagePercentage = percentDone, - overallPercentage = percentDone, // For backup, stage and overall are the same - totalFiles = json.optInt("total_files", 0), - filesProcessed = json.optInt("files_done", 0), - totalBytes = json.optLong("total_bytes", 0), - bytesProcessed = json.optLong("bytes_done", 0), - currentFile = json.optJSONArray("current_files")?.optString(0) ?: "", - stageTitle = context.getString(R.string.restic_stage_backing_up) - ) - } - } - - private fun parseSummary(json: JSONObject, context: Context): OperationProgress { - val filesNew = json.optInt("files_new", 0) - val filesChanged = json.optInt("files_changed", 0) - val dataAdded = json.optLong("data_added", 0) - val totalDuration = json.optDouble("total_duration", 0.0) - val snapshotId = if (json.has("snapshot_id")) json.getString("snapshot_id") else null - - return OperationProgress( - stagePercentage = 1.0f, - overallPercentage = 1.0f, - totalFiles = json.optInt("total_files_processed", 0), - filesProcessed = json.optInt("total_files_processed", 0), - totalBytes = json.optLong("total_bytes_processed", 0), - bytesProcessed = json.optLong("total_bytes_processed", 0), - stageTitle = context.getString(R.string.restic_stage_finishing), - isFinished = true, - finalSummary = formatSummary(json, context), // Use the helper to create the string - snapshotId = snapshotId, - // Add the new fields - filesNew = filesNew, - filesChanged = filesChanged, - dataAdded = dataAdded, - totalDuration = totalDuration - ) - } - - private fun formatSummary(json: JSONObject, context: Context): String { - val filesNew = json.optInt("files_new", 0) - val filesChanged = json.optInt("files_changed", 0) - val dataAdded = json.optLong("data_added", 0) - val totalDuration = json.optDouble("total_duration", 0.0) - - val seconds = totalDuration.toLong() - val formattedDuration = String.format( - "%02d:%02d:%02d", - TimeUnit.SECONDS.toHours(seconds), - TimeUnit.SECONDS.toMinutes(seconds) % 60, - seconds % 60 - ) - - return context.getString( - R.string.restic_summary_added, - android.text.format.Formatter.formatShortFileSize(context, dataAdded), - context.resources.getQuantityString(R.plurals.restic_summary_new_files, filesNew, filesNew), - context.resources.getQuantityString(R.plurals.restic_summary_changed_files, filesChanged, filesChanged), - formattedDuration - ) - } - - // This is likely not needed anymore with JSON parsing, but keep for now. - fun findSummaryLine(log: String): String? { - val regex = """files:.*?new,.*?changed,.*?unmodified.*?processed.*?added.*?duration:.*?,.*?total""".toRegex() - return regex.find(log)?.value - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/util/ShellCommandUtils.kt b/app/src/main/java/io/github/hddq/restoid/util/ShellCommandUtils.kt deleted file mode 100644 index d57228d2..00000000 --- a/app/src/main/java/io/github/hddq/restoid/util/ShellCommandUtils.kt +++ /dev/null @@ -1,36 +0,0 @@ -package io.github.hddq.restoid.util - -private val ENV_NAME_REGEX = Regex("^[A-Za-z_][A-Za-z0-9_]*$") -private val RESTIC_OPTION_NAME_REGEX = Regex("^[A-Za-z0-9_.-]+$") - -fun isValidEnvironmentVariableName(name: String): Boolean { - return ENV_NAME_REGEX.matches(name) -} - -fun isValidResticOptionName(name: String): Boolean { - return RESTIC_OPTION_NAME_REGEX.matches(name) -} - -fun shellQuote(value: String): String { - return "'" + value.replace("'", "'\"'\"'") + "'" -} - -fun buildShellEnvironmentPrefix(environmentVariables: Map): String { - if (environmentVariables.isEmpty()) return "" - - return environmentVariables.entries - .sortedBy { it.key } - .joinToString(" ") { (name, value) -> - "$name=${shellQuote(value)}" - } -} - -fun buildResticOptionFlags(options: Map): String { - if (options.isEmpty()) return "" - - return options.entries - .sortedBy { it.key } - .joinToString(" ") { (name, value) -> - "-o ${shellQuote("$name=$value")}" - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/util/StorageUtils.kt b/app/src/main/java/io/github/hddq/restoid/util/StorageUtils.kt deleted file mode 100644 index e783cf62..00000000 --- a/app/src/main/java/io/github/hddq/restoid/util/StorageUtils.kt +++ /dev/null @@ -1,74 +0,0 @@ -package io.github.hddq.restoid.util - -import android.net.Uri -import android.os.Environment -import android.provider.DocumentsContract -import com.topjohnwu.superuser.Shell -import java.io.File - -object StorageUtils { - - /** - * Converts a SAF Tree URI (e.g., from a directory picker) into a standard file system path. - * Handles both Primary storage and External SD cards/USB drives. - */ - fun getPathFromTreeUri(treeUri: Uri): String? { - if (treeUri.authority != "com.android.externalstorage.documents") { - return null - } - - val docId = DocumentsContract.getTreeDocumentId(treeUri) - val split = docId.split(":") - if (split.size > 1) { - val type = split[0] - val path = split[1] - return if (type == "primary") { - "${Environment.getExternalStorageDirectory()}/$path" - } else { - // Handle SD cards and USB drives where type is the UUID - "/storage/$type/$path" - } - } - return null - } - - /** - * Dynamically resolves a user-space path (e.g., /storage/UUID/...) to a root-space mount path - * (e.g., /mnt/media_rw/UUID/...). - * - * This is necessary because Shell.FLAG_MOUNT_MASTER places the shell in the global namespace, - * where user-specific FUSE/SDCardFS mounts often don't exist or are inaccessible. - */ - fun resolvePathForShell(inputPath: String): String { - // Pattern to match /storage/UUID/... - val uuidPattern = Regex("^/storage/([A-Fa-f0-9-]+)(/.*)?$") - val match = uuidPattern.find(inputPath) - - if (match != null) { - val uuid = match.groupValues[1] - val relativePath = match.groupValues[2] - - // 1. Check /proc/mounts to find where this UUID is actually mounted - val mounts = Shell.cmd("cat /proc/mounts").exec().out - val mountPoint = mounts - .map { it.split("\\s+".toRegex()) } - .filter { it.size >= 2 } - .map { it[1] } // Mount point is the second field - .find { it.endsWith("/$uuid") } - - if (mountPoint != null) { - return "$mountPoint$relativePath" - } - - // 2. Fallback: Check specific common locations like /mnt/media_rw - val mediaRwPath = "/mnt/media_rw/$uuid" - // Check using shell because Java might not have permission to see /mnt/media_rw - if (Shell.cmd("[ -d '$mediaRwPath' ]").exec().isSuccess) { - return "$mediaRwPath$relativePath" - } - } - - // Return original if it's primary storage or resolution failed - return inputPath - } -} \ No newline at end of file diff --git a/app/src/main/java/io/github/hddq/restoid/work/BackupOperationRunner.kt b/app/src/main/java/io/github/hddq/restoid/work/BackupOperationRunner.kt deleted file mode 100644 index f67d4cfd..00000000 --- a/app/src/main/java/io/github/hddq/restoid/work/BackupOperationRunner.kt +++ /dev/null @@ -1,507 +0,0 @@ -package io.github.hddq.restoid.work - -import android.content.Context -import com.topjohnwu.superuser.CallbackList -import com.topjohnwu.superuser.Shell -import io.github.hddq.restoid.R -import io.github.hddq.restoid.data.AppInfoRepository -import io.github.hddq.restoid.data.OperationLockManager -import io.github.hddq.restoid.data.RepositoriesRepository -import io.github.hddq.restoid.data.RepositoryBackendType -import io.github.hddq.restoid.data.ResticBinaryManager -import io.github.hddq.restoid.data.ResticRepository -import io.github.hddq.restoid.data.ResticState -import io.github.hddq.restoid.model.AppInfo -import io.github.hddq.restoid.model.AppMetadata -import io.github.hddq.restoid.model.RestoidMetadata -import io.github.hddq.restoid.model.CustomDirectoryMetadata -import io.github.hddq.restoid.ui.shared.OperationProgress -import io.github.hddq.restoid.util.ResticOutputParser -import io.github.hddq.restoid.util.buildResticOptionFlags -import io.github.hddq.restoid.util.buildShellEnvironmentPrefix -import io.github.hddq.restoid.util.shellQuote -import kotlinx.serialization.json.Json -import java.io.File - -class BackupOperationRunner( - private val context: Context, - private val repositoriesRepository: RepositoriesRepository, - private val resticBinaryManager: ResticBinaryManager, - private val resticRepository: ResticRepository, - private val appInfoRepository: AppInfoRepository, - private val operationLockManager: OperationLockManager -) { - - suspend fun run( - request: BackupWorkRequest, - onProgress: (OperationProgress) -> Unit, - shouldStop: () -> Boolean = { false }, - stageContext: OperationStageContext = OperationStageContext( - completedStagesBefore = 0, - totalStages = 3 - ) - ): OperationRunResult { - fun throwIfCancelled() { - if (shouldStop()) { - throw OperationCancelledException(context.getString(R.string.operation_interrupted)) - } - } - - val startTime = System.currentTimeMillis() - var progressState = OperationProgress(stageTitle = context.getString(R.string.progress_initializing)) - onProgress(progressState) - throwIfCancelled() - - var operationLockAcquired = false - var fileList: File? = null - var passwordFile: File? = null - var restoidMetadataFile: File? = null - var isSuccess = false - var summary = "" - var finalSummaryProgress: OperationProgress? = null - var repoPath: String? = null - var repositoryEnvironment: Map = emptyMap() - var repositoryResticOptions: Map = emptyMap() - var password: String? = null - var snapshotId: String? = null - var repositoryId: String? = null - val totalStages = 3 - var currentStage = 1 - - try { - throwIfCancelled() - val selectedApps = appInfoRepository.getAppInfoForPackages(request.selectedPackageNames) - val errorState = preflightChecks(request, selectedApps) - if (errorState != null) { - return OperationRunResult(success = false, progress = errorState) - } - - val repository = repositoriesRepository.getRepositoryByKey(request.repositoryKey) - ?: throw IllegalStateException(context.getString(R.string.error_no_backup_repository_selected)) - - operationLockManager.acquire(repository.backendType) - operationLockAcquired = true - - val resticState = resticBinaryManager.resticState.value as ResticState.Installed - repositoryId = repository.id - if (repositoryId == null) throw IllegalStateException(context.getString(R.string.backup_error_repository_id_not_found)) - - val currentRepoPath = repository.path - repositoryEnvironment = repositoriesRepository.getExecutionEnvironmentVariables(request.repositoryKey) - repositoryResticOptions = repositoriesRepository.getExecutionResticOptions(request.repositoryKey) - val currentPassword = repositoriesRepository.getRepositoryPassword(request.repositoryKey) - ?: throw IllegalStateException(context.getString(R.string.error_password_not_found_for_repository)) - repoPath = currentRepoPath - password = currentPassword - - val emitStageProgress: (String, Float, Float) -> Unit = { stageTitle, stagePercentage, overallPercentage -> - progressState = progressState.copy( - stageTitle = stageTitle, - stagePercentage = stagePercentage, - overallPercentage = overallPercentage, - elapsedTime = (System.currentTimeMillis() - startTime) / 1000, - isFinished = false - ) - onProgress(progressState) - } - - emitStageProgress( - context.getString( - R.string.backup_stage_preparing, - stageContext.absoluteStage(currentStage), - stageContext.totalStages - ), - 0f, - 0f - ) - throwIfCancelled() - - val (pathsToBackup, excludePatterns, metadata) = prepareBackupData( - selectedApps, - request.backupTypes, - request.appBackupTypes, - request.customDirectories, - shouldStop - ) - restoidMetadataFile = File(context.cacheDir, "restoid.json") - val json = Json { prettyPrint = true } - restoidMetadataFile.writeText(json.encodeToString(metadata)) - pathsToBackup.add(0, restoidMetadataFile.absolutePath) - - if (pathsToBackup.size <= 1 && metadata.apps.values.none { "permissions" in it.types } && metadata.customDirectories.isEmpty()) { - throw IllegalStateException(context.getString(R.string.backup_error_no_files_selected_for_items)) - } - - currentStage = 2 - val stage2Title = context.getString( - R.string.backup_stage_running, - stageContext.absoluteStage(currentStage), - stageContext.totalStages - ) - emitStageProgress(stage2Title, 0f, (currentStage - 1f) / totalStages) - throwIfCancelled() - - fileList = File.createTempFile("restic-files-", ".txt", context.cacheDir) - fileList.writeText(pathsToBackup.distinct().joinToString("\n")) - - passwordFile = File.createTempFile("restic-pass", ".tmp", context.cacheDir) - passwordFile.writeText(password) - - val tags = listOf("restoid", "backup") - val tagFlags = tags.joinToString(" ") { "--tag '$it'" } - val excludeFlags = excludePatterns.distinct().joinToString(" ") { pattern -> "--exclude $pattern" } - val envPrefix = buildShellEnvironmentPrefix(repositoryEnvironment) - val resticOptionFlags = buildResticOptionFlags(repositoryResticOptions) - - val command = buildString { - if (envPrefix.isNotEmpty()) append(envPrefix).append(' ') - append("RESTIC_PASSWORD_FILE=").append(shellQuote(passwordFile.absolutePath)).append(' ') - append("RESTIC_CACHE_DIR=").append(shellQuote(File(context.cacheDir, "restic").absolutePath)).append(' ') - append(shellQuote(resticState.path)).append(' ') - append("--retry-lock 5s ") - if (resticOptionFlags.isNotEmpty()) append(resticOptionFlags).append(' ') - append("-r ") - append(shellQuote(currentRepoPath)).append(' ') - append("backup --files-from ") - append(shellQuote(fileList.absolutePath)) - append(" --json --verbose=2 ") - append(tagFlags) - append(' ') - append(excludeFlags) - } - - val stdoutCallback = object : CallbackList() { - override fun onAddElement(line: String) { - ResticOutputParser.parse(line, context)?.let { progressUpdate -> - if (progressUpdate.isFinished) { - finalSummaryProgress = progressUpdate - snapshotId = progressUpdate.snapshotId - } - progressState = progressUpdate.copy( - isFinished = false, - stageTitle = stage2Title, - overallPercentage = (currentStage - 1 + progressUpdate.stagePercentage) / totalStages, - elapsedTime = (System.currentTimeMillis() - startTime) / 1000 - ) - onProgress(progressState) - } - } - } - val stderr = mutableListOf() - val result = Shell.cmd(command).to(stdoutCallback, stderr).exec() - throwIfCancelled() - - if (!result.isSuccess || snapshotId == null) { - val errorOutput = stderr.joinToString("\n") - throw IllegalStateException( - if (errorOutput.isEmpty()) { - context.getString(R.string.backup_error_command_failed_with_code, result.code) - } else { - errorOutput - } - ) - } - - currentStage = 3 - val stage3Title = context.getString( - R.string.backup_stage_finalizing, - stageContext.absoluteStage(currentStage), - stageContext.totalStages - ) - emitStageProgress(stage3Title, 0f, (currentStage - 1f) / totalStages) - throwIfCancelled() - - try { - val metadataDir = File(context.filesDir, "metadata/$repositoryId") - if (!metadataDir.exists()) metadataDir.mkdirs() - val destFile = File(metadataDir, "$snapshotId.json") - restoidMetadataFile.copyTo(destFile, overwrite = true) - } catch (_: Exception) { - summary += "\n" + context.getString(R.string.backup_warning_metadata_not_saved) - } - - resticRepository.backupMetadata( - repositoryId, - currentRepoPath, - currentPassword, - repositoryEnvironment, - repositoryResticOptions - ) - throwIfCancelled() - emitStageProgress(stage3Title, 1f, 1f) - - isSuccess = true - summary = finalSummaryProgress?.finalSummary - ?: context.resources.getQuantityString( - R.plurals.backup_summary_success_items, - selectedApps.size, - selectedApps.size - ) - } catch (e: OperationCancelledException) { - isSuccess = false - summary = context.getString(R.string.operation_interrupted) - } catch (e: Exception) { - isSuccess = false - summary = context.getString(R.string.error_fatal_with_message, e.message ?: "") - } finally { - if (operationLockAcquired) { - operationLockManager.release() - } - fileList?.delete() - passwordFile?.delete() - restoidMetadataFile?.delete() - } - - val finalProgress = progressState.copy( - isFinished = true, - error = if (!isSuccess) summary else null, - finalSummary = summary, - filesNew = finalSummaryProgress?.filesNew ?: 0, - filesChanged = finalSummaryProgress?.filesChanged ?: 0, - dataAdded = finalSummaryProgress?.dataAdded ?: 0, - totalDuration = finalSummaryProgress?.totalDuration ?: ((System.currentTimeMillis() - startTime) / 1000.0), - elapsedTime = (System.currentTimeMillis() - startTime) / 1000 - ) - - if (isSuccess && repoPath != null && password != null) { - resticRepository.refreshSnapshots( - repoPath, - password, - repositoryEnvironment, - repositoryResticOptions - ) - } - - return OperationRunResult(success = isSuccess, progress = finalProgress) - } - - private suspend fun prepareBackupData( - selectedApps: List, - backupTypes: BackupTypeSelection, - appBackupTypes: Map, - customDirectories: List, - shouldStop: () -> Boolean - ): Triple, List, RestoidMetadata> { - fun throwIfCancelled() { - if (shouldStop()) { - throw OperationCancelledException(context.getString(R.string.operation_interrupted)) - } - } - - val pathsToBackup = mutableListOf() - val excludePatterns = mutableListOf() - val appMetadataMap = mutableMapOf() - - selectedApps.forEach { app -> - throwIfCancelled() - val effectiveBackupTypes = appBackupTypes[app.packageName] ?: backupTypes - val backupTypesList = mutableListOf().apply { - if (effectiveBackupTypes.apk) add("apk") - if (effectiveBackupTypes.data) add("data") - if (effectiveBackupTypes.deviceProtectedData) add("user_de") - if (effectiveBackupTypes.externalData) add("external_data") - if (effectiveBackupTypes.obb) add("obb") - if (effectiveBackupTypes.media) add("media") - if (effectiveBackupTypes.permissions) add("permissions") - } - val appPaths = generateFilePathsForApp(app, effectiveBackupTypes) - val existingAppPaths = appPaths.filter { Shell.cmd("[ -e '$it' ]").exec().isSuccess } - pathsToBackup.addAll(existingAppPaths) - - if (effectiveBackupTypes.data) { - excludePatterns.add("'/data/user/${getCurrentUserId()}/${app.packageName}/cache'") - excludePatterns.add("'/data/user/${getCurrentUserId()}/${app.packageName}/code_cache'") - } - if (effectiveBackupTypes.externalData) { - excludePatterns.add("'/storage/emulated/${getCurrentUserId()}/Android/data/${app.packageName}/cache'") - } - - throwIfCancelled() - var size = 0L - val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as android.app.AppOpsManager - val mode = appOps.unsafeCheckOpNoThrow( - android.app.AppOpsManager.OPSTR_GET_USAGE_STATS, - android.os.Process.myUid(), - context.packageName - ) - val hasUsageStatsPermission = mode == android.app.AppOpsManager.MODE_ALLOWED - - if (hasUsageStatsPermission) { - try { - val storageStatsManager = context.getSystemService(android.app.usage.StorageStatsManager::class.java) - val uuid = android.os.storage.StorageManager.UUID_DEFAULT - val stats = storageStatsManager.queryStatsForPackage(uuid, app.packageName, android.os.Process.myUserHandle()) - if (effectiveBackupTypes.apk) { - size += stats.appBytes - } - if (effectiveBackupTypes.data || effectiveBackupTypes.deviceProtectedData || effectiveBackupTypes.externalData) { - size += stats.dataBytes - } - } catch (e: Exception) { - size = getDirectorySize(existingAppPaths) - } - } else { - size = getDirectorySize(existingAppPaths) - } - - val grantedRuntimePermissions = if (effectiveBackupTypes.permissions) { - appInfoRepository.getGrantedRuntimePermissions(app.packageName) - } else { - emptyList() - } - appMetadataMap[app.packageName] = AppMetadata( - size = size, - types = backupTypesList, - versionCode = app.versionCode, - versionName = app.versionName, - appName = app.name, - grantedRuntimePermissions = grantedRuntimePermissions - ) - } - val customDirectoryMetadataMap = mutableMapOf() - customDirectories.forEach { dirUriStr -> - throwIfCancelled() - val uri = android.net.Uri.parse(dirUriStr) - val realPath = io.github.hddq.restoid.util.StorageUtils.getPathFromTreeUri(uri) - if (realPath != null) { - // Resolve path for shell in case it's on an external SD card - val shellPath = io.github.hddq.restoid.util.StorageUtils.resolvePathForShell(realPath) - val existing = Shell.cmd("[ -e '$shellPath' ]").exec().isSuccess - if (existing) { - pathsToBackup.add(shellPath) - val size = getDirectorySize(listOf(shellPath)) - customDirectoryMetadataMap[shellPath] = CustomDirectoryMetadata(size = size) - } - } - } - val metadata = RestoidMetadata(apps = appMetadataMap, customDirectories = customDirectoryMetadataMap) - return Triple(pathsToBackup, excludePatterns, metadata) - } - - private fun preflightChecks( - request: BackupWorkRequest, - selectedApps: List - ): OperationProgress? { - if (selectedApps.isEmpty() && request.customDirectories.isEmpty()) { - return OperationProgress( - isFinished = true, - error = context.getString(R.string.error_no_items_selected), - finalSummary = context.getString(R.string.summary_no_items_selected) - ) - } - - val hasAnyBackupType = selectedApps.any { app -> - val backupOptions = request.appBackupTypes[app.packageName] ?: request.backupTypes - backupOptions.apk || - backupOptions.data || - backupOptions.deviceProtectedData || - backupOptions.externalData || - backupOptions.obb || - backupOptions.media || - backupOptions.permissions - } || request.customDirectories.isNotEmpty() - if (!hasAnyBackupType) { - return OperationProgress( - isFinished = true, - error = context.getString(R.string.error_no_backup_types_selected), - finalSummary = context.getString(R.string.summary_no_backup_types_selected) - ) - } - - if (resticBinaryManager.resticState.value !is ResticState.Installed) { - return OperationProgress( - isFinished = true, - error = context.getString(R.string.error_restic_not_installed), - finalSummary = context.getString(R.string.summary_restic_binary_not_installed) - ) - } - - val repository = repositoriesRepository.getRepositoryByKey(request.repositoryKey) - if (repository == null) { - return OperationProgress( - isFinished = true, - error = context.getString(R.string.error_no_backup_repository_selected), - finalSummary = context.getString(R.string.summary_no_backup_repository_selected) - ) - } - - if (repositoriesRepository.getRepositoryPassword(request.repositoryKey) == null) { - return OperationProgress( - isFinished = true, - error = context.getString(R.string.error_password_not_found_for_repository), - finalSummary = context.getString(R.string.summary_password_not_found) - ) - } - - if (repository.backendType == RepositoryBackendType.SFTP && !repositoriesRepository.hasSftpCredentials(request.repositoryKey)) { - return OperationProgress( - isFinished = true, - error = context.getString(R.string.error_sftp_password_not_found_for_repository), - finalSummary = context.getString(R.string.summary_sftp_password_not_found) - ) - } - - if ( - repository.backendType == RepositoryBackendType.REST && - repository.restAuthRequired && - !repositoriesRepository.hasRestCredentials(request.repositoryKey) - ) { - return OperationProgress( - isFinished = true, - error = context.getString(R.string.error_rest_credentials_not_found_for_repository), - finalSummary = context.getString(R.string.summary_rest_credentials_not_found) - ) - } - - if ( - repository.backendType == RepositoryBackendType.S3 && - repository.s3AuthRequired && - !repositoriesRepository.hasS3Credentials(request.repositoryKey) - ) { - return OperationProgress( - isFinished = true, - error = context.getString(R.string.error_s3_credentials_not_found_for_repository), - finalSummary = context.getString(R.string.summary_s3_credentials_not_found) - ) - } - - return null - } - - private fun getCurrentUserId(): Int { - return try { - android.os.UserHandle::class.java.getMethod("myUserId").invoke(null) as Int - } catch (_: Exception) { - android.os.Process.myUid() / 100000 - } - } - - private fun generateFilePathsForApp(app: AppInfo, backupTypes: BackupTypeSelection): List { - val currentUserId = getCurrentUserId() - return mutableListOf().apply { - if (backupTypes.apk) { - app.apkPaths.firstOrNull()?.let { path -> - File(path).parentFile?.absolutePath?.let { add(it) } - } - } - if (backupTypes.data) add("/data/user/$currentUserId/${app.packageName}") - if (backupTypes.deviceProtectedData) add("/data/user_de/$currentUserId/${app.packageName}") - if (backupTypes.externalData) add("/storage/emulated/$currentUserId/Android/data/${app.packageName}") - if (backupTypes.obb) add("/storage/emulated/$currentUserId/Android/obb/${app.packageName}") - if (backupTypes.media) add("/storage/emulated/$currentUserId/Android/media/${app.packageName}") - } - } - - private fun getDirectorySize(paths: List): Long { - if (paths.isEmpty()) return 0L - val command = "du -sb ${paths.joinToString(" ") { "'$it'" }}" - val result = Shell.cmd(command).exec() - var totalSize = 0L - if (result.isSuccess) { - result.out.forEach { line -> - totalSize += line.trim().split("\\s+".toRegex()).firstOrNull()?.toLongOrNull() ?: 0L - } - } - return totalSize - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/work/HeavyOperationWorker.kt b/app/src/main/java/io/github/hddq/restoid/work/HeavyOperationWorker.kt deleted file mode 100644 index f1fe964b..00000000 --- a/app/src/main/java/io/github/hddq/restoid/work/HeavyOperationWorker.kt +++ /dev/null @@ -1,226 +0,0 @@ -package io.github.hddq.restoid.work - -import android.content.Context -import android.content.pm.ServiceInfo -import android.os.Build -import androidx.work.CoroutineWorker -import androidx.work.ForegroundInfo -import androidx.work.WorkerParameters -import io.github.hddq.restoid.R -import io.github.hddq.restoid.RestoidApplication -import io.github.hddq.restoid.data.NotificationRepository -import io.github.hddq.restoid.data.OperationType -import io.github.hddq.restoid.ui.shared.OperationProgress -import kotlinx.coroutines.CancellationException -import kotlin.math.abs - -class HeavyOperationWorker( - appContext: Context, - workerParams: WorkerParameters -) : CoroutineWorker(appContext, workerParams) { - - private val app = appContext.applicationContext as RestoidApplication - private val notificationRepository = app.notificationRepository - private val emitMonitor = Any() - - override suspend fun doWork(): Result { - app.repositoriesRepository.loadRepositories() - - val operationType = OperationWorkContract.parseOperationType( - inputData.getString(OperationWorkContract.INPUT_OPERATION_TYPE) - ) ?: return Result.failure() - - val requestId = inputData.getString(OperationWorkContract.INPUT_REQUEST_ID) - ?: return Result.failure() - - val initialProgress = OperationProgress(stageTitle = applicationContext.getString(R.string.progress_initializing)) - setForeground(createForegroundInfo(operationType, initialProgress)) - app.operationRuntimeRepository.markEnqueued(operationType, initialProgress) - setProgress(OperationWorkContract.progressToData(operationType, initialProgress)) - - var scheduleName: String? = null - val notifier = WorkerProgressNotifier(operationType) - val shouldStop = { isStopped || app.operationRuntimeRepository.state.value.stopRequested } - - val runResult = try { - when (operationType) { - OperationType.BACKUP -> { - val request = app.operationRequestStore.loadBackupRequest(requestId) - val runner = BackupOperationRunner( - context = applicationContext, - repositoriesRepository = app.repositoriesRepository, - resticBinaryManager = app.resticBinaryManager, - resticRepository = app.resticRepository, - appInfoRepository = app.appInfoRepository, - operationLockManager = app.operationLockManager - ) - runner.run(request, notifier::onProgress, shouldStop) - } - - OperationType.RUN_TASKS -> { - val request = app.operationRequestStore.loadRunTasksRequest(requestId) - scheduleName = request.scheduleName - val runner = RunTasksOperationRunner( - context = applicationContext, - repositoriesRepository = app.repositoriesRepository, - resticBinaryManager = app.resticBinaryManager, - resticRepository = app.resticRepository, - appInfoRepository = app.appInfoRepository, - operationLockManager = app.operationLockManager - ) - runner.run(request, notifier::onProgress, shouldStop) - } - - OperationType.RESTORE -> { - val request = app.operationRequestStore.loadRestoreRequest(requestId) - val runner = RestoreOperationRunner( - context = applicationContext, - repositoriesRepository = app.repositoriesRepository, - resticBinaryManager = app.resticBinaryManager, - resticRepository = app.resticRepository, - metadataRepository = app.metadataRepository, - operationLockManager = app.operationLockManager - ) - runner.run(request, notifier::onProgress, shouldStop) - } - - OperationType.MAINTENANCE -> { - val request = app.operationRequestStore.loadMaintenanceRequest(requestId) - val runner = MaintenanceOperationRunner( - context = applicationContext, - repositoriesRepository = app.repositoriesRepository, - resticBinaryManager = app.resticBinaryManager, - resticRepository = app.resticRepository, - operationLockManager = app.operationLockManager - ) - runner.run(request, notifier::onProgress, shouldStop) - } - } - } catch (e: Exception) { - if (e is CancellationException || isStopped) { - cancelledRunResult(operationType) - } else { - val failedProgress = OperationProgress( - stageTitle = applicationContext.getString(R.string.progress_operation_failed, operationName(operationType)), - isFinished = true, - error = applicationContext.getString(R.string.error_fatal_with_message, e.message ?: ""), - finalSummary = applicationContext.getString(R.string.error_fatal_with_message, e.message ?: "") - ) - OperationRunResult(success = false, progress = failedProgress) - } - } finally { - app.operationRequestStore.deleteRequest(requestId) - } - - val interruptedSummary = applicationContext.getString(R.string.operation_interrupted) - val finalResult = when { - runResult.success -> runResult - runResult.progress.error == interruptedSummary || runResult.progress.finalSummary == interruptedSummary -> runResult - isStopped || app.operationRuntimeRepository.state.value.stopRequested -> cancelledRunResult(operationType) - else -> runResult - } - - notifier.forceNotify(finalResult.progress) - app.operationRuntimeRepository.markFinished(operationType, finalResult.success, finalResult.progress) - setProgress(OperationWorkContract.progressToData(operationType, finalResult.progress)) - - val displayName = if (scheduleName != null) { - applicationContext.getString(R.string.operation_schedule_name, scheduleName) - } else { - operationName(operationType) - } - notificationRepository.showOperationFinishedNotification( - displayName, - finalResult.success, - finalResult.progress.finalSummary - ) - - val output = OperationWorkContract.outputToData(operationType, finalResult.success, finalResult.progress) - return if (finalResult.success) Result.success(output) else Result.failure(output) - } - - private fun cancelledRunResult(operationType: OperationType): OperationRunResult { - val summary = applicationContext.getString(R.string.operation_interrupted) - return OperationRunResult( - success = false, - progress = OperationProgress( - stageTitle = applicationContext.getString( - R.string.progress_operation_failed, - operationName(operationType) - ), - isFinished = true, - error = summary, - finalSummary = summary - ) - ) - } - - private fun operationName(operationType: OperationType): String { - return when (operationType) { - OperationType.BACKUP -> applicationContext.getString(R.string.operation_backup) - OperationType.RUN_TASKS -> applicationContext.getString(R.string.operation_run_tasks) - OperationType.RESTORE -> applicationContext.getString(R.string.operation_restore) - OperationType.MAINTENANCE -> applicationContext.getString(R.string.operation_maintenance) - } - } - - private fun createForegroundInfo(operationType: OperationType, progress: OperationProgress): ForegroundInfo { - val notification = notificationRepository.buildOperationProgressNotification( - operationName(operationType), - progress - ) - return ForegroundInfo( - NotificationRepository.PROGRESS_NOTIFICATION_ID, - notification, - foregroundServiceTypeDataSync() - ) - } - - private inner class WorkerProgressNotifier( - private val operationType: OperationType - ) { - private var lastUpdateAtMs: Long = 0L - private var lastOverallPercentage: Float = -1f - private var lastStageTitle: String = "" - - fun onProgress(progress: OperationProgress) { - if (!shouldEmit(progress)) return - emit(progress) - } - - fun forceNotify(progress: OperationProgress) { - emit(progress) - } - - private fun emit(progress: OperationProgress) { - synchronized(emitMonitor) { - lastUpdateAtMs = System.currentTimeMillis() - lastOverallPercentage = progress.overallPercentage - lastStageTitle = progress.stageTitle - - app.operationRuntimeRepository.markProgress(operationType, progress) - setForegroundAsync(createForegroundInfo(operationType, progress)) - setProgressAsync(OperationWorkContract.progressToData(operationType, progress)) - } - } - - private fun shouldEmit(progress: OperationProgress): Boolean { - if (progress.isFinished) return true - val now = System.currentTimeMillis() - if (now - lastUpdateAtMs >= PROGRESS_UPDATE_MIN_INTERVAL_MS) return true - if (abs(progress.overallPercentage - lastOverallPercentage) >= PROGRESS_DELTA_THRESHOLD) return true - if (progress.stageTitle != lastStageTitle) return true - return false - } - } - - private companion object { - const val PROGRESS_UPDATE_MIN_INTERVAL_MS = 1000L - const val PROGRESS_DELTA_THRESHOLD = 0.02f - } - - @Suppress("DEPRECATION") - private fun foregroundServiceTypeDataSync(): Int { - return ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/work/MaintenanceOperationRunner.kt b/app/src/main/java/io/github/hddq/restoid/work/MaintenanceOperationRunner.kt deleted file mode 100644 index eae93e33..00000000 --- a/app/src/main/java/io/github/hddq/restoid/work/MaintenanceOperationRunner.kt +++ /dev/null @@ -1,268 +0,0 @@ -package io.github.hddq.restoid.work - -import android.content.Context -import io.github.hddq.restoid.R -import io.github.hddq.restoid.data.OperationLockManager -import io.github.hddq.restoid.data.RepositoriesRepository -import io.github.hddq.restoid.data.RepositoryBackendType -import io.github.hddq.restoid.data.ResticBinaryManager -import io.github.hddq.restoid.data.ResticRepository -import io.github.hddq.restoid.data.ResticState -import io.github.hddq.restoid.ui.shared.OperationProgress -import io.github.hddq.restoid.util.MaintenanceOutputParser - -class MaintenanceOperationRunner( - private val context: Context, - private val repositoriesRepository: RepositoriesRepository, - private val resticBinaryManager: ResticBinaryManager, - private val resticRepository: ResticRepository, - private val operationLockManager: OperationLockManager -) { - - suspend fun run( - request: MaintenanceWorkRequest, - onProgress: (OperationProgress) -> Unit, - shouldStop: () -> Boolean = { false }, - stageContext: OperationStageContext = OperationStageContext( - completedStagesBefore = 0, - totalStages = countEnabledStages(request) - ) - ): OperationRunResult { - fun throwIfCancelled() { - if (shouldStop()) { - throw OperationCancelledException(context.getString(R.string.operation_interrupted)) - } - } - - val startTime = System.currentTimeMillis() - var progressState = OperationProgress(stageTitle = context.getString(R.string.progress_initializing)) - onProgress(progressState) - throwIfCancelled() - - var operationLockAcquired = false - var finalSummary = StringBuilder() - var overallSuccess = true - var wasCancelled = false - - try { - throwIfCancelled() - val errorState = preflightChecks(request) - if (errorState != null) { - return OperationRunResult(success = false, progress = errorState) - } - - val repository = repositoriesRepository.getRepositoryByKey(request.repositoryKey) - ?: throw IllegalStateException(context.getString(R.string.summary_no_backup_repository_selected)) - - operationLockManager.acquire(repository.backendType) - operationLockAcquired = true - - val selectedRepoPath = repository.path - val repositoryEnvironment = repositoriesRepository.getExecutionEnvironmentVariables(request.repositoryKey) - val repositoryResticOptions = repositoriesRepository.getExecutionResticOptions(request.repositoryKey) - val password = repositoriesRepository.getRepositoryPassword(request.repositoryKey) - ?: throw IllegalStateException(context.getString(R.string.error_password_not_found_for_repository)) - - val tasksToRun = mutableListOf Result>>() - if (request.unlockRepo) { - tasksToRun.add( - context.getString(R.string.maintenance_task_unlock) to { - resticRepository.unlock(selectedRepoPath, password, repositoryEnvironment, repositoryResticOptions) - } - ) - } - if (request.forgetSnapshots) { - tasksToRun.add( - context.getString(R.string.maintenance_task_forget) to { - resticRepository.forget( - selectedRepoPath, - password, - request.keepLast, - request.keepDaily, - request.keepWeekly, - request.keepMonthly, - repositoryEnvironment, - repositoryResticOptions - ) - } - ) - } - if (request.pruneRepo) { - tasksToRun.add( - context.getString(R.string.maintenance_task_prune) to { - resticRepository.prune(selectedRepoPath, password, repositoryEnvironment, repositoryResticOptions) - } - ) - } - if (request.checkRepo) { - tasksToRun.add( - context.getString(R.string.maintenance_task_check) to { - resticRepository.check( - selectedRepoPath, - password, - request.readData, - repositoryEnvironment, - repositoryResticOptions - ) - } - ) - } - - if (tasksToRun.isEmpty()) throw IllegalStateException(context.getString(R.string.maintenance_error_no_tasks_selected)) - - tasksToRun.forEachIndexed { index, (taskName, taskAction) -> - throwIfCancelled() - val stageTitle = context.getString( - R.string.maintenance_stage_running_task, - stageContext.absoluteStage(index + 1), - stageContext.totalStages, - taskName - ) - progressState = progressState.copy( - stageTitle = stageTitle, - overallPercentage = index.toFloat() / tasksToRun.size, - stagePercentage = 0f, - isFinished = false, - elapsedTime = (System.currentTimeMillis() - startTime) / 1000 - ) - onProgress(progressState) - - val result = taskAction() - throwIfCancelled() - - progressState = progressState.copy( - overallPercentage = (index + 1f) / tasksToRun.size, - stagePercentage = 1f, - elapsedTime = (System.currentTimeMillis() - startTime) / 1000 - ) - onProgress(progressState) - - result.fold( - onSuccess = { output -> - val summary = MaintenanceOutputParser.parse(taskName, output, context) - finalSummary.append(context.getString(R.string.maintenance_task_successful, taskName, summary)) - }, - onFailure = { exception -> - overallSuccess = false - finalSummary.append(context.getString(R.string.maintenance_task_failed, taskName, exception.message ?: "")) - } - ) - } - - if ((request.pruneRepo || request.forgetSnapshots) && overallSuccess) { - resticRepository.refreshSnapshots( - selectedRepoPath, - password, - repositoryEnvironment, - repositoryResticOptions - ) - } - } catch (e: OperationCancelledException) { - overallSuccess = false - wasCancelled = true - finalSummary.append(context.getString(R.string.operation_interrupted)) - } catch (e: Exception) { - overallSuccess = false - finalSummary.append(context.getString(R.string.error_fatal_with_message, e.message ?: "")) - } finally { - if (operationLockAcquired) { - operationLockManager.release() - } - } - - val finalProgress = progressState.copy( - isFinished = true, - finalSummary = finalSummary.toString().trim(), - error = if (!overallSuccess) { - if (wasCancelled) context.getString(R.string.operation_interrupted) - else context.getString(R.string.maintenance_error_one_or_more_failed) - } else { - null - }, - elapsedTime = (System.currentTimeMillis() - startTime) / 1000, - overallPercentage = 1f, - stagePercentage = 1f - ) - - return OperationRunResult(success = overallSuccess, progress = finalProgress) - } - - private fun preflightChecks(request: MaintenanceWorkRequest): OperationProgress? { - if (!request.checkRepo && !request.pruneRepo && !request.unlockRepo && !request.forgetSnapshots) { - return OperationProgress( - isFinished = true, - error = context.getString(R.string.maintenance_error_no_tasks), - finalSummary = context.getString(R.string.maintenance_summary_no_tasks) - ) - } - if (resticBinaryManager.resticState.value !is ResticState.Installed) { - return OperationProgress( - isFinished = true, - error = context.getString(R.string.error_restic_not_installed), - finalSummary = context.getString(R.string.summary_restic_binary_not_installed) - ) - } - - val repository = repositoriesRepository.getRepositoryByKey(request.repositoryKey) - if (repository == null) { - return OperationProgress( - isFinished = true, - error = context.getString(R.string.error_no_backup_repository_selected), - finalSummary = context.getString(R.string.summary_no_backup_repository_selected) - ) - } - - if (repositoriesRepository.getRepositoryPassword(request.repositoryKey) == null) { - return OperationProgress( - isFinished = true, - error = context.getString(R.string.error_password_not_found_for_repository), - finalSummary = context.getString(R.string.summary_password_not_found) - ) - } - - if (repository.backendType == RepositoryBackendType.SFTP && !repositoriesRepository.hasSftpCredentials(request.repositoryKey)) { - return OperationProgress( - isFinished = true, - error = context.getString(R.string.error_sftp_password_not_found_for_repository), - finalSummary = context.getString(R.string.summary_sftp_password_not_found) - ) - } - - if ( - repository.backendType == RepositoryBackendType.REST && - repository.restAuthRequired && - !repositoriesRepository.hasRestCredentials(request.repositoryKey) - ) { - return OperationProgress( - isFinished = true, - error = context.getString(R.string.error_rest_credentials_not_found_for_repository), - finalSummary = context.getString(R.string.summary_rest_credentials_not_found) - ) - } - - if ( - repository.backendType == RepositoryBackendType.S3 && - repository.s3AuthRequired && - !repositoriesRepository.hasS3Credentials(request.repositoryKey) - ) { - return OperationProgress( - isFinished = true, - error = context.getString(R.string.error_s3_credentials_not_found_for_repository), - finalSummary = context.getString(R.string.summary_s3_credentials_not_found) - ) - } - - return null - } - - private companion object { - fun countEnabledStages(request: MaintenanceWorkRequest): Int { - return listOf( - request.unlockRepo, - request.forgetSnapshots, - request.pruneRepo, - request.checkRepo - ).count { it } - } - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/work/OperationCancelledException.kt b/app/src/main/java/io/github/hddq/restoid/work/OperationCancelledException.kt deleted file mode 100644 index 2fda42b7..00000000 --- a/app/src/main/java/io/github/hddq/restoid/work/OperationCancelledException.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.github.hddq.restoid.work - -import kotlinx.coroutines.CancellationException - -class OperationCancelledException(message: String) : CancellationException(message) diff --git a/app/src/main/java/io/github/hddq/restoid/work/OperationRequestStore.kt b/app/src/main/java/io/github/hddq/restoid/work/OperationRequestStore.kt deleted file mode 100644 index 164de8eb..00000000 --- a/app/src/main/java/io/github/hddq/restoid/work/OperationRequestStore.kt +++ /dev/null @@ -1,90 +0,0 @@ -package io.github.hddq.restoid.work - -import android.content.Context -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import java.io.File -import java.util.UUID - -class OperationRequestStore(context: Context) { - private val appContext = context.applicationContext - private val rootDir = File(appContext.filesDir, "operation_requests") - private val json = Json { ignoreUnknownKeys = true } - - init { - if (!rootDir.exists()) { - rootDir.mkdirs() - } - } - - fun saveBackupRequest(request: BackupWorkRequest): String { - return saveRequest(OperationRequestType.BACKUP, json.encodeToString(request)) - } - - fun saveRestoreRequest(request: RestoreWorkRequest): String { - return saveRequest(OperationRequestType.RESTORE, json.encodeToString(request)) - } - - fun saveMaintenanceRequest(request: MaintenanceWorkRequest): String { - return saveRequest(OperationRequestType.MAINTENANCE, json.encodeToString(request)) - } - - fun saveRunTasksRequest(request: RunTasksWorkRequest): String { - return saveRequest(OperationRequestType.RUN_TASKS, json.encodeToString(request)) - } - - fun loadBackupRequest(requestId: String): BackupWorkRequest { - val content = loadRequestContent(requestId, OperationRequestType.BACKUP) - return json.decodeFromString(content) - } - - fun loadRestoreRequest(requestId: String): RestoreWorkRequest { - val content = loadRequestContent(requestId, OperationRequestType.RESTORE) - return json.decodeFromString(content) - } - - fun loadMaintenanceRequest(requestId: String): MaintenanceWorkRequest { - val content = loadRequestContent(requestId, OperationRequestType.MAINTENANCE) - return json.decodeFromString(content) - } - - fun loadRunTasksRequest(requestId: String): RunTasksWorkRequest { - val content = loadRequestContent(requestId, OperationRequestType.RUN_TASKS) - return json.decodeFromString(content) - } - - fun deleteRequest(requestId: String) { - rootDir.listFiles()?.forEach { file -> - if (file.name.startsWith("$requestId.")) { - file.delete() - } - } - } - - private fun saveRequest(type: OperationRequestType, payload: String): String { - val requestId = UUID.randomUUID().toString() - val file = requestFile(requestId, type) - file.writeText(payload) - return requestId - } - - private fun loadRequestContent(requestId: String, type: OperationRequestType): String { - val file = requestFile(requestId, type) - if (!file.exists()) { - throw IllegalStateException("Operation request not found: $requestId (${type.name})") - } - return file.readText() - } - - private fun requestFile(requestId: String, type: OperationRequestType): File { - return File(rootDir, "$requestId.${type.fileSuffix}.json") - } -} - -enum class OperationRequestType(val fileSuffix: String) { - BACKUP("backup"), - RESTORE("restore"), - MAINTENANCE("maintenance"), - RUN_TASKS("run_tasks") -} diff --git a/app/src/main/java/io/github/hddq/restoid/work/OperationRequests.kt b/app/src/main/java/io/github/hddq/restoid/work/OperationRequests.kt deleted file mode 100644 index 67522c04..00000000 --- a/app/src/main/java/io/github/hddq/restoid/work/OperationRequests.kt +++ /dev/null @@ -1,103 +0,0 @@ -package io.github.hddq.restoid.work - -import kotlinx.serialization.Serializable - -@Serializable -data class BackupTypeSelection( - val apk: Boolean = true, - val data: Boolean = true, - val deviceProtectedData: Boolean = true, - val externalData: Boolean = false, - val obb: Boolean = false, - val media: Boolean = false, - val permissions: Boolean = true -) - -@Serializable -data class RestoreTypeSelection( - val apk: Boolean = true, - val data: Boolean = true, - val deviceProtectedData: Boolean = true, - val externalData: Boolean = false, - val obb: Boolean = false, - val media: Boolean = false, - val permissions: Boolean = true -) - -@Serializable -data class RestoreAppSelection( - val packageName: String, - val appName: String -) - -@Serializable -data class BackupWorkRequest( - val repositoryKey: String, - val backupTypes: BackupTypeSelection, - val selectedPackageNames: List, - val appBackupTypes: Map = emptyMap(), - val customDirectories: List = emptyList() -) - -@Serializable -data class RestoreWorkRequest( - val repositoryKey: String, - val snapshotId: String, - val restoreTypes: RestoreTypeSelection, - val allowDowngrade: Boolean, - val selectedApps: List, - val appRestoreTypes: Map = emptyMap(), - val selectedCustomDirectories: List = emptyList() -) - -@Serializable -data class MaintenanceWorkRequest( - val repositoryKey: String, - val checkRepo: Boolean, - val pruneRepo: Boolean, - val unlockRepo: Boolean, - val readData: Boolean, - val forgetSnapshots: Boolean, - val keepLast: Int, - val keepDaily: Int, - val keepWeekly: Int, - val keepMonthly: Int -) - -@Serializable -data class RunTasksConfig( - val backupEnabled: Boolean, - val backupTypes: BackupTypeSelection, - val selectedPackageNames: List, - val appBackupTypes: Map = emptyMap(), - val customDirectories: List = emptyList(), - val unlockRepo: Boolean, - val forgetSnapshots: Boolean, - val pruneRepo: Boolean, - val checkRepo: Boolean, - val readData: Boolean, - val keepLast: Int, - val keepDaily: Int, - val keepWeekly: Int, - val keepMonthly: Int -) - -@Serializable -data class RunTasksWorkRequest( - val repositoryKey: String, - val backupEnabled: Boolean, - val backupTypes: BackupTypeSelection, - val selectedPackageNames: List, - val appBackupTypes: Map = emptyMap(), - val customDirectories: List = emptyList(), - val unlockRepo: Boolean, - val forgetSnapshots: Boolean, - val pruneRepo: Boolean, - val checkRepo: Boolean, - val readData: Boolean, - val keepLast: Int, - val keepDaily: Int, - val keepWeekly: Int, - val keepMonthly: Int, - val scheduleName: String? = null -) diff --git a/app/src/main/java/io/github/hddq/restoid/work/OperationRunResult.kt b/app/src/main/java/io/github/hddq/restoid/work/OperationRunResult.kt deleted file mode 100644 index d5cc8f8b..00000000 --- a/app/src/main/java/io/github/hddq/restoid/work/OperationRunResult.kt +++ /dev/null @@ -1,8 +0,0 @@ -package io.github.hddq.restoid.work - -import io.github.hddq.restoid.ui.shared.OperationProgress - -data class OperationRunResult( - val success: Boolean, - val progress: OperationProgress -) diff --git a/app/src/main/java/io/github/hddq/restoid/work/OperationStageContext.kt b/app/src/main/java/io/github/hddq/restoid/work/OperationStageContext.kt deleted file mode 100644 index be649c0d..00000000 --- a/app/src/main/java/io/github/hddq/restoid/work/OperationStageContext.kt +++ /dev/null @@ -1,8 +0,0 @@ -package io.github.hddq.restoid.work - -data class OperationStageContext( - val completedStagesBefore: Int, - val totalStages: Int -) { - fun absoluteStage(localStage: Int): Int = completedStagesBefore + localStage -} diff --git a/app/src/main/java/io/github/hddq/restoid/work/OperationWorkContract.kt b/app/src/main/java/io/github/hddq/restoid/work/OperationWorkContract.kt deleted file mode 100644 index ca9668a4..00000000 --- a/app/src/main/java/io/github/hddq/restoid/work/OperationWorkContract.kt +++ /dev/null @@ -1,99 +0,0 @@ -package io.github.hddq.restoid.work - -import androidx.work.Data -import io.github.hddq.restoid.data.OperationType -import io.github.hddq.restoid.ui.shared.OperationProgress - -object OperationWorkContract { - const val UNIQUE_WORK_NAME = "restoid_heavy_operation" - const val TAG_HEAVY_OPERATION = "restoid_heavy_operation" - - const val INPUT_OPERATION_TYPE = "input_operation_type" - const val INPUT_REQUEST_ID = "input_request_id" - - private const val KEY_OPERATION_TYPE = "operation_type" - private const val KEY_STAGE_TITLE = "stage_title" - private const val KEY_STAGE_PERCENTAGE = "stage_percentage" - private const val KEY_OVERALL_PERCENTAGE = "overall_percentage" - private const val KEY_TOTAL_FILES = "total_files" - private const val KEY_FILES_PROCESSED = "files_processed" - private const val KEY_TOTAL_BYTES = "total_bytes" - private const val KEY_BYTES_PROCESSED = "bytes_processed" - private const val KEY_CURRENT_FILE = "current_file" - private const val KEY_ELAPSED_TIME = "elapsed_time" - private const val KEY_ERROR = "error" - private const val KEY_IS_FINISHED = "is_finished" - private const val KEY_FINAL_SUMMARY = "final_summary" - private const val KEY_SNAPSHOT_ID = "snapshot_id" - private const val KEY_FILES_NEW = "files_new" - private const val KEY_FILES_CHANGED = "files_changed" - private const val KEY_DATA_ADDED = "data_added" - private const val KEY_TOTAL_DURATION = "total_duration" - private const val KEY_SUCCESS = "success" - - private const val MAX_SUMMARY_LENGTH = 3500 - - fun parseOperationType(raw: String?): OperationType? { - return raw?.let { value -> runCatching { OperationType.valueOf(value) }.getOrNull() } - } - - fun progressToData(operationType: OperationType, progress: OperationProgress): Data { - return Data.Builder() - .putString(KEY_OPERATION_TYPE, operationType.name) - .putString(KEY_STAGE_TITLE, progress.stageTitle) - .putFloat(KEY_STAGE_PERCENTAGE, progress.stagePercentage) - .putFloat(KEY_OVERALL_PERCENTAGE, progress.overallPercentage) - .putInt(KEY_TOTAL_FILES, progress.totalFiles) - .putInt(KEY_FILES_PROCESSED, progress.filesProcessed) - .putLong(KEY_TOTAL_BYTES, progress.totalBytes) - .putLong(KEY_BYTES_PROCESSED, progress.bytesProcessed) - .putString(KEY_CURRENT_FILE, progress.currentFile) - .putLong(KEY_ELAPSED_TIME, progress.elapsedTime) - .putString(KEY_ERROR, progress.error) - .putBoolean(KEY_IS_FINISHED, progress.isFinished) - .putString(KEY_FINAL_SUMMARY, progress.finalSummary.take(MAX_SUMMARY_LENGTH)) - .putString(KEY_SNAPSHOT_ID, progress.snapshotId) - .putInt(KEY_FILES_NEW, progress.filesNew) - .putInt(KEY_FILES_CHANGED, progress.filesChanged) - .putLong(KEY_DATA_ADDED, progress.dataAdded) - .putDouble(KEY_TOTAL_DURATION, progress.totalDuration) - .build() - } - - fun outputToData(operationType: OperationType, success: Boolean, progress: OperationProgress): Data { - return Data.Builder() - .putAll(progressToData(operationType, progress)) - .putBoolean(KEY_SUCCESS, success) - .build() - } - - fun operationTypeFromData(data: Data): OperationType? { - return parseOperationType(data.getString(KEY_OPERATION_TYPE)) - } - - fun progressFromData(data: Data): OperationProgress { - return OperationProgress( - stageTitle = data.getString(KEY_STAGE_TITLE).orEmpty(), - stagePercentage = data.getFloat(KEY_STAGE_PERCENTAGE, 0f), - overallPercentage = data.getFloat(KEY_OVERALL_PERCENTAGE, 0f), - totalFiles = data.getInt(KEY_TOTAL_FILES, 0), - filesProcessed = data.getInt(KEY_FILES_PROCESSED, 0), - totalBytes = data.getLong(KEY_TOTAL_BYTES, 0L), - bytesProcessed = data.getLong(KEY_BYTES_PROCESSED, 0L), - currentFile = data.getString(KEY_CURRENT_FILE).orEmpty(), - elapsedTime = data.getLong(KEY_ELAPSED_TIME, 0L), - error = data.getString(KEY_ERROR), - isFinished = data.getBoolean(KEY_IS_FINISHED, false), - finalSummary = data.getString(KEY_FINAL_SUMMARY).orEmpty(), - snapshotId = data.getString(KEY_SNAPSHOT_ID), - filesNew = data.getInt(KEY_FILES_NEW, 0), - filesChanged = data.getInt(KEY_FILES_CHANGED, 0), - dataAdded = data.getLong(KEY_DATA_ADDED, 0L), - totalDuration = data.getDouble(KEY_TOTAL_DURATION, 0.0) - ) - } - - fun successFromData(data: Data): Boolean { - return data.getBoolean(KEY_SUCCESS, false) - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/work/OperationWorkRepository.kt b/app/src/main/java/io/github/hddq/restoid/work/OperationWorkRepository.kt deleted file mode 100644 index b56d6602..00000000 --- a/app/src/main/java/io/github/hddq/restoid/work/OperationWorkRepository.kt +++ /dev/null @@ -1,203 +0,0 @@ -package io.github.hddq.restoid.work - -import android.content.Context -import androidx.work.ExistingWorkPolicy -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.WorkInfo -import androidx.work.WorkManager -import com.topjohnwu.superuser.Shell -import io.github.hddq.restoid.R -import io.github.hddq.restoid.data.OperationRuntimeRepository -import io.github.hddq.restoid.data.OperationRuntimeState -import io.github.hddq.restoid.data.OperationType -import io.github.hddq.restoid.ui.shared.OperationProgress -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import java.util.concurrent.ExecutionException - -class OperationWorkRepository( - context: Context, - private val requestStore: OperationRequestStore, - private val runtimeRepository: OperationRuntimeRepository -) { - private val appContext = context.applicationContext - private val workManager = WorkManager.getInstance(appContext) - private val enqueueMutex = Mutex() - private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - - val operationState: StateFlow = runtimeRepository.state - - suspend fun enqueueBackup(request: BackupWorkRequest): Boolean { - return enqueueOperation( - operationType = OperationType.BACKUP, - requestIdProvider = { requestStore.saveBackupRequest(request) } - ) - } - - suspend fun enqueueRestore(request: RestoreWorkRequest): Boolean { - return enqueueOperation( - operationType = OperationType.RESTORE, - requestIdProvider = { requestStore.saveRestoreRequest(request) } - ) - } - - suspend fun enqueueRunTasks(request: RunTasksWorkRequest): Boolean { - return enqueueOperation( - operationType = OperationType.RUN_TASKS, - requestIdProvider = { requestStore.saveRunTasksRequest(request) } - ) - } - - suspend fun enqueueMaintenance(request: MaintenanceWorkRequest): Boolean { - return enqueueOperation( - operationType = OperationType.MAINTENANCE, - requestIdProvider = { requestStore.saveMaintenanceRequest(request) } - ) - } - - fun clearFinished(operationType: OperationType? = null) { - runtimeRepository.clearFinished(operationType) - } - - suspend fun reconcileStateWithWorkManager() { - if (!hasActiveWork()) { - runtimeRepository.clearStaleRunningState() - } - } - - fun cancelCurrentOperation() { - val current = runtimeRepository.state.value - val operationType = current.operationType ?: return - if (!current.isRunning) return - if (current.stopRequested) return - - runtimeRepository.markStopRequested( - operationType, - current.progress.copy( - stageTitle = appContext.getString(R.string.progress_stopping), - isFinished = false - ) - ) - - repositoryScope.launch { - workManager.cancelUniqueWork(OperationWorkContract.UNIQUE_WORK_NAME) - workManager.cancelAllWorkByTag(OperationWorkContract.TAG_HEAVY_OPERATION) - runCatching { Shell.getCachedShell()?.close() } - finalizeCancellationIfNeeded(operationType) - } - } - - private suspend fun finalizeCancellationIfNeeded(operationType: OperationType) { - var activeWork = hasActiveWork() - var attempts = 0 - while (activeWork && attempts < CANCEL_FINALIZE_RETRY_COUNT) { - delay(CANCEL_FINALIZE_RETRY_DELAY_MS) - activeWork = hasActiveWork() - attempts += 1 - } - - val state = runtimeRepository.state.value - if (state.operationType != operationType) return - if (!state.isRunning && !state.stopRequested) return - if (state.success == true && state.progress.isFinished) return - - val interruptedSummary = appContext.getString(R.string.operation_interrupted) - runtimeRepository.markFinished( - operationType = operationType, - success = false, - progress = state.progress.copy( - stageTitle = appContext.getString( - R.string.progress_operation_failed, - operationLabel(operationType) - ), - isFinished = true, - error = interruptedSummary, - finalSummary = interruptedSummary - ) - ) - } - - private fun operationLabel(operationType: OperationType): String { - return when (operationType) { - OperationType.BACKUP -> appContext.getString(R.string.operation_backup) - OperationType.RUN_TASKS -> appContext.getString(R.string.operation_run_tasks) - OperationType.RESTORE -> appContext.getString(R.string.operation_restore) - OperationType.MAINTENANCE -> appContext.getString(R.string.operation_maintenance) - } - } - - private suspend fun enqueueOperation( - operationType: OperationType, - requestIdProvider: () -> String - ): Boolean = enqueueMutex.withLock { - withContext(Dispatchers.IO) { - val current = runtimeRepository.state.value - if (current.isRunning || current.stopRequested || hasActiveWork()) { - return@withContext false - } - - val requestId = requestIdProvider() - val initialProgress = OperationProgress(stageTitle = appContext.getString(R.string.progress_initializing)) - runtimeRepository.markEnqueued(operationType, initialProgress) - - return@withContext try { - val workRequest = OneTimeWorkRequestBuilder() - .setInputData( - androidx.work.Data.Builder() - .putString(OperationWorkContract.INPUT_OPERATION_TYPE, operationType.name) - .putString(OperationWorkContract.INPUT_REQUEST_ID, requestId) - .build() - ) - .addTag(OperationWorkContract.TAG_HEAVY_OPERATION) - .build() - - workManager.enqueueUniqueWork( - OperationWorkContract.UNIQUE_WORK_NAME, - ExistingWorkPolicy.KEEP, - workRequest - ) - true - } catch (e: Exception) { - requestStore.deleteRequest(requestId) - runtimeRepository.markFinished( - operationType, - success = false, - progress = OperationProgress( - isFinished = true, - error = e.message ?: appContext.getString(R.string.error_operation_already_running), - finalSummary = e.message ?: appContext.getString(R.string.summary_operation_already_running) - ) - ) - false - } - } - } - - private suspend fun hasActiveWork(): Boolean = withContext(Dispatchers.IO) { - val infos = runCatching { - workManager.getWorkInfosForUniqueWork(OperationWorkContract.UNIQUE_WORK_NAME).get() - }.getOrElse { - if (it is InterruptedException || it is ExecutionException) { - return@withContext false - } - throw it - } - infos.any { info -> - info.state == WorkInfo.State.ENQUEUED || - info.state == WorkInfo.State.RUNNING || - info.state == WorkInfo.State.BLOCKED - } - } - - private companion object { - const val CANCEL_FINALIZE_RETRY_COUNT = 30 - const val CANCEL_FINALIZE_RETRY_DELAY_MS = 100L - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/work/RestoreOperationRunner.kt b/app/src/main/java/io/github/hddq/restoid/work/RestoreOperationRunner.kt deleted file mode 100644 index c49b1214..00000000 --- a/app/src/main/java/io/github/hddq/restoid/work/RestoreOperationRunner.kt +++ /dev/null @@ -1,703 +0,0 @@ -package io.github.hddq.restoid.work - -import android.content.Context -import android.os.UserHandle -import android.util.Log -import com.topjohnwu.superuser.CallbackList -import com.topjohnwu.superuser.Shell -import io.github.hddq.restoid.R -import io.github.hddq.restoid.data.MetadataRepository -import io.github.hddq.restoid.data.OperationLockManager -import io.github.hddq.restoid.data.RepositoriesRepository -import io.github.hddq.restoid.data.RepositoryBackendType -import io.github.hddq.restoid.data.ResticBinaryManager -import io.github.hddq.restoid.data.ResticRepository -import io.github.hddq.restoid.data.ResticState -import io.github.hddq.restoid.data.SnapshotInfo -import io.github.hddq.restoid.ui.shared.OperationProgress -import io.github.hddq.restoid.util.ResticOutputParser -import io.github.hddq.restoid.util.buildResticOptionFlags -import io.github.hddq.restoid.util.buildShellEnvironmentPrefix -import io.github.hddq.restoid.util.shellQuote -import java.io.File -import java.util.Locale - -class RestoreOperationRunner( - private val context: Context, - private val repositoriesRepository: RepositoriesRepository, - private val resticBinaryManager: ResticBinaryManager, - private val resticRepository: ResticRepository, - private val metadataRepository: MetadataRepository, - private val operationLockManager: OperationLockManager -) { - - suspend fun run( - request: RestoreWorkRequest, - onProgress: (OperationProgress) -> Unit, - shouldStop: () -> Boolean = { false } - ): OperationRunResult { - fun throwIfCancelled() { - if (shouldStop()) { - throw OperationCancelledException(context.getString(R.string.operation_interrupted)) - } - } - - val startTime = System.currentTimeMillis() - var progressState = OperationProgress(stageTitle = context.getString(R.string.progress_initializing)) - onProgress(progressState) - throwIfCancelled() - - var operationLockAcquired = false - var tempRestoreDir: File? = null - var passwordFile: File? = null - var successes = 0 - var failures = 0 - val failureDetails = mutableListOf() - - val selectedAppPackages = request.selectedApps.map { it.packageName } - val selectedAppNames = request.selectedApps.associate { it.packageName to it.appName } - val effectiveRestoreTypes = selectedAppPackages.associateWith { packageName -> - request.appRestoreTypes[packageName] ?: request.restoreTypes - } - - val apkRestoreSelected = effectiveRestoreTypes.values.any { it.apk } - val anyDataRestoreSelected = effectiveRestoreTypes.values.any { it.data || it.deviceProtectedData || it.externalData || it.obb || it.media } - val permissionsRestoreSelected = effectiveRestoreTypes.values.any { it.permissions } - - val stageList = mutableListOf(context.getString(R.string.restore_stage_restore_files)) - if (apkRestoreSelected || anyDataRestoreSelected || permissionsRestoreSelected) stageList.add(context.getString(R.string.restore_stage_processing_apps)) - if (request.selectedCustomDirectories.isNotEmpty()) stageList.add(context.getString(R.string.restore_stage_processing_custom_directories)) - stageList.add(context.getString(R.string.restore_stage_cleanup)) - val totalStages = stageList.size - var currentStageNum = 1 - - val finalResult = try { - throwIfCancelled() - val resticState = resticBinaryManager.resticState.value - val selectedRepository = repositoriesRepository.getRepositoryByKey(request.repositoryKey) - val selectedRepoPath = selectedRepository?.path - - if (resticState !is ResticState.Installed || selectedRepoPath == null) { - throw IllegalStateException(context.getString(R.string.restore_error_preflight_failed)) - } - - if (selectedAppPackages.isEmpty() && request.selectedCustomDirectories.isEmpty()) { - throw IllegalStateException(context.getString(R.string.restore_error_no_items_selected)) - } - - val currentSnapshot = findSnapshot(request) - ?: throw IllegalStateException(context.getString(R.string.error_snapshot_not_found)) - - operationLockManager.acquire(selectedRepository.backendType) - operationLockAcquired = true - - if (selectedRepository.backendType == RepositoryBackendType.SFTP && !repositoriesRepository.hasSftpCredentials(request.repositoryKey)) { - throw IllegalStateException(context.getString(R.string.error_sftp_password_not_found_for_repository)) - } - - if ( - selectedRepository.backendType == RepositoryBackendType.REST && - selectedRepository.restAuthRequired && - !repositoriesRepository.hasRestCredentials(request.repositoryKey) - ) { - throw IllegalStateException(context.getString(R.string.error_rest_credentials_not_found_for_repository)) - } - - if ( - selectedRepository.backendType == RepositoryBackendType.S3 && - selectedRepository.s3AuthRequired && - !repositoriesRepository.hasS3Credentials(request.repositoryKey) - ) { - throw IllegalStateException(context.getString(R.string.error_s3_credentials_not_found_for_repository)) - } - - val password = repositoriesRepository.getRepositoryPassword(request.repositoryKey) - ?: throw IllegalStateException(context.getString(R.string.restore_error_password_not_found)) - - passwordFile = File.createTempFile("restic-pass", ".tmp", context.cacheDir) - passwordFile.writeText(password) - - tempRestoreDir = File(context.cacheDir, "restic-restore-${System.currentTimeMillis()}").also { it.mkdirs() } - - val pathsToRestore = generatePathsToRestore(selectedAppPackages, currentSnapshot, request.restoreTypes, request.appRestoreTypes).toMutableList() - pathsToRestore.addAll(request.selectedCustomDirectories) - val metadata = selectedRepository.id?.let { repoId -> - metadataRepository.getMetadataForSnapshot(repoId, currentSnapshot.id) - } - - if (pathsToRestore.isEmpty() && !permissionsRestoreSelected) { - throw IllegalStateException(context.getString(R.string.restore_error_no_files_found)) - } - - val restoreStageTitle = "[${currentStageNum}/${totalStages}] ${stageList[0]}" - if (pathsToRestore.isNotEmpty()) { - val includes = pathsToRestore.joinToString(" ") { "--include ${shellQuote(it)}" } - val commandEnvironment = linkedMapOf( - "HOME" to context.filesDir.absolutePath, - "TMPDIR" to context.cacheDir.absolutePath - ).apply { - putAll(repositoriesRepository.getExecutionEnvironmentVariables(request.repositoryKey)) - } - val envPrefix = buildShellEnvironmentPrefix(commandEnvironment) - val resticOptionFlags = buildResticOptionFlags( - repositoriesRepository.getExecutionResticOptions(request.repositoryKey) - ) - - val command = buildString { - if (envPrefix.isNotEmpty()) append(envPrefix).append(' ') - append("RESTIC_PASSWORD_FILE=").append(shellQuote(passwordFile.absolutePath)).append(' ') - append("RESTIC_CACHE_DIR=").append(shellQuote(File(context.cacheDir, "restic").absolutePath)).append(' ') - append(shellQuote(resticState.path)).append(' ') - append("--retry-lock 5s ") - if (resticOptionFlags.isNotEmpty()) append(resticOptionFlags).append(' ') - append("-r ") - append(shellQuote(selectedRepoPath)).append(' ') - append("restore ").append(currentSnapshot.id).append(" --target ") - append(shellQuote(tempRestoreDir.absolutePath)).append(' ') - append("--exclude-xattr 'security.selinux' ") - append(includes) - append(" --json") - } - - val stdoutCallback = object : CallbackList() { - override fun onAddElement(line: String) { - ResticOutputParser.parse(line, context)?.let { progressUpdate -> - val elapsedTime = (System.currentTimeMillis() - startTime) / 1000 - val newProgress = if (progressUpdate.isFinished) { - progressState.copy( - isFinished = false, - stageTitle = restoreStageTitle, - stagePercentage = 1f, - overallPercentage = currentStageNum.toFloat() / totalStages.toFloat(), - elapsedTime = elapsedTime, - finalSummary = "" - ) - } else { - progressUpdate.copy( - stageTitle = restoreStageTitle, - overallPercentage = ((currentStageNum - 1) + progressUpdate.stagePercentage) / totalStages.toFloat(), - elapsedTime = elapsedTime, - isFinished = false - ) - } - progressState = newProgress - onProgress(newProgress) - } - } - } - val stderr = mutableListOf() - val restoreResult = Shell.cmd(command).to(stdoutCallback, stderr).exec() - throwIfCancelled() - - if (!restoreResult.isSuccess) { - val errorOutput = stderr.joinToString("\n") - throw IllegalStateException(if (errorOutput.isEmpty()) context.getString(R.string.restore_error_command_failed) else errorOutput) - } - } else { - progressState = progressState.copy( - stageTitle = restoreStageTitle, - stagePercentage = 1f, - overallPercentage = currentStageNum.toFloat() / totalStages.toFloat(), - elapsedTime = (System.currentTimeMillis() - startTime) / 1000, - isFinished = false - ) - onProgress(progressState) - } - - val processingAppsStageIndex = stageList.indexOf(context.getString(R.string.restore_stage_processing_apps)) - if (processingAppsStageIndex != -1) { - currentStageNum = processingAppsStageIndex + 1 - val processingStageTitle = context.getString(R.string.restore_stage_processing_template_apps, currentStageNum, totalStages) - - for ((index, packageName) in selectedAppPackages.withIndex()) { - throwIfCancelled() - val appName = selectedAppNames[packageName] ?: packageName - var appProcessSuccess = true - val processProgress = (index + 1).toFloat() / selectedAppPackages.size.toFloat() - - progressState = progressState.copy( - stageTitle = processingStageTitle, - stagePercentage = processProgress, - overallPercentage = (processingAppsStageIndex + processProgress) / totalStages.toFloat(), - currentFile = appName, - filesProcessed = index + 1, - totalFiles = selectedAppPackages.size + request.selectedCustomDirectories.size, - isFinished = false, - elapsedTime = (System.currentTimeMillis() - startTime) / 1000 - ) - onProgress(progressState) - - val appRestoreTypes = effectiveRestoreTypes[packageName] ?: request.restoreTypes - - if (appRestoreTypes.apk) { - throwIfCancelled() - val originalApkPath = pathsToRestore.find { it.startsWith("/data/app/") && it.contains("/$packageName-") } - val restoredContentDir = originalApkPath?.let { File(tempRestoreDir, it.drop(1)) } - val apkFiles = restoredContentDir?.walk()?.filter { it.isFile && it.extension == "apk" }?.toList() ?: emptyList() - - if (apkFiles.isNotEmpty()) { - val targetUserId = getCurrentUserId() - val installFlags = buildString { - append("--user $targetUserId ") - if (request.allowDowngrade) append("-r -d") else append("-r") - } - val createResult = Shell.cmd("pm install-create $installFlags").exec() - val sessionId = createResult.out.firstOrNull()?.substringAfterLast('[')?.substringBefore(']') - - if (createResult.isSuccess && sessionId != null) { - var allWritesSucceeded = true - apkFiles.forEachIndexed { splitIndex, apkFile -> - val writeCmd = "pm install-write -S ${apkFile.length()} $sessionId '${splitIndex}_${apkFile.name}' '${apkFile.absolutePath}'" - if (!Shell.cmd(writeCmd).exec().isSuccess) { - allWritesSucceeded = false - return@forEachIndexed - } - } - - if (allWritesSucceeded) { - val commitResult = Shell.cmd("pm install-commit $sessionId").exec() - if (!commitResult.isSuccess || !commitResult.out.any { it.contains("Success") }) { - appProcessSuccess = false - failureDetails.add( - context.getString( - R.string.restore_failure_install_commit, - appName, - commitResult.err.joinToString(" ") - ) - ) - } - } else { - appProcessSuccess = false - failureDetails.add(context.getString(R.string.restore_failure_write_apk_splits, appName)) - Shell.cmd("pm install-abandon $sessionId").exec() - } - } else { - appProcessSuccess = false - failureDetails.add(context.getString(R.string.restore_failure_create_install_session, appName)) - } - } else { - appProcessSuccess = false - failureDetails.add(context.getString(R.string.restore_failure_no_apk_files, appName)) - } - } - - if (appProcessSuccess && with(appRestoreTypes) { data || deviceProtectedData || externalData || obb || media }) { - if (!moveRestoredDataAndFixPerms(packageName, tempRestoreDir, appRestoreTypes)) { - appProcessSuccess = false - failureDetails.add(context.getString(R.string.restore_failure_data_restore, appName)) - } - } - - val permissionsToRestore = if (appRestoreTypes.permissions) { - metadata?.apps?.get(packageName)?.grantedRuntimePermissions.orEmpty() - } else { - emptyList() - } - if (permissionsToRestore.isNotEmpty()) { - if (!isPackageInstalled(packageName)) { - appProcessSuccess = false - failureDetails.add(context.getString(R.string.restore_failure_permissions_app_not_installed, appName)) - } else { - val permissionFailures = restoreGrantedRuntimePermissions(packageName, permissionsToRestore) - if (permissionFailures.isNotEmpty()) { - appProcessSuccess = false - failureDetails.add( - context.getString( - R.string.restore_failure_permissions_partial, - appName, - permissionFailures.joinToString(", ") - ) - ) - } - } - } - - if (appProcessSuccess) successes++ else failures++ - } - } else { - successes = selectedAppPackages.size - } - - val customDirsStageIndex = stageList.indexOf(context.getString(R.string.restore_stage_processing_custom_directories)) - if (customDirsStageIndex != -1) { - currentStageNum = customDirsStageIndex + 1 - val processingStageTitle = context.getString(R.string.restore_stage_processing_template_custom_directories, currentStageNum, totalStages) - - for ((index, path) in request.selectedCustomDirectories.withIndex()) { - throwIfCancelled() - var processSuccess = true - val processProgress = (index + 1).toFloat() / request.selectedCustomDirectories.size.toFloat() - - progressState = progressState.copy( - stageTitle = processingStageTitle, - stagePercentage = processProgress, - overallPercentage = (customDirsStageIndex + processProgress) / totalStages.toFloat(), - currentFile = path.substringAfterLast("/").ifEmpty { path }, - filesProcessed = successes + 1, - totalFiles = selectedAppPackages.size + request.selectedCustomDirectories.size, - isFinished = false, - elapsedTime = (System.currentTimeMillis() - startTime) / 1000 - ) - onProgress(progressState) - - // sourcePath is tempRestoreDir + path (since path is absolute e.g. /sdcard/X3, it will be tempRestoreDir/sdcard/X3) - val sourcePath = File(tempRestoreDir, path.drop(1)).absolutePath - val targetFile = File(path) - val parent = targetFile.parentFile - - if (parent != null) { - Shell.cmd("mkdir -p ${shellQuote(parent.absolutePath)}").exec() - } - - if (Shell.cmd("[ -e ${shellQuote(sourcePath)} ]").exec().isSuccess) { - Shell.cmd("rm -rf ${shellQuote(path)}").exec() - val copyResult = Shell.cmd("cp -R ${shellQuote(sourcePath)} ${shellQuote(path)}").exec() - if (!copyResult.isSuccess) { - processSuccess = false - failureDetails.add(context.getString(R.string.restore_failure_custom_directory, path)) - } else { - if (path.startsWith("/storage/") || path.startsWith("/sdcard/")) { - val ownerResult = Shell.cmd("stat -c '%u:%g' ${shellQuote(parent?.absolutePath ?: "/sdcard")}").exec() - val owner = ownerResult.out.firstOrNull()?.trim() - if (owner != null && owner.isNotEmpty()) { - Shell.cmd("chown -R $owner ${shellQuote(path)}").exec() - } - } - } - } else { - processSuccess = false - failureDetails.add(context.getString(R.string.restore_failure_custom_directory, path)) - } - - if (processSuccess) successes++ else failures++ - } - } - - currentStageNum = totalStages - val cleanupStageTitle = "[${currentStageNum}/${totalStages}] ${context.getString(R.string.restore_stage_cleanup)}" - progressState = progressState.copy( - stageTitle = cleanupStageTitle, - stagePercentage = 0f, - overallPercentage = (totalStages - 1).toFloat() / totalStages.toFloat(), - isFinished = false, - elapsedTime = (System.currentTimeMillis() - startTime) / 1000 - ) - onProgress(progressState) - - tempRestoreDir.let { dir -> Shell.cmd("rm -rf '${dir.absolutePath}'").exec() } - progressState = progressState.copy(stagePercentage = 1f, overallPercentage = 1f) - onProgress(progressState) - throwIfCancelled() - - val finalElapsedTime = (System.currentTimeMillis() - startTime) / 1000 - val summary = buildString { - append(context.getString(R.string.restore_summary_finished_in, formatElapsedTime(finalElapsedTime))) - append( - context.resources.getQuantityString( - R.plurals.restore_summary_processed_items, - successes, - successes - ) - ) - if (failures > 0) { - append( - context.resources.getQuantityString( - R.plurals.restore_summary_failed_items, - failures, - failures - ) - ) - } - if (failureDetails.isNotEmpty()) { - append(context.getString(R.string.restore_summary_details, failureDetails.joinToString("\n- "))) - } - } - - val finalProgress = OperationProgress( - isFinished = true, - finalSummary = summary, - error = if (failures > 0) failureDetails.joinToString(", ") else null, - elapsedTime = finalElapsedTime, - filesProcessed = successes, - totalFiles = selectedAppPackages.size - ) - - OperationRunResult(success = failures == 0, progress = finalProgress) - } catch (e: OperationCancelledException) { - val finalProgress = progressState.copy( - isFinished = true, - error = context.getString(R.string.operation_interrupted), - finalSummary = context.getString(R.string.operation_interrupted), - elapsedTime = (System.currentTimeMillis() - startTime) / 1000 - ) - OperationRunResult(success = false, progress = finalProgress) - } catch (e: Exception) { - val finalProgress = progressState.copy( - isFinished = true, - error = context.getString(R.string.error_fatal_with_message, e.message ?: ""), - finalSummary = context.getString(R.string.error_fatal_with_message, e.message ?: ""), - elapsedTime = (System.currentTimeMillis() - startTime) / 1000 - ) - OperationRunResult(success = false, progress = finalProgress) - } finally { - if (operationLockAcquired) { - operationLockManager.release() - } - passwordFile?.delete() - tempRestoreDir?.let { dir -> Shell.cmd("rm -rf '${dir.absolutePath}'").exec() } - } - - return finalResult - } - - private suspend fun findSnapshot(request: RestoreWorkRequest): SnapshotInfo? { - val repository = repositoriesRepository.getRepositoryByKey(request.repositoryKey) ?: return null - val password = repositoriesRepository.getRepositoryPassword(request.repositoryKey) ?: return null - val snapshotsResult = resticRepository.getSnapshots( - repository.path, - password, - repositoriesRepository.getExecutionEnvironmentVariables(request.repositoryKey), - repositoriesRepository.getExecutionResticOptions(request.repositoryKey) - ) - val snapshots = snapshotsResult.getOrNull() ?: return null - return snapshots.find { it.id == request.snapshotId } ?: snapshots.find { it.id.startsWith(request.snapshotId) } - } - private fun getCurrentUserId(): Int { - return try { - UserHandle::class.java.getMethod("myUserId").invoke(null) as Int - } catch (_: Exception) { - android.os.Process.myUid() / 100000 - } - } - - private fun ceDataPath(userId: Int, packageName: String) = "/data/user/$userId/$packageName" - - private fun deDataPath(userId: Int, packageName: String) = "/data/user_de/$userId/$packageName" - - private fun externalDataPath(userId: Int, packageName: String) = "/storage/emulated/$userId/Android/data/$packageName" - - private fun obbPath(userId: Int, packageName: String) = "/storage/emulated/$userId/Android/obb/$packageName" - - private fun mediaPath(userId: Int, packageName: String) = "/storage/emulated/$userId/Android/media/$packageName" - - private fun generatePathsToRestore( - selectedPackageNames: List, - snapshot: SnapshotInfo, - defaultTypes: RestoreTypeSelection, - appRestoreTypes: Map - ): List { - val paths = mutableListOf() - - selectedPackageNames.forEach { pkg -> - val types = appRestoreTypes[pkg] ?: defaultTypes - - fun addApkPathIfExists() { - snapshot.paths.find { it.startsWith("/data/app/") && it.contains("/$pkg-") }?.let { paths.add(it) } - } - - fun addDataPaths() { - val escapedPackageName = Regex.escape(pkg) - snapshot.paths.filter { - it == "/data/data/$pkg" || it.matches(Regex("^/data/user/\\d+/$escapedPackageName$")) - }.forEach { paths.add(it) } - } - - fun addMatchingProfilePath(pattern: String) { - val regex = Regex(pattern.format(Regex.escape(pkg))) - snapshot.paths.filter { it.matches(regex) }.forEach { paths.add(it) } - } - - if (types.apk) addApkPathIfExists() - if (types.data) addDataPaths() - if (types.deviceProtectedData) addMatchingProfilePath("^/data/user_de/\\d+/%s$") - if (types.externalData) addMatchingProfilePath("^/storage/emulated/\\d+/Android/data/%s$") - if (types.obb) addMatchingProfilePath("^/storage/emulated/\\d+/Android/obb/%s$") - if (types.media) addMatchingProfilePath("^/storage/emulated/\\d+/Android/media/%s$") - } - - return paths.distinct() - } - - private fun moveRestoredDataAndFixPerms( - packageName: String, - tempRestoreDir: File, - types: RestoreTypeSelection - ): Boolean { - var allSucceeded = true - - val currentUserId = getCurrentUserId() - val targetDestination = ceDataPath(currentUserId, packageName) - val ownerResult = Shell.cmd("stat -c '%u:%g' ${shellQuote(targetDestination)}").exec() - if (!ownerResult.isSuccess || ownerResult.out.isEmpty()) return false - val owner = ownerResult.out.first().trim() - - val dataMappings = mutableListOf>() - if (types.data) { - val legacySource = File(tempRestoreDir, "data/data/$packageName") - if (Shell.cmd("[ -e '${legacySource.absolutePath}' ]").exec().isSuccess) { - dataMappings.add(legacySource to targetDestination) - } else { - findRestoredProfilePackageDir(tempRestoreDir, "data/user", 2, packageName)?.let { - dataMappings.add(it to targetDestination) - } - } - } - if (types.deviceProtectedData) { - findRestoredProfilePackageDir(tempRestoreDir, "data/user_de", 2, packageName)?.let { - dataMappings.add(it to deDataPath(currentUserId, packageName)) - } - } - if (types.externalData) { - findRestoredExternalPackageDir(tempRestoreDir, "data", packageName)?.let { - dataMappings.add(it to externalDataPath(currentUserId, packageName)) - } - } - if (types.obb) { - findRestoredExternalPackageDir(tempRestoreDir, "obb", packageName)?.let { - dataMappings.add(it to obbPath(currentUserId, packageName)) - } - } - if (types.media) { - findRestoredExternalPackageDir(tempRestoreDir, "media", packageName)?.let { - dataMappings.add(it to mediaPath(currentUserId, packageName)) - } - } - - Shell.cmd("am force-stop --user $currentUserId ${shellQuote(packageName)}").exec() - if (dataMappings.any { (_, destination) -> destination.startsWith("/data/user/") || destination.startsWith("/data/user_de/") }) { - val clearPackageResult = Shell.cmd("pm clear --user $currentUserId ${shellQuote(packageName)}").exec() - if (!clearPackageResult.isSuccess) { - allSucceeded = false - Log.w("RestoreOperationRunner", "Failed to clear package data before restoring $packageName") - } - } - - for ((source, destination) in dataMappings) { - if (Shell.cmd("[ -e '${source.absolutePath}' ]").exec().isSuccess) { - Shell.cmd("mkdir -p '$destination'").exec() - val isPrivateAppData = destination.startsWith("/data/user/") || destination.startsWith("/data/user_de/") - if (isPrivateAppData) { - Shell.cmd("restorecon -F ${shellQuote(destination)}").exec() - } - val destinationContext = if (isPrivateAppData) { - val contextResult = Shell.cmd("stat -c '%C' ${shellQuote(destination)}").exec() - contextResult.out.firstOrNull()?.trim()?.takeIf { contextResult.isSuccess && it.isNotEmpty() } - } else { - null - } - val clearResult = Shell.cmd( - "find ${shellQuote(destination)} -mindepth 1 -maxdepth 1 -exec rm -rf '{}' '+'" - ).exec() - if (!clearResult.isSuccess) { - allSucceeded = false - continue - } - - val copyResult = Shell.cmd( - "find ${shellQuote(source.absolutePath)} -mindepth 1 -maxdepth 1 -exec cp -R '{}' ${shellQuote(destination)} ';'" - ).exec() - if (!copyResult.isSuccess) { - allSucceeded = false - continue - } - - if (isPrivateAppData) { - val chownResult = Shell.cmd("chown -R $owner ${shellQuote(destination)}").exec() - if (!chownResult.isSuccess) { - allSucceeded = false - } - } - - val relabelCommand = if (isPrivateAppData && destinationContext != null) { - "chcon -R ${shellQuote(destinationContext)} ${shellQuote(destination)}" - } else if (isPrivateAppData) { - "restorecon -F ${shellQuote(destination)}" - } else { - "restorecon -RF ${shellQuote(destination)}" - } - val relabelResult = Shell.cmd(relabelCommand).exec() - if (!relabelResult.isSuccess) { - allSucceeded = false - Log.w("RestoreOperationRunner", "Failed to relabel restored path $destination") - } - } - } - return allSucceeded - } - - private fun findRestoredProfilePackageDir( - tempRestoreDir: File, - rootRelativePath: String, - packageDepth: Int, - packageName: String - ): File? { - val root = File(tempRestoreDir, rootRelativePath) - if (Shell.cmd("[ -d ${shellQuote(root.absolutePath)} ]").exec().isSuccess.not()) return null - - val findResult = Shell.cmd( - "find ${shellQuote(root.absolutePath)} -mindepth $packageDepth -maxdepth $packageDepth -type d -name ${shellQuote(packageName)}" - ).exec() - return if (findResult.isSuccess) { - findResult.out.firstOrNull()?.trim()?.takeIf { it.isNotEmpty() }?.let { File(it) } - } else { - null - } - } - - private fun findRestoredExternalPackageDir( - tempRestoreDir: File, - androidDirName: String, - packageName: String - ): File? { - val root = File(tempRestoreDir, "storage/emulated") - if (Shell.cmd("[ -d ${shellQuote(root.absolutePath)} ]").exec().isSuccess.not()) return null - - val pathPattern = "*/Android/$androidDirName/$packageName" - val findResult = Shell.cmd( - "find ${shellQuote(root.absolutePath)} -mindepth 4 -maxdepth 4 -type d -path ${shellQuote(pathPattern)}" - ).exec() - return if (findResult.isSuccess) { - findResult.out.firstOrNull()?.trim()?.takeIf { it.isNotEmpty() }?.let { File(it) } - } else { - null - } - } - - private fun restoreGrantedRuntimePermissions(packageName: String, permissions: List): List { - if (permissions.isEmpty()) return emptyList() - - val failures = mutableListOf() - val currentUserId = getCurrentUserId() - permissions.forEach { permission -> - val result = Shell.cmd("pm grant --user $currentUserId ${shellQuote(packageName)} ${shellQuote(permission)}").exec() - if (!result.isSuccess) { - val output = (result.err + result.out).joinToString(" ").trim() - failures.add(if (output.isBlank()) permission else "$permission ($output)") - } - } - return failures - } - - private fun isPackageInstalled(packageName: String): Boolean { - return try { - context.packageManager.getPackageInfo( - packageName, - android.content.pm.PackageManager.PackageInfoFlags.of(0) - ) - true - } catch (_: Exception) { - false - } - } - - private fun formatElapsedTime(seconds: Long): String { - val hours = java.util.concurrent.TimeUnit.SECONDS.toHours(seconds) - val minutes = java.util.concurrent.TimeUnit.SECONDS.toMinutes(seconds) % 60 - val secs = seconds % 60 - return if (hours > 0) { - String.format(Locale.US, "%02d:%02d:%02d", hours, minutes, secs) - } else { - String.format(Locale.US, "%02d:%02d", minutes, secs) - } - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/work/RunTasksOperationRunner.kt b/app/src/main/java/io/github/hddq/restoid/work/RunTasksOperationRunner.kt deleted file mode 100644 index 47585f15..00000000 --- a/app/src/main/java/io/github/hddq/restoid/work/RunTasksOperationRunner.kt +++ /dev/null @@ -1,197 +0,0 @@ -package io.github.hddq.restoid.work - -import android.content.Context -import io.github.hddq.restoid.R -import io.github.hddq.restoid.data.AppInfoRepository -import io.github.hddq.restoid.data.OperationLockManager -import io.github.hddq.restoid.data.RepositoriesRepository -import io.github.hddq.restoid.data.ResticBinaryManager -import io.github.hddq.restoid.data.ResticRepository -import io.github.hddq.restoid.ui.shared.OperationProgress - -class RunTasksOperationRunner( - private val context: Context, - private val repositoriesRepository: RepositoriesRepository, - private val resticBinaryManager: ResticBinaryManager, - private val resticRepository: ResticRepository, - private val appInfoRepository: AppInfoRepository, - private val operationLockManager: OperationLockManager -) { - - private val backupRunner by lazy { - BackupOperationRunner( - context = context, - repositoriesRepository = repositoriesRepository, - resticBinaryManager = resticBinaryManager, - resticRepository = resticRepository, - appInfoRepository = appInfoRepository, - operationLockManager = operationLockManager - ) - } - - private val maintenanceRunner by lazy { - MaintenanceOperationRunner( - context = context, - repositoriesRepository = repositoriesRepository, - resticBinaryManager = resticBinaryManager, - resticRepository = resticRepository, - operationLockManager = operationLockManager - ) - } - - suspend fun run( - request: RunTasksWorkRequest, - onProgress: (OperationProgress) -> Unit, - shouldStop: () -> Boolean = { false } - ): OperationRunResult { - fun throwIfCancelled() { - if (shouldStop()) { - throw OperationCancelledException(context.getString(R.string.operation_interrupted)) - } - } - - val backupStageCount = if (request.backupEnabled) 3 else 0 - val maintenanceTaskCount = listOf( - request.unlockRepo, - request.forgetSnapshots, - request.pruneRepo, - request.checkRepo - ).count { it } - val totalEnabledTasks = backupStageCount + maintenanceTaskCount - - if (totalEnabledTasks == 0) { - return OperationRunResult( - success = false, - progress = OperationProgress( - isFinished = true, - error = context.getString(R.string.maintenance_error_no_tasks), - finalSummary = context.getString(R.string.maintenance_summary_no_tasks) - ) - ) - } - - val startTime = System.currentTimeMillis() - var completedTaskUnits = 0 - var progressState = OperationProgress(stageTitle = context.getString(R.string.progress_initializing)) - val summaries = mutableListOf() - var overallSuccess = true - var wasCancelled = false - - fun mapChildProgress(child: OperationProgress, spanTasks: Int): OperationProgress { - val remappedOverall = ( - completedTaskUnits + (child.overallPercentage.coerceIn(0f, 1f) * spanTasks) - ) / totalEnabledTasks.toFloat() - - return child.copy( - overallPercentage = remappedOverall, - elapsedTime = (System.currentTimeMillis() - startTime) / 1000, - isFinished = false, - error = null, - finalSummary = "" - ) - } - - try { - throwIfCancelled() - - if (request.backupEnabled) { - val backupRequest = BackupWorkRequest( - repositoryKey = request.repositoryKey, - backupTypes = request.backupTypes, - selectedPackageNames = request.selectedPackageNames, - appBackupTypes = request.appBackupTypes, - customDirectories = request.customDirectories - ) - - val result = backupRunner.run( - request = backupRequest, - onProgress = { childProgress -> - progressState = mapChildProgress(childProgress, backupStageCount) - onProgress(progressState) - }, - shouldStop = shouldStop, - stageContext = OperationStageContext( - completedStagesBefore = completedTaskUnits, - totalStages = totalEnabledTasks - ) - ) - - completedTaskUnits += backupStageCount - summaries += context.getString( - R.string.run_tasks_phase_summary, - context.getString(R.string.operation_backup), - result.progress.finalSummary - ) - overallSuccess = overallSuccess && result.success - throwIfCancelled() - } - - if (maintenanceTaskCount > 0) { - val maintenanceRequest = MaintenanceWorkRequest( - repositoryKey = request.repositoryKey, - checkRepo = request.checkRepo, - pruneRepo = request.pruneRepo, - unlockRepo = request.unlockRepo, - readData = request.readData, - forgetSnapshots = request.forgetSnapshots, - keepLast = request.keepLast, - keepDaily = request.keepDaily, - keepWeekly = request.keepWeekly, - keepMonthly = request.keepMonthly - ) - - val result = maintenanceRunner.run( - request = maintenanceRequest, - onProgress = { childProgress -> - progressState = mapChildProgress(childProgress, maintenanceTaskCount) - onProgress(progressState) - }, - shouldStop = shouldStop, - stageContext = OperationStageContext( - completedStagesBefore = completedTaskUnits, - totalStages = totalEnabledTasks - ) - ) - - completedTaskUnits += maintenanceTaskCount - summaries += context.getString( - R.string.run_tasks_phase_summary, - context.getString(R.string.operation_maintenance), - result.progress.finalSummary - ) - overallSuccess = overallSuccess && result.success - throwIfCancelled() - } - } catch (e: OperationCancelledException) { - overallSuccess = false - wasCancelled = true - summaries += context.getString(R.string.operation_interrupted) - } catch (e: Exception) { - overallSuccess = false - summaries += context.getString(R.string.error_fatal_with_message, e.message ?: "") - } - - val finalSummary = summaries.joinToString("\n\n").trim() - return OperationRunResult( - success = overallSuccess, - progress = progressState.copy( - stageTitle = if (overallSuccess) { - context.getString(R.string.progress_operation_complete, context.getString(R.string.operation_run_tasks)) - } else { - context.getString(R.string.progress_operation_failed, context.getString(R.string.operation_run_tasks)) - }, - stagePercentage = 1f, - overallPercentage = 1f, - elapsedTime = (System.currentTimeMillis() - startTime) / 1000, - isFinished = true, - error = if (!overallSuccess) { - if (wasCancelled) context.getString(R.string.operation_interrupted) - else context.getString(R.string.run_tasks_error_one_or_more_failed) - } else { - null - }, - finalSummary = finalSummary - ) - ) - } -} diff --git a/app/src/main/java/io/github/hddq/restoid/work/ScheduleWorker.kt b/app/src/main/java/io/github/hddq/restoid/work/ScheduleWorker.kt deleted file mode 100644 index 1668c01d..00000000 --- a/app/src/main/java/io/github/hddq/restoid/work/ScheduleWorker.kt +++ /dev/null @@ -1,87 +0,0 @@ -package io.github.hddq.restoid.work - -import android.content.Context -import android.content.pm.ServiceInfo -import androidx.work.CoroutineWorker -import androidx.work.ForegroundInfo -import androidx.work.WorkerParameters -import io.github.hddq.restoid.R -import io.github.hddq.restoid.RestoidApplication -import io.github.hddq.restoid.ui.shared.OperationProgress - -class ScheduleWorker( - context: Context, - workerParams: WorkerParameters -) : CoroutineWorker(context, workerParams) { - - private val app = applicationContext as RestoidApplication - - override suspend fun doWork(): Result { - app.repositoriesRepository.loadRepositories() - - // Show an initial foreground notification for the schedule worker itself - val initialProgress = OperationProgress(stageTitle = applicationContext.getString(R.string.progress_initializing)) - setForeground(createForegroundInfo(initialProgress)) - - val repoKey = inputData.getString(KEY_REPO_KEY) ?: return Result.failure() - val scheduleId = inputData.getString(KEY_SCHEDULE_ID) ?: return Result.failure() - - val repository = app.repositoriesRepository.getRepositoryByKey(repoKey) ?: return Result.failure() - val repoId = repository.id ?: return Result.failure() - - val schedules = app.metadataRepository.getSchedules(repoId).toMutableList() - val scheduleIndex = schedules.indexOfFirst { it.id == scheduleId } - if (scheduleIndex == -1) return Result.failure() - - val schedule = schedules[scheduleIndex] - if (!schedule.isEnabled) return Result.success() - - // Trigger the run tasks operation - val workRequest = RunTasksWorkRequest( - repositoryKey = repoKey, - backupEnabled = schedule.config.backupEnabled, - backupTypes = schedule.config.backupTypes, - selectedPackageNames = schedule.config.selectedPackageNames, - appBackupTypes = schedule.config.appBackupTypes, - customDirectories = schedule.config.customDirectories, - unlockRepo = schedule.config.unlockRepo, - forgetSnapshots = schedule.config.forgetSnapshots, - pruneRepo = schedule.config.pruneRepo, - checkRepo = schedule.config.checkRepo, - readData = schedule.config.readData, - keepLast = schedule.config.keepLast, - keepDaily = schedule.config.keepDaily, - keepWeekly = schedule.config.keepWeekly, - keepMonthly = schedule.config.keepMonthly, - scheduleName = schedule.name - ) - val enqueued = app.operationWorkRepository.enqueueRunTasks(workRequest) - if (!enqueued) { - return Result.retry() - } - - // Update last run timestamp - val updatedSchedule = schedule.copy(lastRunTimestamp = System.currentTimeMillis()) - schedules[scheduleIndex] = updatedSchedule - app.metadataRepository.saveSchedules(repoId, schedules) - - return Result.success() - } - - private fun createForegroundInfo(progress: OperationProgress): ForegroundInfo { - val notification = app.notificationRepository.buildOperationProgressNotification( - applicationContext.getString(R.string.operation_run_tasks), - progress - ) - return ForegroundInfo( - io.github.hddq.restoid.data.NotificationRepository.PROGRESS_NOTIFICATION_ID, - notification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC - ) - } - - companion object { - const val KEY_REPO_KEY = "repo_key" - const val KEY_SCHEDULE_ID = "schedule_id" - } -} diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml deleted file mode 100644 index ea95fdf7..00000000 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml deleted file mode 100644 index ef49c991..00000000 --- a/app/src/main/res/mipmap-anydpi/ic_launcher.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/resources.properties b/app/src/main/res/resources.properties deleted file mode 100644 index 63b46f93..00000000 --- a/app/src/main/res/resources.properties +++ /dev/null @@ -1 +0,0 @@ -unqualifiedResLocale=en \ No newline at end of file diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml deleted file mode 100644 index ca8ce27a..00000000 --- a/app/src/main/res/values-ar/strings.xml +++ /dev/null @@ -1,466 +0,0 @@ - - - الرئيسية - التراخيص - فحص السلامة - تفاصيل اللقطة - الاسم - كلمة مرور المستودع مطلوبة - تقدم المرحلة - الوقت المنقضي - معرف المستودع غير موجود - الملف الثنائي تالف أو غير صالح - تعذر الحصول على إعدادات المستودع - تعذر تغيير كلمة المرور - تعذر استعادة لقطة النسخ الاحتياطي - جاري استعادة الملفات… - جاري النسخ الاحتياطي… - جاري الإنهاء… - تمت إضافة %1$s (%2$s، %3$s) في %4$s. - - %1$d ملفات جديدة - ملف جديد واحد %1$d - %1$d ملفات جديدة - %1$d ملفات جديدة - %1$d ملفات جديدة - - - %1$d ملفات معدلة - ملف معدل واحد %1$d - %1$d ملفات معدلة - %1$d ملفات معدلة - %1$d ملفات معدلة - - فشل أمر Restic - Restic غير مثبت أو غير جاهز. - اسم واحد أو أكثر من أسماء متغيرات البيئة غير صالح. - اسم واحد أو أكثر من خيارات Restic غير صالح. - اكتملت عملية التنظيف (Prune). - لا توجد لقطات تطابق سياسة الحذف. - اكتملت عملية التحقق. - اكتملت عملية إلغاء القفل. - غير متاح - عام - \n\nالتفاصيل:\n- %1$s - تقليم - لم يتم تحديد أي مهام صيانة. - فشلت مهمة %1$s:\n%2$s\n\n - المستودع مغلق. افتحه لتحميل اللقطات. - تنسيق متغير البيئة غير صالح في السطر %1$d. استخدم KEY=value. - ملف المفتاح الخاص المحدد فارغ. - لا يمكن ترك مفتاح SSH الخاص لـ SFTP فارغًا. - - %1$d/%2$d ملف - %1$d/%2$d ملف - %1$d/%2$d ملفات - %1$d/%2$d ملفات - %1$d/%2$d من الملفات - - إصدار غير معروف - لا يوجد رابط متاح - … (اضغط للتوسيع) - فشل إعداد وحدة تخزين المضيفات المعروفة SSH. - فشل إعداد وحدة تخزين المفتاح الخاص SSH. - فشل جلب البصمة الرقمية لخادم SFTP. - لم يتم العثور على حاوية S3 - ثق - س - ١–٧٢٠ س - %dس - ١ أسبوع - غير معروف - رخصة غير معروفة - رابط الرخصة: %1$s - استعادة اللقطة - رجوع - نسيان اللقطة - إضافة جدولة - استعادة - تشغيل المهام - صيانة - %1$s:\n%2$s - أنواع النسخ الاحتياطي - APK - ملف حزمة التطبيق - البيانات - بيانات الجهاز المحمية - البيانات التي يمكن الوصول إليها قبل فتح قفل الجهاز (/data/user_de/<id>) - البيانات الخارجية - بيانات التطبيق على وحدة التخزين الخارجية (/storage/emulated/<id>/Android/data) - بيانات OBB - ملفات وسائط التطبيقات (/storage/emulated/<id>/Android/media) - أذونات وقت التشغيل الممنوحة - التطبيقات - تبديل الكل - أنواع النسخ الاحتياطي للتطبيقات المحددة - لا يوجد - السماح بالإصدار الأقدم - التطبيقات المطلوب استعادتها - نسخة احتياطية: %1$s -> مثبت: %2$s - نسخة احتياطية: %1$s - تم تحديد %1$d تطبيق - %2$s - احتفظ بآخر %1$d، يومي %2$d، أسبوعي %3$d، شهري %4$d - التحقق من السلامة - البيانات الوصفية فقط - تحقق من حزم بيانات المستودع بالإضافة إلى البيانات الوصفية. - الفترة الزمنية (بالساعات) - آخر تشغيل: %1$s (%2$s) - لم يتم التشغيل أبداً - لديك تغييرات غير محفوظة. هل أنت متأكد أنك تريد تجاهلها؟ - خطأ: %1$s - لم يتم العثور على لقطات للمستودع المحدد. - - لقطات (%1$d) - لقطة (%1$d) - لقطات (%1$d) - لقطات (%1$d) - لقطات (%1$d) - - يرجى إدخال كلمة مرور SFTP للمستودع: %1$s - مفتاح SSH لـ SFTP مطلوب - يرجى إدخال بيانات اعتماد SFTP للمستودع: %1$s - يرجى إدخال اسم المستخدم وكلمة المرور لـ REST للمستودع: %1$s - بيانات اعتماد S3 مطلوبة - يرجى إدخال مفتاح الوصول والمفتاح السري لـ S3 للمستودع: %1$s - - التطبيقات (%1$d): - التطبيق (%1$d): - تطبيقات (%1$d): - تطبيقات (%1$d): - تطبيقات (%1$d): - - لا تتوفر معلومات عن التطبيق لهذا اللقطة. - البيانات الوصفية مفقودة أو تالفة. - تمت استعادة البيانات الوصفية بنجاح. - الآن - - منذ %d دقيقة - منذ %d دقيقة - منذ %d دقيقتين - منذ %d دقيقة - منذ %d دقيقة - - - منذ %d ساعة - منذ %d ساعة - منذ %d ساعتين - منذ %d ساعة - منذ %d ساعة - - - منذ %d شهر - منذ %d شهر - منذ %d شهرين - منذ %d شهر - منذ %d شهر - - - منذ %d سنة - منذ %d سنة - منذ %d سنتين - منذ %d سنة - منذ %d سنة - - إلغاء قفل المستودع - تنظيف المستودع - الاحتفاظ بالأسبوعي - الاحتفاظ بالشهري - قراءة كل البيانات - - التطبيقات المنسوخة احتياطياً (%1$d) - التطبيق المنسوخ احتياطياً (%1$d) - تطبيقات تم نسخها احتياطيًا (%1$d) - تطبيقات تم نسخها احتياطياً (%1$d) - تطبيقات تم نسخها احتياطياً (%1$d) - - الوقت: %1$s - الإصدار: %1$s - الحجم: %2$s - معرف مفتاح الوصول S3 - مفتاح الوصول السري S3 - سيؤدي هذا إلى إزالة إدخال اللقطة. سيتم حذف البيانات الفعلية فقط بعد تشغيل عملية تنظيف. هل أنت متأكد؟ - كلمة المرور - كلمة مرور المستودع - اسم مستخدم REST - لا يمكن أن تكون كلمة المرور فارغة - لا يمكن أن يكون اسم المستخدم فارغاً - حفظ بيانات الاعتماد - موافق - استعادة البيانات الوصفية - الإعدادات - إلغاء - تم - إضافة - حاول مجدداً - تعطيل - يتطلب الوصول إلى استخدام التطبيقات - يُمكّن من حساب حجم التطبيق بشكل أسرع أثناء النسخ الاحتياطي بدلاً من فحص المجلدات - فشل %1$s - التقدم العام - جاري المعالجة: - إضافة مستودع - نوع المستودع - مواصفات المستودع - مفتاح SSH - كلمة مرور مفتاح SSH (اختياري) - الثقة بخادم SFTP - قدّم الخادم %1$s بصمات مفاتيح المضيف التالية: - بصمات المفاتيح - المجلد المحلي - SFTP (SSH) - اختر مجلداً… - إخفاء كلمة المرور - إظهار كلمة المرور - جاري التحقق من صلاحيات الجذر… - تم منح صلاحيات الجذر - التبعيات - حول - الإصدار - تم تعطيل الإشعارات - إذن الإشعارات - ترجمة - تم تفعيل تحسين البطارية - طلب فتح قفل التطبيق عند البدء - خيارات - استخدم بصمة الإصبع أو قفل الشاشة للمتابعة - نسيان بيانات اعتماد REST - حفظ بيانات اعتماد REST - حفظ بيانات اعتماد S3 - المزيد من الخيارات - نسيان كلمة المرور - نسيان كلمة مرور SFTP - حفظ كلمة مرور SFTP - نسيان بيانات اعتماد مفتاح SSH - ملف Restic التنفيذي - جاري التحقق من الحالة… - كلمة المرور الجديدة - إظهار - حفظ كلمة المرور - حفظ كلمة مرور SFTP - حفظ بيانات اعتماد مفتاح SSH - حفظ بيانات اعتماد REST - بيانات الاعتماد المخزنة مشفرة على هذا الجهاز. - عناصر غير معروفة - OBB - الوسائط - لم يتم العثور على معرف المستودع. تعذر حفظ البيانات الوصفية. - [%1$d/%2$d] جارٍ إعداد النسخة الاحتياطية... - [%1$d/%2$d] جارٍ إنهاء النسخة الاحتياطية… - فشل أمر Restic مع رمز الخروج %1$d. - حدث خطأ فادح: %1$s - لم يتم تحديد أنواع للنسخ الاحتياطي. - Restic غير مثبت. - لم يتم تثبيت ملف Restic الثنائي. - لم يتم تحديد مستودع للنسخ الاحتياطي. - لم يتم تحديد مستودع للنسخ الاحتياطي. - لم يتم العثور على كلمة مرور المستودع. - تعذر العثور على كلمة المرور. - تعذر العثور على بيانات اعتماد SFTP. - لم يتم العثور على بيانات اعتماد REST للمستودع. - تعذر العثور على بيانات اعتماد REST. - لم يتم العثور على المستودع أو كلمة المرور أو معرف المستودع - استعادة الملفات - التنظيف - فشلت فحوصات ما قبل الاستعادة. - لم يتم تحديد أنواع استعادة. - كلمة المرور الخاصة بالمستودع غير موجودة. - لم يتم العثور على ملفات في اللقطة للتطبيقات المحددة. - فشل أمر استعادة Restic. - %1$s: فشل تثبيت الالتزام: %2$s - فشلت مهمة تشغيل واحدة أو أكثر. - لم يتم تحديد أي مهام. - كلمة مرور المستودع غير صحيحة. - بيانات اعتماد SFTP غير صحيحة. - %1$s: فشلت كتابة أجزاء ملف APK. - %1$s: فشل إنشاء جلسة التثبيت. - %1$s: لم يتم العثور على ملفات APK في البيانات المستعادة. - %1$s: فشلت استعادة البيانات أو لم تكتمل. - %1$s: فشلت استعادة بعض الأذونات: %2$s - اكتملت الاستعادة في %1$s. - تمت مهمة %1$s بنجاح:\n%2$s\n\n - بيانات اعتماد REST غير صحيحة. - بيانات اعتماد S3 غير صحيحة. - أدخل كلمة المرور مجددًا - أدخل بيانات اعتماد REST مجددًا - أدخل بيانات اعتماد S3 مجددًا - فتح المستودع - فتح SFTP - فتح S3 - لا يمكن ترك المسار وكلمة المرور فارغين. - أدخل اسم المستخدم وكلمة المرور لـ REST، أو اتركهما فارغين. - أدخل مفتاح الوصول والمفتاح السري لـ S3، أو اتركهما فارغين. - متغيرات البيئة غير صالحة. - اسم متغير البيئة غير صالح: %1$s - خيارات restic غير صالحة. - فشل قراءة ملف المفتاح الخاص المحدد. - خطأ غير معروف - العمليات الجارية - العمليات المكتملة - يعرض تقدم النسخ الاحتياطي والاستعادة الجاري بصمت. - ينبه عند اكتمال عملية نسخ احتياطي أو استعادة. - هل تريد إيقاف العملية الحالية؟ سيتم إلغاء أي عمل غير مكتمل. - المستودع موجود بالفعل. - ملف Restic الثنائي غير جاهز. - كلمة مرور غير صالحة أو مستودع تالف. - الدليل موجود ولكنه ليس مستودع restic. - فشل تهيئة المستودع. - المستودع صالح، ولكن تعذر الحصول على المعرّف. - فشل إعداد مصادقة SFTP. - لم يتم العثور على ملف SSH الثنائي على هذا الجهاز. - فشل إنشاء برنامج المساعدة askpass الخاص بـ SSH. - غير معروف - لم يتم العثور على الملف الثنائي المجمّع أو أنه غير قابل للتنفيذ. - فشل تحميل اللقطات - فشل فحص المستودع - فشل فتح قفل المستودع - لم يتم تحديد سياسة \'الحفظ\' لعملية النسيان. - فشل نسيان اللقطات - فشل حذف اللقطة - فشل نسيان لقطات البيانات الوصفية - - تمت إزالة %1$d لقطات. - تمت إزالة لقطة واحدة %1$d. - تمت إزالة %1$d لقطات. - تمت إزالة %1$d لقطات. - تمت إزالة %1$d لقطة. - - الشروط - البطارية ليست منخفضة - الشبكة غير محدودة - تمت إضافة المستودع بنجاح، ولكن فشلت استعادة بياناته الوصفية (الجداول، معلومات النسخ الاحتياطي التفصيلية). عادةً ما يعني هذا أن لقطة البيانات الوصفية مفقودة أو أن المستودع مقفل. للمحاولة مرة أخرى، افتح الإعدادات ← المستودعات، واضغط على قائمة المستودع، واختر \"استعادة البيانات الوصفية\". - تقدم العملية - تشغيل المهام - نسخ احتياطي - جدولة %1$s - ملفات البيانات الثنائية غير الشفافة (OBB) (/storage/emulated/<id>/Android/obb) - بيانات الوسائط - مختلط - التطبيقات - نسيان اللقطات - لم يتم تحديد مستودع. اذهب إلى الإعدادات لإضافة واحد. - Restic غير متاح. تحقق من الإعدادات. - لا توجد جداول زمنية مهيأة لهذا المستودع. - هل أنت متأكد أنك تريد حذف هذا الجدول الزمني؟ - هل تريد تجاهل التغييرات؟ - يرجى إدخال كلمة المرور للمستودع: %1$s - بيانات اعتماد REST مطلوبة - - منذ %d يوم - منذ %d يوم - منذ %d يومين - منذ %d يوم - منذ %d يوم - - الاحتفاظ باليومي - جاري نسيان اللقطة… - كلمة مرور REST - حفظ كلمة المرور - حذف - منح - تم منح صلاحية الوصول لاستخدام التطبيق - متغيرات البيئة - KEY=value (واحدة في كل سطر) - كلمة مرور SFTP - مفتاح خاص لـ SSH لـ SFTP - خادم REST HTTP - اختر ملف المفتاح الخاص - إضافة مستودع - لا توجد مستودعات مهيأة. أضف واحداً للبدء. - ثبّت restic لإدارة المستودعات. - عطّل هذا لتحسين موثوقية عمليات الخلفية الطويلة. - المعرف: %1$s… - حفظ كلمة المرور - حفظ كلمات المرور - فشل تغيير كلمة المرور. - كلمة المرور القديمة - تأكيد كلمة المرور الجديدة - كلمتا المرور غير متطابقتين. - حفظ بيانات اعتماد S3 - الأذونات - [%1$d/%2$d] جارٍ نسخ التطبيقات احتياطيًا… - تحذير: تعذر حفظ ملف البيانات الوصفية للنسخ الاحتياطي محلياً. - لم يتم العثور على بيانات اعتماد SFTP للمستودع. - لم يتم العثور على بيانات اعتماد S3 للمستودع. - توجد عملية أخرى قيد التشغيل بالفعل. - %1$s: فشلت استعادة الأذونات لأن التطبيق غير مثبت. - كلمة مرور SFTP مطلوبة - لم يتم العثور على لقطة. - المُعرف: %1$s - هل تريد نسيان اللقطة؟ - إيقاف - تغيير - حفظ - نسيان - تفعيل - جاري التهيئة… - جاري الإيقاف… - اكتمل %1$s - العناصر - الحجم - طريقة المصادقة - كلمة مرور مفتاح SSH - المسار - اختر مجلداً - مستودعات النسخ الاحتياطي - النظام - تم رفض صلاحيات الجذر - تم تفعيل الإشعارات - تم تعطيل تحسين البطارية - فتح قفل Restoid - حفظ بيانات اعتماد مفتاح SSH - نسيان بيانات اعتماد S3 - تغيير كلمة المرور - خطأ: %1$s - تغيير كلمة المرور - إخفاء - لم يتم تحديد أنواع للنسخ الاحتياطي. - تعذر العثور على بيانات اعتماد S3. - عملية أخرى قيد التشغيل. انتظر حتى تنتهي. - لم يتم العثور على اللقطة. - معالجة التطبيقات - إلغاء القفل - نسيان - فحص - [%1$d/%2$d] جاري تشغيل \'%3$s\'… - فشلت مهمة واحدة أو أكثر. - لم يتم تحديد أي مهام صيانة. - أدخل بيانات اعتماد SFTP مجددًا - فتح REST - فشل إنشاء الدليل. - فشل تقليم المستودع - الجهاز قيد الشحن - ١ شهر - موقع المشروع: %1$s - فتح الرابط - نسخ الرخصة - فشل استعادة البيانات الوصفية - الإعدادات - تقدم العملية - تراخيص المصادر المفتوحة - بدء الاستعادة - استعادة - بيانات التطبيق الداخلية (/data/user/<id>) - الأذونات - خيارات الاستعادة - لم يتم العثور على بيانات تطبيقات في هذه اللقطة. - حذف الجدول الزمني؟ - يتم تشفير بيانات الاعتماد المخزنة على هذا الجهاز. - جاري فحص الملفات… - تمت عملية %1$s بنجاح - فشلت عملية %1$s - لا توجد عملية نشطة حاليًا. - تمت مقاطعة العملية. - إيقاف العملية؟ - تجاهل - الاحتفاظ بآخر - تراخيص المصادر المفتوحة - تفاصيل اللقطة - استعادة - الجداول الزمنية - تكوين النسخ الاحتياطي - نسيان السياسة - الجداول الزمنية - إضافة جدول زمني - تعديل الجدول الزمني - المهام - المهام - diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml deleted file mode 100644 index 2838fece..00000000 --- a/app/src/main/res/values-de/strings.xml +++ /dev/null @@ -1,476 +0,0 @@ - - - Start - Einstellungen - Snapshot-Details - Wiederherstellen - Lizenzen - Zeitpläne - Fortschritt - Backup-Konfiguration - Forget-Richtlinie - Integritätsprüfung - Zeitpläne - Zeitplan hinzufügen - Zeitplan bearbeiten - Fortschritt - Open-Source-Lizenzen - Snapshot-Details - Snapshot wiederherstellen - Zurück - Snapshot vergessen - Tasks ausführen - Zeitplan hinzufügen - Wiederherstellen - Restore starten - Backup - Tasks ausführen - Zeitplan %1$s - Wiederherstellen - Wartung - %1$s:\n%2$s - Backup-Typen - APK - Daten - Gerätegeschützte Daten - Externe Daten - OBB-Daten - Medien-Daten - Apps - Alle umschalten - Restore-Optionen - Downgrade zulassen - Wiederherzustellende Apps - Keine App-Daten in diesem Snapshot gefunden. - Backup: %1$s -> Installiert: %2$s - Backup: %1$s - Restoid - Anwendungen - %1$d Apps ausgewählt - %2$s - Snapshots vergessen - Behalte letzte %1$d, täglich %2$d, wöchentlich %3$d, monatlich %4$d - Integrität prüfen - Nur Metadaten - Überprüft Repository-Datenpakete zusätzlich zu den Metadaten. - Kein Repository ausgewählt. Gehe zu den Einstellungen, um eines hinzuzufügen. - Restic nicht verfügbar. Einstellungen prüfen. - Keine Zeitpläne für dieses Repository konfiguriert. - Intervall (Stunden) - Name - Letzter Lauf: %1$s (%2$s) - Nie gelaufen - Zeitplan löschen? - Bist du sicher, dass du diesen Zeitplan löschen willst? - Änderungen verwerfen? - Du hast ungespeicherte Änderungen. Willst du sie wirklich verwerfen? - Verwerfen - Fehler: %1$s - Keine Snapshots für das ausgewählte Repository gefunden. - - Snapshot (%1$d) - Snapshots (%1$d) - - Repository-Passwort benötigt - Bitte gib das Passwort für das Repository ein: %1$s - SFTP-Passwort benötigt - Bitte gib das SFTP-Passwort für das Repository ein: %1$s - SFTP-SSH-Key benötigt - Bitte gib die SFTP-Zugangsdaten für das Repository ein: %1$s - REST-Zugangsdaten benötigt - Bitte gib REST-Benutzername und Passwort für das Repository ein: %1$s - S3-Zugangsdaten benötigt - Bitte gib S3 Access Key und Secret Key für das Repository ein: %1$s - - App (%1$d): - Apps (%1$d): - - Keine App-Infos für diesen Snapshot verfügbar. - Metadaten fehlen oder sind beschädigt. - Repository entsperren - Repository bereinigen (Prune) - Behalte letzte - Behalte täglich - Behalte wöchentlich - Behalte monatlich - Alle Daten lesen - - Gesicherte App (%1$d) - Gesicherte Apps (%1$d) - - Kein Snapshot gefunden. - Snapshot wird vergessen… - ID: %1$s - Zeit: %1$s - Version: %1$s - Größe: %2$s - Snapshot vergessen? - Dies entfernt den Snapshot-Eintrag. Die tatsächlichen Daten werden erst nach einem Prune gelöscht. Bist du sicher? - Passwort - Repository-Passwort - REST-Benutzername - REST-Passwort - S3 Access Key ID - S3 Secret Access Key - Passwort darf nicht leer sein - Benutzername darf nicht leer sein - Passwort speichern - Zugangsdaten speichern - OK - Abbrechen - Stopp - Fertig - Hinzufügen - Löschen - Ändern - Speichern - Gewähren - Einstellungen - Vergessen - Erneut versuchen - Deaktivieren - Initialisiere… - Stoppe… - %1$s abgeschlossen - %1$s fehlgeschlagen - Gesamtfortschritt - Phasenfortschritt - Vergangen - Elemente - Größe - Verarbeite: - ... - Repository hinzufügen - Repository-Typ - Repository-Spezifikation - Umgebungsvariablen - KEY=value (eine pro Zeile) - Authentifizierungsmethode - SFTP-Passwort - SSH-Key - SFTP SSH Private Key - -----BEGIN OPENSSH PRIVATE KEY-----\n... - SSH-Key Passphrase - SSH-Key Passphrase (optional) - SFTP-Server vertrauen - Der Server %1$s hat diese Host-Key-Fingerabdrücke präsentiert: - Fingerabdrücke - Lokales Verzeichnis - SFTP (SSH) - HTTP REST Server - Amazon S3 / MinIO - user@host:/srv/restic-repo - http://host:8000/ - s3.us-east-1.amazonaws.com/bucket_name - Pfad - Wähle ein Verzeichnis… - Ordner auswählen - Private Key Datei auswählen - Passwort verbergen - Passwort anzeigen - Backup-Repositories - Repository hinzufügen - Keine Repositories konfiguriert. Füge eines hinzu, um loszulegen. - Installiere Restic, um Repositories zu verwalten. - System - Root-Zugriff verweigert - Prüfe auf Root… - Root-Zugriff gewährt - Abhängigkeiten - Über - Version - Open-Source-Lizenzen - Übersetzen - Benachrichtigungen aktiviert - Benachrichtigungen deaktiviert - Benachrichtigungsberechtigung - Akku-Optimierung aktiviert - Akku-Optimierung deaktiviert - Deaktiviere dies, um die Zuverlässigkeit langer Hintergrundprozesse zu verbessern. - App-Entsperrung beim Start anfordern - Optionen - Restoid entsperren - Verwende Fingerabdruck oder Bildschirmsperre, um fortzufahren - ID: %1$s… - Weitere Optionen - Passwort vergessen - SFTP-Passwort vergessen - SFTP-Passwort speichern - SSH-Key-Zugangsdaten vergessen - SSH-Key-Zugangsdaten speichern - REST-Zugangsdaten vergessen - REST-Zugangsdaten speichern - S3-Zugangsdaten vergessen - S3-Zugangsdaten speichern - Passwort speichern - Passwörter speichern - Passwort ändern - Restic Binary - Prüfe Status… - Fehler: %1$s - Passwort konnte nicht geändert werden. - Passwort ändern - Altes Passwort - Neues Passwort - Neues Passwort bestätigen - Verbergen - Anzeigen - Passwörter stimmen nicht überein. - Passwort speichern - SFTP-Passwort speichern - SSH-Key-Zugangsdaten speichern - REST-Zugangsdaten speichern - S3-Zugangsdaten speichern - Gespeicherte Zugangsdaten werden auf diesem Gerät verschlüsselt. - Gespeicherte Zugangsdaten werden auf diesem Gerät verschlüsselt. - Berechtigungen - Unbekannte Elemente - OBB - Medien - Repository-ID nicht gefunden. Metadaten können nicht gespeichert werden. - [%1$d/%2$d] Bereite Backup vor... - [%1$d/%2$d] Sichere Apps… - [%1$d/%2$d] Schließe Backup ab… - Restic-Befehl mit Exit-Code %1$d fehlgeschlagen. - Warnung: Backup-Metadaten-Datei konnte nicht lokal gespeichert werden. - Ein fataler Fehler ist aufgetreten: %1$s - Keine Backup-Typen ausgewählt. - Es wurden keine Backup-Typen ausgewählt. - Restic ist nicht installiert. - Restic-Binary ist nicht installiert. - Kein Backup-Repository ausgewählt. - Es ist kein Backup-Repository ausgewählt. - Passwort für Repository nicht gefunden. - Konnte das Passwort nicht finden. - SFTP-Zugangsdaten für Repository nicht gefunden. - Konnte die SFTP-Zugangsdaten nicht finden. - REST-Zugangsdaten für Repository nicht gefunden. - Konnte REST-Zugangsdaten nicht finden. - S3-Zugangsdaten für Repository nicht gefunden. - Konnte S3-Zugangsdaten nicht finden. - Ein anderer Vorgang läuft bereits. - Ein anderer Vorgang läuft. Warte, bis er abgeschlossen ist. - Snapshot nicht gefunden. - Repository, Passwort oder Repo-ID nicht gefunden - Dateien wiederherstellen - Verarbeite Apps - Aufräumen - Vorabprüfungen für Restore fehlgeschlagen. - Passwort für Repository nicht gefunden. - Keine Dateien im Snapshot für die ausgewählten Apps gefunden. - Restic-Restore-Befehl fehlgeschlagen. - %1$s: Install Commit fehlgeschlagen: %2$s - %1$s: Schreiben der APK-Splits fehlgeschlagen. - %1$s: Konnte Installations-Session nicht erstellen. - %1$s: Keine APK-Dateien in wiederhergestellten Daten gefunden. - %1$s: Daten-Restore fehlgeschlagen oder unvollständig. - %1$s: Berechtigungen konnten nicht wiederhergestellt werden, da die App nicht installiert ist. - %1$s: Einige Berechtigungen konnten nicht wiederhergestellt werden: %2$s - Restore abgeschlossen in %1$s. - \n\nDetails:\n- %1$s - Entsperren - Vergessen - Bereinigen (Prune) - Prüfen - Keine Wartungsaufgaben ausgewählt. - [%1$d/%2$d] Führe \'%3$s\' aus… - %1$s erfolgreich:\n%2$s\n\n - %1$s fehlgeschlagen:\n%2$s\n\n - Eine oder mehrere Aufgaben sind fehlgeschlagen. - Eine oder mehrere Ausführungsaufgaben sind fehlgeschlagen. - Keine Aufgaben ausgewählt. - Es wurden keine Wartungsaufgaben ausgewählt. - Repository-ID nicht gefunden - Repository-Passwort ist falsch. - SFTP-Zugangsdaten sind falsch. - REST-Zugangsdaten sind falsch. - S3-Zugangsdaten sind falsch. - Repository ist geschlossen. Öffne es, um Snapshots zu laden. - Passwort erneut eingeben - SFTP-Zugangsdaten erneut eingeben - REST-Zugangsdaten erneut eingeben - S3-Zugangsdaten erneut eingeben - Repository öffnen - SFTP öffnen - REST öffnen - S3 öffnen - Pfad und Passwort dürfen nicht leer sein. - Gib sowohl REST-Benutzername als auch Passwort ein, oder lasse beide leer. - Gib sowohl S3 Access Key als auch Secret Key ein, oder lasse beide leer. - Ungültige Umgebungsvariablen. - Ungültiges Format für Umgebungsvariable in Zeile %1$d. Nutze KEY=value. - Ungültiger Name für Umgebungsvariable: %1$s - Ungültige Restic-Optionen. - Konnte die ausgewählte Private-Key-Datei nicht lesen. - Die ausgewählte Private-Key-Datei ist leer. - SFTP SSH Private Key darf nicht leer sein. - Unbekannter Fehler - Laufende Vorgänge - Abgeschlossene Vorgänge - Zeigt still den Fortschritt von laufenden Backups und Restores an. - Alarmiert, wenn ein Backup oder Restore abgeschlossen ist. - - %1$d/%2$d Datei - %1$d/%2$d Dateien - - Scanne Dateien… - %1$s erfolgreich abgeschlossen - %1$s fehlgeschlagen - Momentan kein aktiver Vorgang. - Vorgang wurde unterbrochen. - Vorgang stoppen? - Möchtest du den aktuellen Vorgang stoppen? Alle unfertigen Arbeiten werden abgebrochen. - Repository existiert bereits. - Restic-Binary nicht bereit. - Ungültiges Passwort oder beschädigtes Repository. - Verzeichnis existiert, ist aber kein Restic-Repository. - Konnte Verzeichnis nicht erstellen. - Konnte Repository nicht initialisieren. - Repo gültig, aber Abrufen der ID fehlgeschlagen. - Konnte SFTP-Authentifizierung nicht konfigurieren. - SSH-Binary wurde auf diesem Gerät nicht gefunden. - Konnte SSH-Askpass-Hilfsskript nicht erstellen. - Konnte Speicher für SSH Known Hosts nicht vorbereiten. - Konnte Speicher für SSH Private Key nicht vorbereiten. - Konnte SFTP-Server-Fingerabdruck nicht abrufen. - S3 Bucket nicht gefunden - Vertrauen - Unbekannte Version - unbekannt - Binary beschädigt oder ungültig - Gebündeltes Binary nicht gefunden oder nicht ausführbar. - Snapshots konnten nicht geladen werden - Repository konnte nicht geprüft werden - Repository konnte nicht bereinigt werden - Repository konnte nicht entsperrt werden - Es wurde keine \'Keep\'-Richtlinie für den Forget-Vorgang angegeben. - Snapshots konnten nicht vergessen werden - Snapshot konnte nicht gelöscht werden - Metadaten-Snapshots konnten nicht vergessen werden - Repo-Konfiguration konnte nicht abgerufen werden - Passwort konnte nicht geändert werden - Snapshot konnte nicht wiederhergestellt werden - Stelle Dateien wieder her… - Sichere… - Schließe ab… - %1$s hinzugefügt (%2$s, %3$s) in %4$s. - - %1$d neue Datei - %1$d neue Dateien - - - %1$d geänderte Datei - %1$d geänderte Dateien - - Restic-Befehl fehlgeschlagen - Restic ist nicht installiert oder bereit. - Ein oder mehrere Namen von Umgebungsvariablen sind ungültig. - Ein oder mehrere Restic-Optionsnamen sind ungültig. - Bereinigungsvorgang (Prune) abgeschlossen. - - %1$d Snapshot entfernt. - %1$d Snapshots entfernt. - - Keine Snapshots entsprachen der Richtlinie zum Entfernen. - Überprüfungsvorgang abgeschlossen. - Entsperrungsvorgang abgeschlossen. - N/A - Allgemein - Bedingungen - Akku ist nicht schwach - Gerät lädt - Netzwerk ist ungetaktet (WLAN) - h - 1–720 h - %dh - 1w - 1m - Unbekannt - Unbekannte Lizenz - Lizenz-URL: %1$s - Projekt-Website: %1$s - Kein Link verfügbar - … (tippen zum Erweitern) - Link öffnen - Lizenz kopieren - gerade eben - - vor %d Minute - vor %d Minuten - - - vor %d Stunde - vor %d Stunden - - - vor %d Tag - vor %d Tagen - - - vor %d Monat - vor %d Monaten - - - vor %d Jahr - vor %d Jahren - - Berechtigungen - Sicherungstypen für ausgewählte Apps - Keine - Gemischt - Keine Wiederherstellungstypen ausgewählt. - Die Anwendungspaketdatei - Interne App-Daten (/data/data) - Daten zugänglich vor Geräteentsperrung (/data/user_de) - App-Daten auf externem Speicher (/sdcard/Android/data) - Opaque Binary Blob-Dateien (/sdcard/Android/obb) - App-Mediendateien (/sdcard/Android/media) - Erteilte Laufzeitberechtigungen - Aktivieren - App-Nutzungszugriff gewährt - Zugriff auf App-Nutzungsdaten erforderlich - Ermöglicht eine schnellere Berechnung der App-Größe bei Backups statt des Durchsuchens von Verzeichnissen - Metadaten erfolgreich wiederhergestellt. - Metadaten wiederherstellen - Wiederherstellung der Metadaten fehlgeschlagen - Das Repository wurde erfolgreich hinzugefügt, aber die Wiederherstellung der Metadaten (Zeitpläne, detaillierte Backup-Infos) schlug fehl. Dies bedeutet normalerweise, dass der Metadaten-Snapshot fehlt oder das Repository gesperrt ist. Um es erneut zu versuchen, öffnen Sie Einstellungen → Repositories, tippen Sie auf das Repository-Menü und wählen Sie „Metadaten wiederherstellen“. - Aufgaben - Aufgaben - Benutzerdefinierte Verzeichnisse - Keine Verzeichnisse ausgewählt - %1$d Verzeichnisse ausgewählt - Verzeichnis hinzufügen - Benutzerdefinierte Verzeichnisse - Wählen Sie benutzerdefinierte Verzeichnisse auf Ihrem Gerät aus, die in das Backup aufgenommen werden sollen. - Das Repository selbst kann nicht als benutzerdefiniertes Verzeichnis für das Backup hinzugefügt werden. - - Gesichert (%1$d): - Gesichert (%1$d): - - - Benutzerdefiniertes Verzeichnis (%1$d): - Benutzerdefinierte Verzeichnisse (%1$d): - - - Gesichertes benutzerdefiniertes Verzeichnis (%1$d) - Gesicherte benutzerdefinierte Verzeichnisse (%1$d) - - Für die ausgewählten Elemente wurden keine Dateien zum Sichern gefunden. - - %1$d Element gesichert. - %1$d Elemente gesichert. - - Keine Elemente ausgewählt. - Es wurden keine Elemente ausgewählt. - Keine Elemente zur Wiederherstellung ausgewählt. - [%1$d/%2$d] Verarbeite Apps - [%1$d/%2$d] Verarbeite benutzerdefinierte Verzeichnisse - Verarbeite benutzerdefinierte Verzeichnisse… - Wiederherstellung des benutzerdefinierten Verzeichnisses fehlgeschlagen: %1$s - - %1$d Element erfolgreich verarbeitet. - %1$d Elemente erfolgreich verarbeitet. - - - Wiederherstellung von %1$d Element fehlgeschlagen. - Wiederherstellung von %1$d Elementen fehlgeschlagen. - - diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml deleted file mode 100644 index 2283a485..00000000 --- a/app/src/main/res/values-es/strings.xml +++ /dev/null @@ -1,437 +0,0 @@ - - - Inicio - Ajustes - Detalles del Snapshot - Restaurar - Licencias - Horarios - Progreso - Configuración de Backup - Política de eliminación - Comprobar integridad - Horarios - Añadir horario - Editar horario - Progreso de la operación - Licencias Open Source - Detalles del Snapshot - Restaurar Snapshot - Atrás - Eliminar Snapshot - Ejecutar tareas - Añadir horario - Restaurar - Iniciar restauración - Backup - Ejecutar tareas - Horario %1$s - Restauración - Mantenimiento - %1$s:\n%2$s - Tipos de Backup - APK - Datos - Datos protegidos del dispositivo - Datos externos - Datos OBB - Datos multimedia - Apps - Seleccionar todo - Opciones de restauración - Permitir downgrade - Apps a restaurar - No se encontraron datos de apps en este snapshot. - Backup: %1$s -> Instalada: %2$s - Backup: %1$s - Restoid - Aplicaciones - %1$d apps seleccionadas - %2$s - Eliminar Snapshots - Mantener últimos %1$d, diarios %2$d, semanales %3$d, mensuales %4$d - Comprobar integridad - Solo metadatos - Verificar paquetes de datos del repositorio además de los metadatos. - Ningún repositorio seleccionado. Ve a ajustes para añadir uno. - Restic no disponible. Comprueba los ajustes. - No hay horarios configurados para este repositorio. - Intervalo (Horas) - Nombre - Última ejecución: %1$s (%2$s) - Nunca ejecutado - ¿Eliminar horario? - ¿Estás seguro de que quieres eliminar este horario? - ¿Descartar cambios? - Tienes cambios sin guardar. ¿Seguro que quieres descartarlos? - Descartar - Error: %1$s - No se encontraron snapshots para el repositorio seleccionado. - - Snapshot (%1$d) - Snapshots (%1$d) - - Se requiere contraseña del repositorio - Por favor, introduce la contraseña del repositorio: %1$s - Se requiere contraseña SFTP - Por favor, introduce la contraseña SFTP para el repositorio: %1$s - Se requiere clave SSH SFTP - Por favor, introduce las credenciales SFTP para el repositorio: %1$s - Se requieren credenciales REST - Por favor, introduce el usuario y contraseña REST para el repositorio: %1$s - Se requieren credenciales S3 - Por favor, introduce la Access Key y Secret Key de S3 para el repositorio: %1$s - - App (%1$d): - Apps (%1$d): - - No hay información de apps disponible para este snapshot. - Faltan los metadatos o están corruptos. - Desbloquear repositorio - Limpiar repositorio (Prune) - Mantener últimos - Mantener diarios - Mantener semanales - Mantener mensuales - Leer todos los datos - - App respaldada (%1$d) - Apps respaldadas (%1$d) - - No se encontró el snapshot. - Eliminando snapshot… - ID: %1$s - Hora: %1$s - Versión: %1$s - Tamaño: %2$s - ¿Eliminar Snapshot? - Esto eliminará la entrada del snapshot. Los datos reales solo se borrarán al ejecutar una limpieza (Prune). ¿Estás seguro? - Contraseña - Contraseña del repositorio - Usuario REST - Contraseña REST - S3 Access Key ID - S3 Secret Access Key - La contraseña no puede estar vacía - El usuario no puede estar vacío - Guardar contraseña - Guardar credenciales - OK - Cancelar - Detener - Hecho - Añadir - Eliminar - Cambiar - Guardar - Conceder - Ajustes - Eliminar - Reintentar - Desactivar - Inicializando… - Deteniendo… - %1$s completado - %1$s fallido - Progreso general - Progreso de fase - Transcurrido - Elementos - Tamaño - Procesando: - ... - Añadir repositorio - Tipo de repositorio - Especificación del repositorio - Variables de entorno - CLAVE=valor (una por línea) - Método de autenticación - Contraseña SFTP - Clave SSH - Clave privada SSH SFTP - -----BEGIN OPENSSH PRIVATE KEY-----\n... - Passphrase de la clave SSH - Passphrase de la clave SSH (opcional) - Confiar en servidor SFTP - El servidor %1$s ha presentado estas huellas (fingerprints) de clave host: - Huellas - Directorio local - SFTP (SSH) - Servidor REST HTTP - Amazon S3 / MinIO - user@host:/srv/restic-repo - http://host:8000/ - s3.us-east-1.amazonaws.com/nombre_bucket - Ruta - Selecciona un directorio… - Seleccionar carpeta - Seleccionar archivo de clave privada - Ocultar contraseña - Mostrar contraseña - Repositorios de Backup - Añadir repositorio - No hay repositorios configurados. Añade uno para empezar. - Instala restic para gestionar repositorios. - Sistema - Acceso root denegado - Comprobando root… - Acceso root concedido - Dependencias - Acerca de - Versión - Licencias Open Source - Traducir - Notificaciones activadas - Notificaciones desactivadas - Permiso de notificación - Optimización de batería activada - Optimización de batería desactivada - Desactiva esto para mejorar la fiabilidad de las operaciones largas en segundo plano. - Requerir desbloqueo al iniciar - Opciones - Desbloquear Restoid - Usa tu huella o bloqueo de pantalla para continuar - ID: %1$s… - Más opciones - Olvidar contraseña - Olvidar contraseña SFTP - Guardar contraseña SFTP - Olvidar credenciales de clave SSH - Guardar credenciales de clave SSH - Olvidar credenciales REST - Guardar credenciales REST - Olvidar credenciales S3 - Guardar credenciales S3 - Guardar contraseña - Guardar contraseñas - Cambiar contraseña - Binario de Restic - Comprobando estado… - Error: %1$s - Error al cambiar la contraseña. - Cambiar contraseña - Contraseña antigua - Nueva contraseña - Confirmar nueva contraseña - Ocultar - Mostrar - Las contraseñas no coinciden. - Guardar contraseña - Guardar contraseña SFTP - Guardar credenciales de clave SSH - Guardar credenciales REST - Guardar credenciales S3 - Las credenciales guardadas se cifran en este dispositivo. - Las credenciales guardadas se cifran en este dispositivo. - Permisos - Elementos desconocidos - OBB - Multimedia - ID del repositorio no encontrado. No se pueden guardar metadatos. - [%1$d/%2$d] Preparando backup... - [%1$d/%2$d] Respaldando apps… - [%1$d/%2$d] Finalizando backup… - El comando de restic falló con el código de salida %1$d. - Aviso: No se pudo guardar el archivo de metadatos del backup localmente. - Ha ocurrido un error fatal: %1$s - No se seleccionaron tipos de backup. - No se seleccionó ningún tipo de backup. - Restic no está instalado. - El binario de Restic no está instalado. - No se ha seleccionado repositorio de backup. - No hay ningún repositorio de backup seleccionado. - No se encontró la contraseña del repositorio. - No se pudo encontrar la contraseña. - No se encontraron las credenciales SFTP del repositorio. - No se pudieron encontrar las credenciales SFTP. - No se encontraron las credenciales REST del repositorio. - No se pudieron encontrar las credenciales REST. - No se encontraron las credenciales S3 del repositorio. - No se pudieron encontrar las credenciales S3. - Ya hay otra operación en ejecución. - Hay otra operación ejecutándose. Espera a que termine. - Snapshot no encontrado. - Repositorio, contraseña o ID no encontrados - Restaurar archivos - Procesando apps - Limpieza - Los chequeos previos a la restauración fallaron. - Contraseña no encontrada para el repositorio. - No se encontraron archivos en el snapshot para las apps seleccionadas. - El comando de restauración de restic falló. - %1$s: Falló el commit de instalación: %2$s - %1$s: Error al escribir fragmentos APK. - %1$s: Error al crear sesión de instalación. - %1$s: No se encontraron archivos APK en los datos restaurados. - %1$s: Restauración de datos fallida o incompleta. - %1$s: Error al restaurar permisos porque la app no está instalada. - %1$s: Error al restaurar algunos permisos: %2$s - Restauración terminada en %1$s. - \n\nDetalles:\n- %1$s - Desbloquear - Eliminar - Limpiar - Comprobar - No se seleccionaron tareas de mantenimiento. - [%1$d/%2$d] Ejecutando \'%3$s\'… - %1$s con éxito:\n%2$s\n\n - %1$s fallida:\n%2$s\n\n - Una o más tareas fallaron. - Una o más tareas de ejecución fallaron. - No se seleccionaron tareas. - No se seleccionó ninguna tarea de mantenimiento. - ID del repositorio no encontrado - La contraseña del repositorio es incorrecta. - Las credenciales SFTP son incorrectas. - Las credenciales REST son incorrectas. - Las credenciales S3 son incorrectas. - El repositorio está cerrado. Ábrelo para cargar los snapshots. - Volver a introducir la contraseña - Volver a introducir credenciales SFTP - Volver a introducir credenciales REST - Volver a introducir credenciales S3 - Abrir repositorio - Abrir SFTP - Abrir REST - Abrir S3 - La ruta y la contraseña no pueden estar vacías. - Introduce el usuario y contraseña REST, o déjalos vacíos. - Introduce el Access Key y Secret Key de S3, o déjalos vacíos. - Variables de entorno inválidas. - Formato inválido en la línea %1$d. Usa CLAVE=valor. - Nombre de variable de entorno inválido: %1$s - Opciones de restic inválidas. - Error al leer el archivo de la clave privada. - El archivo de la clave privada está vacío. - La clave privada SSH SFTP no puede estar vacía. - Error desconocido - Operaciones en curso - Operaciones completadas - Muestra el progreso de backup y restauración de forma silenciosa. - Avisa cuando se completa un backup o restauración. - - %1$d/%2$d archivo - %1$d/%2$d archivos - - Escaneando archivos… - %1$s completado con éxito - %1$s fallido - No hay operaciones activas en este momento. - La operación fue interrumpida. - ¿Detener operación? - ¿Quieres detener la operación actual? Cualquier trabajo sin terminar se cancelará. - El repositorio ya existe. - El binario de Restic no está listo. - Contraseña inválida o repositorio corrupto. - El directorio existe pero no es un repositorio restic. - Error al crear el directorio. - Error al inicializar el repositorio. - Repo válido, pero error al obtener ID. - Error al configurar la autenticación SFTP. - No se encontró el binario SSH en este dispositivo. - Error al crear el script auxiliar SSH askpass. - Error al preparar el almacenamiento de known hosts SSH. - Error al preparar el almacenamiento de clave privada SSH. - Error al obtener el fingerprint del servidor SFTP. - Bucket S3 no encontrado - Confiar - Versión desconocida - desconocido - Binario corrupto o inválido - Binario incluido no encontrado o no ejecutable. - Error al cargar snapshots - Error al comprobar el repositorio - Error al limpiar el repositorio - Error al desbloquear el repositorio - No se especificó política \'keep\' para la operación de eliminar. - Error al eliminar snapshots - Error al borrar el snapshot - Error al eliminar snapshots de metadatos - Error al obtener la configuración del repo - Error al cambiar la contraseña - Error al restaurar el snapshot - Restaurando archivos… - Respaldando… - Terminando… - Añadido %1$s (%2$s, %3$s) en %4$s. - - %1$d archivo nuevo - %1$d archivos nuevos - - - %1$d archivo modificado - %1$d archivos modificados - - El comando de Restic falló - Restic no está instalado o listo. - Uno o más nombres de variables de entorno son inválidos. - Uno o más nombres de opciones de restic son inválidos. - Operación de limpieza completada. - - Se eliminó %1$d snapshot. - Se eliminaron %1$d snapshots. - - Ningún snapshot coincidió con la política para ser eliminado. - Operación de comprobación completada. - Operación de desbloqueo terminada. - N/A - General - Condiciones - La batería no está baja - El dispositivo está cargando - La red es sin límite de datos (Wi-Fi) - h - 1–720 h - %dh - 1 sem - 1 mes - Desconocido - Licencia Desconocida - URL de Licencia: %1$s - Web del proyecto: %1$s - No hay enlace disponible - … (toca para expandir) - Abrir enlace - Copiar Licencia - ahora mismo - - hace %d minuto - hace %d minutos - - - hace %d hora - hace %d horas - - - hace %d día - hace %d días - - - hace %d mes - hace %d meses - - - hace %d año - hace %d años - - Permisos - Tipos de copia de seguridad para las aplicaciones seleccionadas - Ninguno - Mixto - No se han seleccionado tipos de restauración. - Archivo del paquete de la aplicación - Datos internos de la aplicación (/data/user/<id>) - Datos accesibles antes de desbloquear el dispositivo (/data/user_de/<id>) - Datos de la aplicación en almacenamiento externo (/storage/emulated/<id>/Android/data) - Archivos Opaque Binary Blob (/storage/emulated/<id>/Android/obb) - Archivos multimedia de la aplicación (/storage/emulated/<id>/Android/media) - Permisos de tiempo de ejecución concedidos - Activar - Acceso al uso de aplicaciones concedido - Se requiere acceso al uso de la aplicación - Permite un cálculo más rápido del tamaño de la aplicación durante las copias de seguridad en lugar de escanear directorios - Metadatos restaurados correctamente. - Restaurar metadatos - Error en la restauración de metadatos - El repositorio se añadió correctamente, pero falló la restauración de sus metadatos (programaciones, información detallada de copias de seguridad). Esto suele significar que falta la instantánea de metadatos o que el repositorio está bloqueado. Para intentarlo de nuevo, abra Ajustes → Repositorios, toque el menú del repositorio y elija \"Restaurar metadatos\". - Tareas - Tareas - diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml deleted file mode 100644 index 67a4a084..00000000 --- a/app/src/main/res/values-fr/strings.xml +++ /dev/null @@ -1,437 +0,0 @@ - - - Accueil - Paramètres - Détails du Snapshot - Restaurer - Licences - Planifications - Progression - Configuration de sauvegarde - Règles de suppression (Forget) - Vérifier l\'intégrité - Planifications - Ajouter une planification - Modifier la planification - Progression de l\'opération - Licences Open Source - Détails du Snapshot - Restaurer le Snapshot - Retour - Supprimer le Snapshot - Exécuter - Ajouter - Restaurer - Lancer la restauration - Sauvegarde - Exécuter les tâches - Planification %1$s - Restauration - Maintenance - %1$s :\n%2$s - Types de sauvegarde - APK - Données - Données protégées par l\'appareil - Données externes - Données OBB - Données multimédias - Applications - Tout basculer - Options de restauration - Autoriser le downgrade - Applications à restaurer - Aucune donnée d\'application trouvée dans ce snapshot. - Sauvegarde : %1$s -> Installée : %2$s - Sauvegarde : %1$s - Restoid - Applications - %1$d apps sélectionnées - %2$s - Supprimer les Snapshots - Garder : %1$d derniers, %2$d quotidiens, %3$d hebdo, %4$d mensuels - Vérifier l\'intégrité - Métadonnées uniquement - Vérifier les paquets de données du dépôt en plus des métadonnées. - Aucun dépôt sélectionné. Allez dans les paramètres pour en ajouter un. - Restic non disponible. Vérifiez les paramètres. - Aucune planification configurée pour ce dépôt. - Intervalle (Heures) - Nom - Dernière exécution : %1$s (%2$s) - Jamais exécuté - Supprimer la planification ? - Êtes-vous sûr de vouloir supprimer cette planification ? - Ignorer les modifications ? - Vous avez des modifications non enregistrées. Voulez-vous vraiment les ignorer ? - Ignorer - Erreur : %1$s - Aucun snapshot trouvé pour le dépôt sélectionné. - - Snapshot (%1$d) - Snapshots (%1$d) - - Mot de passe du dépôt requis - Veuillez entrer le mot de passe pour le dépôt : %1$s - Mot de passe SFTP requis - Veuillez entrer le mot de passe SFTP pour le dépôt : %1$s - Clé SSH SFTP requise - Veuillez entrer les identifiants SFTP pour le dépôt : %1$s - Identifiants REST requis - Veuillez entrer le nom d\'utilisateur et le mot de passe REST pour le dépôt : %1$s - Identifiants S3 requis - Veuillez entrer l\'Access Key et la Secret Key S3 pour le dépôt : %1$s - - Application (%1$d) : - Applications (%1$d) : - - Aucune information d\'application disponible pour ce snapshot. - Les métadonnées sont manquantes ou corrompues. - Déverrouiller le dépôt - Nettoyer le dépôt (Prune) - Garder les derniers - Garder les quotidiens - Garder les hebdomadaires - Garder les mensuels - Lire toutes les données - - App sauvegardée (%1$d) - Apps sauvegardées (%1$d) - - Aucun snapshot trouvé. - Suppression du snapshot en cours… - ID : %1$s - Date : %1$s - Version : %1$s - Taille : %2$s - Supprimer le Snapshot ? - Cela supprimera l\'entrée du snapshot. Les données réelles ne seront supprimées qu\'après un nettoyage (Prune). Êtes-vous sûr ? - Mot de passe - Mot de passe du dépôt - Nom d\'utilisateur REST - Mot de passe REST - S3 Access Key ID - S3 Secret Access Key - Le mot de passe ne peut pas être vide - Le nom d\'utilisateur ne peut pas être vide - Enregistrer le mot de passe - Enregistrer les identifiants - OK - Annuler - Arrêter - Terminé - Ajouter - Supprimer - Modifier - Enregistrer - Autoriser - Paramètres - Supprimer - Réessayer - Désactiver - Initialisation… - Arrêt en cours… - %1$s terminé - %1$s a échoué - Progression globale - Progression de l\'étape - Écoulé - Éléments - Taille - Traitement : - ... - Ajouter un dépôt - Type de dépôt - Spécification du dépôt - Variables d\'environnement - CLE=valeur (une par ligne) - Méthode d\'authentification - Mot de passe SFTP - Clé SSH - Clé privée SSH SFTP - -----BEGIN OPENSSH PRIVATE KEY-----\n... - Passphrase de la clé SSH - Passphrase de la clé SSH (facultatif) - Faire confiance au serveur SFTP - Le serveur %1$s a présenté ces empreintes (fingerprints) de clé hôte : - Empreintes - Répertoire local - SFTP (SSH) - Serveur REST HTTP - Amazon S3 / MinIO - user@host:/srv/restic-repo - http://host:8000/ - s3.us-east-1.amazonaws.com/nom_du_bucket - Chemin - Sélectionner un répertoire… - Sélectionner un dossier - Sélectionner un fichier de clé privée - Masquer le mot de passe - Afficher le mot de passe - Dépôts de sauvegarde - Ajouter un dépôt - Aucun dépôt configuré. Ajoutez-en un pour commencer. - Installez restic pour gérer les dépôts. - Système - Accès root refusé - Vérification de l\'accès root… - Accès root accordé - Dépendances - À propos - Version - Licences Open Source - Traduire - Notifications activées - Notifications désactivées - Autorisation de notification - Optimisation de la batterie activée - Optimisation de la batterie désactivée - Désactivez ceci pour améliorer la fiabilité des opérations en arrière-plan longues. - Verrouiller l\'application au démarrage - Options - Déverrouiller Restoid - Utilisez l\'empreinte digitale ou le verrouillage de l\'écran pour continuer - ID : %1$s… - Plus d\'options - Oublier le mot de passe - Oublier le mot de passe SFTP - Enregistrer le mot de passe SFTP - Oublier les identifiants de clé SSH - Enregistrer les identifiants de clé SSH - Oublier les identifiants REST - Enregistrer les identifiants REST - Oublier les identifiants S3 - Enregistrer les identifiants S3 - Enregistrer le mot de passe - Enregistrer les mots de passe - Modifier le mot de passe - Binaire Restic - Vérification de l\'état… - Erreur : %1$s - Échec de la modification du mot de passe. - Modifier le mot de passe - Ancien mot de passe - Nouveau mot de passe - Confirmer le nouveau mot de passe - Masquer - Afficher - Les mots de passe ne correspondent pas. - Enregistrer le mot de passe - Enregistrer le mot de passe SFTP - Enregistrer les identifiants de clé SSH - Enregistrer les identifiants REST - Enregistrer les identifiants S3 - Les identifiants enregistrés sont chiffrés sur cet appareil. - Les identifiants enregistrés sont chiffrés sur cet appareil. - Autorisations - Éléments inconnus - OBB - Médias - ID du dépôt introuvable. Impossible d\'enregistrer les métadonnées. - [%1$d/%2$d] Préparation de la sauvegarde... - [%1$d/%2$d] Sauvegarde des apps en cours… - [%1$d/%2$d] Finalisation de la sauvegarde… - La commande Restic a échoué avec le code de sortie %1$d. - Avertissement : Impossible d\'enregistrer le fichier de métadonnées de sauvegarde localement. - Une erreur fatale s\'est produite : %1$s - Aucun type de sauvegarde sélectionné. - Aucun type de sauvegarde n\'a été sélectionné. - Restic n\'est pas installé. - Le binaire Restic n\'est pas installé. - Aucun dépôt de sauvegarde sélectionné. - Aucun dépôt de sauvegarde n\'est sélectionné. - Mot de passe du dépôt introuvable. - Impossible de trouver le mot de passe. - Identifiants SFTP du dépôt introuvables. - Impossible de trouver les identifiants SFTP. - Identifiants REST du dépôt introuvables. - Impossible de trouver les identifiants REST. - Identifiants S3 du dépôt introuvables. - Impossible de trouver les identifiants S3. - Une autre opération est déjà en cours. - Une autre opération est en cours. Attendez qu\'elle se termine. - Snapshot introuvable. - Dépôt, mot de passe ou ID de repo introuvable - Restaurer les fichiers - Traitement des apps - Nettoyage - Les vérifications préalables à la restauration ont échoué. - Mot de passe introuvable pour le dépôt. - Aucun fichier trouvé dans le snapshot pour les applications sélectionnées. - La commande de restauration Restic a échoué. - %1$s : L\'installation a échoué : %2$s - %1$s : Échec de l\'écriture des splits APK. - %1$s : Échec de la création de la session d\'installation. - %1$s : Aucun fichier APK trouvé dans les données restaurées. - %1$s : La restauration des données a échoué ou est incomplète. - %1$s : Échec de la restauration des autorisations car l\'application n\'est pas installée. - %1$s : Échec de la restauration de certaines autorisations : %2$s - Restauration terminée en %1$s. - \n\nDétails :\n- %1$s - Déverrouiller - Supprimer - Nettoyer - Vérifier - Aucune tâche de maintenance sélectionnée. - [%1$d/%2$d] Exécution de \'%3$s\'… - %1$s réussi :\n%2$s\n\n - %1$s a échoué :\n%2$s\n\n - Une ou plusieurs tâches ont échoué. - Une ou plusieurs tâches d\'exécution ont échoué. - Aucune tâche sélectionnée. - Aucune tâche de maintenance n\'a été sélectionnée. - ID de dépôt introuvable - Le mot de passe du dépôt est incorrect. - Les identifiants SFTP sont incorrects. - Les identifiants REST sont incorrects. - Les identifiants S3 sont incorrects. - Le dépôt est fermé. Ouvrez-le pour charger les snapshots. - Entrer à nouveau le mot de passe - Entrer à nouveau les identifiants SFTP - Entrer à nouveau les identifiants REST - Entrer à nouveau les identifiants S3 - Ouvrir le dépôt - Ouvrir SFTP - Ouvrir REST - Ouvrir S3 - Le chemin et le mot de passe ne peuvent pas être vides. - Entrez le nom d\'utilisateur et le mot de passe REST, ou laissez les deux vides. - Entrez l\'Access Key et la Secret Key S3, ou laissez les deux vides. - Variables d\'environnement invalides. - Format de variable d\'environnement invalide à la ligne %1$d. Utilisez CLE=valeur. - Nom de variable d\'environnement invalide : %1$s - Options restic invalides. - Échec de la lecture du fichier de clé privée sélectionné. - Le fichier de clé privée sélectionné est vide. - La clé privée SSH SFTP ne peut pas être vide. - Erreur inconnue - Opérations en cours - Opérations terminées - Affiche la progression des sauvegardes et restaurations en silence. - Alerte lorsqu\'une sauvegarde ou une restauration est terminée. - - %1$d/%2$d fichier - %1$d/%2$d fichiers - - Analyse des fichiers… - %1$s terminé avec succès - %1$s a échoué - Aucune opération active pour le moment. - L\'opération a été interrompue. - Arrêter l\'opération ? - Voulez-vous arrêter l\'opération en cours ? Tout travail inachevé sera annulé. - Le dépôt existe déjà. - Le binaire Restic n\'est pas prêt. - Mot de passe invalide ou dépôt corrompu. - Le répertoire existe mais n\'est pas un dépôt restic. - Échec de la création du répertoire. - Échec de l\'initialisation du dépôt. - Repo valide, mais échec de la récupération de l\'ID. - Échec de la configuration de l\'authentification SFTP. - Le binaire SSH est introuvable sur cet appareil. - Échec de la création du script d\'assistance SSH askpass. - Échec de la préparation du stockage des hôtes connus SSH. - Échec de la préparation du stockage de la clé privée SSH. - Échec de la récupération de l\'empreinte du serveur SFTP. - Bucket S3 introuvable - Faire confiance - Version inconnue - inconnu - Binaire corrompu ou invalide - Binaire intégré introuvable ou non exécutable. - Échec du chargement des snapshots - Échec de la vérification du dépôt - Échec du nettoyage du dépôt - Échec du déverrouillage du dépôt - Aucune règle \'keep\' n\'a été spécifiée pour l\'opération de suppression. - Échec de la suppression des snapshots - Échec de la suppression du snapshot - Échec de la suppression des snapshots de métadonnées - Échec de la récupération de la configuration du repo - Échec de la modification du mot de passe - Échec de la restauration du snapshot - Restauration des fichiers… - Sauvegarde en cours… - Finalisation… - Ajouté %1$s (%2$s, %3$s) en %4$s. - - %1$d nouveau fichier - %1$d nouveaux fichiers - - - %1$d fichier modifié - %1$d fichiers modifiés - - La commande Restic a échoué - Restic n\'est pas installé ou n\'est pas prêt. - Un ou plusieurs noms de variables d\'environnement sont invalides. - Un ou plusieurs noms d\'options restic sont invalides. - Opération de nettoyage terminée. - - %1$d snapshot supprimé. - %1$d snapshots supprimés. - - Aucun snapshot ne correspond à la règle de suppression. - Opération de vérification terminée. - Opération de déverrouillage terminée. - N/D - Général - Conditions - La batterie n\'est pas faible - L\'appareil est en charge - Réseau non mesuré (Wi-Fi) - h - 1–720 h - %dh - 1s - 1m - Inconnu - Licence Inconnue - URL de la Licence : %1$s - Site du Projet : %1$s - Aucun lien disponible - … (appuyer pour étendre) - Ouvrir le lien - Copier la licence - à l\'instant - - il y a %d minute - il y a %d minutes - - - il y a %d heure - il y a %d heures - - - il y a %d jour - il y a %d jours - - - il y a %d mois - il y a %d mois - - - il y a %d an - il y a %d ans - - Autorisations - Types de sauvegarde pour les applications sélectionnées - Aucun - Mixte - Aucun type de restauration sélectionné. - Le fichier package de l\'application - Données internes de l\'application (/data/user/<id>) - Données accessibles avant le déverrouillage de l\'appareil (/data/user_de/<id>) - Données d\'application sur le stockage externe (/storage/emulated/<id>/Android/data) - Fichiers Opaque Binary Blob (/storage/emulated/<id>/Android/obb) - Fichiers multimédias de l\'application (/storage/emulated/<id>/Android/media) - Autorisations d\'exécution accordées - Activer - Accès aux statistiques d\'utilisation accordé - Accès aux statistiques d\'utilisation requis - Permet un calcul plus rapide de la taille des applications lors des sauvegardes au lieu d\'analyser les répertoires - Métadonnées restaurées avec succès. - Restaurer les métadonnées - Échec de la restauration des métadonnées - Le dépôt a été ajouté avec succès, mais la restauration de ses métadonnées (plannings, informations détaillées sur les sauvegardes) a échoué. Cela signifie généralement que l\'instantané de métadonnées est manquant ou que le dépôt est verrouillé. Pour réessayer, ouvrez Paramètres → Dépôts, appuyez sur le menu du dépôt et choisissez « Restaurer les métadonnées ». - Tâches - Tâches - diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml deleted file mode 100644 index 51b0216a..00000000 --- a/app/src/main/res/values-hi/strings.xml +++ /dev/null @@ -1,437 +0,0 @@ - - - होम - सेटिंग्स - स्नैपशॉट विवरण - रीस्टोर - लाइसेंस - शेड्यूल - प्रगति - बैकअप कॉन्फ़िगरेशन - हटाने की नीति (Forget) - अखंडता जांच (Integrity) - शेड्यूल - शेड्यूल जोड़ें - शेड्यूल संपादित करें - ऑपरेशन प्रगति - ओपन सोर्स लाइसेंस - स्नैपशॉट विवरण - स्नैपशॉट रीस्टोर करें - पीछे - स्नैपशॉट हटाएं - टास्क चलाएं - शेड्यूल जोड़ें - रीस्टोर - रीस्टोर शुरू करें - बैकअप - टास्क चलाएं - शेड्यूल %1$s - रीस्टोर - रखरखाव - %1$s:\n%2$s - बैकअप प्रकार - APK - डेटा - डिवाइस सुरक्षित डेटा - बाहरी डेटा - OBB डेटा - मीडिया डेटा - ऐप्स - सभी चुनें - रीस्टोर विकल्प - डाउनग्रेड की अनुमति दें - रीस्टोर करने के लिए ऐप्स - इस स्नैपशॉट में कोई ऐप डेटा नहीं मिला। - बैकअप: %1$s -> स्थापित: %2$s - बैकअप: %1$s - Restoid - एप्लिकेशन - %1$d ऐप्स चुने गए - %2$s - स्नैपशॉट हटाएं - नवीनतम %1$d रखें, दैनिक %2$d, साप्ताहिक %3$d, मासिक %4$d - अखंडता जांचें - केवल मेटाडेटा - मेटाडेटा के अलावा रिपॉजिटरी डेटा पैक की पुष्टि करें। - कोई रिपॉजिटरी नहीं चुनी गई। जोड़ने के लिए सेटिंग्स में जाएं। - Restic उपलब्ध नहीं है। सेटिंग्स जांचें। - इस रिपॉजिटरी के लिए कोई शेड्यूल कॉन्फ़िगर नहीं किया गया है। - अंतराल (घंटे) - नाम - पिछला रन: %1$s (%2$s) - कभी नहीं चला - शेड्यूल हटाएं? - क्या आप वाकई इस शेड्यूल को हटाना चाहते हैं? - बदलाव रद्द करें? - आपके पास सहेजे न गए बदलाव हैं। क्या आप वाकई उन्हें रद्द करना चाहते हैं? - रद्द करें - त्रुटि: %1$s - चुनी गई रिपॉजिटरी के लिए कोई स्नैपशॉट नहीं मिला। - - स्नैपशॉट (%1$d) - स्नैपशॉट (%1$d) - - रिपॉजिटरी पासवर्ड आवश्यक है - कृपया रिपॉजिटरी के लिए पासवर्ड दर्ज करें: %1$s - SFTP पासवर्ड आवश्यक है - कृपया रिपॉजिटरी के लिए SFTP पासवर्ड दर्ज करें: %1$s - SFTP SSH कुंजी आवश्यक है - कृपया रिपॉजिटरी के लिए SFTP क्रेडेंशियल दर्ज करें: %1$s - REST क्रेडेंशियल आवश्यक हैं - कृपया रिपॉजिटरी के लिए REST उपयोगकर्ता नाम और पासवर्ड दर्ज करें: %1$s - S3 क्रेडेंशियल आवश्यक हैं - कृपया रिपॉजिटरी के लिए S3 Access Key और Secret Key दर्ज करें: %1$s - - ऐप (%1$d): - ऐप्स (%1$d): - - इस स्नैपशॉट के लिए कोई ऐप जानकारी उपलब्ध नहीं है। - मेटाडेटा गायब है या दूषित है। - रिपॉजिटरी अनलॉक करें - रिपॉजिटरी साफ करें (Prune) - नवीनतम रखें - दैनिक रखें - साप्ताहिक रखें - मासिक रखें - सभी डेटा पढ़ें - - बैकअप किया गया ऐप (%1$d) - बैकअप किए गए ऐप्स (%1$d) - - कोई स्नैपशॉट नहीं मिला। - स्नैपशॉट हटाया जा रहा है… - ID: %1$s - समय: %1$s - संस्करण: %1$s - आकार: %2$s - स्नैपशॉट हटाएं? - यह स्नैपशॉट प्रविष्टि को हटा देगा। वास्तविक डेटा केवल सफाई (Prune) चलाने के बाद ही हटाया जाएगा। क्या आप सुनिश्चित हैं? - पासवर्ड - रिपॉजिटरी पासवर्ड - REST उपयोगकर्ता नाम - REST पासवर्ड - S3 Access Key ID - S3 Secret Access Key - पासवर्ड खाली नहीं हो सकता - उपयोगकर्ता नाम खाली नहीं हो सकता - पासवर्ड सहेजें - क्रेडेंशियल सहेजें - ठीक है - रद्द करें - रोकें - हो गया - जोड़ें - हटाएं - बदलें - सहेजें - अनुमति दें - सेटिंग्स - हटाएं - पुनः प्रयास करें - अक्षम करें - आरंभ किया जा रहा है… - रोका जा रहा है… - %1$s पूर्ण - %1$s विफल - कुल प्रगति - चरण की प्रगति - बीता हुआ समय - आइटम - आकार - प्रसंस्करण: - ..। - रिपॉजिटरी जोड़ें - रिपॉजिटरी प्रकार - रिपॉजिटरी विशिष्टता - पर्यावरण चर (Environment variables) - KEY=value (प्रति पंक्ति एक) - प्रमाणीकरण विधि - SFTP पासवर्ड - SSH कुंजी - SFTP SSH निजी कुंजी - -----BEGIN OPENSSH PRIVATE KEY-----\n..। - SSH कुंजी पासफ़्रेज़ - SSH कुंजी पासफ़्रेज़ (वैकल्पिक) - SFTP सर्वर पर विश्वास करें - सर्वर %1$s ने ये होस्ट की फ़िंगरप्रिंट प्रस्तुत किए हैं: - फ़िंगरप्रिंट - स्थानीय निर्देशिका (Local directory) - SFTP (SSH) - HTTP REST सर्वर - Amazon S3 / MinIO - user@host:/srv/restic-repo - http://host:8000/ - s3.us-east-1.amazonaws.com/bucket_name - पथ (Path) - एक निर्देशिका चुनें… - फ़ोल्डर चुनें - निजी कुंजी फ़ाइल चुनें - पासवर्ड छिपाएं - पासवर्ड दिखाएं - बैकअप रिपॉजिटरी - रिपॉजिटरी जोड़ें - कोई रिपॉजिटरी कॉन्फ़िगर नहीं की गई है। शुरू करने के लिए एक जोड़ें। - रिपॉजिटरी प्रबंधित करने के लिए restic स्थापित करें। - सिस्टम - रूट एक्सेस अस्वीकृत - रूट की जाँच की जा रही है… - रूट एक्सेस स्वीकृत - निर्भरताएं (Dependencies) - इसके बारे में - संस्करण - ओपन सोर्स लाइसेंस - अनुवाद करें - सूचनाएं सक्षम - सूचनाएं अक्षम - सूचना अनुमति - बैटरी अनुकूलन सक्षम - बैटरी अनुकूलन अक्षम - लंबे पृष्ठभूमि संचालन की विश्वसनीयता में सुधार करने के लिए इसे अक्षम करें। - शुरुआत में ऐप अनलॉक की आवश्यकता है - विकल्प - Restoid अनलॉक करें - जारी रखने के लिए फ़िंगरप्रिंट या स्क्रीन लॉक का उपयोग करें - ID: %1$s… - अधिक विकल्प - पासवर्ड भूल जाएं - SFTP पासवर्ड भूल जाएं - SFTP पासवर्ड सहेजें - SSH कुंजी क्रेडेंशियल भूल जाएं - SSH कुंजी क्रेडेंशियल सहेजें - REST क्रेडेंशियल भूल जाएं - REST क्रेडेंशियल सहेजें - S3 क्रेडेंशियल भूल जाएं - S3 क्रेडेंशियल सहेजें - पासवर्ड सहेजें - पासवर्ड सहेजें - पासवर्ड बदलें - Restic बाइनरी - स्थिति की जांच की जा रही है… - त्रुटि: %1$s - पासवर्ड बदलने में विफल। - पासवर्ड बदलें - पुराना पासवर्ड - नया पासवर्ड - नए पासवर्ड की पुष्टि करें - छिपाएं - दिखाएं - पासवर्ड मेल नहीं खाते। - पासवर्ड सहेजें - SFTP पासवर्ड सहेजें - SSH कुंजी क्रेडेंशियल सहेजें - REST क्रेडेंशियल सहेजें - S3 क्रेडेंशियल सहेजें - संग्रहीत क्रेडेंशियल इस डिवाइस पर एन्क्रिप्ट किए गए हैं। - संग्रहीत क्रेडेंशियल इस डिवाइस पर एन्क्रिप्ट किए गए हैं। - अनुमतियां - अज्ञात आइटम - OBB - मीडिया - रिपॉजिटरी ID नहीं मिली। मेटाडेटा सहेज नहीं सकते। - [%1$d/%2$d] बैकअप तैयार किया जा रहा है..। - [%1$d/%2$d] ऐप्स का बैकअप लिया जा रहा है… - [%1$d/%2$d] बैकअप पूरा किया जा रहा है… - Restic कमांड निकास कोड %1$d के साथ विफल रहा। - चेतावनी: बैकअप मेटाडेटा फ़ाइल स्थानीय रूप से सहेजी नहीं जा सकी। - एक गंभीर त्रुटि हुई: %1$s - कोई बैकअप प्रकार नहीं चुना गया। - कोई बैकअप प्रकार नहीं चुना गया था। - Restic स्थापित नहीं है। - Restic बाइनरी स्थापित नहीं है। - कोई बैकअप रिपॉजिटरी नहीं चुनी गई। - कोई बैकअप रिपॉजिटरी नहीं चुनी गई है। - रिपॉजिटरी के लिए पासवर्ड नहीं मिला। - पासवर्ड नहीं मिल सका। - रिपॉजिटरी के लिए SFTP क्रेडेंशियल नहीं मिले। - SFTP क्रेडेंशियल नहीं मिल सके। - रिपॉजिटरी के लिए REST क्रेडेंशियल नहीं मिले। - REST क्रेडेंशियल नहीं मिल सके। - रिपॉजिटरी के लिए S3 क्रेडेंशियल नहीं मिले। - S3 क्रेडेंशियल नहीं मिल सके। - एक अन्य ऑपरेशन पहले से ही चल रहा है। - एक अन्य ऑपरेशन चल रहा है। इसके पूरा होने की प्रतीक्षा करें। - स्नैपशॉट नहीं मिला। - रिपॉजिटरी, पासवर्ड, या रेपो ID नहीं मिला - फ़ाइलें रीस्टोर करें - ऐप्स प्रोसेस किए जा रहे हैं - सफाई (Cleanup) - रीस्टोर से पहले की जांच विफल रही। - रिपॉजिटरी के लिए पासवर्ड नहीं मिला। - चुने गए ऐप्स के लिए स्नैपशॉट में कोई फ़ाइल नहीं मिली। - Restic रीस्टोर कमांड विफल रहा। - %1$s: इंस्टॉल कमिट विफल रहा: %2$s - %1$s: APK स्प्लिट्स लिखने में विफल। - %1$s: इंस्टॉल सत्र बनाने में विफल। - %1$s: रीस्टोर किए गए डेटा में कोई APK फ़ाइल नहीं मिली। - %1$s: डेटा रीस्टोर विफल या अधूरा रहा। - %1$s: अनुमतियां रीस्टोर करने में विफल क्योंकि ऐप स्थापित नहीं है। - %1$s: कुछ अनुमतियां रीस्टोर करने में विफल: %2$s - रीस्टोर %1$s में समाप्त हुआ। - \n\nविवरण:\n- %1$s - अनलॉक - हटाएं - साफ करें - जांचें - कोई रखरखाव टास्क नहीं चुना गया। - [%1$d/%2$d] \'%3$s\' चलाया जा रहा है… - %1$s सफल रहा:\n%2$s\n\n - %1$s विफल रहा:\n%2$s\n\n - एक या अधिक टास्क विफल रहे। - एक या अधिक रन टास्क विफल रहे। - कोई टास्क नहीं चुना गया। - कोई रखरखाव टास्क नहीं चुना गया था। - रिपॉजिटरी ID नहीं मिली - रिपॉजिटरी पासवर्ड गलत है। - SFTP क्रेडेंशियल गलत हैं। - REST क्रेडेंशियल गलत हैं। - S3 क्रेडेंशियल गलत हैं। - रिपॉजिटरी बंद है। स्नैपशॉट लोड करने के लिए इसे खोलें। - पासवर्ड फिर से दर्ज करें - SFTP क्रेडेंशियल फिर से दर्ज करें - REST क्रेडेंशियल फिर से दर्ज करें - S3 क्रेडेंशियल फिर से दर्ज करें - रिपॉजिटरी खोलें - SFTP खोलें - REST खोलें - S3 खोलें - पथ और पासवर्ड खाली नहीं हो सकते। - REST उपयोगकर्ता नाम और पासवर्ड दोनों दर्ज करें, या दोनों को खाली छोड़ दें। - S3 Access Key और Secret Key दोनों दर्ज करें, या दोनों को खाली छोड़ दें। - अमान्य पर्यावरण चर। - पंक्ति %1$d पर अमान्य पर्यावरण चर प्रारूप। KEY=value का उपयोग करें। - अमान्य पर्यावरण चर नाम: %1$s - अमान्य restic विकल्प। - चुनी गई निजी कुंजी फ़ाइल को पढ़ने में विफल। - चुनी गई निजी कुंजी फ़ाइल खाली है। - SFTP SSH निजी कुंजी खाली नहीं हो सकती। - अज्ञात त्रुटि - चल रहे ऑपरेशन - पूर्ण ऑपरेशन - चल रहे बैकअप और रीस्टोर की प्रगति को चुपचाप दिखाता है। - बैकअप या रीस्टोर पूरा होने पर अलर्ट करता है। - - %1$d/%2$d फ़ाइल - %1$d/%2$d फ़ाइलें - - फ़ाइलें स्कैन की जा रही हैं… - %1$s सफलतापूर्वक समाप्त हुआ - %1$s विफल रहा - अभी कोई सक्रिय ऑपरेशन नहीं है। - ऑपरेशन बाधित हो गया था। - ऑपरेशन रोकें? - क्या आप वर्तमान ऑपरेशन को रोकना चाहते हैं? कोई भी अधूरा काम रद्द कर दिया जाएगा। - रिपॉजिटरी पहले से मौजूद है। - Restic बाइनरी तैयार नहीं है। - अमान्य पासवर्ड या दूषित रिपॉजिटरी। - निर्देशिका मौजूद है लेकिन यह restic रिपॉजिटरी नहीं है। - निर्देशिका बनाने में विफल। - रिपॉजिटरी आरंभ करने में विफल। - रेपो मान्य है, लेकिन ID प्राप्त करने में विफल। - SFTP प्रमाणीकरण कॉन्फ़िगर करने में विफल। - इस डिवाइस पर SSH बाइनरी नहीं मिली। - SSH askpass सहायक स्क्रिप्ट बनाने में विफल। - SSH known hosts स्टोरेज तैयार करने में विफल। - SSH निजी कुंजी स्टोरेज तैयार करने में विफल। - SFTP सर्वर फ़िंगरप्रिंट प्राप्त करने में विफल। - S3 Bucket नहीं मिला - विश्वास करें - अज्ञात संस्करण - अज्ञात - बाइनरी दूषित या अमान्य है - बंडल की गई बाइनरी नहीं मिली या निष्पादन योग्य नहीं है। - स्नैपशॉट लोड करने में विफल - रिपॉजिटरी जांचने में विफल - रिपॉजिटरी साफ करने में विफल - रिपॉजिटरी अनलॉक करने में विफल - हटाने (forget) के ऑपरेशन के लिए कोई \'keep\' नीति निर्दिष्ट नहीं की गई थी। - स्नैपशॉट हटाने में विफल - स्नैपशॉट हटाने में विफल - मेटाडेटा स्नैपशॉट हटाने में विफल - रेपो कॉन्फ़िगरेशन प्राप्त करने में विफल - पासवर्ड बदलने में विफल - स्नैपशॉट रीस्टोर करने में विफल - फ़ाइलें रीस्टोर की जा रही हैं… - बैकअप लिया जा रहा है… - समाप्त किया जा रहा है… - %4$s में %1$s (%2$s, %3$s) जोड़ा गया। - - %1$d नई फ़ाइल - %1$d नई फ़ाइलें - - - %1$d बदली गई फ़ाइल - %1$d बदली गई फ़ाइलें - - Restic कमांड विफल रहा - Restic स्थापित या तैयार नहीं है। - एक या अधिक पर्यावरण चर नाम अमान्य हैं। - एक या अधिक restic विकल्प नाम अमान्य हैं। - सफाई (Prune) ऑपरेशन पूरा हुआ। - - %1$d स्नैपशॉट हटाया गया। - %1$d स्नैपशॉट हटाए गए। - - हटाने की नीति से कोई स्नैपशॉट मेल नहीं खाता। - जांच ऑपरेशन पूरा हुआ। - अनलॉक ऑपरेशन समाप्त हुआ। - N/A - सामान्य - शर्तें - बैटरी कम नहीं है - डिवाइस चार्ज हो रहा है - नेटवर्क अनमीटर्ड है (Wi-Fi) - घंटे - 1–720 घंटे - %dघंटे - 1सप्ताह - 1महीना - अज्ञात - अज्ञात लाइसेंस - लाइसेंस URL: %1$s - प्रोजेक्ट वेबसाइट: %1$s - कोई लिंक उपलब्ध नहीं है - … (विस्तार करने के लिए टैप करें) - लिंक खोलें - लाइसेंस कॉपी करें - अभी - - %d मिनट पहले - %d मिनट पहले - - - %d घंटे पहले - %d घंटे पहले - - - %d दिन पहले - %d दिन पहले - - - %d महीने पहले - %d महीने पहले - - - %d साल पहले - %d साल पहले - - अनुमतियाँ - चयनित ऐप्स के लिए बैकअप प्रकार - कोई नहीं - मिश्रित - कोई रिस्टोर प्रकार नहीं चुना गया। - एप्लिकेशन पैकेज फ़ाइल - आंतरिक ऐप डेटा (/data/user/<id>) - डिवाइस अनलॉक करने से पहले एक्सेस योग्य डेटा (/data/user_de/<id>) - बाहरी स्टोरेज पर ऐप डेटा (/storage/emulated/<id>/Android/data) - ओपेक बाइनरी ब्लॉब फाइलें (/storage/emulated/<id>/Android/obb) - ऐप मीडिया फाइलें (/storage/emulated/<id>/Android/media) - अनुमतियाँ प्रदान की गईं - सक्षम करें - ऐप उपयोग एक्सेस प्रदान किया गया - ऐप उपयोग का एक्सेस आवश्यक है - बैकअप के दौरान डायरेक्टरी स्कैन करने के बजाय तेज़ी से एप्लिकेशन के आकार की गणना करने में सक्षम बनाता है - मेटाडेटा सफलतापूर्वक रिस्टोर हो गया। - मेटाडेटा रिस्टोर करें - मेटाडेटा रिस्टोरेशन विफल - रिपॉजिटरी सफलतापूर्वक जोड़ दी गई, लेकिन इसका मेटाडेटा (शेड्यूल, विस्तृत बैकअप जानकारी) रिस्टोर करने में विफलता हुई। इसका आमतौर पर मतलब है कि मेटाडेटा स्नैपशॉट गायब है या रिपॉजिटरी लॉक है। पुनः प्रयास करने के लिए, सेटिंग्स → रिपॉजिटरी खोलें, रेपो मेनू पर टैप करें, और \"मेटाडेटा रिस्टोर करें\" चुनें। - कार्य - कार्य - diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml deleted file mode 100644 index 63ca113e..00000000 --- a/app/src/main/res/values-hr/strings.xml +++ /dev/null @@ -1,444 +0,0 @@ - - - Postavke - Restore - Licence - Napredak Operacije - Konfiguracija Sigurnosne Kopije - Provjeri Integritet - Natrag - Početna - Detalji snapshota - Rasporedi - Pravila brisanja (Forget) - Rasporedi - Dodaj raspored - Uredi raspored - Napredak operacije - Open Source licence - Detalji snapshota - Vrati snapshot - Ukloni snapshot - Pokreni zadatke - Dodaj raspored - Restore - Pokreni restore - Backup - Pokreni zadatke - Raspored %1$s - Restore - Održavanje - %1$s:\n%2$s - Vrste backupa - APK - Podaci - Podaci zaštićeni uređajem - Vanjski podaci - OBB podaci - Medijski podaci - Aplikacije - Odaberi sve - Opcije vraćanja - Dopusti downgrade - Aplikacije za vraćanje - U ovom snapshotu nisu pronađeni podaci aplikacije. - Backup: %1$s -> Instalirano: %2$s - Backup: %1$s - Restoid - Aplikacije - Odabrano %1$d aplikacija - %2$s - Ukloni snapshotove - Zadrži zadnjih %1$d, dnevnih %2$d, tjednih %3$d, mjesečnih %4$d - Provjeri integritet - Samo metapodaci - Provjeri pakete podataka repozitorija uz metapodatke. - Nema odabranog repozitorija. Odi u postavke da ga dodaš. - Restic nije dostupan. Provjeri postavke. - Nema konfiguriranih rasporeda za ovaj repozitorij. - Interval (Sati) - Ime - Zadnje pokretanje: %1$s (%2$s) - Nikad pokrenuto - Obriši raspored? - Jesi li siguran da želiš obrisati ovaj raspored? - Odbaci promjene? - Imaš nespremljene promjene. Jesi li siguran da ih želiš odbaciti? - Odbaci - Greška: %1$s - Nema snapshotova za odabrani repozitorij. - - Snapshot (%1$d) - Snapshota (%1$d) - Snapshotova (%1$d) - - Potrebna lozinka repozitorija - Unesi lozinku za repozitorij: %1$s - Potrebna SFTP lozinka - Unesi SFTP lozinku za repozitorij: %1$s - Potreban SFTP SSH ključ - Unesi SFTP vjerodajnice za repozitorij: %1$s - Potrebne REST vjerodajnice - Unesi REST korisničko ime i lozinku za repozitorij: %1$s - Potrebne S3 vjerodajnice - Unesi S3 Access Key i Secret Key za repozitorij: %1$s - - Aplikacija (%1$d): - Aplikacije (%1$d): - Aplikacija (%1$d): - - Nema informacija o aplikaciji za ovaj snapshot. - Metapodaci nedostaju ili su oštećeni. - Otključaj repozitorij - Očisti repozitorij (Prune) - Zadrži zadnje - Zadrži dnevne - Zadrži tjedne - Zadrži mjesečne - Pročitaj sve podatke - - Backupirana aplikacija (%1$d) - Backupirane aplikacije (%1$d) - Backupiranih aplikacija (%1$d) - - Snapshot nije pronađen. - Uklanjanje snapshota… - ID: %1$s - Vrijeme: %1$s - Verzija: %1$s - Veličina: %2$s - Ukloni snapshot? - Ovo će ukloniti unos snapshota. Stvarni podaci bit će izbrisani tek nakon čišćenja (Prune). Jesi li siguran? - Lozinka - Lozinka repozitorija - REST korisničko ime - REST lozinka - S3 Access Key ID - S3 Secret Access Key - Lozinka ne može biti prazna - Korisničko ime ne može biti prazno - Spremi lozinku - Spremi vjerodajnice - OK - Odustani - Zaustavi - Gotovo - Dodaj - Obriši - Promijeni - Spremi - Dopusti - Postavke - Ukloni - Pokušaj ponovno - Onemogući - Inicijalizacija… - Zaustavljanje… - %1$s završeno - %1$s nije uspjelo - Ukupni napredak - Napredak faze - Proteklo - Stavke - Veličina - Obrađujem: - ... - Dodaj repozitorij - Vrsta repozitorija - Specifikacija repozitorija - Varijable okruženja - KLJUČ=vrijednost (jedna po liniji) - Metoda provjere autentičnosti - SFTP lozinka - SSH ključ - SFTP SSH privatni ključ - -----BEGIN OPENSSH PRIVATE KEY-----\n... - Passphrase SSH ključa - Passphrase SSH ključa (opcionalno) - Vjeruj SFTP serveru - Server %1$s je predstavio ove otiske (fingerprints) host ključa: - Otisci (Fingerprints) - Lokalni direktorij - SFTP (SSH) - HTTP REST server - Amazon S3 / MinIO - user@host:/srv/restic-repo - http://host:8000/ - s3.us-east-1.amazonaws.com/ime_bucketa - Putanja - Odaberi direktorij… - Odaberi mapu - Odaberi datoteku privatnog ključa - Sakrij lozinku - Prikaži lozinku - Backup repozitoriji - Dodaj repozitorij - Nema konfiguriranih repozitorija. Dodaj jedan za početak. - Instaliraj restic za upravljanje repozitorijima. - Sustav - Root pristup odbijen - Provjera root pristupa… - Root pristup odobren - Ovisnosti - O aplikaciji - Verzija - Open Source licence - Prevedi - Obavijesti omogućene - Obavijesti onemogućene - Dopuštenje za obavijesti - Optimizacija baterije omogućena - Optimizacija baterije onemogućena - Onemogući ovo kako bi poboljšao pouzdanost dugih procesa u pozadini. - Zahtijevaj otključavanje pri pokretanju - Opcije - Otključaj Restoid - Za nastavak koristi otisak prsta ili zaključavanje zaslona - ID: %1$s… - Više opcija - Zaboravi lozinku - Zaboravi SFTP lozinku - Spremi SFTP lozinku - Zaboravi SSH vjerodajnice - Spremi SSH vjerodajnice - Zaboravi REST vjerodajnice - Spremi REST vjerodajnice - Zaboravi S3 vjerodajnice - Spremi S3 vjerodajnice - Spremi lozinku - Spremi lozinke - Promijeni lozinku - Restic binarna datoteka - Provjera statusa… - Greška: %1$s - Promjena lozinke nije uspjela. - Promijeni lozinku - Stara lozinka - Nova lozinka - Potvrdi novu lozinku - Sakrij - Prikaži - Lozinke se ne podudaraju. - Spremi lozinku - Spremi SFTP lozinku - Spremi SSH vjerodajnice - Spremi REST vjerodajnice - Spremi S3 vjerodajnice - Spremljene vjerodajnice kriptirane su na ovom uređaju. - Spremljene vjerodajnice kriptirane su na ovom uređaju. - Dopuštenja - Nepoznate stavke - OBB - Mediji - ID repozitorija nije pronađen. Nemoguće spremiti metapodatke. - [%1$d/%2$d] Priprema backupa... - [%1$d/%2$d] Backupiranje aplikacija… - [%1$d/%2$d] Završavanje backupa… - Restic naredba nije uspjela (izlazni kod %1$d). - Upozorenje: Nije bilo moguće lokalno spremiti datoteku metapodataka backupa. - Dogodila se fatalna greška: %1$s - Nisu odabrane vrste backupa. - Nijedna vrsta backupa nije odabrana. - Restic nije instaliran. - Restic binarna datoteka nije instalirana. - Nema odabranog backup repozitorija. - Nema odabranog backup repozitorija. - Lozinka repozitorija nije pronađena. - Lozinka se ne može pronaći. - SFTP vjerodajnice za repozitorij nisu pronađene. - SFTP vjerodajnice se ne mogu pronaći. - REST vjerodajnice za repozitorij nisu pronađene. - REST vjerodajnice se ne mogu pronaći. - S3 vjerodajnice za repozitorij nisu pronađene. - S3 vjerodajnice se ne mogu pronaći. - Druga operacija se već izvršava. - Druga operacija je u tijeku. Pričekaj da završi. - Snapshot nije pronađen. - Repozitorij, lozinka ili ID repozitorija nisu pronađeni - Vrati datoteke - Obrada aplikacija - Čišćenje - Provjere prije vraćanja nisu uspjele. - Lozinka repozitorija nije pronađena. - U snapshotu nisu pronađene datoteke za odabrane aplikacije. - Restic naredba za vraćanje nije uspjela. - %1$s: Install commit nije uspio: %2$s - %1$s: Zapisivanje APK splitova nije uspjelo. - %1$s: Stvaranje instalacijske sesije nije uspjelo. - %1$s: U vraćenim podacima nema APK datoteka. - %1$s: Vraćanje podataka nije uspjelo ili je nepotpuno. - %1$s: Vraćanje dopuštenja nije uspjelo jer aplikacija nije instalirana. - %1$s: Vraćanje nekih dopuštenja nije uspjelo: %2$s - Vraćanje završeno za %1$s. - \n\nDetalji:\n- %1$s - Otključaj - Ukloni - Očisti - Provjeri - Nisu odabrani zadaci održavanja. - [%1$d/%2$d] Izvršavam \'%3$s\'… - %1$s uspješno:\n%2$s\n\n - %1$s nije uspjelo:\n%2$s\n\n - Jedan ili više zadataka nije uspjelo. - Jedan ili više zadataka za pokretanje nije uspjelo. - Nisu odabrani zadaci. - Nijedan zadatak održavanja nije odabran. - ID repozitorija nije pronađen - Lozinka repozitorija nije točna. - SFTP vjerodajnice nisu točne. - REST vjerodajnice nisu točne. - S3 vjerodajnice nisu točne. - Repozitorij je zatvoren. Otvori ga za učitavanje snapshotova. - Ponovno unesi lozinku - Ponovno unesi SFTP vjerodajnice - Ponovno unesi REST vjerodajnice - Ponovno unesi S3 vjerodajnice - Otvori repozitorij - Otvori SFTP - Otvori REST - Otvori S3 - Putanja i lozinka ne smiju biti prazne. - Unesi REST korisničko ime i lozinku ili ostavi oboje prazno. - Unesi S3 Access Key i Secret Key ili ostavi oboje prazno. - Nevažeće varijable okruženja. - Nevažeći format varijable okruženja na liniji %1$d. Koristi KLJUČ=vrijednost. - Nevažeće ime varijable okruženja: %1$s - Nevažeće restic opcije. - Čitanje odabrane datoteke privatnog ključa nije uspjelo. - Odabrana datoteka privatnog ključa je prazna. - SFTP SSH privatni ključ ne smije biti prazan. - Nepoznata greška - Operacije u tijeku - Završene operacije - Tiho prikazuje napredak backupa i vraćanja. - Šalje upozorenje kad backup ili vraćanje završi. - - %1$d/%2$d datoteka - %1$d/%2$d datoteke - %1$d/%2$d datoteka - - Skeniram datoteke… - %1$s uspješno završeno - %1$s nije uspjelo - Trenutno nema aktivne operacije. - Operacija prekinuta. - Zaustavi operaciju? - Želiš li zaustaviti trenutnu operaciju? Sav nezavršeni posao bit će otkazan. - Repozitorij već postoji. - Restic binarna datoteka nije spremna. - Nevažeća lozinka ili oštećen repozitorij. - Direktorij postoji, ali nije restic repozitorij. - Stvaranje direktorija nije uspjelo. - Inicijalizacija repozitorija nije uspjela. - Repo je valjan, ali dohvaćanje ID-a nije uspjelo. - Konfiguracija SFTP provjere autentičnosti nije uspjela. - SSH binarna datoteka nije pronađena na ovom uređaju. - Stvaranje pomoćne skripte SSH askpass nije uspjelo. - Priprema prostora za SSH known hosts nije uspjela. - Priprema prostora za SSH privatni ključ nije uspjela. - Dohvaćanje otiska (fingerprint) SFTP servera nije uspjelo. - S3 Bucket nije pronađen - Vjeruj - Nepoznata verzija - nepoznato - Binarna datoteka oštećena ili nevažeća - Uključena binarna datoteka nije pronađena ili se ne može izvršiti. - Učitavanje snapshotova nije uspjelo - Provjera repozitorija nije uspjela - Čišćenje repozitorija nije uspjelo - Otključavanje repozitorija nije uspjelo - Nije navedeno \'keep\' pravilo za brisanje. - Brisanje snapshotova nije uspjelo - Brisanje snapshota nije uspjelo - Brisanje metapodataka snapshota nije uspjelo - Dohvaćanje konfiguracije repoa nije uspjelo - Promjena lozinke nije uspjela - Vraćanje snapshota nije uspjelo - Vraćanje datoteka… - Backupiranje… - Završavanje… - Dodano %1$s (%2$s, %3$s) u %4$s. - - %1$d nova datoteka - %1$d nove datoteke - %1$d novih datoteka - - - %1$d izmijenjena datoteka - %1$d izmijenjene datoteke - %1$d izmijenjenih datoteka - - Restic naredba nije uspjela - Restic nije instaliran ili spreman. - Ime jedne ili više varijabli okruženja je nevažeće. - Ime jedne ili više restic opcija je nevažeće. - Čišćenje završeno. - - Uklonjen %1$d snapshot. - Uklonjena %1$d snapshota. - Uklonjeno %1$d snapshotova. - - Nijedan snapshot ne odgovara pravilu za uklanjanje. - Provjera završena. - Otključavanje završeno. - N/A - Općenito - Uvjeti - Baterija nije slaba - Uređaj se puni - Mreža nema ograničenje (Wi-Fi) - h - 1–720 h - %dh - 1tj - 1mj - Nepoznato - Nepoznata licenca - URL licence: %1$s - Web stranica projekta: %1$s - Nema dostupne poveznice - … (dodirni za proširenje) - Otvori poveznicu - Kopiraj licencu - upravo sada - - prije %d minute - Prije %d minuta - - - prije %d sati - Prije %d sati - - - prije %d dana - Prije %d dana - - - prije %d mjeseca - Prije %d mjeseci - - - prije %d godine - Prije %d godina - - Dozvole - Vrste sigurnosnih kopija za odabrane aplikacije - Nijedna - Mješovito - Nisu odabrane vrste vraćanja. - Datoteka paketa aplikacije - Interni podaci aplikacije (/data/user/<id>) - Podaci dostupni prije otključavanja uređaja (/data/user_de/<id>) - Podaci aplikacije na vanjskoj pohrani (/storage/emulated/<id>/Android/data) - Opaque Binary Blob datoteke (/storage/emulated/<id>/Android/obb) - Medijske datoteke aplikacije (/storage/emulated/<id>/Android/media) - Odobrene runtime dozvole - Omogući - Pristup uporabi aplikacije je omogućen - Potreban je pristup uporabi aplikacije - Omogućuje brži izračun veličine aplikacija tijekom izrade sigurnosnih kopija umjesto skeniranja direktorija - Metapodaci su uspješno vraćeni. - Vrati metapodatke - Neuspjelo vraćanje metapodataka - Repozitorij je uspješno dodan, ali vraćanje njegovih metapodataka (rasporedi, detaljne informacije o sigurnosnim kopijama) nije uspjelo. To obično znači da nedostaje snimka metapodataka ili je repozitorij zaključan. Kako biste pokušali ponovo, otvorite Postavke → Repozitoriji, dodirnite izbornik repozitorija i odaberite „Vrati metapodatke“. - Zadaci - Zadaci - diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml deleted file mode 100644 index b2972e64..00000000 --- a/app/src/main/res/values-in/strings.xml +++ /dev/null @@ -1,425 +0,0 @@ - - - Beranda - Pengaturan - Detail Snapshot - Pulihkan - Lisensi - Jadwal - Kemajuan - Konfigurasi Backup - Kebijakan Retensi (Lupakan) - Cek Integritas - Jadwal - Tambah Jadwal - Edit Jadwal - Kemajuan Operasi - Lisensi Open Source - Detail Snapshot - Pulihkan Snapshot - Kembali - Lupakan Snapshot - Jalankan Tugas - Tambah Jadwal - Pulihkan - Mulai Pemulihan - Backup - Jalankan Tugas - Jadwal %1$s - Pulihkan - Pemeliharaan - %1$s:\n%2$s - Tipe Backup - APK - Data - Data Terlindungi Perangkat - Data Eksternal - Data OBB - Data Media - Aplikasi - Pilih Semua - Opsi Pemulihan - Izinkan Downgrade - Aplikasi yang Dipulihkan - Tidak ada data aplikasi di snapshot ini. - Backup: %1$s -> Terinstal: %2$s - Backup: %1$s - Restoid - Aplikasi - %1$d aplikasi dipilih - %2$s - Lupakan Snapshot - Simpan %1$d terakhir, harian %2$d, mingguan %3$d, bulanan %4$d - Cek Integritas - Hanya metadata - Verifikasi paket data repositori selain metadata. - Belum ada repositori yang dipilih. Buka pengaturan untuk menambahkannya. - Restic tidak tersedia. Cek pengaturan. - Tidak ada jadwal yang dikonfigurasi untuk repositori ini. - Interval (Jam) - Nama - Terakhir jalan: %1$s (%2$s) - Belum pernah jalan - Hapus Jadwal? - Yakin ingin menghapus jadwal ini? - Buang perubahan? - Ada perubahan yang belum disimpan. Yakin ingin membuangnya? - Buang - Error: %1$s - Tidak ada snapshot yang ditemukan di repositori ini. - - Snapshot (%1$d) - - Butuh Password Repositori - Masukkan password untuk repositori: %1$s - Butuh Password SFTP - Masukkan password SFTP untuk repositori: %1$s - Butuh Kunci SSH SFTP - Masukkan kredensial SFTP untuk repositori: %1$s - Butuh Kredensial REST - Masukkan username dan password REST untuk repositori: %1$s - Butuh Kredensial S3 - Masukkan S3 Access Key dan Secret Key untuk repositori: %1$s - - Aplikasi (%1$d): - - Tidak ada info aplikasi untuk snapshot ini. - Metadata hilang atau rusak. - Buka kunci repositori - Bersihkan repositori (Prune) - Simpan Terakhir - Simpan Harian - Simpan Mingguan - Simpan Bulanan - Baca semua data - - Aplikasi di-backup (%1$d) - - Tidak ada snapshot yang ditemukan. - Melupakan snapshot… - ID: %1$s - Waktu: %1$s - Versi: %1$s - Ukuran: %2$s - Lupakan Snapshot? - Tindakan ini akan menghapus entri snapshot. Data sebenarnya baru akan terhapus setelah proses pembersihan (Prune). Yakin? - Password - Password repositori - Username REST - Password REST - S3 Access Key ID - S3 Secret Access Key - Password tidak boleh kosong - Username tidak boleh kosong - Simpan password - Simpan kredensial - OK - Batal - Berhenti - Selesai - Tambah - Hapus - Ubah - Simpan - Izinkan - Pengaturan - Lupakan - Coba Lagi - Nonaktifkan - Menginisialisasi… - Menghentikan… - %1$s Selesai - %1$s Gagal - Kemajuan Total - Kemajuan Tahap - Berjalan - Item - Ukuran - Memproses: - ... - Tambah Repositori - Tipe repositori - Spesifikasi repositori - Variabel environment - KEY=value (satu per baris) - Metode autentikasi - Password SFTP - Kunci SSH - Kunci Privat SSH SFTP - -----BEGIN OPENSSH PRIVATE KEY-----\n... - Passphrase Kunci SSH - Passphrase Kunci SSH (opsional) - Percayai server SFTP - Server %1$s menyajikan fingerprint kunci host ini: - Fingerprint - Direktori lokal - SFTP (SSH) - Server HTTP REST - Amazon S3 / MinIO - user@host:/srv/restic-repo - http://host:8000/ - s3.us-east-1.amazonaws.com/nama_bucket - Path - Pilih direktori… - Pilih Folder - Pilih File Kunci Privat - Sembunyikan password - Tampilkan password - Repositori Backup - Tambah Repositori - Belum ada repositori. Tambahkan untuk memulai. - Instal restic untuk mengelola repositori. - Sistem - Akses root ditolak - Mengecek root… - Akses root diberikan - Dependensi - Tentang - Versi - Lisensi Open Source - Terjemahkan - Notifikasi aktif - Notifikasi nonaktif - Izin notifikasi - Optimalisasi baterai aktif - Optimalisasi baterai nonaktif - Nonaktifkan ini untuk meningkatkan keandalan operasi di background. - Wajibkan buka kunci aplikasi saat dimulai - Opsi - Buka Kunci Restoid - Gunakan sidik jari atau kunci layar untuk lanjut - ID: %1$s… - Opsi lainnya - Lupakan Password - Lupakan Password SFTP - Simpan Password SFTP - Lupakan Kredensial Kunci SSH - Simpan Kredensial Kunci SSH - Lupakan Kredensial REST - Simpan Kredensial REST - Lupakan Kredensial S3 - Simpan Kredensial S3 - Simpan Password - Simpan Password - Ubah password - Binary Restic - Mengecek status… - Error: %1$s - Gagal mengubah password. - Ubah Password - Password Lama - Password Baru - Konfirmasi Password Baru - Sembunyikan - Tampilkan - Password tidak cocok. - Simpan Password - Simpan Password SFTP - Simpan Kredensial Kunci SSH - Simpan Kredensial REST - Simpan Kredensial S3 - Kredensial yang disimpan dienkripsi di perangkat ini. - Kredensial yang disimpan dienkripsi di perangkat ini. - Izin - Item tidak dikenal - OBB - Media - ID Repositori tidak ditemukan. Gagal menyimpan metadata. - [%1$d/%2$d] Menyiapkan backup... - [%1$d/%2$d] Mem-backup aplikasi… - [%1$d/%2$d] Menyelesaikan backup… - Perintah restic gagal dengan kode exit %1$d. - Peringatan: Gagal menyimpan file metadata backup secara lokal. - Terjadi error fatal: %1$s - Tipe backup belum dipilih. - Tidak ada tipe backup yang dipilih. - Restic belum diinstal. - Binary Restic tidak terinstal. - Tidak ada repositori backup yang dipilih. - Belum ada repositori backup yang dipilih. - Password untuk repositori tidak ditemukan. - Tidak dapat menemukan password. - Kredensial SFTP untuk repositori tidak ditemukan. - Tidak dapat menemukan kredensial SFTP. - Kredensial REST untuk repositori tidak ditemukan. - Tidak dapat menemukan kredensial REST. - Kredensial S3 untuk repositori tidak ditemukan. - Tidak dapat menemukan kredensial S3. - Ada operasi lain yang sedang berjalan. - Operasi lain sedang berjalan. Tunggu sampai selesai. - Snapshot tidak ditemukan. - Repositori, password, atau ID repo tidak ditemukan - Pulihkan File - Memproses Aplikasi - Pembersihan - Pengecekan pra-pemulihan gagal. - Password repositori tidak ditemukan. - Tidak ada file yang ditemukan dalam snapshot untuk aplikasi yang dipilih. - Perintah pemulihan restic gagal. - %1$s: Commit instalasi gagal: %2$s - %1$s: Gagal menulis bagian-bagian APK. - %1$s: Gagal membuat sesi instalasi. - %1$s: Tidak ada file APK dalam data yang dipulihkan. - %1$s: Pemulihan data gagal atau tidak lengkap. - %1$s: Gagal memulihkan izin karena aplikasi belum terinstal. - %1$s: Gagal memulihkan beberapa izin: %2$s - Pemulihan selesai dalam %1$s. - \n\nDetail:\n- %1$s - Buka Kunci - Lupakan - Bersihkan - Cek - Tidak ada tugas pemeliharaan yang dipilih. - [%1$d/%2$d] Menjalankan \'%3$s\'… - %1$s berhasil:\n%2$s\n\n - %1$s gagal:\n%2$s\n\n - Satu atau lebih tugas gagal. - Satu atau lebih tugas eksekusi gagal. - Tidak ada tugas yang dipilih. - Tidak ada tugas pemeliharaan yang dipilih. - ID Repositori tidak ditemukan - Password repositori salah. - Kredensial SFTP salah. - Kredensial REST salah. - Kredensial S3 salah. - Repositori tertutup. Buka untuk memuat snapshot. - Masukkan password lagi - Masukkan kredensial SFTP lagi - Masukkan kredensial REST lagi - Masukkan kredensial S3 lagi - Buka repositori - Buka SFTP - Buka REST - Buka S3 - Path dan password tidak boleh kosong. - Masukkan username dan password REST, atau kosongkan keduanya. - Masukkan S3 Access Key dan Secret Key, atau kosongkan keduanya. - Variabel environment tidak valid. - Format variabel tidak valid di baris %1$d. Gunakan KEY=value. - Nama variabel tidak valid: %1$s - Opsi restic tidak valid. - Gagal membaca file kunci privat yang dipilih. - File kunci privat yang dipilih kosong. - Kunci privat SSH SFTP tidak boleh kosong. - Error tidak dikenal - Operasi Berlangsung - Operasi Selesai - Menampilkan kemajuan backup dan pemulihan secara diam-diam. - Memberi peringatan saat backup atau pemulihan selesai. - - %1$d/%2$d file - - Memindai file… - %1$s selesai dengan sukses - %1$s gagal - Tidak ada operasi aktif saat ini. - Operasi terputus. - Hentikan operasi? - Ingin menghentikan operasi saat ini? Semua pekerjaan yang belum selesai akan dibatalkan. - Repositori sudah ada. - Binary Restic belum siap. - Password tidak valid atau repositori rusak. - Direktori ada, namun bukan repositori restic. - Gagal membuat direktori. - Gagal menginisialisasi repositori. - Repo valid, tapi gagal mendapat ID. - Gagal mengkonfigurasi autentikasi SFTP. - Binary SSH tidak ditemukan di perangkat ini. - Gagal membuat skrip bantuan SSH askpass. - Gagal menyiapkan penyimpanan known hosts SSH. - Gagal menyiapkan penyimpanan kunci privat SSH. - Gagal mendapatkan fingerprint server SFTP. - S3 Bucket tidak ditemukan - Percayai - Versi tidak dikenal - tidak dikenal - Binary rusak atau tidak valid - Binary bawaan tidak ditemukan atau tidak bisa dieksekusi. - Gagal memuat snapshot - Gagal mengecek repositori - Gagal membersihkan repositori - Gagal membuka kunci repositori - Tidak ada kebijakan \'keep\' yang ditentukan untuk operasi lupakan (forget). - Gagal melupakan snapshot - Gagal menghapus snapshot - Gagal melupakan snapshot metadata - Gagal mengambil config repo - Gagal mengubah password - Gagal memulihkan snapshot - Memulihkan file… - Mem-backup… - Menyelesaikan… - Menambahkan %1$s (%2$s, %3$s) dalam %4$s. - - %1$d file baru - - - %1$d file diubah - - Perintah Restic gagal - Restic belum diinstal atau belum siap. - Satu atau lebih nama variabel tidak valid. - Satu atau lebih opsi restic tidak valid. - Operasi pembersihan selesai. - - Menghapus %1$d snapshot. - - Tidak ada snapshot yang cocok dengan kebijakan untuk dihapus. - Operasi pengecekan selesai. - Operasi buka kunci selesai. - N/A - Umum - Kondisi - Baterai tidak lemah - Perangkat sedang mengisi daya - Jaringan tidak dibatasi (Wi-Fi) - j - 1–720 j - %dj - 1 mgg - 1 bln - Tidak Diketahui - Lisensi Tidak Dikenal - URL Lisensi: %1$s - Website Proyek: %1$s - Tidak ada tautan tersedia - … (ketuk untuk perluas) - Buka Tautan - Salin Lisensi - baru saja - - %d menit yang lalu - - - %d jam yang lalu - - - %d hari yang lalu - - - %d bulan yang lalu - - - %d tahun yang lalu - - Izin - Jenis cadangan untuk aplikasi yang dipilih - Tidak ada - Campuran - Tidak ada jenis pemulihan yang dipilih. - File paket aplikasi - Data aplikasi internal (/data/user/<id>) - Data yang dapat diakses sebelum perangkat tidak terkunci (/data/user_de/<id>) - Data aplikasi pada penyimpanan eksternal (/storage/emulated/<id>/Android/data) - File Opaque Binary Blob (/storage/emulated/<id>/Android/obb) - File media aplikasi (/storage/emulated/<id>/Android/media) - Izin runtime yang diberikan - Aktifkan - Akses penggunaan aplikasi diberikan - Akses penggunaan aplikasi diperlukan - Memungkinkan perhitungan ukuran aplikasi yang lebih cepat selama pencadangan alih-alih memindai direktori - Metadata berhasil dipulihkan. - Pulihkan metadata - Pemulihan Metadata Gagal - Repositori berhasil ditambahkan, tetapi pemulihan metadatanya (jadwal, info cadangan terperinci) gagal. Ini biasanya berarti snapshot metadata hilang atau repositori terkunci. Untuk mencoba lagi, buka Pengaturan → Repositori, ketuk menu repo, dan pilih “Pulihkan metadata”. - Tugas - Tugas - diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml deleted file mode 100644 index e0ea2ceb..00000000 --- a/app/src/main/res/values-it/strings.xml +++ /dev/null @@ -1,437 +0,0 @@ - - - Home - Impostazioni - Dettagli snapshot - Ripristina - Licenze - Pianificazioni - Avanzamento - Configurazione backup - Policy di Rimozione (Forget) - Verifica integrità - Pianificazioni - Aggiungi Pianificazione - Modifica Pianificazione - Avanzamento Operazione - Licenze Open Source - Dettagli Snapshot - Ripristina Snapshot - Indietro - Rimuovi Snapshot - Esegui Task - Aggiungi Pianificazione - Ripristina - Inizia Ripristino - Backup - Esegui Task - Pianificazione %1$s - Ripristino - Manutenzione - %1$s:\n%2$s - Tipi di Backup - APK - Dati - Dati Protetti del Dispositivo - Dati Esterni - Dati OBB - Dati Media - App - Seleziona Tutto - Opzioni di Ripristino - Consenti Downgrade - App da Ripristinare - Nessun dato app trovato in questo snapshot. - Backup: %1$s -> Installata: %2$s - Backup: %1$s - Restoid - Applicazioni - %1$d app selezionate - %2$s - Rimuovi Snapshot - Mantieni gli ultimi %1$d, giornalieri %2$d, settimanali %3$d, mensili %4$d - Verifica Integrità - Solo metadati - Verifica i pacchetti dati del repository oltre ai metadati. - Nessun repository selezionato. Vai nelle impostazioni per aggiungerne uno. - Restic non disponibile. Controlla le impostazioni. - Nessuna pianificazione configurata per questo repository. - Intervallo (Ore) - Nome - Ultima esecuzione: %1$s (%2$s) - Mai eseguito - Eliminare Pianificazione? - Sei sicuro di voler eliminare questa pianificazione? - Annullare le modifiche? - Hai delle modifiche non salvate. Sei sicuro di volerle scartare? - Scarta - Errore: %1$s - Nessun snapshot trovato per il repository selezionato. - - Snapshot (%1$d) - Snapshot (%1$d) - - Password Repository Richiesta - Inserisci la password per il repository: %1$s - Password SFTP Richiesta - Inserisci la password SFTP per il repository: %1$s - Chiave SSH SFTP Richiesta - Inserisci le credenziali SFTP per il repository: %1$s - Credenziali REST Richieste - Inserisci username e password REST per il repository: %1$s - Credenziali S3 Richieste - Inserisci Access Key e Secret Key S3 per il repository: %1$s - - App (%1$d): - App (%1$d): - - Nessuna informazione app disponibile per questo snapshot. - Metadati mancanti o corrotti. - Sblocca repository - Pulisci repository (Prune) - Mantieni Ultimi - Mantieni Giornalieri - Mantieni Settimanali - Mantieni Mensili - Leggi tutti i dati - - App nel backup (%1$d) - App nel backup (%1$d) - - Nessun snapshot trovato. - Rimozione snapshot in corso… - ID: %1$s - Data: %1$s - Versione: %1$s - Dimensione: %2$s - Rimuovere Snapshot? - Questo eliminerà la voce del snapshot. I dati reali verranno eliminati solo dopo aver eseguito una pulizia (Prune). Sei sicuro? - Password - Password repository - Username REST - Password REST - S3 Access Key ID - S3 Secret Access Key - La password non può essere vuota - L\'username non può essere vuoto - Salva password - Salva credenziali - OK - Annulla - Ferma - Fatto - Aggiungi - Elimina - Cambia - Salva - Concedi - Impostazioni - Rimuovi - Riprova - Disabilita - Inizializzazione… - Arresto in corso… - %1$s Completato - %1$s Fallito - Avanzamento Globale - Avanzamento Fase - Tempo Trascorso - Elementi - Dimensione - Elaborazione: - ... - Aggiungi Repository - Tipo di repository - Specifica del repository - Variabili d\'ambiente - CHIAVE=valore (una per riga) - Metodo di autenticazione - Password SFTP - Chiave SSH - Chiave privata SSH SFTP - -----BEGIN OPENSSH PRIVATE KEY-----\n... - Passphrase chiave SSH - Passphrase chiave SSH (opzionale) - Fidati del server SFTP - Il server %1$s ha presentato questi fingerprint di chiave host: - Fingerprint - Directory locale - SFTP (SSH) - Server REST HTTP - Amazon S3 / MinIO - user@host:/srv/restic-repo - http://host:8000/ - s3.us-east-1.amazonaws.com/nome_bucket - Percorso - Seleziona una directory… - Seleziona Cartella - Seleziona file chiave privata - Nascondi password - Mostra password - Repository di Backup - Aggiungi Repository - Nessun repository configurato. Aggiungine uno per iniziare. - Installa restic per gestire i repository. - Sistema - Accesso root negato - Controllo dei permessi root… - Accesso root concesso - Dipendenze - Informazioni - Versione - Licenze Open Source - Traduci - Notifiche abilitate - Notifiche disabilitate - Permesso notifiche - Ottimizzazione batteria abilitata - Ottimizzazione batteria disabilitata - Disabilita questa opzione per migliorare l\'affidabilità delle operazioni lunghe in background. - Richiedi sblocco app all\'avvio - Opzioni - Sblocca Restoid - Usa l\'impronta digitale o il blocco schermo per continuare - ID: %1$s… - Altre opzioni - Dimentica Password - Dimentica Password SFTP - Salva Password SFTP - Dimentica Credenziali Chiave SSH - Salva Credenziali Chiave SSH - Dimentica Credenziali REST - Salva Credenziali REST - Dimentica Credenziali S3 - Salva Credenziali S3 - Salva Password - Salva Password - Cambia password - Binario Restic - Controllo dello stato… - Errore: %1$s - Errore durante la modifica della password. - Cambia Password - Vecchia Password - Nuova Password - Conferma Nuova Password - Nascondi - Mostra - Le password non corrispondono. - Salva Password - Salva Password SFTP - Salva Credenziali Chiave SSH - Salva Credenziali REST - Salva Credenziali S3 - Le credenziali salvate sono criptate su questo dispositivo. - Le credenziali salvate sono criptate su questo dispositivo. - Permessi - Elementi sconosciuti - OBB - Media - ID repository non trovato. Impossibile salvare i metadati. - [%1$d/%2$d] Preparazione backup in corso... - [%1$d/%2$d] Backup delle app in corso… - [%1$d/%2$d] Finalizzazione backup in corso… - Comando Restic fallito con codice di uscita %1$d. - Attenzione: Impossibile salvare il file dei metadati di backup localmente. - Si è verificato un errore fatale: %1$s - Nessun tipo di backup selezionato. - Non è stato selezionato alcun tipo di backup. - Restic non è installato. - Il binario di Restic non è installato. - Nessun repository di backup selezionato. - Nessun repository di backup è stato selezionato. - Password del repository non trovata. - Impossibile trovare la password. - Credenziali SFTP per il repository non trovate. - Impossibile trovare le credenziali SFTP. - Credenziali REST per il repository non trovate. - Impossibile trovare le credenziali REST. - Credenziali S3 per il repository non trovate. - Impossibile trovare le credenziali S3. - Un\'altra operazione è già in corso. - C\'è un\'altra operazione in corso. Attendi che finisca. - Snapshot non trovato. - Repository, password o ID repo non trovati - Ripristino File in corso - Elaborazione App in corso - Pulizia - I controlli pre-ripristino sono falliti. - Password non trovata per il repository. - Nessun file trovato nel snapshot per le app selezionate. - Il comando di ripristino Restic è fallito. - %1$s: Commit dell\'installazione fallito: %2$s - %1$s: Errore durante la scrittura degli split APK. - %1$s: Errore durante la creazione della sessione di installazione. - %1$s: Nessun file APK trovato nei dati ripristinati. - %1$s: Ripristino dei dati fallito o incompleto. - %1$s: Errore durante il ripristino dei permessi perché l\'app non è installata. - %1$s: Errore durante il ripristino di alcuni permessi: %2$s - Ripristino terminato in %1$s. - \n\nDettagli:\n- %1$s - Sblocca - Rimuovi - Pulisci - Verifica - Nessun task di manutenzione selezionato. - [%1$d/%2$d] Esecuzione di \'%3$s\' in corso… - %1$s completato con successo:\n%2$s\n\n - %1$s fallito:\n%2$s\n\n - Uno o più task sono falliti. - Uno o più task di esecuzione sono falliti. - Nessun task selezionato. - Nessun task di manutenzione è stato selezionato. - ID del repository non trovato - La password del repository non è corretta. - Le credenziali SFTP non sono corrette. - Le credenziali REST non sono corrette. - Le credenziali S3 non sono corrette. - Il repository è chiuso. Aprilo per caricare i snapshot. - Reinserisci password - Reinserisci credenziali SFTP - Reinserisci credenziali REST - Reinserisci credenziali S3 - Apri repository - Apri SFTP - Apri REST - Apri S3 - Il percorso e la password non possono essere vuoti. - Inserisci username e password REST, oppure lasciali entrambi vuoti. - Inserisci Access Key e Secret Key S3, oppure lasciali entrambi vuoti. - Variabili d\'ambiente non valide. - Formato variabile d\'ambiente non valido alla riga %1$d. Usa CHIAVE=valore. - Nome variabile d\'ambiente non valido: %1$s - Opzioni restic non valide. - Errore durante la lettura del file della chiave privata selezionato. - Il file della chiave privata selezionato è vuoto. - La chiave privata SSH SFTP non può essere vuota. - Errore sconosciuto - Operazioni in Corso - Operazioni Completate - Mostra silenziosamente il progresso dei backup e ripristini in corso. - Avvisa quando un backup o un ripristino è terminato. - - %1$d/%2$d file - %1$d/%2$d file - - Scansione dei file in corso… - %1$s completato con successo - %1$s fallito - Nessuna operazione attiva in questo momento. - L\'operazione è stata interrotta. - Fermare l\'operazione? - Vuoi fermare l\'operazione corrente? Qualsiasi lavoro non completato verrà annullato. - Il repository esiste già. - Il binario di Restic non è pronto. - Password non valida o repository corrotto. - La directory esiste ma non è un repository restic. - Errore durante la creazione della directory. - Errore durante l\'inizializzazione del repository. - Repo valido, ma errore nel recupero dell\'ID. - Errore durante la configurazione dell\'autenticazione SFTP. - Il binario SSH non è stato trovato su questo dispositivo. - Errore durante la creazione dello script di supporto SSH askpass. - Errore durante la preparazione dello storage degli host noti SSH. - Errore durante la preparazione dello storage della chiave privata SSH. - Errore nel recupero del fingerprint del server SFTP. - Bucket S3 non trovato - Fidati - Versione sconosciuta - sconosciuto - Binario corrotto o non valido - Binario integrato non trovato o non eseguibile. - Errore durante il caricamento dei snapshot - Errore durante la verifica del repository - Errore durante la pulizia del repository - Errore durante lo sblocco del repository - Nessuna policy \'keep\' specificata per l\'operazione di rimozione. - Errore durante la rimozione dei snapshot - Errore durante l\'eliminazione del snapshot - Errore durante la rimozione dei snapshot dei metadati - Errore durante il recupero della configurazione del repo - Errore durante la modifica della password - Errore durante il ripristino del snapshot - Ripristino dei file in corso… - Backup in corso… - Completamento in corso… - Aggiunto %1$s (%2$s, %3$s) in %4$s. - - %1$d nuovo file - %1$d nuovi file - - - %1$d file modificato - %1$d file modificati - - Il comando Restic è fallito - Restic non è installato o non è pronto. - Uno o più nomi delle variabili d\'ambiente non sono validi. - Uno o più nomi delle opzioni restic non sono validi. - Operazione di pulizia completata. - - Rimosso %1$d snapshot. - Rimossi %1$d snapshot. - - Nessun snapshot corrisponde alla policy per la rimozione. - Operazione di verifica completata. - Operazione di sblocco terminata. - N/D - Generale - Condizioni - La batteria non è scarica - Il dispositivo è in carica - Rete senza limiti di dati (Wi-Fi) - h - 1–720 h - %dh - 1 sett - 1 mese - Sconosciuto - Licenza Sconosciuta - URL Licenza: %1$s - Sito del Progetto: %1$s - Nessun link disponibile - … (tocca per espandere) - Apri Link - Copia Licenza - proprio ora - - %d minuto fa - %d minuti fa - - - %d ora fa - %d ore fa - - - %d giorno fa - %d giorni fa - - - %d mese fa - %d mesi fa - - - %d anno fa - %d anni fa - - Autorizzazioni - Tipi di backup per le app selezionate - Nessuno - Misto - Nessun tipo di ripristino selezionato. - Il file del pacchetto dell\'applicazione - Dati interni dell\'app (/data/user/<id>) - Dati accessibili prima dello sblocco del dispositivo (/data/user_de/<id>) - Dati dell\'app su memoria esterna (/storage/emulated/<id>/Android/data) - File Opaque Binary Blob (/storage/emulated/<id>/Android/obb) - File multimediali dell\'app (/storage/emulated/<id>/Android/media) - Autorizzazioni runtime concesse - Abilita - Accesso all\'utilizzo app concesso - Accesso all\'utilizzo app richiesto - Consente un calcolo più rapido delle dimensioni dell\'applicazione durante i backup invece di scansionare le directory - Metadati ripristinati correttamente. - Ripristina metadati - Ripristino metadati non riuscito - Il repository è stato aggiunto correttamente, ma il ripristino dei suoi metadati (pianificazioni, informazioni dettagliate sui backup) non è riuscito. Solitamente ciò significa che l\'istantanea dei metadati manca o che il repository è bloccato. Per riprovare, apri Impostazioni → Repository, tocca il menu del repository e scegli \"Ripristina metadati\". - Attività - Attività - diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml deleted file mode 100644 index 22d288d1..00000000 --- a/app/src/main/res/values-ja/strings.xml +++ /dev/null @@ -1,448 +0,0 @@ - - - ホーム - 設定 - スナップショットの詳細 - 復元 - ライセンス - スケジュール - 操作の進捗 - バックアップ設定 - ポリシーを削除 - 整合性を確認 - スケジュール - スケジュールを追加 - スケジュールを編集 - 操作の進捗 - オープンソースライセンス - スナップショットの詳細 - スナップショットを復元 - 戻る - スナップショットを破棄 - タスクを実行 - スケジュールを追加 - 復元 - 復元を開始 - バックアップ - タスクを実行 - スケジュール %1$s - 復元 - メンテナンス - %1$s:\n%2$s - バックアップの種類 - APK - アプリケーションパッケージファイル - データ - 内部アプリデータ (/data/data) - デバイス保護データ - デバイスのロック解除前にアクセス可能なデータ (/data/user_de) - 外部データ - 外部ストレージ上のアプリデータ (/sdcard/Android/data) - OBBデータ - 不透明バイナリブロブファイル (/sdcard/Android/obb) - メディアデータ - アプリメディアファイル (/sdcard/Android/media) - 権限 - 許可されたランタイム権限 - アプリ - すべて切り替え - 選択したアプリのバックアップタイプ - なし - 混在 - 復元オプション - ダウングレードを許可 - 復元するアプリ - このスナップショットにアプリデータが見つかりません。 - バックアップ: %1$s -> インストール済み: %2$s - バックアップ: %1$s - アプリケーション - %1$d 個のアプリを選択済み - %2$s - スナップショットを破棄 - 直近 %1$d件、日次 %2$d件、週次 %3$d件、月次 %4$d件を保持 - 整合性の確認 - メタデータのみ - メタデータに加えてリポジトリのデータパックも検証します。 - リポジトリが選択されていません。設定から追加してください。 - Resticが利用できません。設定を確認してください。 - このリポジトリにはスケジュールが設定されていません。 - 間隔(時間) - 名前 - 前回の実行: %1$s (%2$s) - 未実行 - スケジュールを削除しますか? - このスケジュールを削除してもよろしいですか? - 変更を破棄しますか? - 保存されていない変更があります。破棄してもよろしいですか? - 破棄 - エラー: %1$s - 選択したリポジトリにスナップショットが見つかりませんでした。 - - スナップショット (%1$d) - - リポジトリのパスワードが必要です - リポジトリのパスワードを入力してください: %1$s - SFTPパスワードが必要です - リポジトリのSFTPパスワードを入力してください: %1$s - SFTP SSHキーが必要です - リポジトリのSFTP資格情報を入力してください: %1$s - REST資格情報が必要です - リポジトリのRESTユーザー名とパスワードを入力してください: %1$s - S3資格情報が必要です - リポジトリのS3アクセスキーとシークレットキーを入力してください: %1$s - - アプリ (%1$d): - - このスナップショットのアプリ情報は利用できません。 - メタデータが見つからないか破損しています。 - たった今 - - %d分前 - - - %d時間前 - - - %d日前 - - - %dヶ月前 - - - %d年前 - - リポジトリのロックを解除 - リポジトリを整理 - 最新を保持 - 日次を保持 - 週次を保持 - 月次を保持 - すべてのデータを読み取る - - バックアップ済みアプリ (%1$d) - - スナップショットが見つかりません。 - スナップショットを削除中… - ID: %1$s - 時間: %1$s - バージョン: %1$s - サイズ: %2$s - スナップショットを削除しますか? - スナップショットのエントリが削除されます。実際のデータはプルーニングの実行後に削除されます。よろしいですか? - パスワード - リポジトリのパスワード - REST ユーザー名 - REST パスワード - S3 アクセスキー ID - S3 シークレットアクセスキー - パスワードを空にすることはできません - ユーザー名を入力してください - パスワードを保存 - 資格情報を保存 - OK - キャンセル - 停止 - 完了 - 追加 - 削除 - 変更 - 保存 - 許可 - 設定 - 削除 - 再試行 - 無効化 - 初期化中… - 停止中… - %1$s 完了 - %1$s 失敗 - 全体進捗 - ステージ進捗 - 経過時間 - 項目数 - サイズ - 処理中: - リポジトリの追加 - リポジトリタイプ - リポジトリ仕様 - 環境変数 - KEY=値 (1行につき1つ) - 認証方式 - SFTPパスワード - SSH鍵 - SFTP SSH秘密鍵 - SSH鍵のパスフレーズ - SSH鍵のパスフレーズ (任意) - SFTPサーバーを信頼 - サーバー %1$s が以下のホスト鍵フィンガープリントを提示しました: - フィンガープリント - ローカルディレクトリ - SFTP (SSH) - HTTP REST サーバー - パス - ディレクトリを選択… - フォルダーを選択 - 秘密鍵ファイルを選択 - パスワードを隠す - パスワードを表示 - バックアップリポジトリ - リポジトリを追加 - リポジトリが設定されていません。追加して開始してください。 - リポジトリを管理するにはresticをインストールしてください。 - システム - root権限が拒否されました - root権限を確認中… - root権限が許可されました - 依存関係 - 情報 - バージョン - オープンソースライセンス - 翻訳 - 通知を有効化 - 通知を無効化 - 通知の権限 - バッテリー最適化を有効化 - バッテリー最適化を無効化 - これを無効にすると、長時間のバックグラウンド処理の信頼性が向上します。 - 起動時にアプリのロック解除を要求 - オプション - Restoidのロック解除 - 指紋認証または画面ロックを使用して続行してください - ID: %1$s… - その他のオプション - パスワードを削除 - SFTPパスワードを削除 - SFTPパスワードを保存 - SSH鍵情報を削除 - SSH鍵情報を保存 - REST認証情報を削除 - REST認証情報を保存 - S3認証情報を削除 - S3認証情報を保存 - パスワードを保存 - パスワードを保存 - パスワードを変更 - Resticバイナリ - ステータスを確認中… - エラー: %1$s - パスワードの変更に失敗しました。 - パスワードを変更 - 現在のパスワード - 新しいパスワード - 新しいパスワードを確認 - 非表示 - 表示 - パスワードが一致しません。 - パスワードを保存 - SFTPパスワードを保存 - SSHキー認証情報を保存 - RESTの認証情報を保存 - S3の認証情報を保存 - 保存された認証情報は、本端末上で暗号化されます。 - 保存された資格情報は、このデバイス上で暗号化されています。 - 権限 - 不明なアイテム - OBB - メディア - リポジトリIDが見つかりません。メタデータを保存できません。 - [%1$d/%2$d] バックアップを準備中… - [%1$d/%2$d] アプリをバックアップ中… - [%1$d/%2$d] バックアップを完了中… - Resticコマンドが終了コード %1$d で失敗しました。 - 警告: バックアップメタデータファイルをローカルに保存できませんでした。 - 致命的なエラーが発生しました: %1$s - バックアップタイプが選択されていません。 - バックアップの種類が選択されていません。 - Resticがインストールされていません。 - Resticバイナリがインストールされていません。 - バックアップリポジトリが選択されていません。 - バックアップリポジトリが選択されていません。 - リポジトリのパスワードが見つかりません。 - パスワードが見つかりません。 - リポジトリのSFTP認証情報が見つかりません。 - SFTP認証情報が見つかりません。 - リポジトリのREST認証情報が見つかりません。 - REST認証情報が見つかりません。 - リポジトリのS3認証情報が見つかりません。 - S3認証情報が見つかりません。 - 別の操作が既に実行中です。 - 別の操作が実行中です。終了するまでお待ちください。 - スナップショットが見つかりません。 - リポジトリ、パスワード、またはリポジトリIDが見つかりません - ファイルの復元 - アプリの処理中 - クリーンアップ - アプリ使用履歴へのアクセスが必要です - ディレクトリをスキャンする代わりに、バックアップ時のアプリケーションサイズの計算を高速化します - 選択したアプリのスナップショット内にファイルが見つかりません。 - Restic復元コマンドが失敗しました。 - %1$s: APK分割ファイルの書き込みに失敗しました。 - %1$s: インストールセッションの作成に失敗しました。 - %1$s: 復元されたデータの中にAPKファイルが見つかりません。 - %1$s: データの復元に失敗したか、不完全です。 - \n\n詳細:\n- %1$s - 1つ以上のタスクが失敗しました。 - REST認証情報が正しくありません。 - S3のアクセスキーとシークレットキーの両方を入力するか、両方とも空にしてください。 - 環境変数が無効です。 - resticオプションが無効です。 - 選択した秘密鍵ファイルの読み取りに失敗しました。 - 完了した操作 - %1$sが失敗しました - 操作が中断されました。 - 操作を停止しますか? - 現在の操作を停止しますか?未完了の作業はすべてキャンセルされます。 - Resticバイナリの準備ができていません。 - ディレクトリは存在しますが、resticリポジトリではありません。 - リポジトリは有効ですが、IDの取得に失敗しました。 - SFTP認証の設定に失敗しました。 - SSH askpassヘルパースクリプトの作成に失敗しました。 - SSH秘密鍵ストレージの準備に失敗しました。 - SFTPサーバーのフィンガープリントの取得に失敗しました。 - S3バケットが見つかりません - 不明なバージョン - 不明 - スナップショットの読み込みに失敗しました - リポジトリのチェックに失敗しました - リポジトリのプルーニングに失敗しました - リポジトリのロック解除に失敗しました - 「保持」ポリシーが指定されていません。 - スナップショットの削除(forget)に失敗しました - メタデータスナップショットの削除(forget)に失敗しました - リポジトリ設定の取得に失敗しました - - %1$d 個の変更されたファイル - - Resticがインストールされていないか、準備ができていません。 - 1つ以上のResticオプション名が無効です。 - Prune操作が完了しました。 - チェック操作が完了しました。 - Unlock操作が終了しました。 - デバイスは充電中です - ネットワークは従量制ではありません - プロジェクトウェブサイト: %1$s - リンクを開く - リポジトリは正常に追加されましたが、そのメタデータ(スケジュール、バックアップ詳細情報)の復元に失敗しました。これは通常、メタデータスナップショットが存在しないか、リポジトリがロックされていることを意味します。再試行するには、[設定] → [リポジトリ] を開き、リポジトリメニューをタップして「メタデータを復元」を選択してください。 - %1$s: インストールのコミットに失敗しました: %2$s - 削除 - [%1$d/%2$d] \'%3$s\' を実行中… - 1つ以上の実行タスクが失敗しました。 - タスクが選択されていません。 - メンテナンスタスクが選択されていませんでした。 - パスワードを再入力 - SFTP認証情報を再入力 - リポジトリを開く - S3を開く - %1$d行目の環境変数の形式が無効です。KEY=value形式を使用してください。 - 環境変数名が無効です: %1$s - 不明なエラー - 進行中の操作 - パスワードが無効か、リポジトリが破損しています。 - リポジトリの初期化に失敗しました。 - このデバイスにSSHバイナリが見つかりませんでした。 - バックアップまたは復元が完了したときに通知します。 - ディレクトリの作成に失敗しました。 - ファイルをスキャン中… - - %1$d / %2$d ファイル - - %1$sが正常に完了しました - SSH known_hostsストレージの準備に失敗しました。 - バイナリが破損しているか無効です - バンドルされたバイナリが見つからないか、実行できません。 - スナップショットの削除に失敗しました - ファイルを復元中… - バックアップ中… - ポリシーに一致する削除対象のスナップショットはありませんでした。 - 該当なし - 1〜720時間 - メタデータの復元が完了しました。 - メタデータの復元に失敗しました - %1$s: アプリがインストールされていないため、権限を復元できませんでした。 - 復元が %1$s で完了しました。 - リポジトリは閉じられています。スナップショットを読み込むには開いてください。 - REST認証情報を再入力 - S3認証情報を再入力 - SFTPを開く - パスとパスワードは空にできません。 - RESTのユーザー名とパスワードの両方を入力するか、両方とも空にしてください。 - 選択した秘密鍵ファイルが空です。 - SFTP SSH秘密鍵を空にすることはできません。 - 現在、実行中の操作はありません。 - リポジトリは既に存在します。 - 信頼 - パスワードの変更に失敗しました - スナップショットの復元に失敗しました - 終了処理中… - %4$s に %1$s (%2$s, %3$s) を追加しました。 - - %1$d 個の新規ファイル - - 1つ以上の環境変数名が無効です。 - - %1$d 個のスナップショットを削除しました。 - - 条件 - バッテリー残量は十分です - メタデータを復元 - 有効にする - RESTを開く - Resticコマンドが失敗しました - 一般 - ライセンスをコピー - … (タップして展開) - 不明なライセンス - ライセンスURL: %1$s - %1$s: 一部の権限の復元に失敗しました: %2$s - ロック解除 - 復元前チェックに失敗しました。 - 復元タイプが選択されていません。 - リポジトリのパスワードが見つかりません。 - 時間 - リポジトリIDが見つかりません - リポジトリのパスワードが正しくありません。 - SFTP認証情報が正しくありません。 - S3認証情報が正しくありません。 - バックアップおよび復元の進捗状況を通知なしで表示します。 - アプリ使用履歴へのアクセスを許可しました - 整理 - 確認 - メンテナンスタスクが選択されていません。 - %1$s 成功:\n%2$s\n\n - %1$s 失敗:\n%2$s\n\n - %d時間 - 1週間 - 1か月 - 不明 - リンクなし - タスク - タスク - カスタムディレクトリ - ディレクトリが選択されていません - %1$d個のディレクトリを選択済み - ディレクトリを追加 - カスタムディレクトリ - バックアップに含めるデバイス内のカスタムディレクトリを選択します。 - リポジトリ自体をバックアップ対象のカスタムディレクトリとして追加することはできません。 - - バックアップ済み (%1$d): - - - カスタムディレクトリ (%1$d): - - - バックアップ済みカスタムディレクトリ (%1$d) - - 選択された項目について、バックアップ対象のファイルが見つかりませんでした。 - - %1$d 個の項目をバックアップしました。 - - 項目が選択されていません。 - 項目が選択されていませんでした。 - 復元対象の項目が選択されていません。 - [%1$d/%2$d] アプリを処理中 - [%1$d/%2$d] カスタムディレクトリを処理中 - カスタムディレクトリを処理中… - カスタムディレクトリの復元に失敗しました: %1$s - - %1$d 個の項目を正常に処理しました。 - - diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml deleted file mode 100644 index 1996edd3..00000000 --- a/app/src/main/res/values-ko/strings.xml +++ /dev/null @@ -1,425 +0,0 @@ - - - - 설정 - 스냅샷 상세 - 복원 - 라이선스 - 예약 - 작업 진행률 - 백업 구성 - 삭제 정책 (Forget) - 무결성 검사 - 예약 - 예약 추가 - 예약 수정 - 작업 진행률 - 오픈소스 라이선스 - 스냅샷 상세 - 스냅샷 복원 - 뒤로 - 스냅샷 삭제 - 작업 실행 - 예약 추가 - 복원 - 복원 시작 - 백업 - 작업 실행 - 예약 %1$s - 복원 - 유지보수 - %1$s:\n%2$s - 백업 유형 - APK - 데이터 - 디바이스 보호 데이터 - 외부 데이터 - OBB 데이터 - 미디어 데이터 - - 모두 선택 - 복원 옵션 - 다운그레이드 허용 - 복원할 앱 - 이 스냅샷에서 앱 데이터를 찾을 수 없습니다. - 백업: %1$s -> 설치됨: %2$s - 백업: %1$s - Restoid - 애플리케이션 - %1$d개 앱 선택됨 - %2$s - 스냅샷 삭제 - 최근 %1$d개, 일별 %2$d개, 주별 %3$d개, 월별 %4$d개 유지 - 무결성 검사 - 메타데이터만 - 메타데이터 외에 저장소 데이터 팩도 검증합니다. - 선택된 저장소가 없습니다. 설정에서 추가하세요. - Restic을 사용할 수 없습니다. 설정을 확인하세요. - 이 저장소에 구성된 예약이 없습니다. - 주기 (시간) - 이름 - 마지막 실행: %1$s (%2$s) - 실행된 적 없음 - 예약 삭제? - 정말로 이 예약을 삭제하시겠습니까? - 변경사항 취소? - 저장하지 않은 변경사항이 있습니다. 정말로 취소하시겠습니까? - 취소 - 오류: %1$s - 선택한 저장소에서 스냅샷을 찾을 수 없습니다. - - 스냅샷 (%1$d) - - 저장소 비밀번호 필요 - 저장소의 비밀번호를 입력하세요: %1$s - SFTP 비밀번호 필요 - 저장소의 SFTP 비밀번호를 입력하세요: %1$s - SFTP SSH 키 필요 - 저장소의 SFTP 자격 증명을 입력하세요: %1$s - REST 자격 증명 필요 - 저장소의 REST 사용자 이름과 비밀번호를 입력하세요: %1$s - S3 자격 증명 필요 - 저장소의 S3 Access Key와 Secret Key를 입력하세요: %1$s - - 앱 (%1$d): - - 이 스냅샷에 대한 앱 정보가 없습니다. - 메타데이터가 없거나 손상되었습니다. - 저장소 잠금 해제 - 저장소 정리 (Prune) - 최근 유지 - 일별 유지 - 주별 유지 - 월별 유지 - 모든 데이터 읽기 - - 백업된 앱 (%1$d) - - 스냅샷을 찾을 수 없습니다. - 스냅샷 삭제 중… - ID: %1$s - 시간: %1$s - 버전: %1$s - 크기: %2$s - 스냅샷 삭제? - 이 작업은 스냅샷 항목을 제거합니다. 실제 데이터는 정리(Prune)를 실행한 후에만 삭제됩니다. 계속하시겠습니까? - 비밀번호 - 저장소 비밀번호 - REST 사용자 이름 - REST 비밀번호 - S3 Access Key ID - S3 Secret Access Key - 비밀번호는 비워둘 수 없습니다. - 사용자 이름은 비워둘 수 없습니다. - 비밀번호 저장 - 자격 증명 저장 - 확인 - 취소 - 중지 - 완료 - 추가 - 삭제 - 변경 - 저장 - 권한 부여 - 설정 - 삭제 - 다시 시도 - 비활성화 - 초기화 중… - 중지 중… - %1$s 완료 - %1$s 실패 - 전체 진행률 - 단계 진행률 - 경과 시간 - 항목 - 크기 - 처리 중: - ... - 저장소 추가 - 저장소 유형 - 저장소 사양 - 환경 변수 - KEY=value (한 줄에 하나씩) - 인증 방법 - SFTP 비밀번호 - SSH 키 - SFTP SSH 개인 키 - -----BEGIN OPENSSH PRIVATE KEY-----\n... - SSH 키 암호(Passphrase) - SSH 키 암호 (선택사항) - SFTP 서버 신뢰 - 서버 %1$s에서 다음 호스트 키 지문(Fingerprint)을 제공했습니다: - 지문(Fingerprint) - 로컬 디렉토리 - SFTP (SSH) - HTTP REST 서버 - Amazon S3 / MinIO - user@host:/srv/restic-repo - http://host:8000/ - s3.us-east-1.amazonaws.com/bucket_name - 경로 - 디렉토리를 선택하세요… - 폴더 선택 - 개인 키 파일 선택 - 비밀번호 숨기기 - 비밀번호 표시 - 백업 저장소 - 저장소 추가 - 구성된 저장소가 없습니다. 시작하려면 추가하세요. - 저장소를 관리하려면 restic을 설치하세요. - 시스템 - 루트 권한 거부됨 - 루트 권한 확인 중… - 루트 권한 허용됨 - 종속성 - 정보 - 버전 - 오픈소스 라이선스 - 번역 - 알림 켜짐 - 알림 꺼짐 - 알림 권한 - 배터리 최적화 켜짐 - 배터리 최적화 꺼짐 - 오래 걸리는 백그라운드 작업의 안정성을 높이려면 이 기능을 끄세요. - 시작 시 앱 잠금 해제 필요 - 옵션 - Restoid 잠금 해제 - 계속하려면 지문 또는 화면 잠금을 사용하세요 - ID: %1$s… - 추가 옵션 - 비밀번호 지우기 - SFTP 비밀번호 지우기 - SFTP 비밀번호 저장 - SSH 키 자격 증명 지우기 - SSH 키 자격 증명 저장 - REST 자격 증명 지우기 - REST 자격 증명 저장 - S3 자격 증명 지우기 - S3 자격 증명 저장 - 비밀번호 저장 - 모든 비밀번호 저장 - 비밀번호 변경 - Restic 바이너리 - 상태 확인 중… - 오류: %1$s - 비밀번호 변경 실패. - 비밀번호 변경 - 이전 비밀번호 - 새 비밀번호 - 새 비밀번호 확인 - 숨기기 - 표시 - 비밀번호가 일치하지 않습니다. - 비밀번호 저장 - SFTP 비밀번호 저장 - SSH 키 자격 증명 저장 - REST 자격 증명 저장 - S3 자격 증명 저장 - 저장된 자격 증명은 이 기기에 암호화되어 저장됩니다. - 저장된 자격 증명은 이 기기에 암호화되어 저장됩니다. - 권한 - 알 수 없는 항목 - OBB - 미디어 - 저장소 ID를 찾을 수 없습니다. 메타데이터를 저장할 수 없습니다. - [%1$d/%2$d] 백업 준비 중... - [%1$d/%2$d] 앱 백업 중… - [%1$d/%2$d] 백업 마무리 중… - Restic 명령이 종료 코드 %1$d(으)로 실패했습니다. - 경고: 로컬에 백업 메타데이터 파일을 저장할 수 없습니다. - 치명적인 오류 발생: %1$s - 선택된 백업 유형이 없습니다. - 선택된 백업 유형이 없습니다. - Restic이 설치되어 있지 않습니다. - Restic 바이너리가 설치되지 않았습니다. - 선택된 백업 저장소가 없습니다. - 선택된 백업 저장소가 없습니다. - 저장소 비밀번호를 찾을 수 없습니다. - 비밀번호를 찾을 수 없습니다. - 저장소에 대한 SFTP 자격 증명을 찾을 수 없습니다. - SFTP 자격 증명을 찾을 수 없습니다. - 저장소에 대한 REST 자격 증명을 찾을 수 없습니다. - REST 자격 증명을 찾을 수 없습니다. - 저장소에 대한 S3 자격 증명을 찾을 수 없습니다. - S3 자격 증명을 찾을 수 없습니다. - 다른 작업이 이미 실행 중입니다. - 다른 작업이 실행 중입니다. 끝날 때까지 기다리세요. - 스냅샷을 찾을 수 없습니다. - 저장소, 비밀번호 또는 저장소 ID를 찾을 수 없습니다. - 파일 복원 - 앱 처리 중 - 정리 - 복원 전 확인에 실패했습니다. - 저장소 비밀번호를 찾을 수 없습니다. - 선택한 앱에 대한 스냅샷에서 파일을 찾을 수 없습니다. - Restic 복원 명령이 실패했습니다. - %1$s: 설치 커밋 실패: %2$s - %1$s: 분할 APK 쓰기 실패. - %1$s: 설치 세션 생성 실패. - %1$s: 복원된 데이터에서 APK 파일을 찾을 수 없습니다. - %1$s: 데이터 복원 실패 또는 불완전. - %1$s: 앱이 설치되어 있지 않아 권한 복원 실패. - %1$s: 일부 권한 복원 실패: %2$s - 복원 소요 시간: %1$s. - \n\n상세:\n- %1$s - 잠금 해제 - 삭제 - 정리 - 검사 - 선택된 유지보수 작업이 없습니다. - [%1$d/%2$d] \'%3$s\' 실행 중… - %1$s 성공:\n%2$s\n\n - %1$s 실패:\n%2$s\n\n - 하나 이상의 작업이 실패했습니다. - 하나 이상의 실행 작업이 실패했습니다. - 선택된 작업이 없습니다. - 유지보수 작업이 선택되지 않았습니다. - 저장소 ID를 찾을 수 없습니다. - 저장소 비밀번호가 틀렸습니다. - SFTP 자격 증명이 틀렸습니다. - REST 자격 증명이 틀렸습니다. - S3 자격 증명이 틀렸습니다. - 저장소가 닫혀 있습니다. 스냅샷을 로드하려면 여세요. - 비밀번호 다시 입력 - SFTP 자격 증명 다시 입력 - REST 자격 증명 다시 입력 - S3 자격 증명 다시 입력 - 저장소 열기 - SFTP 열기 - REST 열기 - S3 열기 - 경로와 비밀번호는 비워둘 수 없습니다. - REST 사용자 이름과 비밀번호를 모두 입력하거나 모두 비워 두세요. - S3 Access Key와 Secret Key를 모두 입력하거나 모두 비워 두세요. - 잘못된 환경 변수입니다. - %1$d번째 줄의 환경 변수 형식이 잘못되었습니다. KEY=value를 사용하세요. - 잘못된 환경 변수 이름: %1$s - 잘못된 restic 옵션입니다. - 선택한 개인 키 파일을 읽지 못했습니다. - 선택한 개인 키 파일이 비어 있습니다. - SFTP SSH 개인 키는 비워둘 수 없습니다. - 알 수 없는 오류 - 진행 중인 작업 - 완료된 작업 - 백업 및 복원 진행 상황을 조용히 표시합니다. - 백업 또는 복원이 완료되면 알립니다. - - %1$d/%2$d 파일 - - 파일 스캔 중… - %1$s 성공적으로 완료됨 - %1$s 실패 - 현재 활성화된 작업이 없습니다. - 작업이 중단되었습니다. - 작업을 중지할까요? - 현재 작업을 중지하시겠습니까? 완료되지 않은 모든 작업은 취소됩니다. - 저장소가 이미 존재합니다. - Restic 바이너리가 준비되지 않았습니다. - 비밀번호가 틀리거나 저장소가 손상되었습니다. - 디렉토리는 존재하지만 restic 저장소가 아닙니다. - 디렉토리 생성에 실패했습니다. - 저장소 초기화에 실패했습니다. - 저장소는 유효하지만 ID를 가져오는 데 실패했습니다. - SFTP 인증 구성에 실패했습니다. - 이 기기에서 SSH 바이너리를 찾을 수 없습니다. - SSH askpass 도우미 스크립트를 생성하지 못했습니다. - SSH known hosts 스토리지를 준비하지 못했습니다. - SSH 개인 키 스토리지를 준비하지 못했습니다. - SFTP 서버 지문을 가져오지 못했습니다. - S3 Bucket을 찾을 수 없습니다. - 신뢰 - 알 수 없는 버전 - 알 수 없음 - 바이너리가 손상되었거나 유효하지 않습니다. - 번들 바이너리를 찾을 수 없거나 실행할 수 없습니다. - 스냅샷 로드 실패 - 저장소 검사 실패 - 저장소 정리 실패 - 저장소 잠금 해제 실패 - 삭제(Forget) 작업에 대한 \'keep\' 정책이 지정되지 않았습니다. - 스냅샷 삭제 실패 - 스냅샷 삭제 실패 - 메타데이터 스냅샷 삭제 실패 - 저장소 구성 가져오기 실패 - 비밀번호 변경 실패 - 스냅샷 복원 실패 - 파일 복원 중… - 백업 중… - 마무리 중… - %4$s 만에 %1$s (%2$s, %3$s) 추가됨. - - 새 파일 %1$d개 - - - 변경된 파일 %1$d개 - - Restic 명령 실패 - Restic이 설치되어 있지 않거나 준비되지 않았습니다. - 하나 이상의 환경 변수 이름이 잘못되었습니다. - 하나 이상의 restic 옵션 이름이 잘못되었습니다. - 정리 작업이 완료되었습니다. - - %1$d개의 스냅샷이 삭제되었습니다. - - 삭제 정책과 일치하는 스냅샷이 없습니다. - 검사 작업이 완료되었습니다. - 잠금 해제 작업이 끝났습니다. - N/A - 일반 - 조건 - 배터리 부족하지 않음 - 기기 충전 중 - 무제한 네트워크 (Wi-Fi) - 시간 - 1–720 시간 - %d시간 - 1주 - 1개월 - 알 수 없음 - 알 수 없는 라이선스 - 라이선스 URL: %1$s - 프로젝트 웹사이트: %1$s - 사용 가능한 링크 없음 - … (탭하여 펼치기) - 링크 열기 - 라이선스 복사 - 방금 전 - - %d분 전 - - - %d시간 전 - - - %d일 전 - - - %d개월 전 - - - %d년 전 - - 권한 - 선택한 앱의 백업 유형 - 없음 - 혼합 - 복원 유형이 선택되지 않았습니다. - 애플리케이션 패키지 파일 - 내부 앱 데이터 (/data/user/<id>) - 기기 잠금 해제 전 액세스 가능한 데이터 (/data/user_de/<id>) - 외부 저장소의 앱 데이터 (/storage/emulated/<id>/Android/data) - 불투명 바이너리 블롭(OBB) 파일 (/storage/emulated/<id>/Android/obb) - 앱 미디어 파일 (/storage/emulated/<id>/Android/media) - 부여된 런타임 권한 - 사용 - 앱 사용 정보 액세스 권한 허용됨 - 앱 사용 기록 액세스 권한 필요 - 디렉터리 스캔 대신 백업 중 더 빠른 앱 크기 계산을 활성화합니다 - 메타데이터 복원에 성공했습니다. - 메타데이터 복원 - 메타데이터 복원 실패 - 저장소가 성공적으로 추가되었지만 메타데이터(일정, 세부 백업 정보) 복원에 실패했습니다. 이는 일반적으로 메타데이터 스냅샷이 없거나 저장소가 잠겨 있을 때 발생합니다. 다시 시도하려면 설정 → 저장소로 이동하여 저장소 메뉴를 탭하고 \"메타데이터 복원\"을 선택하세요. - 작업 - 작업 - diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml deleted file mode 100644 index 95a61917..00000000 --- a/app/src/main/res/values-night/themes.xml +++ /dev/null @@ -1,3 +0,0 @@ - -