├── .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 |
5 |
6 |
7 |
8 |
11 |
18 |
19 |
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