├── .editorconfig ├── .githooks └── pre-push ├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ └── feature-request.yml ├── actions │ └── setup-android-build │ │ ├── action.yml │ │ └── setup-gradle-properties.sh ├── dependabot.yml └── workflows │ ├── android-ci.yml │ ├── crowdin.yml │ └── signed-build.yml ├── .gitignore ├── .run └── custom-configurations.run.xml ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-dictionary.txt ├── proguard-rules.pro ├── schemas │ └── com.fibelatti.photowidget.widget.data.PhotoWidgetDatabase │ │ ├── 1.json │ │ ├── 2.json │ │ ├── 3.json │ │ └── 4.json └── src │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── com │ │ └── fibelatti │ │ └── photowidget │ │ ├── App.kt │ │ ├── chooser │ │ ├── PhotoWidgetChooserActivity.kt │ │ └── PhotoWidgetChooserViewModel.kt │ │ ├── configure │ │ ├── ColorMatrixPicker.kt │ │ ├── ConfigureTabs.kt │ │ ├── DirectorySortingPicker.kt │ │ ├── ExactAlarmsDialog.kt │ │ ├── ExistingWidgetPicker.kt │ │ ├── IntentKtx.kt │ │ ├── LocalSamplePhoto.kt │ │ ├── PhotoCropActivity.kt │ │ ├── PhotoWidgetAspectRatioPicker.kt │ │ ├── PhotoWidgetBorderPicker.kt │ │ ├── PhotoWidgetConfigureActivity.kt │ │ ├── PhotoWidgetConfigureScreen.kt │ │ ├── PhotoWidgetConfigureState.kt │ │ ├── PhotoWidgetConfigureViewModel.kt │ │ ├── PhotoWidgetCycleModePicker.kt │ │ ├── PhotoWidgetOffsetPicker.kt │ │ ├── PhotoWidgetPinnedReceiver.kt │ │ ├── PhotoWidgetPinningCache.kt │ │ ├── PhotoWidgetSourcePicker.kt │ │ ├── PhotoWidgetTapActionPicker.kt │ │ └── SavePhotoWidgetUseCase.kt │ │ ├── di │ │ ├── EntryPointKtx.kt │ │ ├── PhotoWidgetEntryPoint.kt │ │ └── PhotoWidgetModule.kt │ │ ├── hints │ │ └── HintStorage.kt │ │ ├── home │ │ ├── HelpArticle.kt │ │ ├── HelpScreen.kt │ │ ├── HomeActivity.kt │ │ ├── HomeScreen.kt │ │ ├── HomeViewModel.kt │ │ ├── MyWidgetsScreen.kt │ │ ├── NewWidgetScreen.kt │ │ └── SettingsScreen.kt │ │ ├── licenses │ │ └── OssLicensesActivity.kt │ │ ├── model │ │ ├── DirectorySorting.kt │ │ ├── LocalPhoto.kt │ │ ├── PhotoWidget.kt │ │ ├── PhotoWidgetAspectRatio.kt │ │ ├── PhotoWidgetBorder.kt │ │ ├── PhotoWidgetColors.kt │ │ ├── PhotoWidgetCycleMode.kt │ │ ├── PhotoWidgetLoopingInterval.kt │ │ ├── PhotoWidgetShape.kt │ │ ├── PhotoWidgetShapeBuilder.kt │ │ ├── PhotoWidgetSource.kt │ │ ├── PhotoWidgetStatus.kt │ │ ├── PhotoWidgetTapAction.kt │ │ ├── PhotoWidgetTapActions.kt │ │ ├── TapActionArea.kt │ │ └── WidgetPhotos.kt │ │ ├── platform │ │ ├── AppTheme.kt │ │ ├── BackgroundRestrictedSheetDialog.kt │ │ ├── BitmapKtx.kt │ │ ├── BundleDelegate.kt │ │ ├── ColorPalette.kt │ │ ├── ComposeBottomSheetDialog.kt │ │ ├── ConfigurationChangedReceiver.kt │ │ ├── ContextKtx.kt │ │ ├── DialogKtx.kt │ │ ├── EntryPointBroadcastReceiver.kt │ │ ├── EnumKtx.kt │ │ ├── FormatKtx.kt │ │ ├── IntentKtx.kt │ │ ├── PhotoDecoder.kt │ │ ├── SelectionDialog.kt │ │ └── WidgetSizeProvider.kt │ │ ├── preferences │ │ ├── Appearance.kt │ │ ├── DataSaverPicker.kt │ │ ├── UserPreferences.kt │ │ ├── UserPreferencesStorage.kt │ │ ├── WidgetDefaultsActivity.kt │ │ ├── WidgetDefaultsScreen.kt │ │ └── WidgetDefaultsViewModel.kt │ │ ├── ui │ │ ├── AsyncPhotoViewer.kt │ │ ├── ColoredShape.kt │ │ ├── LoadingIndicator.kt │ │ ├── MyWidgetBadge.kt │ │ ├── ShapedPhoto.kt │ │ ├── ShapesBanner.kt │ │ ├── SliderSmallThumb.kt │ │ ├── Toggle.kt │ │ └── WarningSign.kt │ │ ├── viewer │ │ ├── PhotoWidgetViewerActivity.kt │ │ └── PhotoWidgetViewerViewModel.kt │ │ └── widget │ │ ├── CyclePhotoUseCase.kt │ │ ├── DeleteStaleDataUseCase.kt │ │ ├── DuplicatePhotoWidgetUseCase.kt │ │ ├── LoadPhotoWidgetUseCase.kt │ │ ├── PhotoWidgetAlarmManager.kt │ │ ├── PhotoWidgetProvider.kt │ │ ├── PhotoWidgetRescheduleReceiver.kt │ │ ├── PhotoWidgetSyncReceiver.kt │ │ ├── PrepareCurrentPhotoUseCase.kt │ │ ├── ToggleCyclingFeedbackActivity.kt │ │ └── data │ │ ├── PhotoWidgetDatabase.kt │ │ ├── PhotoWidgetExternalFileStorage.kt │ │ ├── PhotoWidgetInternalFileStorage.kt │ │ ├── PhotoWidgetSharedPreferences.kt │ │ └── PhotoWidgetStorage.kt │ └── res │ ├── drawable-nodpi │ ├── image_sample.jpg │ └── widget_preview.png │ ├── drawable │ ├── ic_alarm.xml │ ├── ic_appearance.xml │ ├── ic_back.xml │ ├── ic_check.xml │ ├── ic_chevron_down.xml │ ├── ic_chevron_left.xml │ ├── ic_chevron_right.xml │ ├── ic_crop.xml │ ├── ic_default.xml │ ├── ic_dynamic_color.xml │ ├── ic_expand.xml │ ├── ic_file_not_found.xml │ ├── ic_hard_drive.xml │ ├── ic_hourglass.xml │ ├── ic_launcher_monochrome.xml │ ├── ic_my_widgets.xml │ ├── ic_my_widgets_selected.xml │ ├── ic_new_widget.xml │ ├── ic_new_widget_selected.xml │ ├── ic_privacy_policy.xml │ ├── ic_question.xml │ ├── ic_rate.xml │ ├── ic_settings.xml │ ├── ic_settings_selected.xml │ ├── ic_share.xml │ ├── ic_translation.xml │ ├── ic_trash.xml │ ├── ic_trash_clock.xml │ └── ic_warning.xml │ ├── layout │ ├── photo_crop_activity.xml │ ├── photo_widget.xml │ └── photo_widget_preview.xml │ ├── mipmap-anydpi │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ └── ic_launcher_foreground.png │ ├── mipmap-mdpi │ └── ic_launcher_foreground.png │ ├── mipmap-xhdpi │ └── ic_launcher_foreground.png │ ├── mipmap-xxhdpi │ └── ic_launcher_foreground.png │ ├── mipmap-xxxhdpi │ └── ic_launcher_foreground.png │ ├── raw │ └── aboutlibraries.json │ ├── resources.properties │ ├── values-es │ └── strings.xml │ ├── values-fr │ └── strings.xml │ ├── values-iw │ └── strings.xml │ ├── values-night │ └── colors.xml │ ├── values-pt │ └── strings.xml │ ├── values-ru │ └── strings.xml │ ├── values-sw840dp │ └── bool.xml │ ├── values-tr │ └── strings.xml │ ├── values │ ├── bool.xml │ ├── colors.xml │ ├── ic_launcher_background.xml │ ├── strings.xml │ └── themes.xml │ └── xml │ ├── file_paths.xml │ └── photo_widget_info.xml ├── build.gradle.kts ├── compose_compiler_config.conf ├── docs ├── _config.yml └── index.md ├── fastlane └── metadata │ └── android │ └── en-US │ ├── changelogs │ ├── 1000000.txt │ ├── 1010000.txt │ ├── 1010002.txt │ ├── 1010100.txt │ ├── 1020000.txt │ ├── 1030000.txt │ ├── 1040000.txt │ ├── 1040001.txt │ ├── 1050000.txt │ ├── 1060000.txt │ ├── 1060001.txt │ ├── 1060003.txt │ ├── 1060004.txt │ ├── 1060100.txt │ ├── 1070000.txt │ ├── 1080000.txt │ ├── 1080100.txt │ ├── 1080200.txt │ ├── 1080300.txt │ ├── 1080301.txt │ ├── 1090000.txt │ ├── 1090100.txt │ ├── 1100001.txt │ ├── 1110000.txt │ ├── 1120000.txt │ ├── 1120100.txt │ ├── 1130000.txt │ ├── 1130100.txt │ ├── 1130200.txt │ ├── 1130300.txt │ ├── 1140000.txt │ ├── 1150000.txt │ ├── 1150100.txt │ ├── 1150200.txt │ ├── 1160000.txt │ ├── 1160100.txt │ ├── 1160200.txt │ ├── 1170000.txt │ ├── 1180000.txt │ ├── 1180100.txt │ ├── 1190000.txt │ ├── 1200000.txt │ ├── 1200100.txt │ ├── 1210000.txt │ ├── 1210100.txt │ ├── 1220000.txt │ ├── 1220100.txt │ ├── 1230000.txt │ ├── 1240000.txt │ ├── 1240001.txt │ ├── 1240100.txt │ ├── 1240200.txt │ ├── 1250000.txt │ ├── 1250100.txt │ ├── 1250200.txt │ ├── 1260000.txt │ ├── 1260100.txt │ ├── 1270000.txt │ ├── 1280000.txt │ ├── 1280100.txt │ ├── 1280200.txt │ ├── 1280300.txt │ ├── 1280400.txt │ ├── 1290000.txt │ ├── 1300000.txt │ └── 1300001.txt │ ├── full_description.txt │ ├── images │ ├── icon.png │ └── phoneScreenshots │ │ ├── 1.png │ │ ├── 2.png │ │ ├── 3.png │ │ ├── 4.png │ │ └── 5.png │ ├── short_description.txt │ └── title.txt ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── keystore └── debug.keystore ├── lint.xml ├── settings.gradle.kts ├── ui ├── .gitignore ├── build.gradle.kts └── src │ └── main │ ├── java │ └── com │ │ └── fibelatti │ │ └── ui │ │ ├── foundation │ │ ├── ButtonToggleGroup.kt │ │ ├── Density.kt │ │ ├── EmptyPainter.kt │ │ ├── Insets.kt │ │ ├── Modifier.kt │ │ ├── TextWithLinks.kt │ │ └── TransformGestureDetector.kt │ │ ├── imageviewer │ │ ├── ZoomableImageViewer.kt │ │ └── ZoomableImageViewerState.kt │ │ ├── preview │ │ ├── AllPreviews.kt │ │ ├── DevicePreviews.kt │ │ ├── LocalePreviews.kt │ │ └── ThemePreviews.kt │ │ ├── text │ │ └── AutoSizeText.kt │ │ └── theme │ │ ├── ExtendedColors.kt │ │ └── ExtendedTheme.kt │ └── res │ └── drawable │ └── ic_check.xml └── update-libraries-json.sh /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [gradlew.bat] 12 | end_of_line = crlf 13 | 14 | [*.{kt,kts}] 15 | max_line_length = 120 16 | ij_kotlin_allow_trailing_comma = true 17 | ij_kotlin_allow_trailing_comma_on_call_site = true 18 | ij_kotlin_imports_layout = * # alphabetical with capital letters before lower case letters (e.g. Z before a), no blank lines 19 | 20 | [*.yml] 21 | indent_size = 2 22 | -------------------------------------------------------------------------------- /.githooks/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo "Running Spotless" 3 | ./gradlew spotlessCheck 4 | exit 0 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Report something that's not working as expected 3 | title: "[Bug]: " 4 | labels: [ bug ] 5 | body: 6 | - type: textarea 7 | attributes: 8 | label: Describe the problem 9 | description: | 10 | Describe what's happening to the best of your ability. 11 | You can also write reproduction steps if you're comfortable with it. 12 | placeholder: | 13 | 1. Click on... 14 | 2. Choose... 15 | 3. Observe that... 16 | validations: 17 | required: true 18 | - type: markdown 19 | attributes: 20 | value: | 21 | # Device information 22 | - type: input 23 | attributes: 24 | label: Device model 25 | description: The name of the device model and manufacturer. 26 | placeholder: Google Pixel 9 27 | validations: 28 | required: false 29 | - type: input 30 | attributes: 31 | label: Android version 32 | description: You can find this in your phone's Settings app, then "About phone". 33 | placeholder: Android 14 34 | validations: 35 | required: true 36 | - type: input 37 | attributes: 38 | label: App version 39 | description: You can find this in the app menu. 40 | placeholder: 1.0.0 41 | validations: 42 | required: true 43 | - type: textarea 44 | attributes: 45 | label: Additional information 46 | description: | 47 | Use the field below to share any additional information you think might help. 48 | You can attach screenshots or screen recordings by dragging and dropping files. 49 | validations: 50 | required: false 51 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest a new feature or improvement 3 | title: "[Feature Request]: " 4 | labels: [ enhancement ] 5 | body: 6 | - type: textarea 7 | attributes: 8 | label: Describe your idea 9 | description: Describe it to the best of your ability. If your idea stems from a problem, try explaining why it is a problem and why it could be better 10 | validations: 11 | required: true 12 | - type: textarea 13 | attributes: 14 | label: Describe how you think it could work 15 | description: Try describing where and how you'd like to see your idea added to the app 16 | validations: 17 | required: true 18 | - type: textarea 19 | attributes: 20 | label: Additional context 21 | description: | 22 | Use the field below to share any additional information you think might help. 23 | You can attach screenshots or screen recordings by dragging and dropping files. 24 | validations: 25 | required: false 26 | -------------------------------------------------------------------------------- /.github/actions/setup-android-build/action.yml: -------------------------------------------------------------------------------- 1 | name: "Setup Android Build" 2 | description: "Common steps before running a Gradle command" 3 | 4 | runs: 5 | using: "composite" 6 | steps: 7 | - name: Setup JDK 8 | uses: actions/setup-java@v4 9 | with: 10 | distribution: 'temurin' 11 | java-version: 21 12 | 13 | - name: Setup Gradle 14 | uses: gradle/actions/setup-gradle@v4 15 | with: 16 | add-job-summary: never 17 | 18 | - name: Optimize for Gradle build 19 | shell: bash 20 | run: ${{ github.action_path }}/setup-gradle-properties.sh 21 | -------------------------------------------------------------------------------- /.github/actions/setup-android-build/setup-gradle-properties.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | set -e 4 | 5 | readonly workdir="$GITHUB_WORKSPACE" 6 | readonly properties_file="$workdir/gradle.properties" 7 | 8 | write_property() { 9 | echo "$1" >>$properties_file 10 | } 11 | 12 | write_common_properties() { 13 | echo "Writing common Gradle properties for the GHA runner" 14 | 15 | # Gradle properties common to all build environments 16 | write_property "org.gradle.caching=true" 17 | write_property "org.gradle.configuration-cache=true" 18 | write_property "org.gradle.configuration-cache.parallel=true" 19 | write_property "org.gradle.configureondemand=true" 20 | write_property "org.gradle.daemon=true" 21 | write_property "org.gradle.parallel=true" 22 | write_property "org.gradle.logging.stacktrace=all" 23 | 24 | # Kotlin properties common to all build environments 25 | write_property "kotlin.code.style=official" 26 | write_property "ksp.useKSP2=true" 27 | 28 | # Android properties common to all build environments 29 | write_property "android.useAndroidX=true" 30 | } 31 | 32 | write_macos_properties() { 33 | echo "Fine tuning Gradle properties for MacOS GHA runner" 34 | 35 | write_property "org.gradle.jvmargs=-Xmx8g -Xms512m -XX:+HeapDumpOnOutOfMemoryError -XX:+UseParallelGC -Dfile.encoding=UTF-8" 36 | write_property "kotlin.daemon.jvmargs=-Xmx2g -Xms512m -XX:+HeapDumpOnOutOfMemoryError -XX:+UseParallelGC -Dfile.encoding=UTF-8" 37 | } 38 | 39 | write_linux_properties() { 40 | echo "Fine tuning Gradle properties for Linux GHA runner" 41 | 42 | write_property "org.gradle.jvmargs=-Xmx4g -Xms512m -XX:+HeapDumpOnOutOfMemoryError -XX:+UseParallelGC -Dfile.encoding=UTF-8" 43 | write_property "kotlin.daemon.jvmargs=-Xmx2g -Xms512m -XX:+HeapDumpOnOutOfMemoryError -XX:+UseParallelGC -Dfile.encoding=UTF-8" 44 | } 45 | 46 | rm "$properties_file" && touch "$properties_file" 47 | 48 | echo 49 | write_common_properties 50 | 51 | case "$RUNNER_OS" in 52 | "macOS") 53 | write_macos_properties 54 | ;; 55 | *) 56 | write_linux_properties 57 | ;; 58 | esac 59 | 60 | echo 61 | cat $properties_file 62 | echo 63 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | 8 | - package-ecosystem: "gradle" 9 | directory: "/" 10 | open-pull-requests-limit: 10 11 | schedule: 12 | interval: "daily" 13 | groups: 14 | kotlin: 15 | patterns: 16 | - "org.jetbrains.kotlin:*" 17 | - "org.jetbrains.kotlinx:*" 18 | - "com.google.devtools.ksp" 19 | -------------------------------------------------------------------------------- /.github/workflows/android-ci.yml: -------------------------------------------------------------------------------- 1 | name: Android CI 2 | 3 | on: [ push, pull_request ] 4 | 5 | concurrency: 6 | group: ${{ github.workflow }}-${{ github.ref }} 7 | cancel-in-progress: true 8 | 9 | jobs: 10 | build: 11 | name: Build 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Project checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup Android build 19 | uses: ./.github/actions/setup-android-build 20 | 21 | - name: Build 22 | run: ./gradlew assemble 23 | 24 | - name: Upload APK 25 | if: success() 26 | uses: actions/upload-artifact@v4 27 | with: 28 | path: app/build/outputs/apk/debug/*.apk 29 | 30 | code_analysis: 31 | name: Code Analysis 32 | runs-on: ubuntu-latest 33 | steps: 34 | - name: Project checkout 35 | uses: actions/checkout@v4 36 | 37 | - name: Setup Android build 38 | uses: ./.github/actions/setup-android-build 39 | 40 | - name: Spotless check 41 | run: ./gradlew spotlessCheck 42 | 43 | lint: 44 | name: Lint 45 | runs-on: ubuntu-latest 46 | 47 | steps: 48 | - name: Project checkout 49 | uses: actions/checkout@v4 50 | 51 | - name: Setup Android build 52 | uses: ./.github/actions/setup-android-build 53 | 54 | - name: Build 55 | run: ./gradlew lint 56 | 57 | license-check: 58 | name: License Check 59 | runs-on: ubuntu-latest 60 | steps: 61 | - name: Project checkout 62 | uses: actions/checkout@v4 63 | 64 | - name: Setup Android build 65 | uses: ./.github/actions/setup-android-build 66 | 67 | - name: Build 68 | run: ./gradlew licensee 69 | -------------------------------------------------------------------------------- /.github/workflows/crowdin.yml: -------------------------------------------------------------------------------- 1 | name: Crowdin Translations 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - app/src/main/res/values*/strings.xml 9 | schedule: 10 | - cron: '0 9 * * 0' # Every Sunday at 9 11 | workflow_dispatch: 12 | 13 | permissions: 14 | contents: write 15 | pull-requests: write 16 | 17 | jobs: 18 | synchronize-with-crowdin: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Project checkout 22 | uses: actions/checkout@v4 23 | 24 | - name: Synchronize 25 | uses: crowdin/github-action@v2 26 | with: 27 | upload_sources: true 28 | upload_translations: true 29 | auto_approve_imported: true 30 | download_translations: true 31 | source: app/src/main/res/values/strings.xml 32 | translation: app/src/main/res/values-%two_letters_code%/strings.xml 33 | localization_branch_name: l10n_crowdin_translations 34 | create_pull_request: true 35 | pull_request_title: 'New Crowdin Translations' 36 | pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)' 37 | pull_request_base_branch_name: 'main' 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} 41 | CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} 42 | -------------------------------------------------------------------------------- /.github/workflows/signed-build.yml: -------------------------------------------------------------------------------- 1 | name: Signed Build 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | build-signed-apk: 14 | name: Build Signed APK 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Project Checkout 19 | uses: actions/checkout@v4 20 | 21 | - name: Setup Android Build 22 | uses: ./.github/actions/setup-android-build 23 | 24 | - name: Decode Keystore 25 | run: | 26 | echo "${{ secrets.KEYSTORE }}" | base64 -d > ${{ github.workspace }}/keystore/release.jks 27 | 28 | - name: Build 29 | run: ./gradlew app:assembleRelease 30 | env: 31 | SIGN_BUILD: true 32 | SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }} 33 | SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }} 34 | SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }} 35 | 36 | - name: Upload APK 37 | if: success() 38 | uses: actions/upload-artifact@v4 39 | with: 40 | name: signed-apk 41 | path: app/build/outputs/apk/release/*.apk 42 | 43 | - id: version 44 | name: Get version code 45 | if: ${{ github.ref_type == 'tag' }} 46 | run: | 47 | echo "VERSION_CODE=$(./gradlew -q app:printReleaseVersionCode | tail -n 1)" >> $GITHUB_OUTPUT 48 | 49 | - name: Create GitHub release 50 | if: ${{ github.ref_type == 'tag' }} 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 53 | run: | 54 | gh release create ${{ github.ref_name }} \ 55 | app/build/outputs/apk/release/*.apk \ 56 | --title="${{ github.ref_name }}" \ 57 | --notes-file fastlane/metadata/android/en-US/changelogs/${{ steps.version.outputs.VERSION_CODE }}.txt 58 | 59 | build-signed-aab: 60 | name: Build Signed AAB 61 | runs-on: ubuntu-latest 62 | 63 | steps: 64 | - name: Project Checkout 65 | uses: actions/checkout@v4 66 | 67 | - name: Setup Android Build 68 | uses: ./.github/actions/setup-android-build 69 | 70 | - name: Decode Keystore 71 | run: | 72 | echo "${{ secrets.KEYSTORE }}" | base64 -d > ${{ github.workspace }}/keystore/release.jks 73 | 74 | - name: Build 75 | run: ./gradlew app:bundleRelease 76 | env: 77 | SIGN_BUILD: true 78 | SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }} 79 | SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }} 80 | SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }} 81 | 82 | - name: Upload AAB 83 | if: success() 84 | uses: actions/upload-artifact@v4 85 | with: 86 | name: signed-aab 87 | path: app/build/outputs/bundle/release/*.aab 88 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Built application files 2 | *.apk 3 | *.aab 4 | *.ap_ 5 | 6 | # Keystore 7 | /keystore/ 8 | 9 | # Gradle 10 | # ------ 11 | .gradle/ 12 | /build 13 | /buildSrc/build 14 | /captures 15 | .externalNativeBuild 16 | 17 | # IDEA 18 | # ---- 19 | .idea/* 20 | *.iml 21 | 22 | # Local configuration file (sdk path, etc) 23 | local.properties 24 | 25 | # OS-specific files 26 | .DS_Store 27 | .DS_Store? 28 | ._* 29 | .Spotlight-V100 30 | .Trashes 31 | ehthumbs.db 32 | Thumbs.db 33 | -------------------------------------------------------------------------------- /.run/custom-configurations.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 18 | 20 | true 21 | true 22 | false 23 | false 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/proguard-dictionary.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This obfuscation dictionary contains names that are not allowed as file names 3 | # in Windows, not even with extensions like .class or .java. They can however 4 | # be used without problems in jar archives, which just begs to apply them as 5 | # obfuscated class names. Trying to unpack the obfuscated archives in Windows 6 | # will probably generate some sparks. 7 | # 8 | 9 | aux 10 | Aux 11 | aUx 12 | AUx 13 | auX 14 | AuX 15 | aUX 16 | AUX 17 | con 18 | Con 19 | cOn 20 | COn 21 | coN 22 | CoN 23 | cON 24 | CON 25 | nul 26 | Nul 27 | nUl 28 | NUl 29 | nuL 30 | NuL 31 | nUL 32 | NUL 33 | prn 34 | Prn 35 | pRn 36 | PRn 37 | prN 38 | PrN 39 | pRN 40 | PRN 41 | com1 42 | Com1 43 | cOm1 44 | COm1 45 | coM1 46 | CoM1 47 | cOM1 48 | COM1 49 | com2 50 | Com2 51 | cOm2 52 | COm2 53 | coM2 54 | CoM2 55 | cOM2 56 | COM2 57 | com3 58 | Com3 59 | cOm3 60 | COm3 61 | coM3 62 | CoM3 63 | cOM3 64 | COM3 65 | com4 66 | Com4 67 | cOm4 68 | COm4 69 | coM4 70 | CoM4 71 | cOM4 72 | COM4 73 | com5 74 | Com5 75 | cOm5 76 | COm5 77 | coM5 78 | CoM5 79 | cOM5 80 | COM5 81 | com6 82 | Com6 83 | cOm6 84 | COm6 85 | coM6 86 | CoM6 87 | cOM6 88 | COM6 89 | com7 90 | Com7 91 | cOm7 92 | COm7 93 | coM7 94 | cOM7 95 | COM7 96 | com8 97 | Com8 98 | cOm8 99 | COm8 100 | coM8 101 | CoM8 102 | cOM8 103 | COM8 104 | com9 105 | Com9 106 | cOm9 107 | COm9 108 | coM9 109 | CoM9 110 | cOM9 111 | COM9 112 | lpt1 113 | Lpt1 114 | lPt1 115 | LPt1 116 | lpT1 117 | LpT1 118 | lPT1 119 | LPT1 120 | lpt2 121 | Lpt2 122 | lPt2 123 | LPt2 124 | lpT2 125 | LpT2 126 | lPT2 127 | LPT2 128 | lpt3 129 | Lpt3 130 | lPt3 131 | LPt3 132 | lpT3 133 | LpT3 134 | lPT3 135 | LPT3 136 | lpt4 137 | Lpt4 138 | lPt4 139 | LPt4 140 | lpT4 141 | LpT4 142 | lPT4 143 | LPT4 144 | lpt5 145 | Lpt5 146 | lPt5 147 | LPt5 148 | lpT5 149 | LpT5 150 | lPT5 151 | LPT5 152 | lpt6 153 | Lpt6 154 | lPt6 155 | LPt6 156 | lpT6 157 | LpT6 158 | lPT6 159 | LPT6 160 | lpt7 161 | Lpt7 162 | lPt7 163 | LPt7 164 | lpT7 165 | LpT7 166 | lPT7 167 | LPT7 168 | lpt8 169 | Lpt8 170 | lPt8 171 | LPt8 172 | lpT8 173 | LpT8 174 | LPT8 175 | lpt9 176 | Lpt9 177 | lPt9 178 | LPt9 179 | lpT9 180 | LpT9 181 | lPT9 182 | LPT9 183 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | -obfuscationdictionary proguard-dictionary.txt 9 | -packageobfuscationdictionary proguard-dictionary.txt 10 | -classobfuscationdictionary proguard-dictionary.txt 11 | 12 | # Debugging 13 | -keepattributes LineNumberTable, SourceFile 14 | 15 | # Common attributes 16 | -keepattributes Signature, Exceptions, InnerClasses, EnclosingMethod, *Annotation* 17 | 18 | # Kotlin 19 | -keep class kotlin.** { *; } 20 | -keep class kotlin.Metadata { *; } 21 | -keepclassmembers class kotlin.Metadata { 22 | public ; 23 | } 24 | -assumenosideeffects class kotlin.jvm.internal.Intrinsics { 25 | static void checkParameterIsNotNull(java.lang.Object, java.lang.String); 26 | } 27 | 28 | -dontnote kotlin.coroutines.jvm.internal.DebugMetadataKt** 29 | -dontnote kotlin.internal.PlatformImplementationsKt 30 | -dontnote kotlin.jvm.internal.Reflection 31 | -dontnote kotlin.reflect.jvm.internal.KClassImpl** 32 | -dontwarn kotlinx.atomicfu.AtomicBoolean 33 | 34 | # Coroutines 35 | -keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} 36 | -keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} 37 | -keepclassmembernames class kotlinx.** { 38 | volatile ; 39 | } 40 | -keepnames class kotlinx.coroutines.android.AndroidExceptionPreHandler {} 41 | -keepnames class kotlinx.coroutines.android.AndroidDispatcherFactory {} 42 | -dontwarn kotlinx.coroutines.flow.**inlined** 43 | 44 | # Material 45 | -dontnote com.google.android.material.** 46 | -dontnote android.widget.** 47 | 48 | # UCrop 49 | 50 | -dontwarn com.yalantis.ucrop** 51 | -keep class com.yalantis.ucrop** { *; } 52 | -keep interface com.yalantis.ucrop** { *; } 53 | -------------------------------------------------------------------------------- /app/schemas/com.fibelatti.photowidget.widget.data.PhotoWidgetDatabase/1.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 1, 5 | "identityHash": "bf626604ad6149299ba3a484f665b707", 6 | "entities": [ 7 | { 8 | "tableName": "photo_widget_order", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`widgetId` INTEGER NOT NULL, `photoIndex` INTEGER NOT NULL, `photoId` TEXT NOT NULL, PRIMARY KEY(`widgetId`, `photoIndex`))", 10 | "fields": [ 11 | { 12 | "fieldPath": "widgetId", 13 | "columnName": "widgetId", 14 | "affinity": "INTEGER", 15 | "notNull": true 16 | }, 17 | { 18 | "fieldPath": "photoIndex", 19 | "columnName": "photoIndex", 20 | "affinity": "INTEGER", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "photoId", 25 | "columnName": "photoId", 26 | "affinity": "TEXT", 27 | "notNull": true 28 | } 29 | ], 30 | "primaryKey": { 31 | "autoGenerate": false, 32 | "columnNames": [ 33 | "widgetId", 34 | "photoIndex" 35 | ] 36 | }, 37 | "indices": [], 38 | "foreignKeys": [] 39 | } 40 | ], 41 | "views": [], 42 | "setupQueries": [ 43 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 44 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bf626604ad6149299ba3a484f665b707')" 45 | ] 46 | } 47 | } -------------------------------------------------------------------------------- /app/schemas/com.fibelatti.photowidget.widget.data.PhotoWidgetDatabase/2.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 2, 5 | "identityHash": "29e226a2a0e838d33ddd25dbc29586f5", 6 | "entities": [ 7 | { 8 | "tableName": "photo_widget_order", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`widgetId` INTEGER NOT NULL, `photoIndex` INTEGER NOT NULL, `photoId` TEXT NOT NULL, PRIMARY KEY(`widgetId`, `photoIndex`))", 10 | "fields": [ 11 | { 12 | "fieldPath": "widgetId", 13 | "columnName": "widgetId", 14 | "affinity": "INTEGER", 15 | "notNull": true 16 | }, 17 | { 18 | "fieldPath": "photoIndex", 19 | "columnName": "photoIndex", 20 | "affinity": "INTEGER", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "photoId", 25 | "columnName": "photoId", 26 | "affinity": "TEXT", 27 | "notNull": true 28 | } 29 | ], 30 | "primaryKey": { 31 | "autoGenerate": false, 32 | "columnNames": [ 33 | "widgetId", 34 | "photoIndex" 35 | ] 36 | }, 37 | "indices": [], 38 | "foreignKeys": [] 39 | }, 40 | { 41 | "tableName": "pending_deletion_widget_photos", 42 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`widgetId` INTEGER NOT NULL, `photoId` TEXT NOT NULL, `deletionTimestamp` INTEGER NOT NULL, PRIMARY KEY(`widgetId`, `photoId`))", 43 | "fields": [ 44 | { 45 | "fieldPath": "widgetId", 46 | "columnName": "widgetId", 47 | "affinity": "INTEGER", 48 | "notNull": true 49 | }, 50 | { 51 | "fieldPath": "photoId", 52 | "columnName": "photoId", 53 | "affinity": "TEXT", 54 | "notNull": true 55 | }, 56 | { 57 | "fieldPath": "deletionTimestamp", 58 | "columnName": "deletionTimestamp", 59 | "affinity": "INTEGER", 60 | "notNull": true 61 | } 62 | ], 63 | "primaryKey": { 64 | "autoGenerate": false, 65 | "columnNames": [ 66 | "widgetId", 67 | "photoId" 68 | ] 69 | }, 70 | "indices": [], 71 | "foreignKeys": [] 72 | } 73 | ], 74 | "views": [], 75 | "setupQueries": [ 76 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 77 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '29e226a2a0e838d33ddd25dbc29586f5')" 78 | ] 79 | } 80 | } -------------------------------------------------------------------------------- /app/src/main/java/com/fibelatti/photowidget/App.kt: -------------------------------------------------------------------------------- 1 | package com.fibelatti.photowidget 2 | 3 | import android.app.Application 4 | import android.os.StrictMode 5 | import androidx.appcompat.app.AppCompatDelegate 6 | import com.fibelatti.photowidget.platform.ConfigurationChangedReceiver 7 | import com.fibelatti.photowidget.preferences.Appearance 8 | import com.fibelatti.photowidget.preferences.UserPreferencesStorage 9 | import com.fibelatti.photowidget.widget.DeleteStaleDataUseCase 10 | import com.google.android.material.color.DynamicColors 11 | import com.google.android.material.color.DynamicColorsOptions 12 | import dagger.hilt.android.HiltAndroidApp 13 | import javax.inject.Inject 14 | import kotlinx.coroutines.CoroutineScope 15 | import kotlinx.coroutines.launch 16 | import timber.log.Timber 17 | 18 | @HiltAndroidApp 19 | class App : Application() { 20 | 21 | @Inject 22 | lateinit var userPreferencesStorage: UserPreferencesStorage 23 | 24 | @Inject 25 | lateinit var coroutineScope: CoroutineScope 26 | 27 | @Inject 28 | lateinit var deleteStaleDataUseCase: DeleteStaleDataUseCase 29 | 30 | override fun onCreate() { 31 | super.onCreate() 32 | 33 | setupDebugMode() 34 | setupNightMode() 35 | setupDynamicColors() 36 | deleteStaleData() 37 | registerReceivers() 38 | } 39 | 40 | private fun setupDebugMode() { 41 | if (!BuildConfig.DEBUG) return 42 | 43 | Timber.plant(Timber.DebugTree()) 44 | 45 | StrictMode.setThreadPolicy( 46 | StrictMode.ThreadPolicy.Builder() 47 | .detectAll() 48 | .penaltyLog() 49 | .build(), 50 | ) 51 | 52 | StrictMode.setVmPolicy( 53 | StrictMode.VmPolicy.Builder() 54 | .detectAll() 55 | .penaltyLog() 56 | .build(), 57 | ) 58 | } 59 | 60 | private fun setupNightMode() { 61 | val mode = when (userPreferencesStorage.appearance) { 62 | Appearance.DARK -> AppCompatDelegate.MODE_NIGHT_YES 63 | Appearance.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO 64 | else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM 65 | } 66 | 67 | AppCompatDelegate.setDefaultNightMode(mode) 68 | } 69 | 70 | private fun setupDynamicColors() { 71 | val dynamicColorsOptions = DynamicColorsOptions.Builder() 72 | .setThemeOverlay(R.style.AppTheme_Overlay) 73 | .setPrecondition { _, _ -> userPreferencesStorage.dynamicColors } 74 | .build() 75 | 76 | DynamicColors.applyToActivitiesIfAvailable(this, dynamicColorsOptions) 77 | } 78 | 79 | private fun deleteStaleData() { 80 | coroutineScope.launch { 81 | deleteStaleDataUseCase() 82 | } 83 | } 84 | 85 | private fun registerReceivers() { 86 | ConfigurationChangedReceiver.register(context = this) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /app/src/main/java/com/fibelatti/photowidget/chooser/PhotoWidgetChooserViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.fibelatti.photowidget.chooser 2 | 3 | import android.appwidget.AppWidgetManager 4 | import androidx.lifecycle.SavedStateHandle 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.viewModelScope 7 | import com.fibelatti.photowidget.model.LocalPhoto 8 | import com.fibelatti.photowidget.model.PhotoWidget 9 | import com.fibelatti.photowidget.platform.savedState 10 | import com.fibelatti.photowidget.widget.LoadPhotoWidgetUseCase 11 | import com.fibelatti.photowidget.widget.data.PhotoWidgetStorage 12 | import dagger.hilt.android.lifecycle.HiltViewModel 13 | import javax.inject.Inject 14 | import kotlinx.coroutines.flow.SharingStarted 15 | import kotlinx.coroutines.flow.StateFlow 16 | import kotlinx.coroutines.flow.stateIn 17 | import timber.log.Timber 18 | 19 | @HiltViewModel 20 | class PhotoWidgetChooserViewModel @Inject constructor( 21 | savedStateHandle: SavedStateHandle, 22 | loadPhotoWidgetUseCase: LoadPhotoWidgetUseCase, 23 | private val photoWidgetStorage: PhotoWidgetStorage, 24 | ) : ViewModel() { 25 | 26 | private val appWidgetId: Int by savedStateHandle.savedState( 27 | key = AppWidgetManager.EXTRA_APPWIDGET_ID, 28 | default = AppWidgetManager.INVALID_APPWIDGET_ID, 29 | ) 30 | 31 | val state: StateFlow = loadPhotoWidgetUseCase(appWidgetId = appWidgetId) 32 | .stateIn(scope = viewModelScope, started = SharingStarted.WhileSubscribed(), initialValue = null) 33 | 34 | suspend fun setPhoto(photo: LocalPhoto) { 35 | Timber.d("Updating current photo to ${photo.photoId}") 36 | photoWidgetStorage.clearDisplayedPhotos(appWidgetId = appWidgetId) 37 | photoWidgetStorage.saveDisplayedPhoto(appWidgetId = appWidgetId, photoId = photo.photoId) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/src/main/java/com/fibelatti/photowidget/configure/DirectorySortingPicker.kt: -------------------------------------------------------------------------------- 1 | package com.fibelatti.photowidget.configure 2 | 3 | import android.content.Context 4 | import androidx.compose.foundation.layout.fillMaxWidth 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.Text 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.res.stringResource 10 | import androidx.compose.ui.text.style.TextAlign 11 | import androidx.compose.ui.unit.dp 12 | import com.fibelatti.photowidget.R 13 | import com.fibelatti.photowidget.model.DirectorySorting 14 | import com.fibelatti.photowidget.platform.SelectionDialog 15 | 16 | object DirectorySortingPicker { 17 | 18 | fun show( 19 | context: Context, 20 | onItemClick: (DirectorySorting) -> Unit, 21 | ) { 22 | SelectionDialog.show( 23 | context = context, 24 | title = context.getString(R.string.photo_widget_directory_sort_title), 25 | options = DirectorySorting.entries, 26 | optionName = { context.getString(it.label) }, 27 | onOptionSelected = onItemClick, 28 | footer = { 29 | Text( 30 | text = stringResource(R.string.photo_widget_directory_sort_explanation), 31 | modifier = Modifier 32 | .fillMaxWidth() 33 | .padding(all = 16.dp), 34 | color = MaterialTheme.colorScheme.onBackground, 35 | textAlign = TextAlign.Center, 36 | style = MaterialTheme.typography.labelMedium, 37 | ) 38 | }, 39 | ) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /app/src/main/java/com/fibelatti/photowidget/configure/ExactAlarmsDialog.kt: -------------------------------------------------------------------------------- 1 | package com.fibelatti.photowidget.configure 2 | 3 | import androidx.compose.material3.AlertDialog 4 | import androidx.compose.material3.Text 5 | import androidx.compose.material3.TextButton 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.res.stringResource 9 | import com.fibelatti.photowidget.R 10 | 11 | @Composable 12 | fun ExactAlarmsDialog( 13 | onDismiss: () -> Unit, 14 | onConfirm: () -> Unit, 15 | modifier: Modifier = Modifier, 16 | ) { 17 | AlertDialog( 18 | onDismissRequest = onDismiss, 19 | confirmButton = { 20 | TextButton( 21 | onClick = onConfirm, 22 | ) { 23 | Text(text = stringResource(id = R.string.photo_widget_configure_interval_open_settings)) 24 | } 25 | }, 26 | modifier = modifier, 27 | title = { 28 | Text(text = stringResource(id = R.string.photo_widget_configure_interval_permission_dialog_title)) 29 | }, 30 | text = { 31 | Text(text = stringResource(id = R.string.photo_widget_configure_interval_permission_dialog_description)) 32 | }, 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/java/com/fibelatti/photowidget/configure/IntentKtx.kt: -------------------------------------------------------------------------------- 1 | package com.fibelatti.photowidget.configure 2 | 3 | import android.appwidget.AppWidgetManager 4 | import android.content.Intent 5 | import android.net.Uri 6 | import com.fibelatti.photowidget.model.PhotoWidgetAspectRatio 7 | import com.fibelatti.photowidget.platform.intentExtras 8 | 9 | var Intent.appWidgetId: Int by intentExtras( 10 | key = AppWidgetManager.EXTRA_APPWIDGET_ID, 11 | default = AppWidgetManager.INVALID_APPWIDGET_ID, 12 | ) 13 | 14 | var Intent.duplicateFromId: Int? by intentExtras() 15 | 16 | var Intent.restoreFromId: Int? by intentExtras() 17 | 18 | var Intent.aspectRatio: PhotoWidgetAspectRatio by intentExtras() 19 | 20 | var Intent.sharedPhotos: List? by intentExtras() 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/fibelatti/photowidget/configure/LocalSamplePhoto.kt: -------------------------------------------------------------------------------- 1 | package com.fibelatti.photowidget.configure 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Context 5 | import android.content.res.Resources 6 | import android.graphics.Bitmap 7 | import android.graphics.BitmapFactory 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.LaunchedEffect 10 | import androidx.compose.runtime.getValue 11 | import androidx.compose.runtime.mutableStateOf 12 | import androidx.compose.runtime.remember 13 | import androidx.compose.runtime.setValue 14 | import androidx.compose.runtime.staticCompositionLocalOf 15 | import androidx.compose.ui.platform.LocalContext 16 | import androidx.compose.ui.platform.LocalResources 17 | import com.fibelatti.photowidget.R 18 | import com.fibelatti.photowidget.di.PhotoWidgetEntryPoint 19 | import com.fibelatti.photowidget.di.entryPoint 20 | import com.fibelatti.photowidget.model.LocalPhoto 21 | import com.fibelatti.photowidget.platform.PhotoDecoder 22 | 23 | @SuppressLint("ComposeCompositionLocalUsage") 24 | val LocalSamplePhoto = staticCompositionLocalOf { null } 25 | 26 | @Composable 27 | fun rememberSampleBitmap(): Bitmap { 28 | val localContext: Context = LocalContext.current 29 | val localResources: Resources = LocalResources.current 30 | val localPhoto: LocalPhoto? = LocalSamplePhoto.current 31 | val decoder: PhotoDecoder by remember { 32 | lazy { entryPoint(localContext).photoDecoder() } 33 | } 34 | 35 | var bitmap: Bitmap by remember { 36 | mutableStateOf(BitmapFactory.decodeResource(localResources, R.drawable.image_sample)) 37 | } 38 | 39 | LaunchedEffect(localPhoto) { 40 | localPhoto?.getPhotoPath()?.let { path -> 41 | decoder.decode(data = path, maxDimension = 400)?.let { result -> 42 | bitmap = result 43 | } 44 | } 45 | } 46 | 47 | return bitmap 48 | } 49 | -------------------------------------------------------------------------------- /app/src/main/java/com/fibelatti/photowidget/configure/PhotoWidgetAspectRatioPicker.kt: -------------------------------------------------------------------------------- 1 | package com.fibelatti.photowidget.configure 2 | 3 | import android.content.Context 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.material3.MaterialTheme 9 | import androidx.compose.material3.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.res.stringResource 13 | import androidx.compose.ui.text.style.TextAlign 14 | import androidx.compose.ui.unit.dp 15 | import com.fibelatti.photowidget.R 16 | import com.fibelatti.photowidget.home.AspectRatioPicker 17 | import com.fibelatti.photowidget.model.PhotoWidgetAspectRatio 18 | import com.fibelatti.photowidget.platform.ComposeBottomSheetDialog 19 | 20 | object PhotoWidgetAspectRatioPicker { 21 | 22 | fun show( 23 | context: Context, 24 | onAspectRatioSelected: (PhotoWidgetAspectRatio) -> Unit, 25 | ) { 26 | ComposeBottomSheetDialog(context) { 27 | AspectRatioPickerContent( 28 | onAspectRatioSelected = { newAspectRatio -> 29 | onAspectRatioSelected(newAspectRatio) 30 | dismiss() 31 | }, 32 | ) 33 | }.show() 34 | } 35 | } 36 | 37 | @Composable 38 | private fun AspectRatioPickerContent( 39 | onAspectRatioSelected: (PhotoWidgetAspectRatio) -> Unit, 40 | ) { 41 | Column( 42 | modifier = Modifier 43 | .fillMaxWidth() 44 | .padding(vertical = 16.dp), 45 | verticalArrangement = Arrangement.spacedBy(8.dp), 46 | ) { 47 | Text( 48 | text = stringResource(R.string.photo_widget_aspect_ratio_title), 49 | modifier = Modifier.fillMaxWidth(), 50 | color = MaterialTheme.colorScheme.onSurface, 51 | textAlign = TextAlign.Center, 52 | style = MaterialTheme.typography.titleLarge, 53 | ) 54 | 55 | AspectRatioPicker( 56 | onAspectRatioSelected = onAspectRatioSelected, 57 | modifier = Modifier.fillMaxWidth(), 58 | ) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /app/src/main/java/com/fibelatti/photowidget/configure/PhotoWidgetConfigureState.kt: -------------------------------------------------------------------------------- 1 | package com.fibelatti.photowidget.configure 2 | 3 | import android.net.Uri 4 | import com.fibelatti.photowidget.model.LocalPhoto 5 | import com.fibelatti.photowidget.model.PhotoWidget 6 | import com.fibelatti.photowidget.model.PhotoWidgetAspectRatio 7 | 8 | data class PhotoWidgetConfigureState( 9 | val photoWidget: PhotoWidget = PhotoWidget(), 10 | val selectedPhoto: LocalPhoto? = null, 11 | val isProcessing: Boolean = true, 12 | val cropQueue: List = emptyList(), 13 | val messages: List = emptyList(), 14 | val hasEdits: Boolean = false, 15 | ) { 16 | 17 | sealed class Message { 18 | 19 | data object SuggestImport : Message() 20 | 21 | data object ImportFailed : Message() 22 | 23 | data object TooManyPhotos : Message() 24 | 25 | data class LaunchCrop( 26 | val source: Uri, 27 | val destination: Uri, 28 | val aspectRatio: PhotoWidgetAspectRatio, 29 | ) : Message() 30 | 31 | data object RequestPin : Message() 32 | 33 | data class AddWidget(val appWidgetId: Int) : Message() 34 | 35 | data object MissingPhotos : Message() 36 | 37 | data object CancelWidget : Message() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/src/main/java/com/fibelatti/photowidget/configure/PhotoWidgetPinnedReceiver.kt: -------------------------------------------------------------------------------- 1 | package com.fibelatti.photowidget.configure 2 | 3 | import android.appwidget.AppWidgetManager 4 | import android.content.BroadcastReceiver 5 | import android.content.Context 6 | import android.content.Intent 7 | import androidx.localbroadcastmanager.content.LocalBroadcastManager 8 | import com.fibelatti.photowidget.di.PhotoWidgetEntryPoint 9 | import com.fibelatti.photowidget.platform.EntryPointBroadcastReceiver 10 | import com.fibelatti.photowidget.widget.PhotoWidgetProvider 11 | import kotlinx.coroutines.launch 12 | import timber.log.Timber 13 | 14 | /** 15 | * [BroadcastReceiver] to handle the callback from [AppWidgetManager.requestPinAppWidget]. 16 | */ 17 | class PhotoWidgetPinnedReceiver : EntryPointBroadcastReceiver() { 18 | 19 | override fun doWork(context: Context, intent: Intent, entryPoint: PhotoWidgetEntryPoint) { 20 | Timber.d("Working... (appWidgetId=${intent.appWidgetId})") 21 | 22 | val widgetId = intent.appWidgetId 23 | .takeUnless { it == AppWidgetManager.INVALID_APPWIDGET_ID } 24 | // Workaround Samsung devices that fail to update the intent with the actual ID 25 | ?: PhotoWidgetProvider.ids(context = context).lastOrNull() 26 | // Exit early if the widget was not placed 27 | ?: return 28 | 29 | val pinningCache = entryPoint.photoWidgetPinningCache() 30 | 31 | // The widget data is missing, it's impossible to continue 32 | val photoWidget = pinningCache.consume() ?: return 33 | 34 | Timber.d("New widget ID: $widgetId") 35 | 36 | val saveUseCase = entryPoint.savePhotoWidgetUseCase() 37 | val coroutineScope = entryPoint.coroutineScope() 38 | 39 | coroutineScope.launch { 40 | // Persist the widget data since it was placed on the home screen 41 | saveUseCase(appWidgetId = widgetId, photoWidget = photoWidget) 42 | 43 | // Update the widget UI using the updated storage data 44 | PhotoWidgetProvider.update(context = context, appWidgetId = widgetId) 45 | 46 | // Finally finish the configure activity since it's no longer needed 47 | val finishIntent = Intent(PhotoWidgetConfigureActivity.ACTION_FINISH).apply { 48 | this.appWidgetId = widgetId 49 | } 50 | LocalBroadcastManager.getInstance(context).sendBroadcast(finishIntent) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/src/main/java/com/fibelatti/photowidget/configure/PhotoWidgetPinningCache.kt: -------------------------------------------------------------------------------- 1 | package com.fibelatti.photowidget.configure 2 | 3 | import com.fibelatti.photowidget.model.PhotoWidget 4 | import javax.inject.Inject 5 | import javax.inject.Singleton 6 | 7 | /** 8 | * This class keeps temporary widget data available in-memory. The data is available between a pin 9 | * request and its confirmation callback being received. 10 | * 11 | * It is used to workaround two scenarios: 12 | * 13 | * - `AppWidgetManager#requestPinAppWidget` can fail due to the transaction being too large if the 14 | * [widget data][PhotoWidget] is added to the bundle of the success callback intent. The extras are 15 | * also not delivered on Samsung devices, so instead they are shared using this class. 16 | * 17 | * - `AppWidgetProvider#onUpdate` is called with the new widget ID as the user starts dragging the 18 | * widget from the pinning dialog to their home screen, but `PhotoWidgetPinnedReceiver` wouldn't 19 | * have been called yet to move the content to the directory that corresponds to that ID. 20 | */ 21 | @Singleton 22 | class PhotoWidgetPinningCache @Inject constructor() { 23 | 24 | /** 25 | * Returns the [PhotoWidget] that's currently being pinned. 26 | */ 27 | var pendingWidget: PhotoWidget? = null 28 | private set 29 | 30 | /** 31 | * Sets the data corresponding to the widget that's being pinned. 32 | */ 33 | fun populate(pendingWidget: PhotoWidget) { 34 | this.pendingWidget = pendingWidget 35 | } 36 | 37 | /** 38 | * Gets the data for the widget being pinned and clears the cache. 39 | */ 40 | fun consume(): PhotoWidget? = pendingWidget.also { pendingWidget = null } 41 | } 42 | -------------------------------------------------------------------------------- /app/src/main/java/com/fibelatti/photowidget/di/EntryPointKtx.kt: -------------------------------------------------------------------------------- 1 | package com.fibelatti.photowidget.di 2 | 3 | import android.content.Context 4 | import dagger.hilt.android.EntryPointAccessors 5 | 6 | inline fun entryPoint(context: Context): T { 7 | return EntryPointAccessors.fromApplication(context = context.applicationContext) 8 | } 9 | -------------------------------------------------------------------------------- /app/src/main/java/com/fibelatti/photowidget/di/PhotoWidgetEntryPoint.kt: -------------------------------------------------------------------------------- 1 | package com.fibelatti.photowidget.di 2 | 3 | import com.fibelatti.photowidget.configure.PhotoWidgetPinningCache 4 | import com.fibelatti.photowidget.configure.SavePhotoWidgetUseCase 5 | import com.fibelatti.photowidget.platform.PhotoDecoder 6 | import com.fibelatti.photowidget.preferences.UserPreferencesStorage 7 | import com.fibelatti.photowidget.widget.CyclePhotoUseCase 8 | import com.fibelatti.photowidget.widget.LoadPhotoWidgetUseCase 9 | import com.fibelatti.photowidget.widget.PhotoWidgetAlarmManager 10 | import com.fibelatti.photowidget.widget.PrepareCurrentPhotoUseCase 11 | import com.fibelatti.photowidget.widget.data.PhotoWidgetStorage 12 | import dagger.hilt.EntryPoint 13 | import dagger.hilt.InstallIn 14 | import dagger.hilt.components.SingletonComponent 15 | import kotlinx.coroutines.CoroutineScope 16 | 17 | @EntryPoint 18 | @InstallIn(SingletonComponent::class) 19 | interface PhotoWidgetEntryPoint { 20 | 21 | fun userPreferencesStorage(): UserPreferencesStorage 22 | 23 | fun photoWidgetStorage(): PhotoWidgetStorage 24 | 25 | fun photoWidgetPinningCache(): PhotoWidgetPinningCache 26 | 27 | fun photoWidgetAlarmManager(): PhotoWidgetAlarmManager 28 | 29 | fun loadPhotoWidgetUseCase(): LoadPhotoWidgetUseCase 30 | 31 | fun savePhotoWidgetUseCase(): SavePhotoWidgetUseCase 32 | 33 | fun prepareCurrentPhotoUseCase(): PrepareCurrentPhotoUseCase 34 | 35 | fun cyclePhotoUseCase(): CyclePhotoUseCase 36 | 37 | fun photoDecoder(): PhotoDecoder 38 | 39 | fun coroutineScope(): CoroutineScope 40 | } 41 | -------------------------------------------------------------------------------- /app/src/main/java/com/fibelatti/photowidget/hints/HintStorage.kt: -------------------------------------------------------------------------------- 1 | package com.fibelatti.photowidget.hints 2 | 3 | import android.content.Context 4 | import androidx.core.content.edit 5 | import dagger.hilt.android.qualifiers.ApplicationContext 6 | import javax.inject.Inject 7 | 8 | class HintStorage @Inject constructor(@ApplicationContext context: Context) { 9 | 10 | private val sharedPreferences = context.getSharedPreferences( 11 | "com.fibelatti.photowidget.HintPreferences", 12 | Context.MODE_PRIVATE, 13 | ) 14 | 15 | var showHomeBackgroundRestrictionsHint: Boolean 16 | get() = sharedPreferences.getBoolean(Hint.HOME_BACKGROUND_RESTRICTIONS.value, true) 17 | set(value) { 18 | sharedPreferences.edit { putBoolean(Hint.HOME_BACKGROUND_RESTRICTIONS.value, value) } 19 | } 20 | 21 | private enum class Hint(val value: String) { 22 | HOME_BACKGROUND_RESTRICTIONS(value = "hint_home_background_restrictions"), 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/java/com/fibelatti/photowidget/home/HelpArticle.kt: -------------------------------------------------------------------------------- 1 | package com.fibelatti.photowidget.home 2 | 3 | import androidx.annotation.StringRes 4 | import com.fibelatti.photowidget.R 5 | 6 | data class HelpArticle( 7 | @StringRes val title: Int, 8 | @StringRes val body: Int, 9 | ) { 10 | 11 | companion object { 12 | 13 | fun allArticles(): List = listOf( 14 | HelpArticle(title = R.string.help_article_title_1, body = R.string.help_article_body_1), 15 | HelpArticle(title = R.string.help_article_title_2, body = R.string.help_article_body_2), 16 | HelpArticle(title = R.string.help_article_title_3, body = R.string.help_article_body_3), 17 | HelpArticle(title = R.string.help_article_title_4, body = R.string.help_article_body_4), 18 | ) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/fibelatti/photowidget/model/DirectorySorting.kt: -------------------------------------------------------------------------------- 1 | package com.fibelatti.photowidget.model 2 | 3 | import androidx.annotation.StringRes 4 | import com.fibelatti.photowidget.R 5 | 6 | enum class DirectorySorting( 7 | @StringRes val label: Int, 8 | ) { 9 | 10 | NEWEST_FIRST(label = R.string.photo_widget_directory_sort_newest_first), 11 | OLDEST_FIRST(label = R.string.photo_widget_directory_sort_oldest_first), 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/fibelatti/photowidget/model/LocalPhoto.kt: -------------------------------------------------------------------------------- 1 | package com.fibelatti.photowidget.model 2 | 3 | import android.net.Uri 4 | import android.os.Parcelable 5 | import kotlinx.parcelize.Parcelize 6 | 7 | @Parcelize 8 | data class LocalPhoto( 9 | val photoId: String, 10 | val croppedPhotoPath: String? = null, 11 | val originalPhotoPath: String? = null, 12 | val externalUri: Uri? = null, 13 | val cropping: Boolean = false, 14 | val timestamp: Long = System.currentTimeMillis(), 15 | ) : Parcelable { 16 | 17 | /** 18 | * Returns the path of the photo to be displayed to the user. 19 | * 20 | * If the photo has been cropped and [viewOriginalPhoto] is false, the cropped photo path is 21 | * returned. Otherwise, the original photo path is returned (`originalPhotoPath` and 22 | * `externalUri` are mutually exclusive and only one of them is expected to be not null). 23 | */ 24 | fun getPhotoPath(viewOriginalPhoto: Boolean = false): String? { 25 | return when { 26 | !croppedPhotoPath.isNullOrEmpty() && !viewOriginalPhoto -> croppedPhotoPath 27 | originalPhotoPath != null -> originalPhotoPath 28 | externalUri != null -> externalUri.toString() 29 | else -> null 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/src/main/java/com/fibelatti/photowidget/model/PhotoWidgetAspectRatio.kt: -------------------------------------------------------------------------------- 1 | package com.fibelatti.photowidget.model 2 | 3 | import androidx.annotation.StringRes 4 | import com.fibelatti.photowidget.R 5 | 6 | enum class PhotoWidgetAspectRatio( 7 | val x: Float, 8 | val y: Float, 9 | @StringRes val label: Int, 10 | @StringRes val description: Int, 11 | val isConstrained: Boolean = true, 12 | ) { 13 | 14 | /** 15 | * A square aspect ratio that supports shapes, but not rounded corners. 16 | * 17 | * The enum name was kept unchanged when [ROUNDED_SQUARE] was introduced for backwards 18 | * compatibility since it is persisted to local storage. 19 | */ 20 | SQUARE( 21 | x = 1f, 22 | y = 1f, 23 | label = R.string.photo_widget_aspect_ratio_shape, 24 | description = R.string.photo_widget_aspect_ratio_shape_description, 25 | ), 26 | 27 | /** 28 | * A square aspect ratio that supports rounded corners, but not shapes. 29 | */ 30 | ROUNDED_SQUARE( 31 | x = 1f, 32 | y = 1f, 33 | label = R.string.photo_widget_aspect_ratio_square, 34 | description = R.string.photo_widget_aspect_ratio_square_description, 35 | ), 36 | TALL( 37 | x = 10f, 38 | y = 16f, 39 | label = R.string.photo_widget_aspect_ratio_tall, 40 | description = R.string.photo_widget_aspect_ratio_tall_description, 41 | ), 42 | WIDE( 43 | x = 16f, 44 | y = 10f, 45 | label = R.string.photo_widget_aspect_ratio_wide, 46 | description = R.string.photo_widget_aspect_ratio_wide_description, 47 | ), 48 | ORIGINAL( 49 | x = 4f, 50 | y = 5f, 51 | label = R.string.photo_widget_aspect_ratio_original, 52 | description = R.string.photo_widget_aspect_ratio_original_description, 53 | isConstrained = false, 54 | ), 55 | FILL_WIDGET( 56 | x = 4f, 57 | y = 3f, 58 | label = R.string.photo_widget_aspect_ratio_fill_widget, 59 | description = R.string.photo_widget_aspect_ratio_fill_widget_description, 60 | isConstrained = false, 61 | ), 62 | ; 63 | 64 | val aspectRatio: Float 65 | get() = x / y 66 | } 67 | -------------------------------------------------------------------------------- /app/src/main/java/com/fibelatti/photowidget/model/PhotoWidgetColors.kt: -------------------------------------------------------------------------------- 1 | package com.fibelatti.photowidget.model 2 | 3 | import android.os.Parcelable 4 | import com.fibelatti.photowidget.model.PhotoWidget.Companion.DEFAULT_BRIGHTNESS 5 | import com.fibelatti.photowidget.model.PhotoWidget.Companion.DEFAULT_OPACITY 6 | import com.fibelatti.photowidget.model.PhotoWidget.Companion.DEFAULT_SATURATION 7 | import kotlinx.parcelize.Parcelize 8 | 9 | @Parcelize 10 | data class PhotoWidgetColors( 11 | val opacity: Float = DEFAULT_OPACITY, 12 | val saturation: Float = DEFAULT_SATURATION, 13 | val brightness: Float = DEFAULT_BRIGHTNESS, 14 | ) : Parcelable { 15 | 16 | companion object { 17 | 18 | /** 19 | * Saturation is persisted using a [0..200] range, while the UI expects a [-100..100] 20 | * range. This function converts the persisted value to the UI value. 21 | */ 22 | fun pickerSaturation(saturation: Float): Float = saturation - 100 23 | 24 | /** 25 | * The saturation picker UI uses a [-100..100] range, while the persisted value uses 26 | * [0..200]. This function converts the UI value to the persisted value. 27 | */ 28 | fun persistenceSaturation(saturation: Float): Float = saturation + 100 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/src/main/java/com/fibelatti/photowidget/model/PhotoWidgetCycleMode.kt: -------------------------------------------------------------------------------- 1 | package com.fibelatti.photowidget.model 2 | 3 | import android.os.Parcelable 4 | import kotlinx.parcelize.Parcelize 5 | 6 | sealed interface PhotoWidgetCycleMode : Parcelable { 7 | 8 | @Parcelize 9 | data class Interval(val loopingInterval: PhotoWidgetLoopingInterval) : PhotoWidgetCycleMode 10 | 11 | @Parcelize 12 | data class Schedule(val triggers: Set