diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1083c7a..2644c9f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,6 +1,7 @@ # Project overview + ## Fastlane Fastlane is used to keep metadata for playstore which includes: tilte, description, screenshots and changelog diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 3521fc5..f10c185 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -12,15 +12,16 @@ jobs: timeout-minutes: 30 steps: - name: Checkout the repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: set up JDK 17 - uses: actions/setup-java@v1 + uses: actions/setup-java@v4 with: - java-version: 17 + distribution: 'temurin' + java-version: '17' - name: Cache gradle - uses: actions/cache@v1 + uses: actions/cache@v4 with: path: ~/.gradle/caches key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }} @@ -33,7 +34,6 @@ jobs: APP_BUILD_NUMBER: ${{ env.GITHUB_RUN_ID }} APP_VERSION_CODE: ${{ github.run_number }} APP_VERSION_NAME: ${{ github.ref_name }} - PIXABAY_API: ${{ secrets.PIXABAY_API }} with: lane: unit_test skip-tracking: false @@ -41,7 +41,7 @@ jobs: bundle-install-path: CACHE_BUNDLER - name: Upload Bundle - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: android_debug_apk_${{ github.run_number }}.zip path: app/build/outputs/apk/debug/app-debug.apk \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ca633b4..caf0bbc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,19 +4,32 @@ on: push: tags: - '*' + workflow_dispatch: jobs: build: - name: Create Release + name: Build and Deploy runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: set up JDK 17 - uses: actions/setup-java@v1 + uses: actions/setup-java@v4 with: - java-version: 17 + distribution: 'temurin' + java-version: '17' + + - name: Setup Environment + run: | + VERSION_NAME=$(grep "versionName" app/build.gradle | head -1 | awk -F'\"' '{print $2}') + if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + echo "LANE=beta" >> $GITHUB_ENV + echo "APP_VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV + else + echo "LANE=deploy" >> $GITHUB_ENV + echo "APP_VERSION_NAME=${{ github.ref_name }}" >> $GITHUB_ENV + fi - name: Create release.keystore env: @@ -32,14 +45,13 @@ jobs: - name: Publish to Google Play uses: maierj/fastlane-action@v3.1.0 env: - PIXABAY_API: ${{ secrets.PIXABAY_API }} APP_SIGN_KEYSTORE_PATH: /tmp/release.keystore APP_SIGN_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} APP_SIGN_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} APP_SIGN_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} APP_VERSION_CODE: ${{ github.run_number }} - APP_VERSION_NAME: ${{ github.ref_name }} + APP_VERSION_NAME: ${{ env.APP_VERSION_NAME }} SUPPLY_JSON_KEY_DATA: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }} with: - lane: deploy - subdirectory: fastlane \ No newline at end of file + lane: ${{ env.LANE }} + subdirectory: fastlane diff --git a/.gitignore b/.gitignore index a5599fa..cdf2c22 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,23 @@ -*.iml -.gradle +# Build artifacts **/build -/build -/captures +**/captures + +# Gradle +.gradle +.gradle_home/ +local.properties + +# IntelliJ **/.idea -/local.properties +*.iml +.kotlin/** + +# Other +.DS_Store +.externalNativeBuild +.cxx +**/metadata/android/screenshots.html +**/report.xml .DS_Store .externalNativeBuild .cxx diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ed4ce92 --- /dev/null +++ b/LICENSE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if distributed along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/PRIVACY.md b/PRIVACY.md index c9cb711..d7aacd0 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -14,6 +14,12 @@ For a better experience, while using our Service, I may require you to provide u The app does use third-party services that may collect information used to identify you. +**On-Device AI Wallpaper Generation (Local-First)** + +Beginning with Version 3.0, GalleryWall provides a generative wallpaper feature that utilizes a local, on-device artificial intelligence model (such as Stable Diffusion). +* **Local Processing:** All image generation inference is performed locally on your device. The prompts you input and the generated wallpaper images are processed and stored strictly on your device. We do not collect, store, or transmit your prompts, generated images, or model configuration data to our servers or any third parties. +* **Model Downloads:** The app allows you to download AI models from remote third-party repositories (such as Hugging Face or GitHub) to your device. When downloading these models, the app establishes direct network connections to these third-party platforms. Your interaction with these platforms is subject to their respective privacy policies. + Link to the privacy policy of third-party service providers used by the app * [Google Play Services](https://www.google.com/policies/privacy/) diff --git a/README.md b/README.md index 625fbbf..87ac6a3 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,65 @@ -# GalleryWall +![Header](/fastlane/metadata/android/en-US/images/featureGraphic.png) -[![Actions Status](https://github.com/bossly/gallerywall/workflows/Create%20Release/badge.svg)](https://github.com/bossly/gallerywall/actions) +# GalleryWall: AI Wallpaper Generator (Local-First) + +[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![The most recent tag](https://img.shields.io/github/v/release/bossly/gallerywall.svg?logo=github)](https://github.com/bossly/gallerywall/tags) [![API](https://img.shields.io/badge/API-36%2B-orange.svg?logo=android)](https://android-arsenal.com/api?level=36) [![StandWithUkraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/badges/StandWithUkraine.svg)](https://github.com/vshymanskyy/StandWithUkraine/blob/main/docs/README.md) +Starting with **Version 3.0**, GalleryWall has pivoted to leverage a **generative on-device AI model**. GalleryWall is an open-source, local-first Android agentic app that generates beautiful wallpapers using AI models. It performs local inference directly on your device to ensure privacy, zero latency, and offline support. + +## Downloads + [![GooglePlay](https://play.google.com/intl/en_us/badges/images/badge_new.png)](https://play.google.com/store/apps/details?id=com.baysoft.gallerywall) -| ![Primary screen](screens/screen1.png) | ![Secondary screen](screens/screen2.png) | ![Widget screen](screens/screen3.png) | -|-|-|-| +## Screenshots + +| ![Gallery](/fastlane/metadata/android/en-US/images/phoneScreenshots/screen1.png) | ![Providers](/fastlane/metadata/android/en-US/images/phoneScreenshots/screen2.png) | ![Automation screen](/fastlane/metadata/android/en-US/images/phoneScreenshots/screen3.png) | +|-------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------| + +## Features + +- **Generative On-Device Models**: Performs local inference to generate custom wallpapers directly on your device without sending private data to external servers. +- **Three-Tab Navigation**: + - **Gallery**: View and manage recently generated wallpapers grouped by date and filtered by provider. + - **Providers**: Manage wallpaper providers. Select the local model provider by default, choose local models, or download new ones directly from GitHub. + - **Automation**: Configure agentic background automation to periodically refresh your wallpaper using a prompt builder and custom intervals. +- **Image Filters & Custom Styling**: + - Apply post-processing filters: **Black & White**, **Sepia**, **Invert**, and **Blur**. + - Scaling and styling options: **Fit**, **Fill**, and **Seamless Pattern Tile**. +- **Seamless repeating patterns**: + - Applying a Gradient Map (Half-Cosine falloff filter) to the edges of the Bitmap scales down the image's opacity radially toward the borders. This eliminates the visible hard stitching line or seam, allowing any generated image to tile seamlessly as a background. +- Direct boot support (immediately active after device restart) +- No ads and no analytics. + +## Contribution + +If you run into a bug or miss a feature, please [open an issue](https://github.com/bossly/gallerywall/issues) in this repository. + +### Custom Providers & Extensibility -Android application to refresh your wallpaper with in duration that can be set in settings. Each wallpaper picture comes from the free source following the any topic that been typed as search query. +GalleryWall supports a modular strategy pattern for adding custom wallpaper generators. Developers can register custom providers (for example, remote cloud generators powered by the **Firebase AI SDK** / **Vertex AI Imagen**) using the compile-time and runtime registry. -It's optimized for the low battery usage using latest system's API. +For a detailed step-by-step walkthrough on creating your own custom wallpaper provider, see the [Custom Wallpaper Provider Guide](../custom_provider.md). -# Setup +--- -Currently there only Pixabay service used to fetch wallpaper images. +## Local AI Model Setup -Edit `local.properties` with your api key from https://pixabay.com/service/about/api/ +By default, GalleryWall uses local on-device models (such as Stable Diffusion). +- Stable Diffusion model file (`stable_diffusion_v1_5.zip` ~1.78 GB) and other model configurations are managed via a JSON manifest. +- Model list format example: + ```json + { + "fileSize": 1789496203, + "url": "https://huggingface.co/runwayml/stable-diffusion-v1-5/resolve/main/v1-5-pruned.safetensors?download=true", + "sha256": "a0978e01159311d0a29e078b0dbf9a5c40d35f1e8e3f47cc0d3446c722f8316f" + } + ``` +## License -```groovy -api_pixabay= -``` +This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details. -# References -* [4pda](http://4pda.ru/forum/index.php?showtopic=158065&st=2660#entry29540977) - 21/02/2014 -* [http://androidforums.com](https://androidforums.com/threads/free-automation-wallpaper-switcher.831706/#post6458208) - 25/02/2014 -* [reddit: AppHunt](https://www.reddit.com/r/AppHunt/comments/l66fjs/gallerywall_automated_wallpaper/) -* [reddit: fossdroid](https://www.reddit.com/r/fossdroid/comments/l66has/apache_20_gallerywall_opensourced_automated/) -* [reddit: androidapps](https://www.reddit.com/r/androidapps/comments/l628f4/gallerywall_opensourced_automated_wallpaper_app/) diff --git a/TERMS.md b/TERMS.md index f1ee801..4e9a46d 100644 --- a/TERMS.md +++ b/TERMS.md @@ -22,6 +22,14 @@ With respect to bossly’s responsibility for your use of the app, when you’re At some point, we may wish to update the app. The app is currently available on Android – the requirements for the system(and for any additional systems we decide to extend the availability of the app to) may change, and you’ll need to download the updates if you want to keep using the app. bossly does not promise that it will always update the app so that it is relevant to you and/or works with the Android version that you have installed on your device. However, you promise to always accept updates to the application when offered to you, We may also wish to stop providing the app, and may terminate use of it at any time without giving notice of termination to you. Unless we tell you otherwise, upon any termination, (a) the rights and licenses granted to you in these terms will end; (b) you must stop using the app, and (if needed) delete it from your device. +**On-Device AI and Third-Party Models** + +Beginning with Version 3.0, GalleryWall includes features that allow you to generate wallpapers locally using generative artificial intelligence models (such as Stable Diffusion v1.5). By using these features, you acknowledge and agree to the following terms: +* **On-Device Resource Consumption:** Local AI image generation is computationally intensive. It requires significant hardware resources, including device memory (RAM), processor and graphics power (CPU/GPU/NPU), and battery. Running local inference may cause your device to heat up, experience temporary performance slowdowns, or drain the battery rapidly. bossly is not responsible for any issues, battery depletion, or hardware performance impacts resulting from running local models on your device. +* **Data and Bandwidth Charges:** The application allows you to download local AI model files to your device. These files are very large (typically 1.78 GB or more). Downloading these files can consume a significant amount of internet data. You are solely responsible for any data transmission fees or network charges from your mobile operator or internet service provider. We strongly recommend downloading models only over an unlimited Wi-Fi connection. +* **No Ownership or Modification of Models:** The app utilizes third-party AI models, including Stable Diffusion v1.5 from RunwayML (available at `https://huggingface.co/runwayml/stable-diffusion-v1-5`). bossly does not own, modify, or claim any intellectual property rights to these models. Your download and use of these models are governed by their respective original licenses (such as the CreativeML OpenRAIL-M license). You agree to fully comply with all terms and restrictions set forth in those licenses. +* **No Guarantee of Generative Outputs:** The wallpapers are generated dynamically on your device using probabilistic machine learning models. bossly does not guarantee the aesthetic quality, safety, accuracy, suitability, or legality of the generated images. You assume all risk and responsibility for the prompts you input and the content of the generated wallpapers. + **Changes to This Terms and Conditions** I may update our Terms and Conditions from time to time. Thus, you are advised to review this page periodically for any changes. I will notify you of any changes by posting the new Terms and Conditions on this page. diff --git a/app/build.gradle b/app/build.gradle index b46c339..bcf30a9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,12 +1,9 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' + id 'org.jetbrains.kotlin.plugin.compose' id 'androidx.navigation.safeargs.kotlin' -} - -Properties properties = new Properties() -if (rootProject.file("local.properties").exists()) { - properties.load(rootProject.file("local.properties").newDataInputStream()) + id 'kotlin-kapt' } android { @@ -14,16 +11,14 @@ android { defaultConfig { applicationId "com.baysoft.gallerywall" - minSdk 35 + minSdk 31 compileSdk 36 - versionCode 35 - versionName "2.3.0" + versionCode 36 + versionName "3.0.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - - def pixabay_key = System.getenv("PIXABAY_API") ?: properties.getProperty('api_pixabay') - buildConfigField("String", "PIXABAY_API", "\"$pixabay_key\"") } + buildTypes { release { minifyEnabled true @@ -40,16 +35,55 @@ android { kotlinOptions { jvmTarget = '17' } + testOptions { + unitTests { + includeAndroidResources = true + } + } + packaging { + jniLibs { + useLegacyPackaging = false + } + resources { + excludes += '/META-INF/INDEX.LIST' + excludes += 'google/protobuf/*.proto' + excludes += 'google/protobuf/compiler/*.proto' + } + } buildFeatures { viewBinding true dataBinding true buildConfig true + compose true + } + aaptOptions { + noCompress 'tflite' } } -def retrofitVersion = '2.9.0' +configurations.all { + resolutionStrategy { + force 'com.google.protobuf:protobuf-java:3.25.3' + dependencySubstitution { + substitute module('com.google.protobuf:protobuf-javalite') using module('com.google.protobuf:protobuf-java:3.25.3') + } + } +} dependencies { + // Jetpack Compose + implementation platform('androidx.compose:compose-bom:2024.12.01') + implementation 'androidx.compose.ui:ui' + implementation 'androidx.compose.ui:ui-graphics' + implementation 'androidx.compose.ui:ui-tooling-preview' + debugImplementation 'androidx.compose.ui:ui-tooling' + implementation 'androidx.compose.material3:material3' + implementation 'androidx.activity:activity-compose:1.9.3' + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7' + implementation 'androidx.navigation:navigation-compose:2.8.5' + implementation 'androidx.compose.runtime:runtime-livedata' + implementation 'io.coil-kt:coil-compose:2.6.0' + implementation 'androidx.preference:preference-ktx:1.2.0' implementation 'androidx.core:core-ktx:1.16.0' implementation 'androidx.appcompat:appcompat:1.7.0' @@ -57,15 +91,30 @@ dependencies { implementation 'com.google.android.material:material:1.12.0' // 3rd party - implementation "com.squareup.retrofit2:retrofit:$retrofitVersion" - implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion" implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1' implementation 'com.github.bumptech.glide:glide:4.11.0' - androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.6.1' + implementation 'androidx.room:room-common-jvm:2.7.2' + implementation 'androidx.room:room-ktx:2.7.2' + implementation 'androidx.room:room-runtime:2.7.2' + implementation 'androidx.work:work-runtime-ktx:2.10.1' + implementation 'androidx.navigation:navigation-fragment-ktx:2.9.3' + implementation 'androidx.navigation:navigation-ui-ktx:2.9.3' + kapt 'androidx.room:room-compiler:2.7.2' + androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.7.0' annotationProcessor 'com.github.bumptech.glide:compiler:4.11.0' + // Google MediaPipe Image Generator + implementation 'com.google.mediapipe:tasks-vision-image-generator:0.10.26.1' + testImplementation 'junit:junit:4.13.2' + testImplementation 'androidx.test:core:1.7.0' + testImplementation 'androidx.work:work-testing:2.10.1' + testImplementation 'org.robolectric:robolectric:4.14.1' androidTestImplementation 'androidx.test.ext:junit:1.2.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0' + androidTestImplementation 'androidx.test:rules:1.7.0' androidTestImplementation 'tools.fastlane:screengrab:2.1.1' + androidTestImplementation platform('androidx.compose:compose-bom:2024.12.01') + androidTestImplementation 'androidx.compose.ui:ui-test-junit4' + debugImplementation 'androidx.compose.ui:ui-test-manifest' } \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 481bb43..8f6f6e5 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -18,4 +18,11 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +#-renamesourcefileattribute SourceFile + +# WorkManager / CoroutineWorker +-keep class * extends androidx.work.Worker +-keep class * extends androidx.work.CoroutineWorker +-keepclassmembers class * extends androidx.work.CoroutineWorker { + (android.content.Context,androidx.work.WorkerParameters); +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/baysoft/gallerywall/MainActivityInstrumentedTest.kt b/app/src/androidTest/java/com/baysoft/gallerywall/MainActivityInstrumentedTest.kt index fa3b872..9f314fd 100644 --- a/app/src/androidTest/java/com/baysoft/gallerywall/MainActivityInstrumentedTest.kt +++ b/app/src/androidTest/java/com/baysoft/gallerywall/MainActivityInstrumentedTest.kt @@ -2,71 +2,122 @@ package com.baysoft.gallerywall import android.os.Bundle import android.util.Log -import androidx.recyclerview.widget.RecyclerView -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.* -import androidx.test.espresso.assertion.ViewAssertions.* -import androidx.test.espresso.contrib.RecyclerViewActions -import androidx.test.espresso.matcher.ViewMatchers.* -import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import com.baysoft.gallerywall.data.WallpaperDatabase +import com.baysoft.gallerywall.data.WallpaperEntity +import com.baysoft.gallerywall.provider.ColorProvider +import com.baysoft.gallerywall.provider.WallpaperProviderRegistry +import kotlinx.coroutines.runBlocking import org.junit.* import org.junit.runner.RunWith import tools.fastlane.screengrab.Screengrab import tools.fastlane.screengrab.UiAutomatorScreenshotStrategy import tools.fastlane.screengrab.cleanstatusbar.CleanStatusBar import tools.fastlane.screengrab.locale.LocaleTestRule +import java.io.File @LargeTest @RunWith(AndroidJUnit4::class) class MainActivityInstrumentedTest { @get:Rule - val activityRule = ActivityScenarioRule(MainActivity::class.java) + val permissionRule: GrantPermissionRule = GrantPermissionRule.grant( + android.Manifest.permission.POST_NOTIFICATIONS, + android.Manifest.permission.WRITE_EXTERNAL_STORAGE, + android.Manifest.permission.READ_EXTERNAL_STORAGE + ) + + @get:Rule + val composeTestRule = createAndroidComposeRule() @get:Rule val localeTestRule = LocaleTestRule() @Before fun before() { + WallpaperProviderRegistry.unregister(ColorProvider) Screengrab.setDefaultScreenshotStrategy(UiAutomatorScreenshotStrategy()) val extras: Bundle = InstrumentationRegistry.getArguments() // https://docs.fastlane.tools/getting-started/android/screenshots/#advanced-screengrab Log.d("extras", extras.toString()) - CleanStatusBar.enableWithDefaults(); + CleanStatusBar.enableWithDefaults() + + setupGallery() + } + + private fun setupGallery() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val db = WallpaperDatabase.getInstance(context) + val dao = db.wallpaperDao() + + runBlocking { + // Clear existing wallpapers + db.clearAllTables() + + // Generate and populate with test images + val colors = listOf( + android.graphics.Color.parseColor("#2196F3"), // Blue + android.graphics.Color.parseColor("#4CAF50"), // Green + android.graphics.Color.parseColor("#F44336"), // Red + android.graphics.Color.parseColor("#FFEB3B"), // Yellow + android.graphics.Color.parseColor("#9C27B0"), // Purple + android.graphics.Color.parseColor("#FF9800") // Orange + ) + + colors.forEachIndexed { index, color -> + val fileName = "test_image_$index.jpg" + val file = File(context.filesDir, fileName) + + val bitmap = android.graphics.Bitmap.createBitmap(1080, 1920, android.graphics.Bitmap.Config.ARGB_8888) + val canvas = android.graphics.Canvas(bitmap) + canvas.drawColor(color) + + file.outputStream().use { out -> + bitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 90, out) + } + + dao.insert(WallpaperEntity( + filePath = file.absolutePath, + dateAdded = System.currentTimeMillis() - (index * 60000), + providerId = "test_provider", + prompt = "Sample Wallpaper ${index + 1}" + )) + } + } } @After - fun afterAll() { + fun after() { CleanStatusBar.disable() } @Test - fun homeScreen() { - // app has actionbar - val actionBar = onView(withId(com.google.android.material.R.id.action_bar)) - actionBar.check(matches(isDisplayed())) - + fun captureScreenshots() { + val barItems = composeTestRule.onNodeWithTag("BottomNavigationBar", useUnmergedTree = true) + .onChild() + .onChildren() + + // Capture screen1 (Gallery screen) + composeTestRule.waitForIdle() + // wait 1 second to allow screen to load wallpapers + Thread.sleep(1000) Screengrab.screenshot("screen1") - } - - @Test - fun searchDialog() { - val recyclerView = onView(withId(androidx.preference.R.id.recycler_view)) - recyclerView.check(matches(hasChildCount(5))) - - recyclerView.perform( - RecyclerViewActions.actionOnItemAtPosition( - 1, click() - ) - ) - - val textView = onView(withId(com.google.android.material.R.id.alertTitle)) - textView.check(matches(withText(R.string.pref_query_title))) + // Navigate to Providers screen (index 1) and capture screen2 + barItems[1].performClick() + composeTestRule.waitForIdle() Screengrab.screenshot("screen2") + + // Navigate to Automation screen (index 2) and capture screen3 + barItems[2].performClick() + composeTestRule.waitForIdle() + Screengrab.screenshot("screen3") } } + diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..6027638 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1281542..18b31b5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,20 +1,35 @@ - - + + + + + + + + + + + + - - + android:exported="true"> - - - - - - - - \ No newline at end of file diff --git a/app/src/main/java/com/baysoft/gallerywall/GalleryAppWidget.kt b/app/src/main/java/com/baysoft/gallerywall/GalleryAppWidget.kt deleted file mode 100644 index 059d5c2..0000000 --- a/app/src/main/java/com/baysoft/gallerywall/GalleryAppWidget.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.baysoft.gallerywall - -import android.app.PendingIntent -import android.appwidget.AppWidgetManager -import android.appwidget.AppWidgetProvider -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.view.View -import android.widget.RemoteViews - -class GalleryAppWidget : AppWidgetProvider() { - - companion object { - fun updateLoaded(context: Context) { - val views = RemoteViews(context.packageName, R.layout.app_widget) - views.setViewVisibility(R.id.v_btn_update, View.VISIBLE) - views.setViewVisibility(android.R.id.progress, View.GONE) - updateAppWidgets(context, views) - } - - fun updateLoading(context: Context) { - val views = RemoteViews(context.packageName, R.layout.app_widget) - views.setViewVisibility(R.id.v_btn_update, View.GONE) - views.setViewVisibility(android.R.id.progress, View.VISIBLE) - updateAppWidgets(context, views) - } - - private fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) { - val views = RemoteViews(context.packageName, R.layout.app_widget) - val activateIntent = Intent(context, GalleryWallReceiver::class.java) - activateIntent.action = "update" - - val activatePending = PendingIntent.getBroadcast( - context, 1, activateIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - views.setOnClickPendingIntent(R.id.v_btn_update, activatePending) - - val inActivity = Intent(context, MainActivity::class.java) - inActivity.putExtra("key", "value1") - val activityIntent = PendingIntent.getActivity(context, 0, inActivity, PendingIntent.FLAG_IMMUTABLE) - views.setOnClickPendingIntent(R.id.v_btn_settings, activityIntent) - - appWidgetManager.updateAppWidget(appWidgetId, views) - } - - private fun updateAppWidgets(context: Context?, remoteViews: RemoteViews?) { - val appWidgetManager = AppWidgetManager.getInstance(context) - val ids = appWidgetManager.getAppWidgetIds( - context?.let { - ComponentName(it, GalleryAppWidget::class.java) - } - ) - appWidgetManager.updateAppWidget(ids, remoteViews) - } - } - - override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { - // There may be multiple widgets active, so update all of them - for (index in appWidgetIds) { - updateAppWidget(context, appWidgetManager, index) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/baysoft/gallerywall/GalleryApplication.kt b/app/src/main/java/com/baysoft/gallerywall/GalleryApplication.kt index a5f3bf0..0bbe0bc 100644 --- a/app/src/main/java/com/baysoft/gallerywall/GalleryApplication.kt +++ b/app/src/main/java/com/baysoft/gallerywall/GalleryApplication.kt @@ -1,12 +1,20 @@ package com.baysoft.gallerywall import android.app.Application +import androidx.preference.PreferenceManager +import com.baysoft.gallerywall.provider.ColorProvider +import com.baysoft.gallerywall.provider.WallpaperProviderRegistry class GalleryApplication : Application() { override fun onCreate() { super.onCreate() - GalleryWallService.createNotificationChannel(this) + Settings.migrateLegacyPrefsIfNeeded(PreferenceManager.getDefaultSharedPreferences(this)) + GalleryWallNotifications.createNotificationChannel(this) + + if (BuildConfig.DEBUG) { + WallpaperProviderRegistry.register(ColorProvider) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/baysoft/gallerywall/GalleryWall.kt b/app/src/main/java/com/baysoft/gallerywall/GalleryWall.kt index 762580d..b6e77a7 100644 --- a/app/src/main/java/com/baysoft/gallerywall/GalleryWall.kt +++ b/app/src/main/java/com/baysoft/gallerywall/GalleryWall.kt @@ -1,64 +1,174 @@ package com.baysoft.gallerywall import android.app.WallpaperManager -import android.app.job.JobInfo -import android.app.job.JobScheduler -import android.content.ComponentName import android.content.Context +import android.content.Intent import android.graphics.Bitmap +import android.util.Log import androidx.preference.PreferenceManager +import com.baysoft.gallerywall.provider.WallpaperProviderRegistry +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequest +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.util.concurrent.TimeUnit /** - * Created on 12.12.2020. - * Copyright by oleg + * Wallpaper helpers and **scheduled refresh** via [WorkManager]. + * + * Wallpapers are generated locally via [com.baysoft.gallerywall.provider.WallpaperProvider]; no remote image API. */ class GalleryWall { companion object { - private const val jobId = 1102 + + private const val TAG = "GalleryWall" + + /** Unique name for [WorkManager.enqueueUniquePeriodicWork]. */ + internal const val UNIQUE_WORK_NAME = "gallery_wall_refresh" + + /** + * Sent when a manual refresh finishes without applying wallpaper so the UI can dismiss + * progress (see [com.baysoft.gallerywall.ui.HomeFragment]). + */ + const val ACTION_REFRESH_IDLE = "com.baysoft.gallerywall.REFRESH_IDLE" + + /** Sent when user clicks "Apply" on a notification. */ + const val ACTION_APPLY_WALLPAPER = "com.baysoft.gallerywall.APPLY_WALLPAPER" + + /** Intent extra for file path to apply. */ + const val EXTRA_FILE_PATH = "extra_file_path" + + /** + * WorkManager enforces a minimum interval (~15 minutes). Call this when building periodic + * requests so invalid shorter intervals from future preference changes never enqueue. + */ + internal fun clampPeriodicIntervalMinutes(minutes: Long): Long { + val minMinutes = + TimeUnit.MILLISECONDS.toMinutes(PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS) + return maxOf(minutes, minMinutes) + } fun cancelSchedule(context: Context) { - val jobScheduler = - context.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler - jobScheduler.cancel(jobId) + WorkManager.getInstance(context).cancelUniqueWork(UNIQUE_WORK_NAME) + } + + internal fun buildWorkConstraints(settings: Settings): Constraints { + val builder = Constraints.Builder() + if (settings.constraintWifi) { + builder.setRequiredNetworkType(NetworkType.UNMETERED) + } else { + builder.setRequiredNetworkType(NetworkType.NOT_REQUIRED) + } + if (settings.constraintCharging) { + builder.setRequiresCharging(true) + } + if (settings.constraintIdle) { + builder.setRequiresDeviceIdle(true) + } + return builder.build() } fun schedule(context: Context, minutes: Long? = null) { - val period = minutes - ?: Settings(PreferenceManager.getDefaultSharedPreferences(context)).period - val jobScheduler = - context.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler - - if (period > 0) { - val component = ComponentName(context, GalleryWallService::class.java) - val myJob = JobInfo.Builder(jobId, component) - .setBackoffCriteria( - TimeUnit.SECONDS.toMillis(10), - JobInfo.BACKOFF_POLICY_LINEAR - ) - .setPeriodic(TimeUnit.MINUTES.toMillis(period)) - .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) - .setRequiresDeviceIdle(false) - .setRequiresCharging(false) - .build() - - jobScheduler.cancel(jobId) - jobScheduler.schedule(myJob) + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + Settings.migrateLegacyPrefsIfNeeded(prefs) + val settings = Settings(prefs) + val wm = WorkManager.getInstance(context.applicationContext) + + val period = settings.period + if (!settings.autoWallpaperEnabled || period <= 0) { + wm.cancelUniqueWork(UNIQUE_WORK_NAME) + return + } + + val intervalMinutes = if (minutes != null) { + minutes } else { - jobScheduler.cancel(jobId) + when (settings.periodUnit) { + "HOURS" -> period * 60L + "DAYS" -> period * 24L * 60L + "WEEKS" -> period * 7L * 24L * 60L + "MONTHS" -> period * 30L * 24L * 60L + else -> period + } } + + val clampedMinutes = clampPeriodicIntervalMinutes(intervalMinutes) + val constraints = buildWorkConstraints(settings) + + val request = PeriodicWorkRequestBuilder( + clampedMinutes, + TimeUnit.MINUTES + ) + .setConstraints(constraints) + .setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.SECONDS) + .build() + + Log.d(TAG, "Automation scheduled: every $clampedMinutes minutes. Next run expected in ~$clampedMinutes minutes.") + + wm.enqueueUniquePeriodicWork( + UNIQUE_WORK_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + request + ) } - suspend fun fetchImageURL(context: Context): String { - val settings = Settings(PreferenceManager.getDefaultSharedPreferences(context)) - val result = ImageProvider.serviceApi.loadPixabay(BuildConfig.PIXABAY_API, settings.query) - result?.hits?.run { - val index = indices.random() - return get(index).imageURL + /** Renders the current wallpaper using the active provider from [Settings]. */ + fun createWallpaperBitmap(context: Context, onStateUpdate: (com.baysoft.gallerywall.provider.ProviderState) -> Unit = {}): Bitmap { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val settings = Settings(prefs) + val providerId = settings.activeProviderId + val colors = settings.generatedColorsHex + + val promptStr = if (providerId == "local_ai") { + val rawPrompt = settings.automationPrompt + val resolvedPrompt = rawPrompt + "Raw: \"$rawPrompt\", Resolved: \"$resolvedPrompt\"" + } else { + "None" + } + + Log.i(TAG, "Wallpaper generation started | Mode: $providerId | Colors: $colors | Prompt: $promptStr") + + val provider = WallpaperProviderRegistry.get(providerId) + ?: WallpaperProviderRegistry.defaultProvider + + val notificationManager = if (providerId != "local_ai") { + context.getSystemService(Context.NOTIFICATION_SERVICE) as? android.app.NotificationManager + } else null + + val rawBitmap = provider.generateBitmap(context) { state -> + onStateUpdate(state) + + // Show progress notification for non-AI providers (AI has its own service notification) + notificationManager?.let { nm -> + val progress = (state.progress * 100).toInt() + val notification = GalleryWallNotifications.buildProgressNotification( + context, + state.message ?: "Generating wallpaper...", + progress = progress, + max = 100 + ) + nm.notify(GalleryWallNotifications.PROGRESS_NOTIFICATION_ID, notification) + + if (state.progress >= 1.0f) { + nm.cancel(GalleryWallNotifications.PROGRESS_NOTIFICATION_ID) + } + } } - return "" + return if (settings.postProcessingFilter != "none") { + WallpaperGenerator.applyPostProcessing(rawBitmap, settings.postProcessingFilter) + } else { + rawBitmap + } } fun updateWallpaper(context: Context, image: Bitmap?) { @@ -67,6 +177,54 @@ class GalleryWall { } } - } + /** Persists which generated file was last applied as the system wallpaper (for UI indicator). */ + fun rememberAppliedWallpaperPath(context: Context, absolutePath: String?) { + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putString(Settings.PREF_LAST_APPLIED_WALLPAPER_PATH, absolutePath) + .apply() + } + + /** + * Saves wallpaper to disk and database. + * @return The absolute path of the saved file. + */ + suspend fun recordWallpaperSync(context: Context, image: Bitmap?, applied: Boolean = true): String? = withContext(Dispatchers.IO) { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val settings = Settings(prefs) + val providerId = settings.activeProviderId + val filePath = image?.let { bmp -> + val file = java.io.File(context.filesDir, "wallpaper_${providerId}_${System.currentTimeMillis()}.jpg") + java.io.FileOutputStream(file).use { out -> + bmp.compress(Bitmap.CompressFormat.JPEG, 95, out) + } + file.absolutePath + } ?: return@withContext null -} \ No newline at end of file + if (applied) { + rememberAppliedWallpaperPath(context.applicationContext, filePath) + } + val db = com.baysoft.gallerywall.data.WallpaperDatabase.getInstance(context) + val repo = com.baysoft.gallerywall.data.WallpaperRepository(db.wallpaperDao()) + + val promptStr = if (providerId == "local_ai" || providerId == "procedural") { + settings.automationPrompt + } else "" + + repo.addWallpaper(filePath, providerId, promptStr) + + // Notify UI to update recents + context.sendBroadcast(Intent("com.baysoft.gallerywall.WALLPAPER_SET")) + + Log.d(TAG, "Wallpaper generated and saved to: $filePath (Applied: $applied)") + + return@withContext filePath + } + + // Keep compatibility if needed, but better to migrate usages + fun recordWallpaper(context: Context, image: Bitmap?, applied: Boolean = true) { + GlobalScope.launch { + recordWallpaperSync(context, image, applied) + } + } + } +} diff --git a/app/src/main/java/com/baysoft/gallerywall/GalleryWallNotifications.kt b/app/src/main/java/com/baysoft/gallerywall/GalleryWallNotifications.kt new file mode 100644 index 0000000..3b3f783 --- /dev/null +++ b/app/src/main/java/com/baysoft/gallerywall/GalleryWallNotifications.kt @@ -0,0 +1,120 @@ +package com.baysoft.gallerywall + +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.graphics.Bitmap +import androidx.core.app.NotificationCompat + +/** + * Notification channel and refresh notifications for periodic wallpaper updates (posted from + * [GalleryWallRefreshWorker], not from a foreground service). + */ +object GalleryWallNotifications { + + const val CHANNEL_ID = "_gallerywall" + const val NOTIFICATION_ID = 1 + const val PROGRESS_NOTIFICATION_ID = 2027 + + fun createNotificationChannel(context: Context) { + val name = context.getString(R.string.app_name) + val description = context.getString(R.string.app_name) + val importance = NotificationManager.IMPORTANCE_DEFAULT + val channel = NotificationChannel(CHANNEL_ID, name, importance) + channel.description = description + context.getSystemService(NotificationManager::class.java)?.createNotificationChannel(channel) + } + + fun buildRefreshNotification( + context: Context, + image: Bitmap?, + filePath: String? = null, + isAlreadyApplied: Boolean = true + ): Notification { + val updateIntent = GalleryWallReceiver.updateIntent(context) + val activatePending = PendingIntent.getBroadcast( + context, + 1, + updateIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val inActivity = Intent(context, MainActivity::class.java) + val activityIntent = PendingIntent.getActivity( + context, + 2, + inActivity, + PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE + ) + + val titleRes = if (isAlreadyApplied) R.string.notification_title_set else R.string.notification_title_generated + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.icon_notification) + .setContentTitle(context.getString(titleRes)) + .setContentIntent(activityIntent) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + + image?.let { + builder.setLargeIcon(it) + builder.setStyle( + NotificationCompat.BigPictureStyle() + .bigPicture(it) + ) + } + + if (!isAlreadyApplied && filePath != null) { + val applyIntent = GalleryWallReceiver.applyIntent(context, filePath) + val applyPending = PendingIntent.getBroadcast( + context, + 3, + applyIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + builder.addAction( + R.drawable.ic_play_gray_32, // Reusing an existing icon + context.getString(R.string.notification_action_apply), + applyPending + ) + } + + builder.addAction( + R.drawable.ic_refresh_gray_32, + context.getString(R.string.notification_action_retry), + activatePending + ) + builder.setOngoing(false).setAutoCancel(true) + + return builder.build() + } + + fun buildProgressNotification( + context: Context, + contentText: String, + progress: Int = -1, + max: Int = -1 + ): Notification { + val pendingIntent = PendingIntent.getActivity( + context, 0, + Intent(context, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE + ) + + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setContentTitle("GalleryWall Generation") + .setContentText(contentText) + .setSmallIcon(R.drawable.icon_notification) + .setContentIntent(pendingIntent) + .setOngoing(true) + + if (max > 0 && progress >= 0) { + builder.setProgress(max, progress, false) + } else if (progress == -2) { // Indeterminate + builder.setProgress(0, 0, true) + } + + return builder.build() + } +} diff --git a/app/src/main/java/com/baysoft/gallerywall/GalleryWallReceiver.kt b/app/src/main/java/com/baysoft/gallerywall/GalleryWallReceiver.kt index 0daa129..bba7042 100644 --- a/app/src/main/java/com/baysoft/gallerywall/GalleryWallReceiver.kt +++ b/app/src/main/java/com/baysoft/gallerywall/GalleryWallReceiver.kt @@ -1,70 +1,82 @@ package com.baysoft.gallerywall +import android.app.NotificationManager import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import android.graphics.Bitmap -import com.bumptech.glide.Glide -import com.bumptech.glide.load.DataSource -import com.bumptech.glide.load.engine.GlideException -import com.bumptech.glide.request.RequestListener -import com.bumptech.glide.request.target.Target +import android.graphics.BitmapFactory +import android.util.Log +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +/** + * Handles [Intent.ACTION_BOOT_COMPLETED] by reinstalling WorkManager periodic work via [GalleryWall.schedule], + * and explicit refresh intents that generate and apply a new wallpaper from settings. + */ class GalleryWallReceiver : BroadcastReceiver() { companion object { - private const val EXTRA_URL = "image.url" + fun updateIntent(context: Context): Intent = + Intent(context, GalleryWallReceiver::class.java) - fun updateIntent(context: Context, imageUrl: String?): Intent { - val intent = Intent(context, GalleryWallReceiver::class.java) - imageUrl?.let { - intent.putExtra(EXTRA_URL, it) + fun applyIntent(context: Context, filePath: String): Intent = + Intent(context, GalleryWallReceiver::class.java).apply { + action = GalleryWall.ACTION_APPLY_WALLPAPER + putExtra(GalleryWall.EXTRA_FILE_PATH, filePath) } - return intent - } } override fun onReceive(context: Context?, intent: Intent?) { - val photo = intent?.getStringExtra(EXTRA_URL) - when (intent?.action) { Intent.ACTION_BOOT_COMPLETED -> { - // device restarted, we need to reschedule events context?.let { GalleryWall.schedule(it) } } - } - - context?.let { - GalleryAppWidget.updateLoading(it) - - GlobalScope.launch { - val imageUrl = photo ?: GalleryWall.fetchImageURL(it) - - Glide.with(it).asBitmap().load(imageUrl) - .addListener(object : RequestListener { - override fun onLoadFailed( - e: GlideException?, model: Any?, - target: Target?, isFirstResource: Boolean - ): Boolean { - GalleryAppWidget.updateLoaded(it) - return false + GalleryWall.ACTION_APPLY_WALLPAPER -> { + val filePath = intent.getStringExtra(GalleryWall.EXTRA_FILE_PATH) ?: return + context?.let { ctx -> + GlobalScope.launch { + try { + val bitmap = BitmapFactory.decodeFile(filePath) + if (bitmap != null) { + GalleryWall.updateWallpaper(ctx, bitmap) + GalleryWall.rememberAppliedWallpaperPath(ctx, filePath) + ctx.sendBroadcast(Intent("com.baysoft.gallerywall.WALLPAPER_SET")) + Log.i("GalleryWallReceiver", "Wallpaper applied from notification: $filePath") + + // Dismiss notification after Apply + val nm = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + nm.cancel(GalleryWallNotifications.NOTIFICATION_ID) } + } catch (e: Exception) { + Log.e("GalleryWallReceiver", "Failed to apply wallpaper from file", e) + } + } + } + } + else -> { + context?.let { ctx -> + val handler = CoroutineExceptionHandler { _, e -> + Log.e("GalleryWallReceiver", "refresh failed", e) + ctx.sendBroadcast(Intent(GalleryWall.ACTION_REFRESH_IDLE)) + } + GlobalScope.launch(handler) { + // Dismiss notification before starting new generation (Retry) + val nm = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + nm.cancel(GalleryWallNotifications.NOTIFICATION_ID) - override fun onResourceReady( - resource: Bitmap?, model: Any?, target: Target?, - dataSource: DataSource?, isFirstResource: Boolean - ): Boolean { - // change wallpaper - GalleryWall.updateWallpaper(it, resource) - GalleryAppWidget.updateLoaded(it) - return false - } - }).submit() + val bitmap = GalleryWall.createWallpaperBitmap(ctx) + if (bitmap == null) { + ctx.sendBroadcast(Intent(GalleryWall.ACTION_REFRESH_IDLE)) + return@launch + } + GalleryWall.updateWallpaper(ctx, bitmap) + GalleryWall.recordWallpaper(ctx, bitmap) + } + } } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/baysoft/gallerywall/GalleryWallRefreshWorker.kt b/app/src/main/java/com/baysoft/gallerywall/GalleryWallRefreshWorker.kt new file mode 100644 index 0000000..415454e --- /dev/null +++ b/app/src/main/java/com/baysoft/gallerywall/GalleryWallRefreshWorker.kt @@ -0,0 +1,86 @@ +package com.baysoft.gallerywall + +import android.content.Context +import android.graphics.Bitmap +import android.util.Log +import androidx.preference.PreferenceManager +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Runs scheduled wallpaper refresh under [WorkManager]. + * + * Generates wallpaper locally via [GalleryWall.createWallpaperBitmap]; no network. + */ +class GalleryWallRefreshWorker( + context: Context, + params: WorkerParameters +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + Log.d(TAG, "Automated wallpaper refresh started") + val context = applicationContext + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val settings = Settings(prefs) + + // Check battery constraint: skip refresh if battery is low (<15%) or power saving is on + if (settings.constraintBatteryLow && isBatteryLowOrSaving(context)) { + Log.i(TAG, "Skipping wallpaper refresh: battery is low or power saving mode is active.") + return@withContext Result.success() + } + + val bitmap = GalleryWall.createWallpaperBitmap(context) + if (bitmap == null) { + Log.w(TAG, "No bitmap produced") + return@withContext Result.retry() + } + + val autoApply = settings.autoApplyWallpaper + if (autoApply) { + GalleryWall.updateWallpaper(context, bitmap) + } + + val filePath = GalleryWall.recordWallpaperSync(context, bitmap, applied = autoApply) + + if (settings.notification) { + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager + val notification = GalleryWallNotifications.buildRefreshNotification( + context, + bitmap, + filePath = filePath, + isAlreadyApplied = autoApply + ) + notificationManager.notify(GalleryWallNotifications.NOTIFICATION_ID, notification) + } + + Result.success() + } + + private fun isBatteryLowOrSaving(context: Context): Boolean { + // 1. Check system power saver mode + val powerManager = context.getSystemService(Context.POWER_SERVICE) as? android.os.PowerManager + if (powerManager?.isPowerSaveMode == true) { + return true + } + + // 2. Check battery level percentage + val filter = android.content.IntentFilter(android.content.Intent.ACTION_BATTERY_CHANGED) + val batteryStatus = context.registerReceiver(null, filter) + val level = batteryStatus?.getIntExtra(android.os.BatteryManager.EXTRA_LEVEL, -1) ?: -1 + val scale = batteryStatus?.getIntExtra(android.os.BatteryManager.EXTRA_SCALE, -1) ?: -1 + if (level >= 0 && scale > 0) { + val percentage = (level / scale.toFloat()) * 100 + if (percentage < 15.0f) { + return true + } + } + return false + } + + companion object { + private const val TAG = "GalleryWallRefreshWorker" + } +} diff --git a/app/src/main/java/com/baysoft/gallerywall/GalleryWallService.kt b/app/src/main/java/com/baysoft/gallerywall/GalleryWallService.kt deleted file mode 100644 index ec23ae3..0000000 --- a/app/src/main/java/com/baysoft/gallerywall/GalleryWallService.kt +++ /dev/null @@ -1,161 +0,0 @@ -package com.baysoft.gallerywall - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.job.JobParameters -import android.app.job.JobService -import android.content.Context -import android.content.Intent -import android.graphics.Bitmap -import android.net.ConnectivityManager -import android.net.NetworkCapabilities -import android.net.Uri -import android.os.Build -import android.util.Log -import androidx.annotation.RequiresApi -import androidx.core.app.NotificationCompat -import androidx.preference.PreferenceManager -import com.bumptech.glide.Glide -import com.bumptech.glide.load.DataSource -import com.bumptech.glide.load.engine.GlideException -import com.bumptech.glide.request.RequestListener -import com.bumptech.glide.request.target.Target -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch - - -// https://developer.android.com/training/notify-user/expanded#kotlin -class GalleryWallService : JobService() { - - companion object { - const val CHANNEL_ID = "_gallerywall" - const val NOTIFICATION_ID = 1 - - // Create the NotificationChannel, but only on API 26+ because - // the NotificationChannel class is new and not in the support library - @RequiresApi(Build.VERSION_CODES.O) - fun createNotificationChannel(context: Context) { - val name = context.getString(R.string.app_name) - val description = context.getString(R.string.app_name) - val importance = NotificationManager.IMPORTANCE_DEFAULT - val channel = NotificationChannel(CHANNEL_ID, name, importance) - channel.description = description - // Register the channel with the system; you can't change the importance - // or other notification behaviors after this - context.getSystemService(NotificationManager::class.java)?.run { - createNotificationChannel(channel) - } - } - } - - private fun showNotification(context: Context = this) { - val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - val settings = Settings(PreferenceManager.getDefaultSharedPreferences(context)) - - // check if wifi - if (Settings(PreferenceManager.getDefaultSharedPreferences(context)).wifiOnly) { - val connManager = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager - val active = connManager.getNetworkCapabilities(connManager.activeNetwork) - - active?.let { - if (!it.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { - return - } - } - } - - GalleryAppWidget.updateLoading(context) - - GlobalScope.launch { - val photo = GalleryWall.fetchImageURL(context) - Log.d("GalleryWallService", photo) - Glide.with(context).asBitmap().load(photo) - .addListener(object : RequestListener { - override fun onLoadFailed( - e: GlideException?, model: Any?, - target: Target?, isFirstResource: Boolean - ): Boolean { - GalleryAppWidget.updateLoaded(context) - return false - } - - override fun onResourceReady( - resource: Bitmap?, model: Any?, target: Target?, - dataSource: DataSource?, isFirstResource: Boolean - ): Boolean { - if (settings.notification) { // if I need to show notification - manager.notify(NOTIFICATION_ID, buildNotification(photo, resource)) - } - - // change wallpaper - context.sendBroadcast(GalleryWallReceiver.updateIntent(context, photo)) - - return false - } - }).submit() - } - } - - private fun buildNotification(url: String, image: Bitmap?): Notification? { - - // load next one - val updateIntent = GalleryWallReceiver.updateIntent(this, null) - val activatePending = PendingIntent.getBroadcast( - this, 1, updateIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - // view the source - val resultIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) - val resultPendingIntent = PendingIntent.getActivity( - this, 1, resultIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - // open app - val inActivity = Intent(this, MainActivity::class.java) - val activityIntent = PendingIntent.getActivity( - this, 1, inActivity, PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE - ) - - val builder = NotificationCompat.Builder(this, CHANNEL_ID) - .setSmallIcon(R.drawable.icon_notification) - .setContentTitle(getString(R.string.notification_title_set)) - .setContentIntent(activityIntent) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - - image?.let { - builder.setLargeIcon(image) - builder.setStyle( - NotificationCompat.BigPictureStyle() - .bigPicture(image) - ) - } - - builder.addAction(R.drawable.ic_refresh_gray_32, getString(R.string.notification_action_next), activatePending) - builder.addAction(R.drawable.ic_launch_gray_24, getString(R.string.notification_action_open), resultPendingIntent) - - builder.setOngoing(false).setAutoCancel(true) - - return builder.build() - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - // return super.onStartCommand(intent, flags, startId) - return START_NOT_STICKY - } - - override fun onStartJob(params: JobParameters?): Boolean { - if (BuildConfig.DEBUG) { - // immediately show notification when Job started - showNotification() - } - - return true - } - - override fun onStopJob(params: JobParameters?): Boolean { - return true - } - -} diff --git a/app/src/main/java/com/baysoft/gallerywall/ImageGenerationService.kt b/app/src/main/java/com/baysoft/gallerywall/ImageGenerationService.kt new file mode 100644 index 0000000..959e13c --- /dev/null +++ b/app/src/main/java/com/baysoft/gallerywall/ImageGenerationService.kt @@ -0,0 +1,259 @@ +package com.baysoft.gallerywall + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.preference.PreferenceManager +import com.baysoft.gallerywall.data.WallpaperDatabase +import com.baysoft.gallerywall.data.WallpaperRepository +import com.baysoft.gallerywall.ml.LocalMLEngine +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.io.File +import java.io.FileOutputStream +import androidx.core.graphics.scale + +class ImageGenerationService : Service() { + + sealed class GenerationState { + object Idle : GenerationState() + object LoadingModel : GenerationState() + data class Generating(val progress: Float, val currentStep: Int, val totalSteps: Int) : GenerationState() + data class Success(val filePath: String) : GenerationState() + data class Error(val message: String) : GenerationState() + } + + companion object { + private const val TAG = "ImageGenService" + private const val NOTIFICATION_ID = 2026 + private const val CHANNEL_ID = "image_generation_channel" + + const val EXTRA_PROMPT = "extra_prompt" + const val EXTRA_MODEL_PATH = "extra_model_path" + const val EXTRA_COLORS_HEX = "extra_colors_hex" + const val EXTRA_AUTO_APPLY = "extra_auto_apply" + + private val _state = MutableStateFlow(GenerationState.Idle) + val state: StateFlow = _state.asStateFlow() + + fun start(context: Context, prompt: String, modelPath: String, colorsHex: String, autoApply: Boolean? = null) { + val intent = Intent(context, ImageGenerationService::class.java).apply { + putExtra(EXTRA_PROMPT, prompt) + putExtra(EXTRA_MODEL_PATH, modelPath) + putExtra(EXTRA_COLORS_HEX, colorsHex) + autoApply?.let { putExtra(EXTRA_AUTO_APPLY, it) } + } + context.startForegroundService(intent) + } + } + + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var activeJob: Job? = null + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val prompt = intent?.getStringExtra(EXTRA_PROMPT) ?: "" + val modelPath = intent?.getStringExtra(EXTRA_MODEL_PATH) ?: "" + + Log.d(TAG, "Image generation service started for prompt: $prompt") + + if (PromptFilter.containsInappropriateContent(prompt)) { + Log.w(TAG, "Aborting generation: Inappropriate prompt detected.") + showErrorNotification("Inappropriate prompt detected. Please try another one.") + _state.value = GenerationState.Error("Inappropriate prompt detected.") + stopSelf() + return START_NOT_STICKY + } + + startForeground(NOTIFICATION_ID, buildNotification("Initializing service...", -2)) + + activeJob?.cancel() + activeJob = serviceScope.launch { + try { + _state.value = GenerationState.LoadingModel + updateNotification("Loading AI model directory...", -2) + + val context = applicationContext + val engine = LocalMLEngine.getInstance() + + // 1. Load the model on background Dispatchers.Default + val modelLoaded = withContext(Dispatchers.Default) { + if (modelPath.isNotEmpty()) { + val file = File(modelPath) + if (file.exists() && file.isDirectory) { + engine.loadModel(context, modelPath) + } else false + } else false + } + + if (!modelLoaded) { + val errorDetail = engine.lastLoadError ?: "Please download the model package first." + throw IllegalStateException("Failed to load Stable Diffusion model. $errorDetail") + } + + updateNotification("Generating wallpaper...", -2) + + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val settings = Settings(prefs) + + // 2. Perform progressive generation on Dispatchers.Default + val rawTile = withContext(Dispatchers.Default) { + engine.generateTileProgressively( + prompt = prompt, + steps = 20, + seed = -1, + supportTransparency = false + ) { step, total -> + val progress = step.toFloat() / total.toFloat() + _state.value = GenerationState.Generating(progress, step, total) + updateNotification("Generating image: step $step/$total", step, total) + } + } + + if (rawTile == null) { + throw IllegalStateException("MediaPipe AI generator returned a null bitmap.") + } + + // 3. Upscale & repeat tile to wallpaper size + val scaleFactor = settings.scaleFactor + val scaledSize = rawTile.width * scaleFactor + val generatedTile = if (scaleFactor > 1) { + rawTile.scale(scaledSize, scaledSize) + } else { + rawTile + } + + val tiledBmp = WallpaperGenerator.renderTiledWallpaper(context, generatedTile) + + // 4. Apply post-processing filter + val wallpaperBmp = if (settings.postProcessingFilter != "none") { + WallpaperGenerator.applyPostProcessing(tiledBmp, settings.postProcessingFilter) + } else { + tiledBmp + } + + // 5. Update wallpaper and database + val autoApply = if (intent?.hasExtra(EXTRA_AUTO_APPLY) == true) { + intent.getBooleanExtra(EXTRA_AUTO_APPLY, true) + } else { + settings.autoApplyWallpaper + } + + if (autoApply) { + GalleryWall.updateWallpaper(context, wallpaperBmp) + } + + val filePath = GalleryWall.recordWallpaperSync(context, wallpaperBmp, applied = autoApply) + + // Notify UI to update recents list (moved inside recordWallpaperSync) + // context.sendBroadcast(Intent("com.baysoft.gallerywall.WALLPAPER_SET")) + + if (!autoApply && settings.notification) { + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val notification = GalleryWallNotifications.buildRefreshNotification( + context, + wallpaperBmp, + filePath = filePath, + isAlreadyApplied = false + ) + notificationManager.notify(GalleryWallNotifications.NOTIFICATION_ID, notification) + } + + _state.value = GenerationState.Success(filePath ?: "") + stopForeground(true) + stopSelf() + + } catch (e: CancellationException) { + Log.i(TAG, "Image generation cancelled") + } catch (e: Exception) { + Log.e(TAG, "Generation service failed", e) + _state.value = GenerationState.Error(e.message ?: "Image generation failed") + showErrorNotification(e.message ?: "Image generation failed") + stopSelf() + } + } + + return START_NOT_STICKY + } + + override fun onDestroy() { + super.onDestroy() + activeJob?.cancel() + serviceScope.cancel() + } + + private fun buildNotification(contentText: String, progress: Int = -1, max: Int = -1): Notification { + val pendingIntent = PendingIntent.getActivity( + this, 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE + ) + + val builder = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("GalleryWall On-Device AI") + .setContentText(contentText) + .setSmallIcon(R.drawable.icon_notification) + .setContentIntent(pendingIntent) + .setOngoing(true) + + if (max > 0 && progress >= 0) { + builder.setProgress(max, progress, false) + } else if (progress == -2) { + builder.setProgress(0, 0, true) + } + + return builder.build() + } + + private fun updateNotification(text: String, progress: Int = -1, max: Int = -1) { + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(NOTIFICATION_ID, buildNotification(text, progress, max)) + } + + private fun showErrorNotification(message: String) { + val pendingIntent = PendingIntent.getActivity( + this, 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("GalleryWall AI Failed") + .setContentText(message) + .setSmallIcon(R.drawable.icon_notification) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .build() + + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(NOTIFICATION_ID + 1, notification) + } + + private fun createNotificationChannel() { + val name = "AI Image Generation" + val descriptionText = "Notifications for on-device AI wallpaper generation progress" + val importance = NotificationManager.IMPORTANCE_LOW + val channel = NotificationChannel(CHANNEL_ID, name, importance).apply { + description = descriptionText + } + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } +} diff --git a/app/src/main/java/com/baysoft/gallerywall/ImageProvider.kt b/app/src/main/java/com/baysoft/gallerywall/ImageProvider.kt deleted file mode 100644 index 322f695..0000000 --- a/app/src/main/java/com/baysoft/gallerywall/ImageProvider.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.baysoft.gallerywall - -import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory -import retrofit2.http.GET -import retrofit2.http.Query - -data class PixabayResultItem( - val imageURL: String, - val tags: String -) - -data class PixabayResult( - val total: Int, - val hits: List -) - -interface ServiceApi { - @GET("api") - suspend fun loadPixabay( - @Query("key") key: String, - @Query("q") query: String, - @Query("response_group") group: String = "high_resolution", - @Query("min_width") width: Long = 1000, - @Query("min_height") height: Long = 1000 - ): PixabayResult? -} - -class ImageProvider { - companion object { - val serviceApi: ServiceApi - get() { - val retrofit = Retrofit.Builder() - .baseUrl("https://pixabay.com/") - .addConverterFactory(GsonConverterFactory.create()) - .build() - return retrofit.create(ServiceApi::class.java) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/baysoft/gallerywall/MainActivity.kt b/app/src/main/java/com/baysoft/gallerywall/MainActivity.kt index 9bae6e6..13b8c8b 100644 --- a/app/src/main/java/com/baysoft/gallerywall/MainActivity.kt +++ b/app/src/main/java/com/baysoft/gallerywall/MainActivity.kt @@ -1,14 +1,23 @@ package com.baysoft.gallerywall import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import com.baysoft.gallerywall.ui.MainScreen +import com.baysoft.gallerywall.ui.theme.GalleryWallTheme - -class MainActivity : AppCompatActivity() { +/** + * Main application Entry Activity. Launches fully themed Material 3 Jetpack Compose UI. + */ +class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) + + setContent { + GalleryWallTheme { + MainScreen() + } + } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/baysoft/gallerywall/PromptFilter.kt b/app/src/main/java/com/baysoft/gallerywall/PromptFilter.kt new file mode 100644 index 0000000..158010f --- /dev/null +++ b/app/src/main/java/com/baysoft/gallerywall/PromptFilter.kt @@ -0,0 +1,51 @@ +package com.baysoft.gallerywall + +import android.util.Base64 +import java.util.regex.Pattern + +object PromptFilter { + + private const val ENCODED_DICT = "KTQoKWk8ODQyJWA8MCI8Yz0/JjEpLj41NTomZyArKDUlL2k6PDEjICVgLDUqOiw8eDYoPSkmJj0qOTY8aywtPzEnKz02NSlgODU8JjkxOCxqOSk6LCFzPSQ+Li8tYCQ8LCx7MTk/LCl+LT81NSwyOmA3KTsrJDcwNG0pPiAxLTY4L2AuMz89K2EyODApLTV4Jj0tOzY8ayUpLyQiMCs2NSlgOzkhIig9Njo0LCh4KCcrIik4MyQoYCI9Kzp7MiAtKjc6OygieCkvPyUnIDErIio3azI5JSY7PTp7IiMvPjk8KmE4MS0pICJ4KDcrI2k6NSAvJ2k8OC00LjglPCN+Iz40eDolOjg1NitzJSQjLm0qLTYxMCwjbT4tPDkhO2EjIS80LCE1JjssP2ktIjM+Izc7Kit7JDQ4LTU/Jj4keDMvJysxNzs6ZzA3IyQ+OyAzK3MjKSMiOHwmID08MSw1" + private const val KEY = "GALLERY_WALL_PROMPT_FILTER_KEY" + + private val patterns: List by lazy { + val decodedBytes = Base64.decode(ENCODED_DICT, Base64.DEFAULT) + val xorBytes = ByteArray(decodedBytes.size) + val keyBytes = KEY.toByteArray() + for (i in decodedBytes.indices) { + xorBytes[i] = (decodedBytes[i].toInt() xor keyBytes[i % keyBytes.size].toInt()).toByte() + } + val words = String(xorBytes).split(",") + words.map { word -> + // Create a regex that allows for optional non-word characters (and underscores) between letters. + // Each letter is mapped to a character class containing common leetspeak substitutions. + val regex = word.map { char -> + when (char) { + 'a' -> "[a4@]" + 'e' -> "[e3]" + 'i' -> "[i1!]" + 'o' -> "[o0]" + 's' -> "[s5\$]" + 't' -> "[t7]" + 'b' -> "[b8]" + 'u' -> "[u3v]" + else -> Pattern.quote(char.toString()) + } + }.joinToString("[\\W_]*") + Pattern.compile("\\b$regex\\b", Pattern.CASE_INSENSITIVE) + } + } + + /** + * Checks if the given prompt contains any inappropriate content. + */ + fun containsInappropriateContent(prompt: String): Boolean { + if (prompt.isBlank()) return false + + val lowercasePrompt = prompt.lowercase() + + return patterns.any { pattern -> + pattern.matcher(lowercasePrompt).find() + } + } +} diff --git a/app/src/main/java/com/baysoft/gallerywall/Settings.kt b/app/src/main/java/com/baysoft/gallerywall/Settings.kt index 5508014..8f6a466 100644 --- a/app/src/main/java/com/baysoft/gallerywall/Settings.kt +++ b/app/src/main/java/com/baysoft/gallerywall/Settings.kt @@ -1,25 +1,116 @@ package com.baysoft.gallerywall import android.content.SharedPreferences +import com.baysoft.gallerywall.provider.WallpaperProviderRegistry class Settings(private val preferences: SharedPreferences) { val period: Long - get() = preferences.getString(PREF_PERIOD, "0")?.toLong() ?: 0 + get() = preferences.getString(PREF_PERIOD, DEFAULT_PERIOD_MINUTES_STRING)?.toLong() + ?: DEFAULT_PERIOD_MINUTES - val query: String - get() = preferences.getString(PREF_QUERY, "cat")!! + /** When false, periodic refresh is not scheduled (master switch). */ + val autoWallpaperEnabled: Boolean + get() = preferences.getBoolean(PREF_AUTO_WALLPAPER_ENABLED, false) - val wifiOnly: Boolean - get() = preferences.getBoolean(PREF_WIFI_ONLY, false) + /** When false, wallpaper is generated but not applied until user confirms via notification. */ + val autoApplyWallpaper: Boolean + get() = preferences.getBoolean(PREF_AUTO_APPLY_WALLPAPER, true) + + val constraintWifi: Boolean + get() = preferences.getBoolean(PREF_CONSTRAINT_WIFI, true) + + val constraintCharging: Boolean + get() = preferences.getBoolean(PREF_CONSTRAINT_CHARGING, false) + + val constraintIdle: Boolean + get() = preferences.getBoolean(PREF_CONSTRAINT_IDLE, false) + + val constraintBatteryLow: Boolean + get() = preferences.getBoolean(PREF_CONSTRAINT_BATTERY_LOW, true) + + /** Comma-separated hex colors for [com.baysoft.gallerywall.provider.GradientProvider]. */ + val generatedColorsHex: String + get() = preferences.getString(PREF_GENERATED_COLORS, DEFAULT_GENERATED_COLORS) + ?: DEFAULT_GENERATED_COLORS val notification: Boolean get() = preferences.getBoolean(PREF_NOTIFICATION, true) + /** Selected wallpaper generator ([com.baysoft.gallerywall.provider.WallpaperProvider.id]). */ + val activeProviderId: String + get() { + val defaultId = if (BuildConfig.DEBUG) "random_color" else "local_ai" + return preferences.getString(PREF_WALLPAPER_PROVIDER, defaultId) ?: defaultId + } + + /** Absolute path of the gallery file last applied as system wallpaper (may be null). */ + val lastAppliedWallpaperPath: String? + get() = preferences.getString(PREF_LAST_APPLIED_WALLPAPER_PATH, null) + + val automationPrompt: String + get() = preferences.getString(PREF_AUTOMATION_PROMPT, DEFAULT_AUTOMATION_PROMPT) ?: DEFAULT_AUTOMATION_PROMPT + + val postProcessingFilter: String + get() = preferences.getString(PREF_POST_PROCESSING_FILTER, "none") ?: "none" + + val activeModelPath: String? + get() = preferences.getString(PREF_ACTIVE_MODEL_PATH, null) + + val periodUnit: String + get() = preferences.getString(PREF_PERIOD_UNIT, DEFAULT_PERIOD_UNIT) ?: DEFAULT_PERIOD_UNIT + + /** Scale factor applied to the native 64×64 model output (e.g. 2 → 128×128). */ + val scaleFactor: Int + get() = preferences.getInt(PREF_SCALE_FACTOR, DEFAULT_SCALE_FACTOR) + companion object { const val PREF_PERIOD = "pref_period" - const val PREF_WIFI_ONLY = "pref_wifi" - const val PREF_QUERY = "pref_query" + const val PREF_AUTO_WALLPAPER_ENABLED = "pref_auto_wallpaper_enabled" + const val PREF_AUTO_APPLY_WALLPAPER = "pref_auto_apply_wallpaper" + const val PREF_CONSTRAINT_WIFI = "pref_constraint_wifi" + const val PREF_CONSTRAINT_CHARGING = "pref_constraint_charging" + const val PREF_CONSTRAINT_IDLE = "pref_constraint_idle" + const val PREF_CONSTRAINT_BATTERY_LOW = "pref_constraint_battery_low" + const val PREF_GENERATED_COLORS = "pref_generated_colors" const val PREF_NOTIFICATION = "pref_notification" + const val PREF_WALLPAPER_PROVIDER = "pref_wallpaper_provider" + const val PREF_WALLPAPER_SOURCE_NAV = "pref_wallpaper_source_nav" + const val PREF_LAST_APPLIED_WALLPAPER_PATH = "pref_last_applied_wallpaper_path" + + const val PREF_AUTOMATION_PROMPT = "pref_automation_prompt" + const val PREF_POST_PROCESSING_FILTER = "pref_post_processing_filter" + const val PREF_ACTIVE_MODEL_PATH = "pref_active_model_path" + const val PREF_PERIOD_UNIT = "pref_period_unit" + const val PREF_SCALE_FACTOR = "pref_scale_factor" + + const val DEFAULT_GENERATED_COLORS = "#6750A4,#625B71,#7D5260" + const val DEFAULT_AUTOMATION_PROMPT = "seamless cute pastel floral pattern" + const val DEFAULT_PERIOD_UNIT = "HOURS" + const val DEFAULT_SCALE_FACTOR = 2 + + const val DEFAULT_PERIOD_MINUTES = 360L + const val DEFAULT_PERIOD_MINUTES_STRING = "360" + + /** + * One-time migration from list periods that included "Off" (0): splits master enablement from interval, + * removes 0, defaults interval when previously off. + */ + fun migrateLegacyPrefsIfNeeded(prefs: SharedPreferences) { + if (prefs.contains(PREF_AUTO_WALLPAPER_ENABLED)) return + val periodStr = prefs.getString(PREF_PERIOD, null) + val legacyPeriod = periodStr?.toLongOrNull() + val edit = prefs.edit() + when { + legacyPeriod == null || legacyPeriod == 0L -> { + edit.putBoolean(PREF_AUTO_WALLPAPER_ENABLED, false) + edit.putString(PREF_PERIOD, DEFAULT_PERIOD_MINUTES_STRING) + } + else -> { + edit.putBoolean(PREF_AUTO_WALLPAPER_ENABLED, true) + } + } + edit.apply() + } } } diff --git a/app/src/main/java/com/baysoft/gallerywall/SettingsFragment.kt b/app/src/main/java/com/baysoft/gallerywall/SettingsFragment.kt index 7659133..0c6863c 100644 --- a/app/src/main/java/com/baysoft/gallerywall/SettingsFragment.kt +++ b/app/src/main/java/com/baysoft/gallerywall/SettingsFragment.kt @@ -1,33 +1,118 @@ package com.baysoft.gallerywall import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import androidx.appcompat.app.AlertDialog +import androidx.preference.CheckBoxPreference import androidx.preference.ListPreference +import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat +import androidx.preference.SwitchPreferenceCompat +import androidx.preference.PreferenceManager +import androidx.navigation.fragment.findNavController +import com.baysoft.gallerywall.provider.WallpaperProviderRegistry class SettingsFragment : PreferenceFragmentCompat() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + } + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) + Settings.migrateLegacyPrefsIfNeeded(prefs) + setPreferencesFromResource(R.xml.root_preferences, rootKey) - findPreference(Settings.PREF_PERIOD)?.run { - setOnPreferenceChangeListener { _, newValue -> + findPreference(Settings.PREF_PERIOD)?.apply { + summaryProvider = Preference.SummaryProvider { pref -> + val entry = pref.entry ?: "" + getString(R.string.pref_period_summary_format, entry) + } + } - newValue.toString().toLongOrNull()?.run { - when (this) { - 0L -> GalleryWall.cancelSchedule(requireActivity()) - else -> { - GalleryWall.schedule(requireActivity(), this) - } - } + findPreference(Settings.PREF_AUTO_WALLPAPER_ENABLED)?.run { + updateDependentPrefs(isChecked) + setOnPreferenceChangeListener { _, newValue -> + val enabled = newValue as Boolean + updateDependentPrefs(enabled) + if (enabled) { + GalleryWall.schedule(requireContext()) + } else { + GalleryWall.cancelSchedule(requireContext()) } + true + } + } + + listOf( + Settings.PREF_CONSTRAINT_WIFI, + Settings.PREF_CONSTRAINT_CHARGING, + Settings.PREF_CONSTRAINT_IDLE, + Settings.PREF_CONSTRAINT_BATTERY_LOW, + ).forEach { key -> + findPreference(key)?.setOnPreferenceChangeListener { _, _ -> + rescheduleIfEnabled() + true + } + } + findPreference(Settings.PREF_PERIOD)?.setOnPreferenceChangeListener { _, _ -> + rescheduleIfEnabled() + true + } + + findPreference(Settings.PREF_WALLPAPER_SOURCE_NAV)?.setOnPreferenceClickListener { + findNavController().navigate(R.id.providersFragment) + true + } + } + + override fun onResume() { + super.onResume() + bindWallpaperSourceSummary() + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.menu_settings, menu) + super.onCreateOptionsMenu(menu, inflater) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.action_help -> { + AlertDialog.Builder(requireContext()) + .setTitle(R.string.settings_help_title) + .setMessage(R.string.settings_help_message) + .setPositiveButton(android.R.string.ok, null) + .show() true } + else -> super.onOptionsItemSelected(item) } + } - } + private fun rescheduleIfEnabled() { + val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) + if (Settings(prefs).autoWallpaperEnabled) { + GalleryWall.schedule(requireContext()) + } + } + + private fun updateDependentPrefs(masterEnabled: Boolean) { + findPreference(Settings.PREF_PERIOD)?.isEnabled = masterEnabled + } - private fun clearPrefs() { - preferenceManager.sharedPreferences?.edit()?.clear()?.apply() + private fun bindWallpaperSourceSummary() { + val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) + val settings = Settings(prefs) + val provider = WallpaperProviderRegistry.get(settings.activeProviderId) + ?: WallpaperProviderRegistry.defaultProvider + val name = getString(provider.titleRes) + findPreference(Settings.PREF_WALLPAPER_SOURCE_NAV)?.summary = + getString(R.string.pref_wallpaper_source_summary_format, name) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/baysoft/gallerywall/WallpaperGenerator.kt b/app/src/main/java/com/baysoft/gallerywall/WallpaperGenerator.kt new file mode 100644 index 0000000..fb235d5 --- /dev/null +++ b/app/src/main/java/com/baysoft/gallerywall/WallpaperGenerator.kt @@ -0,0 +1,144 @@ +package com.baysoft.gallerywall + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.ColorMatrix +import android.graphics.ColorMatrixColorFilter +import android.graphics.LinearGradient +import android.graphics.Paint +import android.graphics.RenderEffect +import android.graphics.Shader + +/** + * Shared rendering helpers for [com.baysoft.gallerywall.provider.WallpaperProvider] implementations. + */ +object WallpaperGenerator { + + /** Hex string `#RRGGBB` for prefs/UI (opaque). */ + fun colorToHexString(rgb: Int): String = + String.format("#%06X", 0xFFFFFF and rgb) + + internal fun parseColors(hexLine: String): List { + val parsed = hexLine.split(',').mapNotNull { segment -> + val s = segment.trim() + if (s.isEmpty()) return@mapNotNull null + try { + android.graphics.Color.parseColor(if (s.startsWith("#")) s else "#$s") + } catch (_: IllegalArgumentException) { + null + } + } + return parsed.ifEmpty { defaultColors() } + } + + private fun defaultColors(): List { + return listOf( + android.graphics.Color.parseColor("#6750A4"), + android.graphics.Color.parseColor("#625B71"), + android.graphics.Color.parseColor("#7D5260") + ) + } + + internal fun resolveSize(context: Context): Pair { + val dm = context.resources.displayMetrics + val w = dm.widthPixels.coerceAtLeast(480) + val h = dm.heightPixels.coerceAtLeast(800) + return w to h + } + + internal fun renderSolid(w: Int, h: Int, color: Int): Bitmap { + val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) + Canvas(bmp).drawColor(color) + return bmp + } + + internal fun renderGradient(w: Int, h: Int, colors: List): Bitmap { + val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + val stops = when { + colors.isEmpty() -> defaultColors() + colors.size == 1 -> listOf(colors[0], colors[0]) + else -> colors + } + val arr = stops.toIntArray() + val positions = gradientPositions(stops.size) + val shader = LinearGradient( + 0f, + 0f, + 0f, + h.toFloat(), + arr, + positions, + Shader.TileMode.CLAMP + ) + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { setShader(shader) } + canvas.drawPaint(paint) + return bmp + } + + internal fun renderTiledWallpaper(context: Context, tile: Bitmap): Bitmap { + val (w, h) = resolveSize(context) + val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + val paint = Paint().apply { + isAntiAlias = false + isFilterBitmap = false + shader = android.graphics.BitmapShader(tile, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT) + } + canvas.drawRect(0f, 0f, w.toFloat(), h.toFloat(), paint) + return bmp + } + + private fun gradientPositions(n: Int): FloatArray { + if (n < 2) return floatArrayOf(0f, 1f) + return FloatArray(n) { i -> i.toFloat() / (n - 1) } + } + + /** Applies post-processing filter to the [bitmap]. */ + fun applyPostProcessing(bitmap: Bitmap, filter: String): Bitmap { + return when (filter) { + "bw" -> applyColorMatrix(bitmap, ColorMatrix().apply { setSaturation(0f) }) + "sepia" -> applyColorMatrix(bitmap, ColorMatrix().apply { + setSaturation(0f) + postConcat(ColorMatrix(floatArrayOf( + 1f, 0f, 0f, 0f, 30f, + 0f, 1f, 0f, 0f, 0f, + 0f, 0f, 0.8f, 0f, 0f, + 0f, 0f, 0f, 1f, 0f + ))) + }) + "invert" -> applyColorMatrix(bitmap, ColorMatrix(floatArrayOf( + -1f, 0f, 0f, 0f, 255f, + 0f, -1f, 0f, 0f, 255f, + 0f, 0f, -1f, 0f, 255f, + 0f, 0f, 0f, 1f, 0f + ))) + "blur" -> { + val outBmp = Bitmap.createBitmap(bitmap.width, bitmap.height, bitmap.config ?: Bitmap.Config.ARGB_8888) + val canvas = Canvas(outBmp) + val paint = Paint() + try { + val method = paint.javaClass.getMethod("setRenderEffect", RenderEffect::class.java) + method.invoke(paint, RenderEffect.createBlurEffect(15f, 15f, Shader.TileMode.CLAMP)) + } catch (e: Exception) { + // Fallback or ignore if reflection fails + } + canvas.drawBitmap(bitmap, 0f, 0f, paint) + outBmp + } + else -> bitmap + } + } + + private fun applyColorMatrix(bitmap: Bitmap, matrix: ColorMatrix): Bitmap { + val outBmp = Bitmap.createBitmap(bitmap.width, bitmap.height, bitmap.config ?: Bitmap.Config.ARGB_8888) + val canvas = Canvas(outBmp) + val paint = Paint().apply { + colorFilter = ColorMatrixColorFilter(matrix) + } + canvas.drawBitmap(bitmap, 0f, 0f, paint) + return outBmp + } +} + diff --git a/app/src/main/java/com/baysoft/gallerywall/data/WallpaperDao.kt b/app/src/main/java/com/baysoft/gallerywall/data/WallpaperDao.kt new file mode 100644 index 0000000..8f5f544 --- /dev/null +++ b/app/src/main/java/com/baysoft/gallerywall/data/WallpaperDao.kt @@ -0,0 +1,21 @@ +package com.baysoft.gallerywall.data + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface WallpaperDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(wallpaper: WallpaperEntity) + + @Query("SELECT * FROM wallpapers WHERE dateAdded >= :from ORDER BY dateAdded DESC") + suspend fun getRecentWallpapers(from: Long): List + + @Query("DELETE FROM wallpapers WHERE dateAdded < :olderThan") + suspend fun deleteOlderThan(olderThan: Long) + + @Query("DELETE FROM wallpapers WHERE id = :id") + suspend fun deleteById(id: Long) +} diff --git a/app/src/main/java/com/baysoft/gallerywall/data/WallpaperDatabase.kt b/app/src/main/java/com/baysoft/gallerywall/data/WallpaperDatabase.kt new file mode 100644 index 0000000..aa58795 --- /dev/null +++ b/app/src/main/java/com/baysoft/gallerywall/data/WallpaperDatabase.kt @@ -0,0 +1,26 @@ +package com.baysoft.gallerywall.data + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase + +@Database(entities = [WallpaperEntity::class], version = 2) +abstract class WallpaperDatabase : RoomDatabase() { + abstract fun wallpaperDao(): WallpaperDao + + companion object { + @Volatile private var INSTANCE: WallpaperDatabase? = null + + fun getInstance(context: Context): WallpaperDatabase = + INSTANCE ?: synchronized(this) { + INSTANCE ?: Room.databaseBuilder( + context.applicationContext, + WallpaperDatabase::class.java, + "wallpaper_db" + ) + .fallbackToDestructiveMigration() + .build().also { INSTANCE = it } + } + } +} diff --git a/app/src/main/java/com/baysoft/gallerywall/data/WallpaperEntity.kt b/app/src/main/java/com/baysoft/gallerywall/data/WallpaperEntity.kt new file mode 100644 index 0000000..42ad721 --- /dev/null +++ b/app/src/main/java/com/baysoft/gallerywall/data/WallpaperEntity.kt @@ -0,0 +1,13 @@ +package com.baysoft.gallerywall.data + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "wallpapers") +data class WallpaperEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val filePath: String, + val dateAdded: Long, // Store as epoch millis + val providerId: String = "", + val prompt: String = "" +) diff --git a/app/src/main/java/com/baysoft/gallerywall/data/WallpaperRepository.kt b/app/src/main/java/com/baysoft/gallerywall/data/WallpaperRepository.kt new file mode 100644 index 0000000..73bc685 --- /dev/null +++ b/app/src/main/java/com/baysoft/gallerywall/data/WallpaperRepository.kt @@ -0,0 +1,30 @@ +package com.baysoft.gallerywall.data + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + + +class WallpaperRepository(private val dao: WallpaperDao) { + suspend fun addWallpaper(filePath: String, providerId: String, prompt: String) { + val now = System.currentTimeMillis() + dao.insert(WallpaperEntity(filePath = filePath, dateAdded = now, providerId = providerId, prompt = prompt)) + } + + suspend fun getRecentWallpapers(): List { + val oneMonthAgo = System.currentTimeMillis() - 30L * 24 * 60 * 60 * 1000 + return dao.getRecentWallpapers(oneMonthAgo) + } + + suspend fun cleanupOldWallpapers() { + val threeMonthsAgo = System.currentTimeMillis() - 90L * 24 * 60 * 60 * 1000 + dao.deleteOlderThan(threeMonthsAgo) + } + + suspend fun deleteWallpaper(wallpaper: WallpaperEntity) { + withContext(Dispatchers.IO) { + dao.deleteById(wallpaper.id) + File(wallpaper.filePath).delete() + } + } +} diff --git a/app/src/main/java/com/baysoft/gallerywall/ml/LocalMLEngine.kt b/app/src/main/java/com/baysoft/gallerywall/ml/LocalMLEngine.kt new file mode 100644 index 0000000..d24f1ce --- /dev/null +++ b/app/src/main/java/com/baysoft/gallerywall/ml/LocalMLEngine.kt @@ -0,0 +1,327 @@ +package com.baysoft.gallerywall.ml + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Color +import android.util.Log +import com.google.mediapipe.framework.image.BitmapExtractor +import com.google.mediapipe.tasks.vision.imagegenerator.ImageGenerator +import com.google.mediapipe.tasks.vision.imagegenerator.ImageGenerator.ImageGeneratorOptions +import java.io.File + +/** + * On-device ML image generator interface using Google AI Edge MediaPipe Tasks Vision. + * Loads a compatible Stable Diffusion v1.5 model directory to generate images + * locally from text prompts. + */ +class LocalMLEngine private constructor() { + + private var imageGenerator: ImageGenerator? = null + private var loadedModelPath: String? = null + var lastLoadError: String? = null + + /** + * Loads the MediaPipe Image Generator using the given local directory path. + */ + fun loadModel(context: Context, modelPath: String): Boolean { + val baseDir = File(modelPath) + if (!baseDir.exists()) { + Log.e(TAG, "Model directory does not exist at: $modelPath") + lastLoadError = "Model directory does not exist at: $modelPath" + return false + } + + // Dynamically locate the nested directory containing the actual model files (e.g. bpe_simple_vocab_16e6.txt) + val resolvedDir = findModelDirectory(baseDir) ?: baseDir + val resolvedPath = resolvedDir.absolutePath + Log.i(TAG, "loadModel: Requested path = $modelPath | Resolved path = $resolvedPath") + + // Validate that all critical model files are present before initializing the native runner + val validationError = validateModelFiles(resolvedDir) + if (validationError != null) { + Log.e(TAG, "Validation failed for model folder: $validationError") + lastLoadError = "Invalid model structure: $validationError. Ensure the model is a fully converted SD 1.5 directory (with all .bin weights and bpe_simple_vocab_16e6.txt)." + return false + } + + if (loadedModelPath == resolvedPath && imageGenerator != null) { + return true + } + + return try { + // Close existing generator to release native resources + imageGenerator?.close() + imageGenerator = null + + val options = ImageGeneratorOptions.builder() + .setImageGeneratorModelDirectory(resolvedPath) + .build() + imageGenerator = ImageGenerator.createFromOptions(context, options) + loadedModelPath = resolvedPath + Log.i(TAG, "MediaPipe Image Generator successfully loaded from path: $resolvedPath") + lastLoadError = null + true + } catch (e: Exception) { + Log.e(TAG, "Error loading MediaPipe Image Generator from: $resolvedPath", e) + val msg = e.toString() + lastLoadError = if (msg.contains("OpenCL") || msg.contains("clSetPerfHintQCOM") || msg.contains("CalculatorGraph::Run") || msg.contains("FAILED_PRECONDITION")) { + "Incompatible hardware: MediaPipe Stable Diffusion requires a physical Android GPU with OpenCL support. Emulators are not supported." + } else { + "Failed to initialize MediaPipe: ${e.localizedMessage}" + } + false + } + } + + /** + * Recursively searches for the directory containing the BPE vocabulary file (indicating the root model weights folder). + */ + private fun findModelDirectory(dir: File): File? { + if (!dir.exists() || !dir.isDirectory) return null + + val vocab = File(dir, "bpe_simple_vocab_16e6.txt") + if (vocab.exists()) { + return dir + } + + val children = dir.listFiles() ?: return null + for (child in children) { + if (child.isDirectory) { + val found = findModelDirectory(child) + if (found != null) { + return found + } + } + } + return null + } + + /** + * Validates that critical files exist and are non-empty to prevent native crashes. + */ + private fun validateModelFiles(dir: File): String? { + val requiredFiles = listOf( + "bpe_simple_vocab_16e6.txt", + "cond_stage_model.transformer.text_model.embeddings.token_embedding.weight.bin", + "model.diffusion_model.input_blocks.0.0.weight.bin", + "first_stage_model.decoder.conv_out.weight.bin" + ) + + for (filename in requiredFiles) { + val file = File(dir, filename) + if (!file.exists()) { + return "Missing required model file: $filename" + } + if (file.length() == 0L) { + return "Model file is empty: $filename" + } + } + return null + } + + /** + * Legacy helper to satisfy existing structure checks. + * MediaPipe Image Generator does not support direct asset folder TFLite loading for SD. + */ + fun loadModelFromAsset(context: Context, assetName: String): Boolean { + Log.w(TAG, "MediaPipe Image Generator does not support direct asset loading for Stable Diffusion v1.5.") + return false + } + + /** + * Generates a square seamless pixel-art tile bitmap based on a text prompt + * using MediaPipe's iterative Image Generator API, reporting progress step by step. + */ + fun generateTileProgressively( + prompt: String, + steps: Int = 20, + seed: Int = -1, + supportTransparency: Boolean = true, + onProgress: (step: Int, totalSteps: Int) -> Unit + ): Bitmap? { + val modelName = loadedModelPath?.let { File(it).name } ?: "Default" + Log.d(TAG, "ML progressive generating process started | Model: $modelName | Seed: $seed | Steps: $steps") + + val startTime = System.currentTimeMillis() + var generatedTile: Bitmap? = null + + try { + val generator = imageGenerator + if (generator == null) { + Log.e(TAG, "Cannot generate: MediaPipe Image Generator is not loaded!") + return null + } + + val actualSeed = if (seed == -1) (0..Int.MAX_VALUE).random() else seed + + // 1. Initialize inputs for iterative generation + generator.setInputs(prompt, steps, actualSeed) + + var rawBitmap: Bitmap? = null + + // 2. Loop through iterations + for (step in 0 until steps) { + // Check intermediate result request: showResult = true ONLY on the last step + // to save substantial device resources and prevent unnecessary CPU/GPU overhead. + val isLast = (step == steps - 1) + + onProgress(step + 1, steps) + + val result = generator.execute(isLast) + + if (isLast) { + val mpImage = result?.generatedImage() ?: run { + Log.e(TAG, "ImageGenerator returned null generatedImage at final step") + return null + } + // Extract native Bitmap from MPImage + rawBitmap = BitmapExtractor.extract(mpImage) + } + } + + if (rawBitmap == null) { + Log.e(TAG, "Failed to extract Bitmap from MPImage") + return null + } + + if (supportTransparency) { + generatedTile = rawBitmap + } else { + // Force alpha channel to opaque (255) if transparency is disabled + val width = rawBitmap.width + val height = rawBitmap.height + val pixelCount = height * width + val pixels = IntArray(pixelCount) + rawBitmap.getPixels(pixels, 0, width, 0, 0, width, height) + + for (i in 0 until pixelCount) { + val color = pixels[i] + val r = Color.red(color) + val g = Color.green(color) + val b = Color.blue(color) + pixels[i] = (255 shl 24) or (r shl 16) or (g shl 8) or b + } + + generatedTile = Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888) + } + + } catch (e: Exception) { + Log.e(TAG, "MediaPipe Image Generator generation failed", e) + throw e + } + + val duration = System.currentTimeMillis() - startTime + if (generatedTile != null) { + Log.d(TAG, "ML progressive generating process SUCCEEDED | Duration: ${duration}ms | Size: ${generatedTile.width}x${generatedTile.height}") + } else { + Log.e(TAG, "ML progressive generating process FAILED | Duration: ${duration}ms") + } + + return generatedTile + } + + /** + * Generates a square seamless pixel-art tile bitmap based on a text prompt + * using MediaPipe's synchronous Image Generator API. + */ + fun generateTile( + prompt: String, + size: Int = 32, + steps: Int = 20, + seed: Int = -1, + circular: Boolean = true, + supportTransparency: Boolean = true + ): Bitmap? { + val modelName = loadedModelPath?.let { File(it).name } ?: "Default" + Log.d(TAG, "ML generating process started | Model: $modelName | Seed: $seed | Steps: $steps") + + val startTime = System.currentTimeMillis() + var generatedTile: Bitmap? = null + + try { + val generator = imageGenerator + if (generator == null) { + Log.e(TAG, "Cannot generate: MediaPipe Image Generator is not loaded!") + return null + } + + val actualSeed = if (seed == -1) (0..Int.MAX_VALUE).random() else seed + + // Run MediaPipe Image Generator (synchronous end-to-end generate) + val result = generator.generate(prompt, steps, actualSeed) + val mpImage = result.generatedImage() ?: run { + Log.e(TAG, "ImageGenerator returned null generatedImage") + return null + } + + // Extract native Bitmap from MPImage + val rawBitmap = BitmapExtractor.extract(mpImage) ?: run { + Log.e(TAG, "Failed to extract Bitmap from MPImage") + return null + } + + if (supportTransparency) { + generatedTile = rawBitmap + } else { + // Force alpha channel to opaque (255) if transparency is disabled + val width = rawBitmap.width + val height = rawBitmap.height + val pixelCount = height * width + val pixels = IntArray(pixelCount) + rawBitmap.getPixels(pixels, 0, width, 0, 0, width, height) + + for (i in 0 until pixelCount) { + val color = pixels[i] + val r = Color.red(color) + val g = Color.green(color) + val b = Color.blue(color) + pixels[i] = (255 shl 24) or (r shl 16) or (g shl 8) or b + } + + generatedTile = Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888) + } + + } catch (e: Exception) { + Log.e(TAG, "MediaPipe Image Generator generation failed", e) + throw e + } + + val duration = System.currentTimeMillis() - startTime + if (generatedTile != null) { + Log.d(TAG, "ML generating process SUCCEEDED | Duration: ${duration}ms | Size: ${generatedTile.width}x${generatedTile.height}") + } else { + Log.e(TAG, "ML generating process FAILED | Duration: ${duration}ms") + } + + return generatedTile + } + + /** + * Unloads the current model and releases all native resources. + */ + fun unloadModel() { + synchronized(this) { + try { + imageGenerator?.close() + Log.i(TAG, "MediaPipe Image Generator successfully closed/unloaded.") + } catch (e: Exception) { + Log.e(TAG, "Error closing MediaPipe Image Generator", e) + } + imageGenerator = null + loadedModelPath = null + } + } + + companion object { + private const val TAG = "LocalMLEngine" + + @Volatile + private var instance: LocalMLEngine? = null + + fun getInstance(): LocalMLEngine { + return instance ?: synchronized(this) { + instance ?: LocalMLEngine().also { instance = it } + } + } + } +} diff --git a/app/src/main/java/com/baysoft/gallerywall/provider/ColorProvider.kt b/app/src/main/java/com/baysoft/gallerywall/provider/ColorProvider.kt new file mode 100644 index 0000000..c533992 --- /dev/null +++ b/app/src/main/java/com/baysoft/gallerywall/provider/ColorProvider.kt @@ -0,0 +1,71 @@ +package com.baysoft.gallerywall.provider + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.LinearGradient +import android.graphics.Paint +import android.graphics.Shader +import android.util.Log +import com.baysoft.gallerywall.R +import java.util.Random + +/** + * Debug-only wallpaper provider that generates a random color gradient wallpaper. + * Visible and set as default when the app is built in debug mode. + */ +object ColorProvider : WallpaperProvider { + private const val TAG = "ColorProvider" + + override val id: String = "random_color" + + override val titleRes: Int = R.string.provider_color_title + override val summaryRes: Int = R.string.provider_color_summary + + override fun generateBitmap(context: Context, onStateUpdate: (ProviderState) -> Unit): Bitmap { + for (i in 1..5) { + val progress = i / 5.0f + onStateUpdate(DefaultProviderState( + progress = progress, + message = "Generating gradient: step $i/5" + )) + try { + Thread.sleep(1000) + } catch (e: InterruptedException) { + // ignore + } + } + + val metrics = context.resources.displayMetrics + val width = metrics.widthPixels.coerceAtLeast(1080) + val height = metrics.heightPixels.coerceAtLeast(1920) + + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + + val rng = Random() + val color1 = (0xFF000000 or rng.nextInt(0x1000000).toLong()).toInt() + val color2 = (0xFF000000 or rng.nextInt(0x1000000).toLong()).toInt() + val color3 = (0xFF000000 or rng.nextInt(0x1000000).toLong()).toInt() + + val paint = Paint() + paint.shader = LinearGradient( + 0f, 0f, width.toFloat(), height.toFloat(), + intArrayOf(color1, color2, color3), + null, + Shader.TileMode.CLAMP + ) + canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint) + + Log.i(TAG, "Generated random gradient wallpaper: ${width}×${height} " + + "colors=#${Integer.toHexString(color1)}, #${Integer.toHexString(color2)}, #${Integer.toHexString(color3)}") + + val finalState = DefaultProviderState( + progress = 1.0f, + result = bitmap, + message = "Done" + ) + onStateUpdate(finalState) + return bitmap + } +} diff --git a/app/src/main/java/com/baysoft/gallerywall/provider/LocalAIProvider.kt b/app/src/main/java/com/baysoft/gallerywall/provider/LocalAIProvider.kt new file mode 100644 index 0000000..7e9b846 --- /dev/null +++ b/app/src/main/java/com/baysoft/gallerywall/provider/LocalAIProvider.kt @@ -0,0 +1,92 @@ +package com.baysoft.gallerywall.provider + +import android.content.Context +import android.graphics.Bitmap +import android.util.Log +import androidx.preference.PreferenceManager +import com.baysoft.gallerywall.R +import com.baysoft.gallerywall.Settings +import com.baysoft.gallerywall.WallpaperGenerator +import com.baysoft.gallerywall.ml.LocalMLEngine + +/** + * On-device local AI wallpaper generator. Uses stable-diffusion.cpp to synthesize a seamless + * tile bitmap from a prompt, and tiles it to cover the screen. + * Falls back to mathematical NoiseGenerator if no model is loaded. + */ +object LocalAIProvider : WallpaperProvider { + private const val TAG = "LocalAIProvider" + + override val id: String = "local_ai" + + override val titleRes: Int = R.string.provider_ai_title + override val summaryRes: Int = R.string.provider_ai_summary + + override fun generateBitmap(context: Context, onStateUpdate: (ProviderState) -> Unit): Bitmap { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val settings = Settings(prefs) + + val promptTemplate = settings.automationPrompt + val activeModelPath = settings.activeModelPath + + // 1. Resolve dynamic prompt placeholders + val prompt = promptTemplate + Log.i(TAG, "Generating AI wallpaper. Prompt: '$prompt'") + + // 2. Load MediaPipe Image Generator model directory + val engine = LocalMLEngine.getInstance() + var modelLoaded = false + + onStateUpdate(DefaultProviderState(progress = 0.0f, message = "Loading AI model directory...")) + if (!activeModelPath.isNullOrEmpty()) { + val modelDir = java.io.File(activeModelPath) + if (modelDir.exists() && modelDir.isDirectory) { + modelLoaded = engine.loadModel(context, activeModelPath) + } + } + + if (!modelLoaded) { + val error = IllegalStateException("MediaPipe on-device AI generation failed: No model directory configured, or directory failed to load. Please download a model from the Providers settings screen first.") + onStateUpdate(DefaultProviderState(progress = 0.0f, error = error, message = error.message)) + throw error + } + + // 3. Generate on-device image using MediaPipe diffusion progressive / step updates + onStateUpdate(DefaultProviderState(progress = 0.1f, message = "Generating wallpaper...")) + + val steps = 20 + val rawTile = engine.generateTileProgressively( + prompt = prompt, + steps = steps, + seed = -1, + supportTransparency = true + ) { step, total -> + val progress = 0.1f + 0.8f * (step.toFloat() / total.toFloat()) + onStateUpdate(DefaultProviderState( + progress = progress, + message = "Generating image: step $step/$total" + )) + } ?: run { + val error = IllegalStateException("MediaPipe AI generation failed: Engine returned a null bitmap.") + onStateUpdate(DefaultProviderState(progress = 1.0f, error = error, message = error.message)) + throw error + } + + onStateUpdate(DefaultProviderState(progress = 0.95f, message = "Upscaling and rendering...")) + + // 4. Upscale tile by the configured scale factor (e.g. 2×) + val scaleFactor = settings.scaleFactor + val scaledSize = rawTile.width * scaleFactor + val generatedTile = if (scaleFactor > 1) { + Bitmap.createScaledBitmap(rawTile, scaledSize, scaledSize, true) + } else { + rawTile + } + + // 5. Repeat tile perfectly across the screen dimensions + val wallpaperBmp = WallpaperGenerator.renderTiledWallpaper(context, generatedTile) + + onStateUpdate(DefaultProviderState(progress = 1.0f, result = wallpaperBmp, message = "Done")) + return wallpaperBmp + } +} diff --git a/app/src/main/java/com/baysoft/gallerywall/provider/WallpaperProvider.kt b/app/src/main/java/com/baysoft/gallerywall/provider/WallpaperProvider.kt new file mode 100644 index 0000000..be8c713 --- /dev/null +++ b/app/src/main/java/com/baysoft/gallerywall/provider/WallpaperProvider.kt @@ -0,0 +1,41 @@ +package com.baysoft.gallerywall.provider + +import android.content.Context +import android.graphics.Bitmap +import androidx.annotation.StringRes + +/** + * State representing the wallpaper generation process. + */ +interface ProviderState { + val progress: Float + val result: Bitmap? + val error: Throwable? + val message: String? +} + +/** + * Default implementation of [ProviderState]. + */ +data class DefaultProviderState( + override val progress: Float, + override val result: Bitmap? = null, + override val error: Throwable? = null, + override val message: String? = null +) : ProviderState + +/** + * Strategy for generating a wallpaper bitmap from current preferences. + * Register new implementations in [WallpaperProviderRegistry]. + */ +interface WallpaperProvider { + val id: String + + @get:StringRes + val titleRes: Int + + @get:StringRes + val summaryRes: Int + + fun generateBitmap(context: Context, onStateUpdate: (ProviderState) -> Unit = {}): Bitmap +} diff --git a/app/src/main/java/com/baysoft/gallerywall/provider/WallpaperProviderRegistry.kt b/app/src/main/java/com/baysoft/gallerywall/provider/WallpaperProviderRegistry.kt new file mode 100644 index 0000000..5a03719 --- /dev/null +++ b/app/src/main/java/com/baysoft/gallerywall/provider/WallpaperProviderRegistry.kt @@ -0,0 +1,36 @@ +package com.baysoft.gallerywall.provider + +import com.baysoft.gallerywall.BuildConfig + +/** + * Registry of [WallpaperProvider] implementations. + * Allows dynamic registration of providers (useful for UI tests). + */ +object WallpaperProviderRegistry { + + private val providers = mutableListOf(LocalAIProvider) + + fun all(): List = synchronized(providers) { + providers.toList() + } + + fun get(id: String): WallpaperProvider? = synchronized(providers) { + providers.find { it.id == id } + } + + /** + * Registers a new provider if it's not already registered. + */ + fun register(provider: WallpaperProvider) = synchronized(providers) { + if (providers.none { it.id == provider.id }) { + providers.add(provider) + } + } + + fun unregister(provider: WallpaperProvider) = synchronized(providers) { + providers.removeAll { it.id == provider.id } + } + + val defaultProvider: WallpaperProvider + get() = if (BuildConfig.DEBUG) ColorProvider else LocalAIProvider +} diff --git a/app/src/main/java/com/baysoft/gallerywall/ui/AutomationScreen.kt b/app/src/main/java/com/baysoft/gallerywall/ui/AutomationScreen.kt new file mode 100644 index 0000000..1c061ce --- /dev/null +++ b/app/src/main/java/com/baysoft/gallerywall/ui/AutomationScreen.kt @@ -0,0 +1,489 @@ +package com.baysoft.gallerywall.ui + +import android.widget.Toast +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.preference.PreferenceManager +import com.baysoft.gallerywall.GalleryWall +import com.baysoft.gallerywall.Settings +import com.baysoft.gallerywall.ui.theme.GalleryWallTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AutomationScreen(modifier: Modifier = Modifier) { + val context = LocalContext.current + val isPreview = LocalInspectionMode.current + val prefs = remember { if (isPreview) null else PreferenceManager.getDefaultSharedPreferences(context) } + + // State bindings + var autoEnabled by remember { mutableStateOf(prefs?.getBoolean(Settings.PREF_AUTO_WALLPAPER_ENABLED, false) ?: false) } + var autoApply by remember { mutableStateOf(prefs?.getBoolean(Settings.PREF_AUTO_APPLY_WALLPAPER, true) ?: true) } + var promptTemplate by remember { mutableStateOf(prefs?.getString(Settings.PREF_AUTOMATION_PROMPT, Settings.DEFAULT_AUTOMATION_PROMPT) ?: Settings.DEFAULT_AUTOMATION_PROMPT) } + var periodValue by remember { mutableStateOf(prefs?.getString(Settings.PREF_PERIOD, "1h") ?: "1s") } + var periodUnit by remember { mutableStateOf(prefs?.getString(Settings.PREF_PERIOD_UNIT, Settings.DEFAULT_PERIOD_UNIT) ?: Settings.DEFAULT_PERIOD_UNIT) } + + var constraintWifi by remember { mutableStateOf(prefs?.getBoolean(Settings.PREF_CONSTRAINT_WIFI, true) ?: true) } + var constraintCharging by remember { mutableStateOf(prefs?.getBoolean(Settings.PREF_CONSTRAINT_CHARGING, false) ?: false) } + var constraintIdle by remember { mutableStateOf(prefs?.getBoolean(Settings.PREF_CONSTRAINT_IDLE, false) ?: false) } + var postProcessingFilter by remember { mutableStateOf(prefs?.getString(Settings.PREF_POST_PROCESSING_FILTER, "none") ?: "none") } + + // Helper to persist and reschedule WorkManager tasks + val saveAndReschedule = { + if (!isPreview && prefs != null) { + val editor = prefs.edit() + editor.putBoolean(Settings.PREF_AUTO_WALLPAPER_ENABLED, autoEnabled) + editor.putBoolean(Settings.PREF_AUTO_APPLY_WALLPAPER, autoApply) + editor.putString(Settings.PREF_AUTOMATION_PROMPT, promptTemplate) + editor.putString(Settings.PREF_PERIOD, periodValue) + editor.putString(Settings.PREF_PERIOD_UNIT, periodUnit) + editor.putBoolean(Settings.PREF_CONSTRAINT_WIFI, constraintWifi) + editor.putBoolean(Settings.PREF_CONSTRAINT_CHARGING, constraintCharging) + editor.putBoolean(Settings.PREF_CONSTRAINT_IDLE, constraintIdle) + editor.putString(Settings.PREF_POST_PROCESSING_FILTER, postProcessingFilter) + editor.apply() + + // Reschedule WorkManager + GalleryWall.schedule(context) + } + } + + AutomationScreenContent( + modifier = modifier, + autoEnabled = autoEnabled, + onAutoEnabledChange = { + autoEnabled = it + saveAndReschedule() + if (!isPreview) { + val msg = if (it) "Auto-Wallpaper Enabled" else "Auto-Wallpaper Disabled" + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + } + }, + autoApply = autoApply, + onAutoApplyChange = { + autoApply = it + saveAndReschedule() + }, + promptTemplate = promptTemplate, + onPromptTemplateChange = { + promptTemplate = it + saveAndReschedule() + }, + periodValue = periodValue, + onPeriodValueChange = { + periodValue = it + saveAndReschedule() + }, + periodUnit = periodUnit, + onPeriodUnitChange = { + periodUnit = it + saveAndReschedule() + }, + constraintWifi = constraintWifi, + onConstraintWifiChange = { + constraintWifi = it + saveAndReschedule() + }, + constraintCharging = constraintCharging, + onConstraintChargingChange = { + constraintCharging = it + saveAndReschedule() + }, + constraintIdle = constraintIdle, + onConstraintIdleChange = { + constraintIdle = it + saveAndReschedule() + }, + postProcessingFilter = postProcessingFilter, + onPostProcessingFilterChange = { + postProcessingFilter = it + saveAndReschedule() + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AutomationScreenContent( + modifier: Modifier = Modifier, + autoEnabled: Boolean, + onAutoEnabledChange: (Boolean) -> Unit, + autoApply: Boolean, + onAutoApplyChange: (Boolean) -> Unit, + promptTemplate: String, + onPromptTemplateChange: (String) -> Unit, + periodValue: String, + onPeriodValueChange: (String) -> Unit, + periodUnit: String, + onPeriodUnitChange: (String) -> Unit, + constraintWifi: Boolean, + onConstraintWifiChange: (Boolean) -> Unit, + constraintCharging: Boolean, + onConstraintChargingChange: (Boolean) -> Unit, + constraintIdle: Boolean, + onConstraintIdleChange: (Boolean) -> Unit, + postProcessingFilter: String, + onPostProcessingFilterChange: (String) -> Unit +) { + val focusManager = LocalFocusManager.current + + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Automation Settings", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "Setup background automatic wallpaper rotation frequency and execution constraints.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f) + ) + + // Master Switch Card + Card( + colors = CardDefaults.cardColors( + containerColor = if (autoEnabled) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ), + shape = MaterialTheme.shapes.large, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .padding(20.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Enable Auto-Wallpaper Switcher", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = "Automatically update wallpaper on a period you define.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) + ) + } + Switch( + checked = autoEnabled, + onCheckedChange = onAutoEnabledChange + ) + } + } + + // Auto Apply Switch Card + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f) + ), + shape = MaterialTheme.shapes.large, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .padding(20.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Apply automatically", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = if (autoEnabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + ) + Text( + text = "Change wallpaper immediately when automation runs.", + style = MaterialTheme.typography.bodySmall, + color = if (autoEnabled) MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + ) + } + Switch( + checked = autoApply, + onCheckedChange = onAutoApplyChange, + enabled = autoEnabled + ) + } + } + + // Prompt Input Configuration Card + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)), + shape = MaterialTheme.shapes.large, + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Wallpaper Prompt", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = "Define standard keywords used for background generations.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.padding(bottom = 12.dp) + ) + + OutlinedTextField( + value = promptTemplate, + onValueChange = onPromptTemplateChange, + modifier = Modifier.fillMaxWidth(), + label = { Text("Prompt") }, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + keyboardType = KeyboardType.Text + ), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + maxLines = 3 + ) + } + } + + // Scheduling Period Card + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)), + shape = MaterialTheme.shapes.large, + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Schedule Interval", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = "Adjust the interval duration below to set how often background generations occur.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.padding(bottom = 12.dp) + ) + + val maxVal = when (periodUnit) { + "HOURS" -> 24f + "DAYS" -> 30f + "WEEKS" -> 12f + "MONTHS" -> 12f + else -> 24f + } + val minVal = 1f + val steps = (maxVal - minVal).toInt() - 1 + val currentValue = (periodValue.toFloatOrNull() ?: 6f).coerceIn(minVal, maxVal) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Every ${currentValue.toInt()} ${periodUnit.lowercase().replaceFirstChar { it.uppercase() }}", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + + // Period Unit Dropdown Selector + var dropdownExpanded by remember { mutableStateOf(false) } + Box { + OutlinedButton( + onClick = { dropdownExpanded = true }, + shape = MaterialTheme.shapes.small + ) { + Text("Unit: $periodUnit") + } + DropdownMenu( + expanded = dropdownExpanded, + onDismissRequest = { dropdownExpanded = false } + ) { + val units = listOf("HOURS", "DAYS", "WEEKS", "MONTHS") + units.forEach { unit -> + DropdownMenuItem( + text = { Text(unit) }, + onClick = { + dropdownExpanded = false + // Adjust period value if it exceeds max for new unit + val newMax = when (unit) { + "HOURS" -> 24f + "DAYS" -> 30f + "WEEKS" -> 12f + "MONTHS" -> 12f + else -> 24f + } + val adjusted = currentValue.coerceIn(1f, newMax) + onPeriodUnitChange(unit) + onPeriodValueChange(adjusted.toInt().toString()) + } + ) + } + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Slider( + value = currentValue, + onValueChange = { + onPeriodValueChange(it.toInt().toString()) + }, + valueRange = minVal..maxVal, + steps = steps, + colors = SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary, + inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) + } + } + + // Post-processing Filter Card + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)), + shape = MaterialTheme.shapes.large, + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Post-processing Filter", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = "Apply a visual filter to the generated wallpaper.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.padding(bottom = 12.dp) + ) + + var filterDropdownExpanded by remember { mutableStateOf(false) } + val filterLabels = mapOf( + "none" to "None", + "bw" to "Black & White", + "sepia" to "Sepia", + "invert" to "Invert", + "blur" to "Blur" + ) + + Box(modifier = Modifier.fillMaxWidth()) { + OutlinedButton( + onClick = { filterDropdownExpanded = true }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.small + ) { + Text(text = "Filter: ${filterLabels[postProcessingFilter] ?: "None"}") + } + DropdownMenu( + expanded = filterDropdownExpanded, + onDismissRequest = { filterDropdownExpanded = false }, + modifier = Modifier.fillMaxWidth(0.9f) + ) { + filterLabels.forEach { (value, label) -> + DropdownMenuItem( + text = { Text(label) }, + onClick = { + onPostProcessingFilterChange(value) + filterDropdownExpanded = false + } + ) + } + } + } + } + } + + // Background System Constraints + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)), + shape = MaterialTheme.shapes.large, + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Execution Constraints", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = "Configure conditions to preserve battery, mobile data, and processor performance.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.padding(bottom = 16.dp) + ) + + // Wifi constraint + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Only on Wi-Fi Networks", style = MaterialTheme.typography.bodyMedium) + Switch( + checked = constraintWifi, + onCheckedChange = onConstraintWifiChange + ) + } + + // Charging constraint + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Only When Device is Charging", style = MaterialTheme.typography.bodyMedium) + Switch( + checked = constraintCharging, + onCheckedChange = onConstraintChargingChange + ) + } + + // Idle constraint + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Only When Device is Idle", style = MaterialTheme.typography.bodyMedium) + Switch( + checked = constraintIdle, + onCheckedChange = onConstraintIdleChange + ) + } + } + } + + Spacer(modifier = Modifier.height(100.dp)) + } +} + +@Preview(showBackground = true) +@Composable +fun AutomationScreenPreview() { + GalleryWallTheme { + AutomationScreen() + } +} diff --git a/app/src/main/java/com/baysoft/gallerywall/ui/GalleryScreen.kt b/app/src/main/java/com/baysoft/gallerywall/ui/GalleryScreen.kt new file mode 100644 index 0000000..9448b9c --- /dev/null +++ b/app/src/main/java/com/baysoft/gallerywall/ui/GalleryScreen.kt @@ -0,0 +1,742 @@ +package com.baysoft.gallerywall.ui + +import android.widget.Toast +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import android.graphics.Bitmap +import android.content.Context +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Done +import androidx.core.content.edit +import androidx.core.graphics.createBitmap +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.preference.PreferenceManager +import com.baysoft.gallerywall.GalleryWall +import com.baysoft.gallerywall.PromptFilter +import com.baysoft.gallerywall.Settings +import com.baysoft.gallerywall.ImageGenerationService +import com.baysoft.gallerywall.data.WallpaperDatabase +import com.baysoft.gallerywall.data.WallpaperEntity +import com.baysoft.gallerywall.data.WallpaperRepository +import com.baysoft.gallerywall.provider.ProviderState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.tooling.preview.Preview +import com.baysoft.gallerywall.ui.theme.GalleryWallTheme + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun GalleryScreen(modifier: Modifier = Modifier) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + // DB Repository + val repository = remember { + val db = WallpaperDatabase.getInstance(context) + WallpaperRepository(db.wallpaperDao()) + } + + var wallpapers by remember { mutableStateOf>(emptyList()) } + var selectedWallpaper by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(true) } + var isGenerating by remember { mutableStateOf(false) } + var currentProviderState by remember { mutableStateOf(null) } + + // Helper to refresh recents + val refreshRecents = { + scope.launch { + isLoading = true + val list = withContext(Dispatchers.IO) { + repository.getRecentWallpapers() + } + wallpapers = list + isLoading = false + } + } + + val serviceState by ImageGenerationService.state.collectAsState() + val prefs = remember { PreferenceManager.getDefaultSharedPreferences(context) } + val settings = remember(prefs) { Settings(prefs) } + val activeProviderId = settings.activeProviderId + + val isCurrentlyGenerating = isGenerating || ( + activeProviderId == "local_ai" && ( + serviceState is ImageGenerationService.GenerationState.LoadingModel || + serviceState is ImageGenerationService.GenerationState.Generating + ) + ) + + var showErrorDialog by remember { mutableStateOf(null) } + var promptText by remember { + mutableStateOf(prefs.getString(Settings.PREF_AUTOMATION_PROMPT, Settings.DEFAULT_AUTOMATION_PROMPT) ?: Settings.DEFAULT_AUTOMATION_PROMPT) + } + + val onGenerate: () -> Unit = { + val providerId = settings.activeProviderId + + if (PromptFilter.containsInappropriateContent(promptText)) { + Toast.makeText(context, "Inappropriate prompt detected. Please try another one.", Toast.LENGTH_LONG).show() + } else if (providerId == "local_ai") { + val activeModelPath = settings.activeModelPath + if (activeModelPath.isNullOrEmpty() || !File(activeModelPath).exists()) { + Toast.makeText(context, "No model loaded. Please download the Stable Diffusion model first.", Toast.LENGTH_LONG).show() + } else { + val resolvedPrompt = promptText + val colors = settings.generatedColorsHex + ImageGenerationService.start(context, resolvedPrompt, activeModelPath, colors, autoApply = false) + } + } else { + scope.launch { + isGenerating = true + currentProviderState = null + try { + val filePath = withContext(Dispatchers.IO) { + val bitmap = GalleryWall.createWallpaperBitmap(context) { state -> + scope.launch { + currentProviderState = state + } + } + + // Don't apply automatically on Gallery tab + val path = GalleryWall.recordWallpaperSync(context, bitmap, applied = false) + + // Post notification with Apply button and preview + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager + val notification = com.baysoft.gallerywall.GalleryWallNotifications.buildRefreshNotification( + context, + bitmap, + filePath = path, + isAlreadyApplied = false + ) + notificationManager.notify(com.baysoft.gallerywall.GalleryWallNotifications.NOTIFICATION_ID, notification) + + path + } + if (filePath != null) { + Toast.makeText(context, "New wallpaper generated! Use notification to apply.", Toast.LENGTH_LONG).show() + } + } catch (e: Exception) { + Toast.makeText(context, "Error: ${e.message}", Toast.LENGTH_LONG).show() + } finally { + refreshRecents() + isGenerating = false + currentProviderState = null + } + } + } + } + + LaunchedEffect(serviceState) { + when (serviceState) { + is ImageGenerationService.GenerationState.Success -> { + refreshRecents() + } + is ImageGenerationService.GenerationState.Error -> { + showErrorDialog = (serviceState as ImageGenerationService.GenerationState.Error).message + } + else -> {} + } + } + + // Load once on composition + LaunchedEffect(Unit) { + refreshRecents() + } + + GalleryScreenContent( + modifier = modifier, + wallpapers = wallpapers, + selectedWallpaper = selectedWallpaper, + isLoading = isLoading, + isCurrentlyGenerating = isCurrentlyGenerating, + currentProviderState = currentProviderState, + serviceState = serviceState, + activeProviderId = activeProviderId, + showErrorDialog = showErrorDialog, + promptText = promptText, + onPromptTextChange = { + promptText = it + prefs.edit { putString(Settings.PREF_AUTOMATION_PROMPT, it) } + }, + onGenerate = onGenerate, + onSelectWallpaper = { selectedWallpaper = it }, + onDeleteWallpaper = { entity -> + scope.launch { + repository.deleteWallpaper(entity) + if (selectedWallpaper == entity) { + selectedWallpaper = null + } + refreshRecents() + Toast.makeText(context, "Deleted wallpaper", Toast.LENGTH_SHORT).show() + } + }, + onApplyWallpaper = { bitmap -> + scope.launch { + withContext(Dispatchers.IO) { + GalleryWall.updateWallpaper(context, bitmap) + } + Toast.makeText(context, "Wallpaper Applied!", Toast.LENGTH_SHORT).show() + selectedWallpaper = null + } + }, + onDismissErrorDialog = { showErrorDialog = null } + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun GalleryScreenContent( + modifier: Modifier = Modifier, + wallpapers: List, + selectedWallpaper: WallpaperEntity?, + isLoading: Boolean, + isCurrentlyGenerating: Boolean, + currentProviderState: ProviderState?, + serviceState: ImageGenerationService.GenerationState, + activeProviderId: String, + showErrorDialog: String?, + promptText: String, + onPromptTextChange: (String) -> Unit, + onGenerate: () -> Unit, + onSelectWallpaper: (WallpaperEntity?) -> Unit, + onDeleteWallpaper: (WallpaperEntity) -> Unit, + onApplyWallpaper: (Bitmap) -> Unit, + onDismissErrorDialog: () -> Unit +) { + val focusManager = LocalFocusManager.current + + Box(modifier = modifier.fillMaxSize()) { + if (isLoading) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } else if (wallpapers.isEmpty() && !isCurrentlyGenerating) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Generate Your First Wallpaper", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Generate your first wallpaper using on-device AI model with the default prompt: '${Settings.DEFAULT_AUTOMATION_PROMPT}'", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(24.dp)) + Button(onClick = { onGenerate() }) { + Icon(Icons.Default.Add, contentDescription = "Generate") + Spacer(modifier = Modifier.width(8.dp)) + Text("Generate") + } + } + } else { + Column(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Historical Wallpapers", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "Long press any wallpaper to apply it directly.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f) + ) + Spacer(modifier = Modifier.height(16.dp)) + + LazyVerticalGrid( + columns = GridCells.Fixed(2), + contentPadding = PaddingValues(bottom = 80.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxSize() + ) { + if (isCurrentlyGenerating) { + item { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) + ), + shape = RoundedCornerShape(16.dp), + modifier = Modifier + .fillMaxWidth() + .aspectRatio(0.6f) + ) { + Column( + modifier = Modifier.fillMaxSize().padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + if (activeProviderId == "local_ai") { + when (val state = serviceState) { + is ImageGenerationService.GenerationState.LoadingModel -> { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(48.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Loading Model...", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + textAlign = TextAlign.Center + ) + Text( + text = "Loading weights to internal storage", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), + textAlign = TextAlign.Center + ) + } + is ImageGenerationService.GenerationState.Generating -> { + LinearProgressIndicator( + progress = { state.progress }, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Generating...", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + textAlign = TextAlign.Center + ) + Text( + text = "Step: ${state.currentStep}/${state.totalSteps}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), + textAlign = TextAlign.Center + ) + } + else -> { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(48.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Initializing...", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + textAlign = TextAlign.Center + ) + } + } + } else { + if (currentProviderState != null) { + LinearProgressIndicator( + progress = { currentProviderState.progress }, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Generating...", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + textAlign = TextAlign.Center + ) + Text( + text = currentProviderState.message ?: "Creating seamless tile", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), + textAlign = TextAlign.Center + ) + } else { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(48.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Generating...", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + textAlign = TextAlign.Center + ) + Text( + text = "Creating seamless tile", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), + textAlign = TextAlign.Center + ) + } + } + } + } + } + } + + items(wallpapers) { entity -> + val file = File(entity.filePath) + val isPreview = LocalInspectionMode.current + if (file.exists() || isPreview) { + val bitmap = remember(entity.filePath) { + if (isPreview) { + createBitmap(200, 300).apply { + eraseColor(android.graphics.Color.DKGRAY) + } + } else { + decodeWallpaperBitmap(entity.filePath) + } + } + if (bitmap != null) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + shape = RoundedCornerShape(16.dp), + modifier = Modifier + .fillMaxWidth() + .aspectRatio(0.6f) + .clip(RoundedCornerShape(16.dp)) + .combinedClickable( + onClick = { onSelectWallpaper(entity) }, + onLongClick = { + onApplyWallpaper(bitmap) + } + ) + ) { + Box(modifier = Modifier.fillMaxSize()) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = "Wallpaper preview", + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + + // Date of generation label + val dateText = remember(entity.dateAdded) { + java.text.DateFormat.getDateTimeInstance( + java.text.DateFormat.SHORT, + java.text.DateFormat.SHORT + ).format(java.util.Date(entity.dateAdded)) + } + Surface( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.75f), + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .padding(8.dp) + .align(Alignment.TopEnd) + ) { + Text( + text = dateText, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp) + ) + } + } + } + } + } + } + } + } + } + + // Bottom prompt input bar + Surface( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth(), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f), + shadowElevation = 8.dp + ) { + Row( + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 8.dp) + .navigationBarsPadding(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = promptText, + onValueChange = onPromptTextChange, + modifier = Modifier.weight(1f), + placeholder = { Text("Describe your wallpaper...") }, + maxLines = 2, + shape = MaterialTheme.shapes.medium, + enabled = !isCurrentlyGenerating + ) + FilledIconButton( + onClick = { + focusManager.clearFocus() + if (isCurrentlyGenerating) return@FilledIconButton + onGenerate() + }, + enabled = !isCurrentlyGenerating, + modifier = Modifier.size(48.dp) + ) { + if (isCurrentlyGenerating) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Generate wallpaper") + } + } + } + } + + // Preview Detail Modal Dialog + selectedWallpaper?.let { entity -> + val isPreview = LocalInspectionMode.current + val bitmap = remember(entity.filePath) { + if (isPreview) { + createBitmap(200, 300).apply { + eraseColor(android.graphics.Color.DKGRAY) + } + } else { + decodeWallpaperBitmap(entity.filePath) + } + } + if (bitmap != null) { + Dialog( + onDismissRequest = { onSelectWallpaper(null) }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Box(modifier = Modifier.fillMaxSize()) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = "Full wallpaper preview", + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + + // Prompt overlay at the top + val previewText = entity.prompt.ifEmpty { + when { + entity.providerId == "local_ai" || entity.filePath.contains("local_ai") -> "Local AI" + entity.providerId == "procedural" || entity.filePath.contains("tile_noise") || entity.filePath.contains("procedural") -> "Procedural" + entity.providerId == "random_color" || entity.filePath.contains("random_color") -> "Color" + else -> "Pattern" + } + } + Surface( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 48.dp, start = 24.dp, end = 24.dp) + .fillMaxWidth() + ) { + Text( + text = previewText, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.padding(16.dp) + ) + } + + // Controls bar at the bottom + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(32.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + Button( + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ), + onClick = { + onDeleteWallpaper(entity) + } + ) { + Icon(Icons.Default.Delete, contentDescription = "Delete") + Spacer(modifier = Modifier.width(8.dp)) + Text("Delete") + } + + Button( + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + onClick = { + onApplyWallpaper(bitmap) + } + ) { + Icon(Icons.Default.Done, contentDescription = "Set Wallpaper") + Spacer(modifier = Modifier.width(8.dp)) + Text("Apply Wallpaper") + } + } + } + } + } + } + } + + showErrorDialog?.let { errorMessage -> + AlertDialog( + onDismissRequest = onDismissErrorDialog, + title = { Text("Generation Failed") }, + text = { Text(errorMessage) }, + confirmButton = { + TextButton(onClick = onDismissErrorDialog) { + Text("OK") + } + } + ) + } + } +} + +private fun decodeWallpaperBitmap(path: String): Bitmap? { + val file = File(path) + if (!file.exists()) return null + return try { + val source = android.graphics.ImageDecoder.createSource(file) + android.graphics.ImageDecoder.decodeBitmap(source) { decoder, _, _ -> + decoder.allocator = android.graphics.ImageDecoder.ALLOCATOR_SOFTWARE + } + } catch (_: Exception) { + android.graphics.BitmapFactory.decodeFile(path) + } +} + +@Preview(showBackground = true) +@Composable +fun GalleryScreenPreview() { + GalleryWallTheme { + GalleryScreenContent( + wallpapers = listOf( + WallpaperEntity( + id = 1, + filePath = "mock_path_1.jpg", + dateAdded = System.currentTimeMillis() - 3600000, + providerId = "local_ai", + prompt = "A futuristic city in cyberpunk style, neon lights" + ), + WallpaperEntity( + id = 2, + filePath = "mock_path_2.jpg", + dateAdded = System.currentTimeMillis() - 7200000, + providerId = "procedural", + prompt = "Geometric seamless tile pattern with green and blue shades" + ), + WallpaperEntity( + id = 3, + filePath = "mock_path_3.jpg", + dateAdded = System.currentTimeMillis() - 10800000, + providerId = "random_color", + prompt = "Warm sunset colors gradient" + ) + ), + selectedWallpaper = null, + isLoading = false, + isCurrentlyGenerating = false, + currentProviderState = null, + serviceState = ImageGenerationService.GenerationState.Idle, + activeProviderId = "local_ai", + showErrorDialog = null, + promptText = "Describe your wallpaper...", + onPromptTextChange = {}, + onGenerate = {}, + onSelectWallpaper = {}, + onDeleteWallpaper = {}, + onApplyWallpaper = {}, + onDismissErrorDialog = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +fun GalleryScreenEmptyPreview() { + GalleryWallTheme { + GalleryScreenContent( + wallpapers = emptyList(), + selectedWallpaper = null, + isLoading = false, + isCurrentlyGenerating = false, + currentProviderState = null, + serviceState = ImageGenerationService.GenerationState.Idle, + activeProviderId = "local_ai", + showErrorDialog = null, + promptText = "", + onPromptTextChange = {}, + onGenerate = {}, + onSelectWallpaper = {}, + onDeleteWallpaper = {}, + onApplyWallpaper = {}, + onDismissErrorDialog = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +fun GalleryScreenGeneratingPreview() { + GalleryWallTheme { + GalleryScreenContent( + wallpapers = emptyList(), + selectedWallpaper = null, + isLoading = false, + isCurrentlyGenerating = true, + currentProviderState = null, + serviceState = ImageGenerationService.GenerationState.Generating(0.45f, 9, 20), + activeProviderId = "local_ai", + showErrorDialog = null, + promptText = "A cute fluffy kitten", + onPromptTextChange = {}, + onGenerate = {}, + onSelectWallpaper = {}, + onDeleteWallpaper = {}, + onApplyWallpaper = {}, + onDismissErrorDialog = {} + ) + } +} diff --git a/app/src/main/java/com/baysoft/gallerywall/ui/HomeFragment.kt b/app/src/main/java/com/baysoft/gallerywall/ui/HomeFragment.kt new file mode 100644 index 0000000..a8ce8b5 --- /dev/null +++ b/app/src/main/java/com/baysoft/gallerywall/ui/HomeFragment.kt @@ -0,0 +1,109 @@ +package com.baysoft.gallerywall.ui + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.RecyclerView +import com.baysoft.gallerywall.GalleryWall +import com.baysoft.gallerywall.R +import com.baysoft.gallerywall.Settings +import com.baysoft.gallerywall.data.WallpaperDatabase +import com.baysoft.gallerywall.data.WallpaperRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class HomeFragment : Fragment() { + private lateinit var recyclerView: RecyclerView + private lateinit var adapter: WallpaperAdapter + private lateinit var repository: WallpaperRepository + private var placeholderText: View? = null + private var wallpaperSetReceiver: BroadcastReceiver? = null + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, + ): View? { + val view = inflater.inflate(R.layout.fragment_home, container, false) + repository = WallpaperRepository(WallpaperDatabase.getInstance(requireContext()).wallpaperDao()) + recyclerView = view.findViewById(R.id.recyclerView) + recyclerView.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(context) + adapter = WallpaperAdapter( + onSetWallpaper = { wallpaper -> + lifecycleScope.launch(Dispatchers.IO) { + try { + val context = requireContext().applicationContext + val bitmap = com.bumptech.glide.Glide.with(context) + .asBitmap() + .load(wallpaper.filePath) + .submit() + .get() + GalleryWall.updateWallpaper(context, bitmap) + GalleryWall.rememberAppliedWallpaperPath(context, wallpaper.filePath) + withContext(Dispatchers.Main) { loadRecents() } + } catch (_: Exception) { + } + } + }, + onDeleteWallpaper = { wallpaper -> + lifecycleScope.launch { + repository.deleteWallpaper(wallpaper) + val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) + if (wallpaper.filePath == Settings(prefs).lastAppliedWallpaperPath) { + GalleryWall.rememberAppliedWallpaperPath(requireContext().applicationContext, null) + } + loadRecents() + } + }, + ) + recyclerView.adapter = adapter + placeholderText = view.findViewById(R.id.placeholderText) + return view + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + loadRecents() + + wallpaperSetReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + loadRecents() + } + } + requireContext().registerReceiver( + wallpaperSetReceiver, + IntentFilter().apply { + addAction("com.baysoft.gallerywall.WALLPAPER_SET") + addAction(GalleryWall.ACTION_REFRESH_IDLE) + }, + ) + } + + private fun loadRecents() { + lifecycleScope.launch { + repository.cleanupOldWallpapers() + val wallpapers = repository.getRecentWallpapers() + val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) + val applied = Settings(prefs).lastAppliedWallpaperPath + adapter.currentAppliedPath = applied + adapter.submitList(wallpapers) { + adapter.notifyDataSetChanged() + } + placeholderText?.visibility = if (wallpapers.isEmpty()) View.VISIBLE else View.GONE + } + } + + override fun onDestroyView() { + super.onDestroyView() + wallpaperSetReceiver?.let { + requireContext().unregisterReceiver(it) + } + } +} diff --git a/app/src/main/java/com/baysoft/gallerywall/ui/MainScreen.kt b/app/src/main/java/com/baysoft/gallerywall/ui/MainScreen.kt new file mode 100644 index 0000000..a9fc095 --- /dev/null +++ b/app/src/main/java/com/baysoft/gallerywall/ui/MainScreen.kt @@ -0,0 +1,73 @@ +package com.baysoft.gallerywall.ui + +import androidx.compose.animation.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.List +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.tooling.preview.Preview +import com.baysoft.gallerywall.ui.theme.GalleryWallTheme + +enum class GalleryTab(val title: String, val icon: ImageVector) { + GALLERY("Gallery", Icons.Default.Home), + PROVIDERS("Providers", Icons.Default.List), + AUTOMATION("Automation", Icons.Default.Settings) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainScreen() { + var selectedTab by remember { mutableStateOf(GalleryTab.GALLERY) } + + Scaffold( + modifier = Modifier.fillMaxSize(), + bottomBar = { + NavigationBar(modifier = Modifier.testTag("BottomNavigationBar")) { + GalleryTab.values().forEach { tab -> + NavigationBarItem( + selected = selectedTab == tab, + onClick = { selectedTab = tab }, + icon = { Icon(tab.icon, contentDescription = tab.title) }, + label = { Text(tab.title) } + ) + } + } + } + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + AnimatedContent( + targetState = selectedTab, + transitionSpec = { + fadeIn() togetherWith fadeOut() + }, + label = "ScreenTransition" + ) { tab -> + when (tab) { + GalleryTab.GALLERY -> GalleryScreen() + GalleryTab.PROVIDERS -> ProvidersScreen() + GalleryTab.AUTOMATION -> AutomationScreen() + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun MainScreenPreview() { + GalleryWallTheme { + MainScreen() + } +} diff --git a/app/src/main/java/com/baysoft/gallerywall/ui/ProvidersFragment.kt b/app/src/main/java/com/baysoft/gallerywall/ui/ProvidersFragment.kt new file mode 100644 index 0000000..88e48ce --- /dev/null +++ b/app/src/main/java/com/baysoft/gallerywall/ui/ProvidersFragment.kt @@ -0,0 +1,98 @@ +package com.baysoft.gallerywall.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.baysoft.gallerywall.R +import com.baysoft.gallerywall.Settings +import com.baysoft.gallerywall.provider.WallpaperProvider +import com.baysoft.gallerywall.provider.WallpaperProviderRegistry +import com.google.android.material.button.MaterialButton +import com.google.android.material.snackbar.Snackbar + +class ProvidersFragment : Fragment() { + + private lateinit var recyclerView: RecyclerView + private lateinit var listAdapter: ProviderListAdapter + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val view = inflater.inflate(R.layout.fragment_providers, container, false) + recyclerView = view.findViewById(R.id.recycler_providers) + recyclerView.layoutManager = LinearLayoutManager(requireContext()) + listAdapter = ProviderListAdapter( + providers = WallpaperProviderRegistry.all(), + onSelect = { provider -> + PreferenceManager.getDefaultSharedPreferences(requireContext()).edit() + .putString(Settings.PREF_WALLPAPER_PROVIDER, provider.id) + .apply() + listAdapter.selectedId = provider.id + listAdapter.notifyDataSetChanged() + Snackbar.make(view, R.string.provider_selected, Snackbar.LENGTH_SHORT).show() + }, + onConfigure = { provider -> + }, + ) + recyclerView.adapter = listAdapter + return view + } + + override fun onResume() { + super.onResume() + val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) + listAdapter.selectedId = Settings(prefs).activeProviderId + listAdapter.notifyDataSetChanged() + } + + private class ProviderListAdapter( + private val providers: List, + private val onSelect: (WallpaperProvider) -> Unit, + private val onConfigure: (WallpaperProvider) -> Unit, + ) : RecyclerView.Adapter() { + + var selectedId: String = "" + + override fun getItemCount(): Int = providers.size + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { + val v = LayoutInflater.from(parent.context) + .inflate(R.layout.item_provider, parent, false) + return VH(v) + } + + override fun onBindViewHolder(holder: VH, position: Int) { + val provider = providers[position] + val ctx = holder.itemView.context + val prefs = PreferenceManager.getDefaultSharedPreferences(ctx) + + holder.title.text = ctx.getString(provider.titleRes) + holder.summary.text = ctx.getString(provider.summaryRes) + holder.radio.isChecked = provider.id == selectedId + holder.card.setOnClickListener { + if (provider.id != selectedId) { + onSelect(provider) + } + } + holder.configure.setOnClickListener { + onConfigure(provider) + } + } + + class VH(itemView: View) : RecyclerView.ViewHolder(itemView) { + val card: View = itemView + val preview: android.widget.ImageView = itemView.findViewById(R.id.preview) + val radio: android.widget.RadioButton = itemView.findViewById(R.id.radio) + val title: android.widget.TextView = itemView.findViewById(R.id.text_title) + val summary: android.widget.TextView = itemView.findViewById(R.id.text_summary) + val configure: MaterialButton = itemView.findViewById(R.id.btnConfigure) + } + } +} diff --git a/app/src/main/java/com/baysoft/gallerywall/ui/ProvidersScreen.kt b/app/src/main/java/com/baysoft/gallerywall/ui/ProvidersScreen.kt new file mode 100644 index 0000000..1bbf0ed --- /dev/null +++ b/app/src/main/java/com/baysoft/gallerywall/ui/ProvidersScreen.kt @@ -0,0 +1,804 @@ +package com.baysoft.gallerywall.ui + +import android.app.DownloadManager +import android.content.Context +import android.net.Uri +import android.os.Environment +import android.util.Log +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.preference.PreferenceManager +import com.baysoft.gallerywall.Settings +import com.baysoft.gallerywall.ml.LocalMLEngine +import com.baysoft.gallerywall.ui.theme.GalleryWallTheme +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File + +data class GgufModel( + val id: String, + val name: String, + val description: String, + val size: String, + val downloadUrl: String, + val sha256: String = "" +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProvidersScreen(modifier: Modifier = Modifier) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val isPreview = LocalInspectionMode.current + val prefs = remember { if (isPreview) null else PreferenceManager.getDefaultSharedPreferences(context) } + + var activeModelPath by remember { mutableStateOf(prefs?.getString(Settings.PREF_ACTIVE_MODEL_PATH, null)) } + var selectedScale by remember { mutableStateOf(prefs?.getInt(Settings.PREF_SCALE_FACTOR, Settings.DEFAULT_SCALE_FACTOR) ?: Settings.DEFAULT_SCALE_FACTOR) } + val downloadingStates = remember { mutableStateMapOf() } + + val baseDir = remember { + if (isPreview) { + File("") + } else { + context.getExternalFilesDir("models") ?: File(context.filesDir, "models") + } + } + val getModelFile = { id: String -> + if (isPreview) File("") else File(baseDir, id) + } + + var refreshTrigger by remember { mutableStateOf(0) } + var modelsList by remember { mutableStateOf>(emptyList()) } + + LaunchedEffect(refreshTrigger) { + val builtIn = listOf( + GgufModel( + id = "stable_diffusion_1_5", + name = "Stable Diffusion v1.5 (Lite)", + description = "On-device Stable Diffusion v1.5 text-to-image model. Zipped package contains pre-converted sub-model components optimized for MediaPipe Tasks Vision.", + size = "1.8 GB", + downloadUrl = "https://github.com/bossly/gallerywall/releases/download/2.3.1/stable_diffusion_v1_5.zip", + sha256 = "56680bed991bc2fe5785504d3bfaa4ce495b62f0535c7f3b72eb206ef09cfcf4" + ), +// GgufModel( +// id = "bk_sdm_small", +// name = "BK-SDM-Small (0.49B-param U-Net)", +// description = "On-device BK-SDM-Small text-to-image model. Zipped package contains pre-converted sub-model components optimized for MediaPipe Tasks Vision.", +// size = "1.41 GB", +// downloadUrl = "https://huggingface.co/nota-ai/bk-sdm-small/resolve/main/bk-sdm-small.zip" +// ), +// GgufModel( +// id = "bk_sdm_base", +// name = "BK-SDM-Base (0.58B-param U-Net)", +// description = "On-device BK-SDM-Base text-to-image model. Zipped package contains pre-converted sub-model components optimized for MediaPipe Tasks Vision.", +// size = "1.56 GB", +// downloadUrl = "https://huggingface.co/nota-ai/bk-sdm-base/resolve/main/bk-sdm-base.zip" +// ) + ) + + val list = builtIn.toMutableList() + val builtInIds = builtIn.map { it.id }.toSet() + + if (!isPreview && baseDir.exists() && baseDir.isDirectory) { + val subDirs = baseDir.listFiles()?.filter { it.isDirectory } ?: emptyList() + for (dir in subDirs) { + if (dir.name !in builtInIds) { + val sizeBytes = getFolderSize(dir) + val sizeStr = formatSize(sizeBytes) + list.add( + GgufModel( + id = dir.name, + name = dir.name, + description = "Imported custom on-device Stable Diffusion model.", + size = sizeStr, + downloadUrl = "" + ) + ) + } + } + } + modelsList = list + } + + val filePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri: Uri? -> + if (uri != null) { + scope.launch { + try { + val fileNameWithExt = getFileName(context, uri) ?: "imported_model" + val modelId = if (fileNameWithExt.endsWith(".zip", ignoreCase = true)) { + fileNameWithExt.substring(0, fileNameWithExt.length - 4) + } else { + fileNameWithExt + } + + Log.d("ProvidersScreen", "Selected model ZIP file for import via picker: $uri for model ID/Name: $modelId") + baseDir.mkdirs() + + Toast.makeText(context, "Importing and unzipping model zip...", Toast.LENGTH_LONG).show() + + withContext(Dispatchers.IO) { + val targetDir = getModelFile(modelId) + if (targetDir.exists()) { + targetDir.deleteRecursively() + } + targetDir.mkdirs() + context.contentResolver.openInputStream(uri)?.use { input -> + unzip(input, targetDir) + } + } + Toast.makeText(context, "Model imported successfully!", Toast.LENGTH_SHORT).show() + refreshTrigger++ + } catch (e: Exception) { + Log.e("ProvidersScreen", "Failed to import selected model zip", e) + Toast.makeText(context, "Failed to import model: ${e.message}", Toast.LENGTH_LONG).show() + } + } + } + } + + var selectedProviderId by remember { + mutableStateOf(prefs?.getString(Settings.PREF_WALLPAPER_PROVIDER, "local_ai") ?: "local_ai") + } + + val allProviders = remember { com.baysoft.gallerywall.provider.WallpaperProviderRegistry.all() } + + LazyColumn( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + contentPadding = PaddingValues(bottom = 80.dp) + ) { + // Header + item { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Wallpaper Providers", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "Choose a wallpaper generation engine.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f) + ) + Spacer(modifier = Modifier.height(16.dp)) + } + + // Provider selection list + items(allProviders.size) { index -> + val provider = allProviders[index] + val isSelected = selectedProviderId == provider.id + Card( + colors = CardDefaults.cardColors( + containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) + else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ), + shape = MaterialTheme.shapes.large, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clip(MaterialTheme.shapes.large) + .clickable { + selectedProviderId = provider.id + prefs?.edit()?.putString(Settings.PREF_WALLPAPER_PROVIDER, provider.id)?.apply() + Toast.makeText(context, "Provider: ${context.getString(provider.titleRes)}", Toast.LENGTH_SHORT).show() + } + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = context.getString(provider.titleRes), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = context.getString(provider.summaryRes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) + ) + } + if (isSelected) { + Icon( + Icons.Default.CheckCircle, + contentDescription = "Selected", + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + } + + // AI Model section – only visible when local_ai provider is selected + if (selectedProviderId == "local_ai") { + item { + Spacer(modifier = Modifier.height(24.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "On-Device AI Models", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground + ) + Button( + onClick = { + filePickerLauncher.launch("*/*") + } + ) { + Text("Import ZIP") + } + } + Spacer(modifier = Modifier.height(8.dp)) + } + + items(modelsList) { model -> + val file = getModelFile(model.id) + val exists = file.exists() && file.isDirectory + val isCurrentlyActive = activeModelPath == file.absolutePath + + // Display size dynamically (actual size on disk if downloaded/imported, otherwise fallback description size) + val displaySize = remember(refreshTrigger, exists) { + if (exists) { + val sizeBytes = getFolderSize(file) + formatSize(sizeBytes) + } else { + model.size + } + } + + key(refreshTrigger) { + Card( + colors = CardDefaults.cardColors( + containerColor = if (isCurrentlyActive) MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.4f) + else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ), + shape = MaterialTheme.shapes.large, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + .clip(MaterialTheme.shapes.large) + .clickable(enabled = exists) { + activeModelPath = file.absolutePath + prefs?.edit()?.putString(Settings.PREF_ACTIVE_MODEL_PATH, file.absolutePath)?.apply() + Toast.makeText(context, "Active model set to: ${model.name}", Toast.LENGTH_SHORT).show() + } + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = model.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Size: $displaySize", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) + if (isCurrentlyActive) { + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.primaryContainer + ) { + Text( + text = "Active & Loaded", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + fontWeight = FontWeight.Bold + ) + } + } else if (exists) { + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.secondaryContainer + ) { + Text( + text = "Downloaded", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + fontWeight = FontWeight.Bold + ) + } + } + } + } + + if (exists) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (isCurrentlyActive) { + Icon( + Icons.Default.CheckCircle, + contentDescription = "Active Model", + tint = MaterialTheme.colorScheme.primary + ) + } + IconButton( + onClick = { + file.deleteRecursively() + if (isCurrentlyActive) { + LocalMLEngine.getInstance().unloadModel() + activeModelPath = null + prefs?.edit()?.remove(Settings.PREF_ACTIVE_MODEL_PATH)?.apply() + } + refreshTrigger++ + Toast.makeText(context, "Deleted model folder", Toast.LENGTH_SHORT).show() + } + ) { + Icon( + Icons.Default.Delete, + contentDescription = "Delete Model Folder", + tint = MaterialTheme.colorScheme.error + ) + } + } + } else { + val currentStatus = downloadingStates[model.id] + if (currentStatus != null) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (currentStatus.startsWith("Error")) { + Column(horizontalAlignment = Alignment.End) { + Text( + text = currentStatus, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(4.dp)) + Button( + onClick = { + downloadingStates.remove(model.id) + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + ) { + Text("Retry") + } + } + } else { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = currentStatus, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold + ) + } + } + } else { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (model.downloadUrl.isNotEmpty()) { + Button( + onClick = { + scope.launch { + try { + Log.d("ProvidersScreen", "Download button clicked for model: ${model.name} (${model.id})") + val urlFilename = try { + Uri.parse(model.downloadUrl).lastPathSegment ?: "${model.id}.zip" + } catch (e: Exception) { + "${model.id}.zip" + } + + val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + val possibleDirs = listOf( + downloadsDir, + File("/sdcard/Download"), + File("/storage/emulated/0/Download") + ) + + var localZip: File? = null + for (dir in possibleDirs) { + val file = File(dir, urlFilename) + if (file.exists() && file.canRead()) { + localZip = file + break + } + val fileAlt = File(dir, "${model.id}.zip") + if (fileAlt.exists() && fileAlt.canRead()) { + localZip = fileAlt + break + } + } + + if (localZip != null) { + Log.d("ProvidersScreen", "Found local zip at: ${localZip.absolutePath}. Unzipping directly...") + + val baseDir = context.getExternalFilesDir("models") ?: File(context.filesDir, "models") + + var errorMessage: String? = null + val unzipSuccess = withContext(Dispatchers.IO) { + try { + if (model.sha256.isNotEmpty()) { + downloadingStates[model.id] = "Verifying..." + val actualSha = calculateSha256(localZip) + if (actualSha != model.sha256) { + Log.e("ProvidersScreen", "Local zip SHA256 mismatch! Expected ${model.sha256}, got $actualSha") + errorMessage = "Local file corrupted (checksum mismatch)" + return@withContext false + } + } + + downloadingStates[model.id] = "Extracting..." + baseDir.mkdirs() + val targetDir = getModelFile(model.id) + if (targetDir.exists()) { + targetDir.deleteRecursively() + } + targetDir.mkdirs() + localZip.inputStream().use { input -> + unzip(input, targetDir) + } + true + } catch (e: Exception) { + Log.e("ProvidersScreen", "Error unzipping local model ZIP", e) + errorMessage = "Extraction failed: ${e.message}" + false + } + } + if (unzipSuccess) { + downloadingStates.remove(model.id) + Toast.makeText(context, "Loaded model successfully from local ZIP!", Toast.LENGTH_SHORT).show() + } else { + Log.e("ProvidersScreen", "Failed to load/extract model from local ZIP for model ${model.id}") + downloadingStates[model.id] = "Error: ${errorMessage ?: "Unknown error"}" + Toast.makeText(context, "Failed to load model from local ZIP: $errorMessage", Toast.LENGTH_LONG).show() + } + refreshTrigger++ + return@launch + } + + val baseDir = context.getExternalFilesDir("models") ?: File(context.filesDir, "models") + val zipFile = File(baseDir, "${model.id}.zip") + if (zipFile.exists()) { + zipFile.delete() + } + baseDir.mkdirs() + + Log.d("ProvidersScreen", "Target local storage path: ${zipFile.absolutePath}") + val request = DownloadManager.Request(Uri.parse(model.downloadUrl)) + .setTitle(model.name) + .setDescription("Downloading on-device Stable Diffusion model package") + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + .setDestinationUri(Uri.fromFile(zipFile)) + .setAllowedOverMetered(true) + .setAllowedOverRoaming(false) + + val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + val downloadId = dm.enqueue(request) + + downloadingStates[model.id] = "Downloading..." + Toast.makeText(context, "Started download in background. Monitor via system tray progress.", Toast.LENGTH_LONG).show() + + scope.launch { + var isDone = false + var isError = false + var errorMessage: String? = null + + while (!isDone && !isError) { + kotlinx.coroutines.delay(1000) + val q = DownloadManager.Query().setFilterById(downloadId) + val c = dm.query(q) + if (c != null && c.moveToFirst()) { + val statusCol = c.getColumnIndex(DownloadManager.COLUMN_STATUS) + if (statusCol != -1) { + val status = c.getInt(statusCol) + when (status) { + DownloadManager.STATUS_SUCCESSFUL -> { + isDone = true + } + DownloadManager.STATUS_FAILED -> { + isError = true + val reasonCol = c.getColumnIndex(DownloadManager.COLUMN_REASON) + val reason = if (reasonCol != -1) c.getInt(reasonCol) else -1 + errorMessage = "Download failed: status code $reason" + } + DownloadManager.STATUS_PAUSED -> { + // still waiting/paused + } + DownloadManager.STATUS_RUNNING -> { + val totalCol = c.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES) + val currentCol = c.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR) + if (totalCol != -1 && currentCol != -1) { + val total = c.getLong(totalCol) + val current = c.getLong(currentCol) + if (total > 0) { + val percent = (current * 100 / total).toInt() + downloadingStates[model.id] = "Downloading ($percent%)" + } + } + } + } + } + c.close() + } else { + isError = true + errorMessage = "Download cancelled or failed" + } + } + + if (isError) { + Log.e("ProvidersScreen", "Download failed for model ${model.id}: $errorMessage") + downloadingStates[model.id] = "Error: $errorMessage" + Toast.makeText(context, errorMessage ?: "Download failed", Toast.LENGTH_LONG).show() + } else { + val unzipSuccess = withContext(Dispatchers.IO) { + try { + if (model.sha256.isNotEmpty()) { + downloadingStates[model.id] = "Verifying..." + val actualSha = calculateSha256(zipFile) + if (actualSha != model.sha256) { + Log.e("ProvidersScreen", "Downloaded zip SHA256 mismatch! Expected ${model.sha256}, got $actualSha") + errorMessage = "Checksum mismatch! Corrupted download." + return@withContext false + } + } + + downloadingStates[model.id] = "Extracting..." + val targetDir = getModelFile(model.id) + if (targetDir.exists()) { + targetDir.deleteRecursively() + } + targetDir.mkdirs() + zipFile.inputStream().use { input -> + unzip(input, targetDir) + } + true + } catch (e: Exception) { + Log.e("ProvidersScreen", "Error unzipping model for model ${model.id}", e) + errorMessage = "Extraction failed: ${e.message}" + false + } finally { + zipFile.delete() + } + } + + if (unzipSuccess) { + downloadingStates.remove(model.id) + Toast.makeText(context, "Loaded model successfully!", Toast.LENGTH_SHORT).show() + } else { + Log.e("ProvidersScreen", "Extraction failed for model ${model.id}: $errorMessage") + downloadingStates[model.id] = "Error: $errorMessage" + Toast.makeText(context, errorMessage ?: "Extraction failed", Toast.LENGTH_LONG).show() + } + } + refreshTrigger++ + } + } catch (e: Exception) { + Log.e("ProvidersScreen", "Exception caught during download initiation flow for model '${model.id}'", e) + downloadingStates[model.id] = "Error: ${e.localizedMessage}" + Toast.makeText(context, "Failed to download model: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + } + ) { + Icon(Icons.Default.Add, contentDescription = "Download") + Spacer(modifier = Modifier.width(4.dp)) + Text("Download") + } + } + } + } + } + } + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = model.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) + ) + } + } + } + } + + /* + // Engine Configuration + item { + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "Engine Configuration", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(modifier = Modifier.height(8.dp)) + + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)), + shape = MaterialTheme.shapes.large, + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Tile Upscale Factor", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = "Upscale: ${selectedScale}×", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) + } + Text( + text = "Scale the native model output tile before repeating.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.padding(bottom = 4.dp) + ) + + Slider( + value = selectedScale.toFloat(), + onValueChange = { selectedScale = it.toInt() }, + onValueChangeFinished = { + prefs?.edit()?.putInt(Settings.PREF_SCALE_FACTOR, selectedScale)?.apply() + }, + valueRange = 2f..12f, + steps = 9, + modifier = Modifier.fillMaxWidth() + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("2×", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text("6×", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text("12×", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } + */ + } + } +} + +private fun getFolderSize(file: File): Long { + if (!file.exists()) return 0L + if (file.isFile) return file.length() + var size = 0L + val files = file.listFiles() + if (files != null) { + for (f in files) { + size += getFolderSize(f) + } + } + return size +} + +private fun formatSize(sizeInBytes: Long): String { + if (sizeInBytes <= 0) return "0 B" + val units = arrayOf("B", "KB", "MB", "GB", "TB") + val digitGroups = (Math.log10(sizeInBytes.toDouble()) / Math.log10(1024.toDouble())).toInt() + return String.format("%.2f %s", sizeInBytes / Math.pow(1024.toDouble(), digitGroups.toDouble()), units[digitGroups]) +} + +private fun getFileName(context: Context, uri: Uri): String? { + var result: String? = null + if (uri.scheme == "content") { + val cursor = context.contentResolver.query(uri, null, null, null, null) + cursor?.use { + if (it.moveToFirst()) { + val index = it.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (index != -1) { + result = it.getString(index) + } + } + } + } + if (result == null) { + result = uri.path + val cut = result?.lastIndexOf('/') + if (cut != null && cut != -1) { + result = result.substring(cut + 1) + } + } + return result +} + +private fun unzip(zipInputStream: java.io.InputStream, targetDirectory: File) { + java.util.zip.ZipInputStream(java.io.BufferedInputStream(zipInputStream)).use { zis -> + var ze = zis.nextEntry + while (ze != null) { + if (ze.name.startsWith("__MACOSX") || ze.name.contains(".DS_Store")) { + ze = zis.nextEntry + continue + } + val file = File(targetDirectory, ze.name) + val dir = if (ze.isDirectory) file else file.parentFile + if (!dir.isDirectory && !dir.mkdirs()) { + throw java.io.IOException("Failed to ensure directory: ${dir.absolutePath}") + } + if (!ze.isDirectory) { + java.io.FileOutputStream(file).use { fos -> + zis.copyTo(fos) + } + } + ze = zis.nextEntry + } + } +} + +private fun calculateSha256(file: File): String { + val digest = java.security.MessageDigest.getInstance("SHA-256") + file.inputStream().use { fis -> + val buffer = ByteArray(8192) + var bytesRead = fis.read(buffer) + while (bytesRead != -1) { + digest.update(buffer, 0, bytesRead) + bytesRead = fis.read(buffer) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } +} + +@Preview(showBackground = true) +@Composable +fun ProvidersScreenPreview() { + GalleryWallTheme { + ProvidersScreen() + } +} diff --git a/app/src/main/java/com/baysoft/gallerywall/ui/WallpaperAdapter.kt b/app/src/main/java/com/baysoft/gallerywall/ui/WallpaperAdapter.kt new file mode 100644 index 0000000..e2fdc50 --- /dev/null +++ b/app/src/main/java/com/baysoft/gallerywall/ui/WallpaperAdapter.kt @@ -0,0 +1,67 @@ +package com.baysoft.gallerywall.ui + +import android.widget.CheckBox +import com.bumptech.glide.Glide +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.baysoft.gallerywall.R +import com.baysoft.gallerywall.data.WallpaperEntity + + +class WallpaperAdapter( + private val onSetWallpaper: (WallpaperEntity) -> Unit, + private val onDeleteWallpaper: (WallpaperEntity) -> Unit, +) : ListAdapter(DIFF) { + + /** Absolute path of the file currently applied as system wallpaper (from prefs). */ + var currentAppliedPath: String? = null + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WallpaperViewHolder { + val view = LayoutInflater.from(parent.context).inflate(R.layout.item_wallpaper, parent, false) + return WallpaperViewHolder(view, onSetWallpaper, onDeleteWallpaper) + } + + override fun onBindViewHolder(holder: WallpaperViewHolder, position: Int) { + holder.bind(getItem(position), currentAppliedPath) + } + + class WallpaperViewHolder( + itemView: View, + private val onSetWallpaper: (WallpaperEntity) -> Unit, + private val onDeleteWallpaper: (WallpaperEntity) -> Unit, + ) : RecyclerView.ViewHolder(itemView) { + private val imageView: ImageView = itemView.findViewById(R.id.imageView) + private val dateText: TextView = itemView.findViewById(R.id.dateText) + private val checkCurrent: CheckBox = itemView.findViewById(R.id.checkCurrentWallpaper) + private val btnSetWallpaper: View? = itemView.findViewById(R.id.btnSetWallpaper) + private val btnDeleteWallpaper: View? = itemView.findViewById(R.id.btnDeleteWallpaper) + fun bind(wallpaper: WallpaperEntity, appliedPath: String?) { + Glide.with(imageView.context) + .load(java.io.File(wallpaper.filePath)) + .centerCrop() + .into(imageView) + val date = java.text.DateFormat.getDateTimeInstance().format(java.util.Date(wallpaper.dateAdded)) + dateText.text = date + checkCurrent.isChecked = appliedPath != null && wallpaper.filePath == appliedPath + btnSetWallpaper?.setOnClickListener { + onSetWallpaper(wallpaper) + } + btnDeleteWallpaper?.setOnClickListener { + onDeleteWallpaper(wallpaper) + } + } + } + + companion object { + val DIFF = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: WallpaperEntity, newItem: WallpaperEntity) = oldItem.id == newItem.id + override fun areContentsTheSame(oldItem: WallpaperEntity, newItem: WallpaperEntity) = oldItem == newItem + } + } +} diff --git a/app/src/main/java/com/baysoft/gallerywall/ui/theme/Theme.kt b/app/src/main/java/com/baysoft/gallerywall/ui/theme/Theme.kt new file mode 100644 index 0000000..053d15f --- /dev/null +++ b/app/src/main/java/com/baysoft/gallerywall/ui/theme/Theme.kt @@ -0,0 +1,126 @@ +package com.baysoft.gallerywall.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +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 +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.shape.RoundedCornerShape + +// Curated colors for a sleek, ultra-premium theme +val Purple80 = androidx.compose.ui.graphics.Color(0xFFD0BCFF) +val PurpleGrey80 = androidx.compose.ui.graphics.Color(0xFFCCC2DC) +val Pink80 = androidx.compose.ui.graphics.Color(0xFFEFB8C8) + +val Purple40 = androidx.compose.ui.graphics.Color(0xFF6750A4) +val PurpleGrey40 = androidx.compose.ui.graphics.Color(0xFF625B71) +val Pink40 = androidx.compose.ui.graphics.Color(0xFF7D5260) + +val DarkBackground = androidx.compose.ui.graphics.Color(0xFF0F0E13) +val DarkSurface = androidx.compose.ui.graphics.Color(0xFF1D1B22) +val LightBackground = androidx.compose.ui.graphics.Color(0xFFFAF8FF) +val LightSurface = androidx.compose.ui.graphics.Color(0xFFF3EDF7) + +val DarkText = androidx.compose.ui.graphics.Color(0xFF1D1B20) +val LightText = androidx.compose.ui.graphics.Color(0xFFE6E1E9) +val Purple20 = androidx.compose.ui.graphics.Color(0xFF381E72) +val PurpleGrey20 = androidx.compose.ui.graphics.Color(0xFF332D41) + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80, + background = DarkBackground, + surface = DarkSurface, + onPrimary = Purple20, + onSecondary = PurpleGrey20, + onBackground = LightText, + onSurface = LightText +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40, + background = LightBackground, + surface = LightSurface, + onPrimary = LightText, + onSecondary = LightText, + onBackground = DarkText, + onSurface = DarkText +) + +val ExpressiveShapes = Shapes( + extraSmall = RoundedCornerShape(8.dp), + small = RoundedCornerShape(12.dp), + medium = RoundedCornerShape(20.dp), + large = RoundedCornerShape(topStart = 32.dp, bottomEnd = 32.dp, topEnd = 8.dp, bottomStart = 8.dp), + extraLarge = RoundedCornerShape(40.dp) +) + +val ExpressiveTypography = Typography( + displayLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.ExtraBold, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp + ), + headlineMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp + ), + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + bodyMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp + ), + labelMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) +) + +@Composable +fun GalleryWallTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + shapes = ExpressiveShapes, + typography = ExpressiveTypography, + content = content + ) +} diff --git a/app/src/main/res/drawable/appwidget_preview.png b/app/src/main/res/drawable/appwidget_preview.png deleted file mode 100644 index 9bf15fc..0000000 Binary files a/app/src/main/res/drawable/appwidget_preview.png and /dev/null differ diff --git a/app/src/main/res/drawable/ic_dice.xml b/app/src/main/res/drawable/ic_dice.xml new file mode 100644 index 0000000..738c0b3 --- /dev/null +++ b/app/src/main/res/drawable/ic_dice.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launch_gray_24.xml b/app/src/main/res/drawable/ic_launch_gray_24.xml deleted file mode 100755 index e076760..0000000 --- a/app/src/main/res/drawable/ic_launch_gray_24.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_tab_providers.xml b/app/src/main/res/drawable/ic_tab_providers.xml new file mode 100644 index 0000000..988effe --- /dev/null +++ b/app/src/main/res/drawable/ic_tab_providers.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_tab_wallpapers.xml b/app/src/main/res/drawable/ic_tab_wallpapers.xml new file mode 100644 index 0000000..3ac5545 --- /dev/null +++ b/app/src/main/res/drawable/ic_tab_wallpapers.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index b3149ec..2fc6fe4 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,16 +1,26 @@ + android:layout_height="0dp" + android:layout_weight="1" + app:defaultNavHost="true" + app:navGraph="@navigation/nav_graph" /> - \ No newline at end of file + + + diff --git a/app/src/main/res/layout/app_widget.xml b/app/src/main/res/layout/app_widget.xml index 9f011d2..3739676 100644 --- a/app/src/main/res/layout/app_widget.xml +++ b/app/src/main/res/layout/app_widget.xml @@ -27,7 +27,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:background="@drawable/button" - android:contentDescription="@string/pref_period_title" + android:contentDescription="@string/refresh" android:src="@drawable/ic_refresh_gray_32" /> + + + + + + + diff --git a/app/src/main/res/layout/fragment_providers.xml b/app/src/main/res/layout/fragment_providers.xml new file mode 100644 index 0000000..244cab3 --- /dev/null +++ b/app/src/main/res/layout/fragment_providers.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/layout/item_provider.xml b/app/src/main/res/layout/item_provider.xml new file mode 100644 index 0000000..0323a13 --- /dev/null +++ b/app/src/main/res/layout/item_provider.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_wallpaper.xml b/app/src/main/res/layout/item_wallpaper.xml new file mode 100644 index 0000000..8226227 --- /dev/null +++ b/app/src/main/res/layout/item_wallpaper.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + +