├── .editorconfig
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
└── workflows
│ ├── CompareScreenshot.yml
│ ├── CompareScreenshotComment.yml
│ ├── StoreScreenshot.yml
│ ├── build.yml
│ └── release.yml
├── .gitignore
├── .idea
└── codeStyles
│ ├── Project.xml
│ └── codeStyleConfig.xml
├── CODE_OF_CONDUCT.md
├── LICENSE
├── README.md
├── app
├── .gitignore
├── build.gradle.kts
├── google-services.json
├── proguard-rules.pro
└── src
│ ├── debug
│ └── AndroidManifest.xml
│ ├── main
│ ├── AndroidManifest.xml
│ ├── kotlin
│ │ └── com
│ │ │ └── mr3y
│ │ │ └── podcaster
│ │ │ ├── MainActivity.kt
│ │ │ ├── PodcasterApplication.kt
│ │ │ ├── service
│ │ │ ├── DownloadMediaService.kt
│ │ │ ├── PlaybackService.kt
│ │ │ └── ServiceMediaPlayer.kt
│ │ │ └── ui
│ │ │ ├── navigation
│ │ │ ├── Destinations.kt
│ │ │ └── NavGraph.kt
│ │ │ ├── presenter
│ │ │ ├── BaseMoleculeViewModel.kt
│ │ │ ├── PodcasterAppState.kt
│ │ │ ├── RefreshResult.kt
│ │ │ ├── UserPreferences.kt
│ │ │ ├── di
│ │ │ │ ├── AppStateModule.kt
│ │ │ │ └── DataStoreModule.kt
│ │ │ ├── downloads
│ │ │ │ ├── DownloadsUIState.kt
│ │ │ │ └── DownloadsViewModel.kt
│ │ │ ├── episodedetails
│ │ │ │ ├── EpisodeDetailsUIState.kt
│ │ │ │ └── EpisodeDetailsViewModel.kt
│ │ │ ├── explore
│ │ │ │ ├── ExploreUIState.kt
│ │ │ │ └── ExploreViewModel.kt
│ │ │ ├── favorites
│ │ │ │ ├── FavoritesUIState.kt
│ │ │ │ └── FavoritesViewModel.kt
│ │ │ ├── opml
│ │ │ │ └── OpmlViewModel.kt
│ │ │ ├── podcastdetails
│ │ │ │ ├── PodcastDetailsUIState.kt
│ │ │ │ └── PodcastDetailsViewModel.kt
│ │ │ └── subscriptions
│ │ │ │ ├── SubscriptionsUIState.kt
│ │ │ │ └── SubscriptionsViewModel.kt
│ │ │ ├── resources
│ │ │ └── Icons.kt
│ │ │ ├── screens
│ │ │ ├── DownloadsScreen.kt
│ │ │ ├── EpisodeDetailsScreen.kt
│ │ │ ├── ExploreScreen.kt
│ │ │ ├── FavoritesScreen.kt
│ │ │ ├── HomeScreen.kt
│ │ │ ├── ImportExportScreen.kt
│ │ │ ├── LibraryScreen.kt
│ │ │ ├── LicensesScreen.kt
│ │ │ ├── PlayerViewScreen.kt
│ │ │ ├── PodcastDetailsScreen.kt
│ │ │ ├── SettingsScreen.kt
│ │ │ └── SubscriptionsScreen.kt
│ │ │ ├── theme
│ │ │ └── StatusBarAppearance.kt
│ │ │ └── utils
│ │ │ ├── DateTime.kt
│ │ │ ├── ShareSheet.kt
│ │ │ └── SharedTransitionKeys.kt
│ ├── play
│ │ └── release-notes
│ │ │ └── en-US
│ │ │ └── production.txt
│ └── res
│ │ ├── drawable-hdpi
│ │ └── ic_notification.png
│ │ ├── drawable-mdpi
│ │ └── ic_notification.png
│ │ ├── drawable-xhdpi
│ │ └── ic_notification.png
│ │ ├── drawable-xxhdpi
│ │ └── ic_notification.png
│ │ ├── drawable-xxxhdpi
│ │ └── ic_notification.png
│ │ ├── drawable
│ │ ├── ic_splash.xml
│ │ └── world_wide_web.xml
│ │ ├── mipmap-anydpi-v26
│ │ └── ic_launcher.xml
│ │ ├── mipmap-hdpi
│ │ ├── ic_launcher.png
│ │ ├── ic_launcher_background.png
│ │ ├── ic_launcher_foreground.png
│ │ └── ic_launcher_monochrome.png
│ │ ├── mipmap-mdpi
│ │ ├── ic_launcher.png
│ │ ├── ic_launcher_background.png
│ │ ├── ic_launcher_foreground.png
│ │ └── ic_launcher_monochrome.png
│ │ ├── mipmap-xhdpi
│ │ ├── ic_launcher.png
│ │ ├── ic_launcher_background.png
│ │ ├── ic_launcher_foreground.png
│ │ └── ic_launcher_monochrome.png
│ │ ├── mipmap-xxhdpi
│ │ ├── ic_launcher.png
│ │ ├── ic_launcher_background.png
│ │ ├── ic_launcher_foreground.png
│ │ └── ic_launcher_monochrome.png
│ │ ├── mipmap-xxxhdpi
│ │ ├── ic_launcher.png
│ │ ├── ic_launcher_background.png
│ │ ├── ic_launcher_foreground.png
│ │ └── ic_launcher_monochrome.png
│ │ ├── values
│ │ ├── colors.xml
│ │ ├── strings.xml
│ │ └── themes.xml
│ │ └── xml
│ │ ├── backup_rules.xml
│ │ └── data_extraction_rules.xml
│ ├── release
│ └── generated
│ │ └── baselineProfiles
│ │ └── baseline-prof.txt
│ └── test
│ ├── kotlin
│ └── com
│ │ └── mr3y
│ │ └── podcaster
│ │ ├── PodcasterApplication.kt
│ │ ├── core
│ │ └── coroutines
│ │ │ └── MainDispatcherRule.kt
│ │ ├── service
│ │ └── ServiceMediaPlayerTest.kt
│ │ └── ui
│ │ ├── presenter
│ │ ├── BasePresenterTest.kt
│ │ ├── PodcasterAppStateIntegrationTest.kt
│ │ ├── downloads
│ │ │ └── DownloadsPresenterTest.kt
│ │ ├── podcastdetails
│ │ │ └── PodcastDetailsPresenterTest.kt
│ │ └── subscriptions
│ │ │ └── SubscriptionsPresenterTest.kt
│ │ └── screens
│ │ ├── BaseScreenshotTest.kt
│ │ ├── EpisodeDetailsScreenshotTest.kt
│ │ ├── PodcastDetailsScreenshotTest.kt
│ │ └── SubscriptionsScreenshotTest.kt
│ └── resources
│ └── adb_test_image.png
├── baselineProfile
├── build.gradle.kts
└── src
│ └── main
│ ├── AndroidManifest.xml
│ └── kotlin
│ └── com
│ └── mr3y
│ └── podcaster
│ └── baselineprofile
│ ├── Utils.kt
│ └── startup
│ ├── StartupBaselineProfile.kt
│ └── StartupBenchmarks.kt
├── build.gradle.kts
├── changelog_config.json
├── convention-plugins
├── gradle.properties
├── plugins
│ ├── .gitignore
│ ├── build.gradle.kts
│ └── src
│ │ └── main
│ │ └── kotlin
│ │ └── com
│ │ └── mr3y
│ │ └── podcaster
│ │ └── gradle
│ │ ├── AndroidApplicationConventionPlugin.kt
│ │ ├── AndroidComposeLibraryConventionPlugin.kt
│ │ ├── AndroidLibraryConventionPlugin.kt
│ │ ├── AndroidTestConventionPlugin.kt
│ │ ├── Java.kt
│ │ ├── JvmConventionPlugin.kt
│ │ ├── Kotlin.kt
│ │ ├── Ktlint.kt
│ │ └── ProjectExtensions.kt
└── settings.gradle.kts
├── core
├── data
│ ├── build.gradle.kts
│ └── src
│ │ ├── main
│ │ ├── AndroidManifest.xml
│ │ └── kotlin
│ │ │ └── com
│ │ │ └── mr3y
│ │ │ └── podcaster
│ │ │ └── core
│ │ │ └── data
│ │ │ ├── PodcastsRepository.kt
│ │ │ ├── di
│ │ │ └── RepositoriesModule.kt
│ │ │ └── internal
│ │ │ └── DefaultPodcastsRepository.kt
│ │ └── test
│ │ └── kotlin
│ │ └── com
│ │ └── mr3y
│ │ └── podcaster
│ │ └── core
│ │ └── data
│ │ └── SyncDataTest.kt
├── database-test-fixtures
│ ├── build.gradle.kts
│ └── src
│ │ └── main
│ │ ├── AndroidManifest.xml
│ │ └── kotlin
│ │ └── com
│ │ └── mr3y
│ │ └── podcaster
│ │ └── core
│ │ └── local
│ │ └── di
│ │ └── FakeDatabaseModule.kt
├── database
│ ├── build.gradle.kts
│ └── src
│ │ ├── main
│ │ ├── AndroidManifest.xml
│ │ ├── kotlin
│ │ │ └── com
│ │ │ │ └── mr3y
│ │ │ │ └── podcaster
│ │ │ │ └── core
│ │ │ │ └── local
│ │ │ │ ├── Adapters.kt
│ │ │ │ ├── Mappers.kt
│ │ │ │ ├── dao
│ │ │ │ ├── PodcastsDao.kt
│ │ │ │ └── RecentSearchesDao.kt
│ │ │ │ └── di
│ │ │ │ ├── DaosModule.kt
│ │ │ │ └── DatabaseModule.kt
│ │ └── sqldelight
│ │ │ ├── com
│ │ │ └── mr3y
│ │ │ │ └── podcaster
│ │ │ │ ├── CurrentlyPlayingEntity.sq
│ │ │ │ ├── DownloadableEpisodeEntity.sq
│ │ │ │ ├── EpisodeEntity.sq
│ │ │ │ ├── PodcastEntity.sq
│ │ │ │ ├── QueueEntity.sq
│ │ │ │ └── RecentSearchEntry.sq
│ │ │ ├── databases
│ │ │ ├── 1.db
│ │ │ ├── 2.db
│ │ │ ├── 3.db
│ │ │ ├── 4.db
│ │ │ ├── 5.db
│ │ │ └── 6.db
│ │ │ └── migrations
│ │ │ ├── 1.sqm
│ │ │ ├── 2.sqm
│ │ │ ├── 3.sqm
│ │ │ ├── 4.sqm
│ │ │ ├── 5.sqm
│ │ │ └── 6.sqm
│ │ └── test
│ │ └── kotlin
│ │ └── com
│ │ └── mr3y
│ │ └── podcaster
│ │ └── core
│ │ └── local
│ │ ├── TestAdapters.kt
│ │ └── dao
│ │ └── DefaultPodcastsDaoTest.kt
├── logger-test-fixtures
│ ├── build.gradle.kts
│ └── src
│ │ └── main
│ │ ├── AndroidManifest.xml
│ │ └── kotlin
│ │ └── com
│ │ └── mr3y
│ │ └── podcaster
│ │ └── core
│ │ └── logger
│ │ └── TestLogger.kt
├── logger
│ ├── build.gradle.kts
│ └── src
│ │ └── main
│ │ ├── AndroidManifest.xml
│ │ └── kotlin
│ │ └── com
│ │ └── mr3y
│ │ └── podcaster
│ │ └── core
│ │ └── logger
│ │ ├── Logger.kt
│ │ ├── di
│ │ └── LoggingModule.kt
│ │ └── internal
│ │ └── DefaultLogger.kt
├── model
│ ├── build.gradle.kts
│ └── src
│ │ └── main
│ │ └── kotlin
│ │ └── com
│ │ └── mr3y
│ │ └── podcaster
│ │ └── core
│ │ ├── model
│ │ ├── CurrentlyPlayingEpisode.kt
│ │ ├── Episode.kt
│ │ ├── EpisodeDownloadMetadata.kt
│ │ ├── EpisodeExt.kt
│ │ ├── EpisodeWithDownloadMetadata.kt
│ │ └── Podcast.kt
│ │ └── sampledata
│ │ └── SampleData.kt
├── network-test-fixtures
│ ├── build.gradle.kts
│ └── src
│ │ └── main
│ │ ├── AndroidManifest.xml
│ │ └── kotlin
│ │ └── com
│ │ └── mr3y
│ │ └── podcaster
│ │ └── core
│ │ └── network
│ │ ├── SampleData.kt
│ │ └── di
│ │ └── FakeHttpClient.kt
├── network
│ ├── build.gradle.kts
│ └── src
│ │ ├── main
│ │ ├── AndroidManifest.xml
│ │ └── kotlin
│ │ │ └── com
│ │ │ └── mr3y
│ │ │ └── podcaster
│ │ │ └── core
│ │ │ └── network
│ │ │ ├── PodcastIndexClient.kt
│ │ │ ├── di
│ │ │ ├── NetworkModule.kt
│ │ │ └── PodcastIndexClientModule.kt
│ │ │ ├── internal
│ │ │ └── DefaultPodcastIndexClient.kt
│ │ │ ├── model
│ │ │ ├── PodcastEpisodeFeed.kt
│ │ │ └── PodcastFeed.kt
│ │ │ └── utils
│ │ │ ├── KtorExtensions.kt
│ │ │ └── Mappers.kt
│ │ └── test
│ │ └── kotlin
│ │ └── com
│ │ └── mr3y
│ │ └── podcaster
│ │ └── core
│ │ └── network
│ │ └── internal
│ │ └── DefaultPodcastIndexClientSerializationTest.kt
├── opml
│ ├── build.gradle.kts
│ └── src
│ │ ├── main
│ │ ├── AndroidManifest.xml
│ │ └── kotlin
│ │ │ └── com
│ │ │ └── mr3y
│ │ │ └── podcaster
│ │ │ └── core
│ │ │ └── opml
│ │ │ ├── FileManager.kt
│ │ │ ├── OpmlAdapter.kt
│ │ │ ├── OpmlManager.kt
│ │ │ ├── di
│ │ │ └── XMLSerializerModule.kt
│ │ │ └── model
│ │ │ ├── Opml.kt
│ │ │ ├── OpmlPodcast.kt
│ │ │ └── OpmlResult.kt
│ │ └── test
│ │ └── kotlin
│ │ └── com
│ │ └── mr3y
│ │ └── podcaster
│ │ └── core
│ │ └── opml
│ │ └── OpmlAdapterTest.kt
└── sync
│ ├── build.gradle.kts
│ └── src
│ └── main
│ ├── AndroidManifest.xml
│ ├── kotlin
│ └── com
│ │ └── mr3y
│ │ └── podcaster
│ │ └── core
│ │ └── sync
│ │ ├── Initializer.kt
│ │ ├── SubscriptionsSyncWorker.kt
│ │ └── SyncNotification.kt
│ └── res
│ ├── drawable-hdpi
│ └── ic_notification.png
│ ├── drawable-mdpi
│ └── ic_notification.png
│ ├── drawable-xhdpi
│ └── ic_notification.png
│ ├── drawable-xxhdpi
│ └── ic_notification.png
│ ├── drawable-xxxhdpi
│ └── ic_notification.png
│ └── values
│ └── strings.xml
├── docs
├── PrivacyPolicy.md
└── README.md
├── gradle.properties
├── gradle
├── libs.versions.toml
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── index.html
├── renovate.json
├── settings.gradle.kts
└── ui
├── design-system
├── build.gradle.kts
└── src
│ └── main
│ ├── AndroidManifest.xml
│ └── kotlin
│ └── com
│ └── mr3y
│ └── podcaster
│ └── ui
│ ├── components
│ ├── DownloadButton.kt
│ ├── Error.kt
│ ├── FavoriteButton.kt
│ ├── HtmlConverter.kt
│ ├── Image.kt
│ ├── LoadingIndicator.kt
│ ├── PaddingValues.kt
│ ├── PlayPauseButton.kt
│ ├── PullToRefresh.kt
│ ├── QueueButtons.kt
│ ├── SharedElementTransition.kt
│ └── TopAppBar.kt
│ └── theme
│ ├── Color.kt
│ ├── ColorUtils.kt
│ ├── Shape.kt
│ ├── Theme.kt
│ └── Type.kt
├── preview
├── build.gradle.kts
└── src
│ └── main
│ ├── AndroidManifest.xml
│ └── kotlin
│ └── com
│ └── mr3y
│ └── podcaster
│ └── ui
│ └── preview
│ └── PodcasterPreview.kt
└── resources
├── build.gradle.kts
└── src
└── main
├── AndroidManifest.xml
└── kotlin
└── com
└── mr3y
└── podcaster
└── ui
└── resources
├── PodcasterEnStrings.kt
├── PodcasterStrings.kt
└── ProvideStrings.kt
/.editorconfig:
--------------------------------------------------------------------------------
1 | [*.{kt,kts}]
2 | ktlint_code_style = intellij_idea
3 | ktlint_function_naming_ignore_when_annotated_with=Composable
4 | ktlint_standard_property-naming = disabled
5 | ktlint_standard_filename = disabled
6 | ktlint_standard_discouraged-comment-location = disabled
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | 🐛 **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | ⚠️ **Current behavior**
14 | A clear and concise description of what happened.
15 |
16 | ✅ **Expected behavior**
17 | A clear and concise description of what you expected to happen.
18 |
19 | 💣 **Steps To Reproduce**
20 | Steps to reproduce the behavior:
21 |
22 | 🖼️ **Screenshots** (If applicable):
23 |
24 | **App Version**:
25 |
26 | **Additional context**:
27 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest a new feature or enhancement for this project
4 | title: ''
5 | labels: enhancement, feature
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.github/workflows/CompareScreenshot.yml:
--------------------------------------------------------------------------------
1 | name: CompareScreenshot
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | tags-ignore:
8 | - 'v*'
9 | paths-ignore:
10 | - '**.md'
11 | - '**.txt'
12 | pull_request:
13 |
14 | concurrency:
15 | group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
16 | cancel-in-progress: true
17 |
18 | permissions: {}
19 |
20 | env:
21 | API_KEY: ${{ secrets.API_KEY }}
22 | API_SECRET: ${{ secrets.API_SECRET }}
23 |
24 | jobs:
25 | compare-screenshot-test:
26 | runs-on: ubuntu-latest
27 | timeout-minutes: 20
28 |
29 | permissions:
30 | contents: read # for clone
31 | actions: write # for upload-artifact
32 |
33 | steps:
34 | - name: Checkout
35 | uses: actions/checkout@v4
36 |
37 | - name: Set up JDK 17
38 | uses: actions/setup-java@v4
39 | with:
40 | distribution: temurin
41 | java-version: 17
42 |
43 | - name: Setup Gradle
44 | uses: gradle/gradle-build-action@v3
45 | with:
46 | gradle-version: wrapper
47 |
48 |
49 | - uses: dawidd6/action-download-artifact@v9
50 | continue-on-error: true
51 | with:
52 | name: screenshot
53 | workflow: StoreScreenshot.yml
54 | branch: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || github.event.repository.default_branch }}
55 |
56 | - name: compare screenshot test
57 | id: compare-screenshot-test
58 | run: |
59 | ./gradlew compareRoborazziDebug --stacktrace -Pscreenshot
60 |
61 | - uses: actions/upload-artifact@v4
62 | if: ${{ always() }}
63 | with:
64 | name: screenshot-diff
65 | path: |
66 | **/build/outputs/roborazzi
67 | retention-days: 30
68 |
69 | - uses: actions/upload-artifact@v4
70 | if: ${{ always() }}
71 | with:
72 | name: screenshot-diff-reports
73 | path: |
74 | **/build/reports
75 | retention-days: 30
76 |
77 | - uses: actions/upload-artifact@v4
78 | if: ${{ always() }}
79 | with:
80 | name: screenshot-diff-test-results
81 | path: |
82 | **/build/test-results
83 | retention-days: 30
84 |
85 | - name: Save PR number
86 | if: ${{ github.event_name == 'pull_request' }}
87 | run: |
88 | mkdir -p ./pr
89 | echo ${{ github.event.number }} > ./pr/NR
90 | - uses: actions/upload-artifact@v4
91 | with:
92 | name: pr
93 | path: pr/
--------------------------------------------------------------------------------
/.github/workflows/StoreScreenshot.yml:
--------------------------------------------------------------------------------
1 | name: StoreScreenshot
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | tags-ignore:
8 | - 'v*'
9 | paths-ignore:
10 | - '**.md'
11 | - '**.txt'
12 | pull_request:
13 |
14 | concurrency:
15 | group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
16 | cancel-in-progress: true
17 |
18 | permissions: {}
19 |
20 | env:
21 | API_KEY: ${{ secrets.API_KEY }}
22 | API_SECRET: ${{ secrets.API_SECRET }}
23 |
24 | jobs:
25 | store-screenshot-test:
26 | runs-on: ubuntu-latest
27 | timeout-minutes: 20
28 |
29 | permissions:
30 | contents: read # for clone
31 | actions: write # for upload-artifact
32 |
33 | steps:
34 | - name: Checkout
35 | uses: actions/checkout@v4
36 |
37 | - name: Set up JDK 17
38 | uses: actions/setup-java@v4
39 | with:
40 | distribution: temurin
41 | java-version: 17
42 |
43 | # Better than caching and/or extensions of actions/setup-java
44 | - name: Setup Gradle
45 | uses: gradle/gradle-build-action@v3
46 | with:
47 | gradle-version: wrapper
48 |
49 | - name: record screenshot
50 | id: record-test
51 | run: |
52 | # Use --rerun-tasks to disable cache for test task
53 | ./gradlew recordRoborazziDebug --stacktrace --rerun-tasks -Pscreenshot
54 |
55 | - uses: actions/upload-artifact@v4
56 | if: ${{ always() }}
57 | with:
58 | name: screenshot
59 | path: |
60 | **/build/outputs/roborazzi
61 | retention-days: 30
62 |
63 | - uses: actions/upload-artifact@v4
64 | if: ${{ always() }}
65 | with:
66 | name: screenshot-reports
67 | path: |
68 | **/build/reports
69 | retention-days: 30
70 |
71 | - uses: actions/upload-artifact@v4
72 | if: ${{ always() }}
73 | with:
74 | name: screenshot-test-results
75 | path: |
76 | **/build/test-results
77 | retention-days: 30
78 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build and run local tests
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | tags-ignore:
8 | - 'v*'
9 | paths-ignore:
10 | - '**.md'
11 | - '**.txt'
12 | pull_request:
13 |
14 | concurrency:
15 | group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
16 | cancel-in-progress: true
17 |
18 | env:
19 | API_KEY: ${{ secrets.API_KEY }}
20 | API_SECRET: ${{ secrets.API_SECRET }}
21 |
22 | jobs:
23 | build:
24 | runs-on: ubuntu-latest
25 |
26 | steps:
27 | - name: Checkout
28 | uses: actions/checkout@v4
29 | with:
30 | fetch-depth: 0
31 |
32 | - name: Validate Gradle Wrapper
33 | uses: gradle/wrapper-validation-action@v3
34 |
35 | - name: set up JDK 17
36 | uses: actions/setup-java@v4
37 | with:
38 | java-version: '17'
39 | distribution: 'temurin'
40 | cache: gradle
41 |
42 | - name: Setup Gradle
43 | uses: gradle/gradle-build-action@v3
44 |
45 | - name: Grant execute permission for gradlew
46 | run: chmod +x gradlew
47 |
48 | - name: Build debug
49 | run: ./gradlew assembleDebug --stacktrace
50 |
51 | - name: Run local tests
52 | run: ./gradlew testDebug --stacktrace
53 |
54 | - name: Apply ktlint formatting to (.kt/s) files.
55 | run: ./gradlew ktlintFormat --stacktrace
56 |
57 | - name: Commit and push changes (if any).
58 | if: ${{ github.ref == 'refs/heads/main' }}
59 | uses: EndBug/add-and-commit@v9
60 | with:
61 | author_name: GitHub Actions
62 | author_email: github-actions@github.com
63 | message: Apply style formatting
64 | push: true
65 |
66 | - name: Upload build outputs (APKs)
67 | uses: actions/upload-artifact@v4
68 | with:
69 | name: build-outputs
70 | path: |
71 | **/build/outputs/*
72 |
73 | - name: Upload build reports
74 | if: always()
75 | uses: actions/upload-artifact@v4
76 | with:
77 | name: build-reports
78 | path: ./**/build/reports
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | /.idea/caches
5 | /.idea/libraries
6 | /.idea/modules.xml
7 | /.idea/workspace.xml
8 | /.idea/navEditor.xml
9 | /.idea/assetWizardSettings.xml
10 | /.idea/*
11 | !/.idea/codeStyles/
12 | .DS_Store
13 | /build
14 | **/build
15 | /captures
16 | /.kotlin
17 | .externalNativeBuild
18 | .cxx
19 | local.properties
20 | keystore.properties
21 | bytemask.properties
22 | debug.keystore
23 | debug.keystore.b64
24 | bytemask.properties.b64
25 | play_config.json
--------------------------------------------------------------------------------
/.idea/codeStyles/Project.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 | /release
--------------------------------------------------------------------------------
/app/google-services.json:
--------------------------------------------------------------------------------
1 | {
2 | "project_info": {
3 | "project_number": "187732499072",
4 | "project_id": "podcaster-9501e",
5 | "storage_bucket": "podcaster-9501e.appspot.com"
6 | },
7 | "client": [
8 | {
9 | "client_info": {
10 | "mobilesdk_app_id": "1:187732499072:android:48e52b48694be7429b2465",
11 | "android_client_info": {
12 | "package_name": "com.mr3y.podcaster"
13 | }
14 | },
15 | "oauth_client": [],
16 | "api_key": [
17 | {
18 | "current_key": "AIzaSyCC1_Eb6AaCz4qBQ_mTd3lR0lrc0MrgQBk"
19 | }
20 | ],
21 | "services": {
22 | "appinvite_service": {
23 | "other_platform_oauth_client": []
24 | }
25 | }
26 | }
27 | ],
28 | "configuration_version": "1"
29 | }
--------------------------------------------------------------------------------
/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 | -verbose
9 | -allowaccessmodification
10 | -repackageclasses
11 |
12 | # If your project uses WebView with JS, uncomment the following
13 | # and specify the fully qualified class name to the JavaScript interface
14 | # class:
15 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
16 | # public *;
17 | #}
18 |
19 | # Uncomment this to preserve the line number information for
20 | # debugging stack traces.
21 | -keepattributes SourceFile,LineNumberTable
22 |
23 | # If you keep the line number information, uncomment this to
24 | # hide the original source file name.
25 | -renamesourcefileattribute SourceFile
26 |
27 | -dontwarn org.slf4j.impl.StaticLoggerBinder
28 |
29 | -keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
--------------------------------------------------------------------------------
/app/src/debug/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
15 |
19 |
20 |
31 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
46 |
47 |
48 |
49 |
50 |
51 |
55 |
56 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
70 |
71 |
72 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/mr3y/podcaster/PodcasterApplication.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster
2 |
3 | import android.app.Application
4 | import androidx.hilt.work.HiltWorkerFactory
5 | import androidx.work.Configuration
6 | import com.mr3y.podcaster.core.opml.FileManager
7 | import com.mr3y.podcaster.core.sync.initializeWorkManagerInstance
8 | import dagger.hilt.android.HiltAndroidApp
9 | import javax.inject.Inject
10 |
11 | @HiltAndroidApp
12 | class PodcasterApplication : Application(), Configuration.Provider {
13 |
14 | @Inject
15 | lateinit var workerFactory: HiltWorkerFactory
16 |
17 | @Inject
18 | lateinit var fileManager: FileManager
19 |
20 | override fun onCreate() {
21 | super.onCreate()
22 | initializeWorkManagerInstance(this)
23 | fileManager.registerActivityWatcher()
24 | }
25 |
26 | override val workManagerConfiguration: Configuration
27 | get() = Configuration.Builder().setWorkerFactory(workerFactory).build()
28 | }
29 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/mr3y/podcaster/ui/navigation/Destinations.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.navigation
2 |
3 | import kotlinx.serialization.Serializable
4 |
5 | sealed interface Destinations {
6 |
7 | @Serializable
8 | data object SubscriptionsGraph : Destinations
9 |
10 | @Serializable
11 | data object ExploreGraph : Destinations
12 |
13 | @Serializable
14 | data object LibraryGraph : Destinations
15 |
16 | @Serializable
17 | data object SettingsGraph : Destinations
18 |
19 | @Serializable
20 | data object Subscriptions : Destinations
21 |
22 | @Serializable
23 | data object Explore : Destinations
24 |
25 | @Serializable
26 | data class PodcastDetailsSubscriptionsGraph(val id: Long) : Destinations
27 |
28 | @Serializable
29 | data class PodcastDetailsExploreGraph(val id: Long) : Destinations
30 |
31 | @Serializable
32 | data class EpisodeDetailsSubscriptionsGraph(val id: Long, val artworkUrl: String) : Destinations
33 |
34 | @Serializable
35 | data class EpisodeDetailsExploreGraph(val id: Long, val artworkUrl: String) : Destinations
36 |
37 | @Serializable
38 | data class EpisodeDetailsLibraryGraph(val id: Long, val artworkUrl: String) : Destinations
39 |
40 | @Serializable
41 | data object Library : Destinations
42 |
43 | @Serializable
44 | data object Settings : Destinations
45 |
46 | @Serializable
47 | data object Downloads : Destinations
48 |
49 | @Serializable
50 | data object Favorites : Destinations
51 |
52 | @Serializable
53 | data object Licenses : Destinations
54 |
55 | @Serializable
56 | data object ImportExport : Destinations
57 | }
58 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/mr3y/podcaster/ui/presenter/BaseMoleculeViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.presenter
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import app.cash.molecule.AndroidUiDispatcher
6 | import kotlinx.coroutines.CoroutineScope
7 | import kotlinx.coroutines.flow.MutableSharedFlow
8 |
9 | abstract class BaseMoleculeViewModel : ViewModel() {
10 |
11 | protected val events = MutableSharedFlow(extraBufferCapacity = 20)
12 |
13 | protected val moleculeScope = CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main)
14 | }
15 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/mr3y/podcaster/ui/presenter/RefreshResult.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.presenter
2 |
3 | sealed interface RefreshResult {
4 | data object Ok : RefreshResult
5 |
6 | data object Error : RefreshResult
7 |
8 | data object Mixed : RefreshResult
9 | }
10 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/mr3y/podcaster/ui/presenter/UserPreferences.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.presenter
2 |
3 | import androidx.datastore.core.DataStore
4 | import androidx.datastore.preferences.core.Preferences
5 | import androidx.datastore.preferences.core.booleanPreferencesKey
6 | import androidx.datastore.preferences.core.edit
7 | import androidx.datastore.preferences.core.stringPreferencesKey
8 | import com.mr3y.podcaster.ui.presenter.di.ApplicationScope
9 | import com.mr3y.podcaster.ui.theme.Theme
10 | import kotlinx.coroutines.CoroutineScope
11 | import kotlinx.coroutines.flow.MutableStateFlow
12 | import kotlinx.coroutines.flow.asStateFlow
13 | import kotlinx.coroutines.flow.collectLatest
14 | import kotlinx.coroutines.flow.update
15 | import kotlinx.coroutines.launch
16 | import javax.inject.Inject
17 |
18 | class UserPreferences @Inject constructor(
19 | private val datastore: DataStore,
20 | @ApplicationScope private val applicationScope: CoroutineScope,
21 | ) {
22 |
23 | private val _selectedTheme = MutableStateFlow(null)
24 | val selectedTheme = _selectedTheme.asStateFlow()
25 |
26 | private val _dynamicColorEnabled = MutableStateFlow(false)
27 | val dynamicColorEnabled = _dynamicColorEnabled.asStateFlow()
28 |
29 | init {
30 | applicationScope.launch {
31 | datastore.data.collectLatest { prefs ->
32 | val selectedThemeValue = prefs[SelectedThemeKey]?.let { Theme.valueOf(it) } ?: Theme.SystemDefault
33 | _selectedTheme.update { selectedThemeValue }
34 | val isDynamicColorOn = prefs[DynamicColorKey] ?: false
35 | _dynamicColorEnabled.update { isDynamicColorOn }
36 | }
37 | }
38 | }
39 |
40 | fun setAppTheme(theme: Theme) {
41 | _selectedTheme.update { theme }
42 | applicationScope.launch {
43 | datastore.edit { prefs ->
44 | prefs[SelectedThemeKey] = theme.name
45 | }
46 | }
47 | }
48 |
49 | fun enableDynamicColor() {
50 | _dynamicColorEnabled.update { true }
51 | applicationScope.launch {
52 | datastore.edit { prefs ->
53 | prefs[DynamicColorKey] = true
54 | }
55 | }
56 | }
57 |
58 | fun disableDynamicColor() {
59 | _dynamicColorEnabled.update { false }
60 | applicationScope.launch {
61 | datastore.edit { prefs ->
62 | prefs[DynamicColorKey] = false
63 | }
64 | }
65 | }
66 |
67 | companion object {
68 | private val SelectedThemeKey = stringPreferencesKey("selected_theme")
69 | private val DynamicColorKey = booleanPreferencesKey("dynamic_color")
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/mr3y/podcaster/ui/presenter/di/AppStateModule.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.presenter.di
2 |
3 | import android.content.Context
4 | import com.mr3y.podcaster.core.data.PodcastsRepository
5 | import com.mr3y.podcaster.ui.presenter.PodcasterAppState
6 | import dagger.Module
7 | import dagger.Provides
8 | import dagger.hilt.InstallIn
9 | import dagger.hilt.android.qualifiers.ApplicationContext
10 | import dagger.hilt.components.SingletonComponent
11 | import kotlinx.coroutines.CoroutineScope
12 | import kotlinx.coroutines.Dispatchers
13 | import kotlinx.coroutines.SupervisorJob
14 | import javax.inject.Qualifier
15 | import javax.inject.Singleton
16 |
17 | @Qualifier
18 | @Retention(AnnotationRetention.BINARY)
19 | @MustBeDocumented
20 | annotation class ApplicationScope
21 |
22 | @Module
23 | @InstallIn(SingletonComponent::class)
24 | object AppStateModule {
25 |
26 | @ApplicationScope
27 | @Provides
28 | fun provideApplicationScope(): CoroutineScope {
29 | return CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
30 | }
31 |
32 | @Provides
33 | @Singleton
34 | fun providePodcasterAppStateInstance(
35 | repo: PodcastsRepository,
36 | @ApplicationScope applicationScope: CoroutineScope,
37 | @ApplicationContext applicationContext: Context,
38 | ): PodcasterAppState {
39 | return PodcasterAppState(repo, applicationScope, applicationContext)
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/mr3y/podcaster/ui/presenter/di/DataStoreModule.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.presenter.di
2 |
3 | import android.content.Context
4 | import androidx.datastore.core.DataStore
5 | import androidx.datastore.core.okio.OkioStorage
6 | import androidx.datastore.preferences.core.PreferenceDataStoreFactory
7 | import androidx.datastore.preferences.core.Preferences
8 | import androidx.datastore.preferences.core.PreferencesSerializer
9 | import androidx.datastore.preferences.preferencesDataStoreFile
10 | import com.mr3y.podcaster.ui.presenter.UserPreferences
11 | import dagger.Module
12 | import dagger.Provides
13 | import dagger.hilt.InstallIn
14 | import dagger.hilt.android.qualifiers.ApplicationContext
15 | import dagger.hilt.components.SingletonComponent
16 | import kotlinx.coroutines.CoroutineScope
17 | import kotlinx.coroutines.Dispatchers
18 | import kotlinx.coroutines.SupervisorJob
19 | import okio.FileSystem
20 | import okio.Path.Companion.toOkioPath
21 | import javax.inject.Singleton
22 |
23 | @Module
24 | @InstallIn(SingletonComponent::class)
25 | object DataStoreModule {
26 |
27 | @Provides
28 | @Singleton
29 | fun providePreferencesDatastoreInstance(@ApplicationContext context: Context): DataStore {
30 | return PreferenceDataStoreFactory.create(
31 | storage = OkioStorage(
32 | fileSystem = FileSystem.SYSTEM,
33 | serializer = PreferencesSerializer,
34 | producePath = { context.preferencesDataStoreFile("podcaster_user_prefs").toOkioPath() },
35 | ),
36 | corruptionHandler = null,
37 | migrations = emptyList(),
38 | scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
39 | )
40 | }
41 |
42 | @Provides
43 | @Singleton
44 | fun provideUserPreferencesInstance(
45 | datastore: DataStore,
46 | @ApplicationScope applicationScope: CoroutineScope,
47 | ): UserPreferences {
48 | return UserPreferences(datastore, applicationScope)
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/mr3y/podcaster/ui/presenter/downloads/DownloadsUIState.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.presenter.downloads
2 |
3 | import com.mr3y.podcaster.core.model.EpisodeWithDownloadMetadata
4 |
5 | data class DownloadsUIState(
6 | val isLoading: Boolean,
7 | val downloads: List,
8 | )
9 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/mr3y/podcaster/ui/presenter/downloads/DownloadsViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.presenter.downloads
2 |
3 | import android.annotation.SuppressLint
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.runtime.LaunchedEffect
6 | import androidx.compose.runtime.collectAsState
7 | import androidx.compose.runtime.getValue
8 | import androidx.compose.runtime.mutableStateOf
9 | import androidx.compose.runtime.remember
10 | import androidx.compose.runtime.setValue
11 | import app.cash.molecule.RecompositionMode
12 | import app.cash.molecule.launchMolecule
13 | import com.mr3y.podcaster.core.data.PodcastsRepository
14 | import com.mr3y.podcaster.ui.presenter.BaseMoleculeViewModel
15 | import dagger.hilt.android.lifecycle.HiltViewModel
16 | import kotlinx.coroutines.delay
17 | import javax.inject.Inject
18 |
19 | @HiltViewModel
20 | class DownloadsViewModel @Inject constructor(
21 | private val podcastsRepository: PodcastsRepository,
22 | ) : BaseMoleculeViewModel() {
23 |
24 | val state = moleculeScope.launchMolecule(mode = RecompositionMode.ContextClock) {
25 | DownloadsPresenter(repository = podcastsRepository)
26 | }
27 | }
28 |
29 | @SuppressLint("ComposableNaming")
30 | @Composable
31 | internal fun DownloadsPresenter(
32 | repository: PodcastsRepository,
33 | ): DownloadsUIState {
34 | var isLoading by remember { mutableStateOf(true) }
35 | val downloads by repository.getDownloads().collectAsState(initial = emptyList())
36 |
37 | LaunchedEffect(downloads) {
38 | if (downloads.isNotEmpty()) {
39 | isLoading = false
40 | } else {
41 | delay(1000)
42 | isLoading = false
43 | }
44 | }
45 |
46 | return DownloadsUIState(
47 | isLoading = isLoading,
48 | downloads = downloads,
49 | )
50 | }
51 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/mr3y/podcaster/ui/presenter/episodedetails/EpisodeDetailsUIState.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.presenter.episodedetails
2 |
3 | import com.mr3y.podcaster.core.model.Episode
4 | import com.mr3y.podcaster.core.model.EpisodeDownloadMetadata
5 | import com.mr3y.podcaster.ui.presenter.RefreshResult
6 |
7 | data class EpisodeDetailsUIState(
8 | val isLoading: Boolean,
9 | val episode: Episode?,
10 | val queueEpisodesIds: List,
11 | val isRefreshing: Boolean,
12 | val refreshResult: RefreshResult?,
13 | val downloadMetadata: EpisodeDownloadMetadata?,
14 | )
15 |
16 | sealed interface EpisodeDetailsUIEvent {
17 |
18 | data object Refresh : EpisodeDetailsUIEvent
19 |
20 | data object RefreshResultConsumed : EpisodeDetailsUIEvent
21 |
22 | data object Retry : EpisodeDetailsUIEvent
23 |
24 | data class PlayEpisode(val episode: Episode) : EpisodeDetailsUIEvent
25 |
26 | data object Pause : EpisodeDetailsUIEvent
27 |
28 | data class DownloadEpisode(val episode: Episode) : EpisodeDetailsUIEvent
29 |
30 | data class ResumeDownloading(val episodeId: Long) : EpisodeDetailsUIEvent
31 |
32 | data class PauseDownloading(val episodeId: Long) : EpisodeDetailsUIEvent
33 |
34 | data class AddEpisodeToQueue(val episode: Episode) : EpisodeDetailsUIEvent
35 |
36 | data class RemoveEpisodeFromQueue(val episodeId: Long) : EpisodeDetailsUIEvent
37 |
38 | data class ToggleEpisodeFavoriteStatus(val isFavorite: Boolean) : EpisodeDetailsUIEvent
39 |
40 | data object ErrorPlayingStatusConsumed : EpisodeDetailsUIEvent
41 | }
42 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/mr3y/podcaster/ui/presenter/explore/ExploreUIState.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.presenter.explore
2 |
3 | import androidx.compose.ui.text.input.TextFieldValue
4 | import com.mr3y.podcaster.core.model.Podcast
5 |
6 | data class ExploreUIState(
7 | val searchQuery: TextFieldValue,
8 | val searchResult: SearchResult?,
9 | val previousSearchQueries: List,
10 | )
11 |
12 | sealed interface SearchResult {
13 | data object Loading : SearchResult
14 |
15 | data class SearchByTermSuccess(val podcasts: List) : SearchResult
16 |
17 | data class SearchByUrlSuccess(val podcast: Podcast) : SearchResult
18 |
19 | data class Error(val isFeedUrl: Boolean, val errorResponse: Any) : SearchResult
20 | }
21 |
22 | sealed interface ExploreUIEvent {
23 | data object Search : ExploreUIEvent
24 |
25 | data class UpdateSearchQuery(val newSearchQuery: TextFieldValue) : ExploreUIEvent
26 |
27 | data class DeleteSearchQuery(val searchQuery: String) : ExploreUIEvent
28 |
29 | data object ClearSearchQuery : ExploreUIEvent
30 |
31 | data object Retry : ExploreUIEvent
32 |
33 | data object ResultConsumed : ExploreUIEvent
34 | }
35 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/mr3y/podcaster/ui/presenter/favorites/FavoritesUIState.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.presenter.favorites
2 |
3 | import com.mr3y.podcaster.core.model.Episode
4 |
5 | data class FavoritesUIState(
6 | val isLoading: Boolean,
7 | val favorites: List,
8 | )
9 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/mr3y/podcaster/ui/presenter/favorites/FavoritesViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.presenter.favorites
2 |
3 | import android.annotation.SuppressLint
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.runtime.LaunchedEffect
6 | import androidx.compose.runtime.collectAsState
7 | import androidx.compose.runtime.getValue
8 | import androidx.compose.runtime.mutableStateOf
9 | import androidx.compose.runtime.remember
10 | import androidx.compose.runtime.setValue
11 | import app.cash.molecule.RecompositionMode
12 | import app.cash.molecule.launchMolecule
13 | import com.mr3y.podcaster.core.data.PodcastsRepository
14 | import com.mr3y.podcaster.ui.presenter.BaseMoleculeViewModel
15 | import dagger.hilt.android.lifecycle.HiltViewModel
16 | import javax.inject.Inject
17 |
18 | @HiltViewModel
19 | class FavoritesViewModel @Inject constructor(
20 | private val podcastsRepository: PodcastsRepository,
21 | ) : BaseMoleculeViewModel() {
22 |
23 | val state = moleculeScope.launchMolecule(mode = RecompositionMode.ContextClock) {
24 | FavoritesPresenter(repository = podcastsRepository)
25 | }
26 | }
27 |
28 | @SuppressLint("ComposableNaming")
29 | @Composable
30 | internal fun FavoritesPresenter(
31 | repository: PodcastsRepository,
32 | ): FavoritesUIState {
33 | var isLoading by remember { mutableStateOf(true) }
34 | val favorites by repository.getFavouriteEpisodes().collectAsState(initial = emptyList())
35 |
36 | LaunchedEffect(Unit) {
37 | repository.countFavouriteEpisodes().apply {
38 | isLoading = false
39 | }
40 | }
41 |
42 | return FavoritesUIState(isLoading, favorites)
43 | }
44 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/mr3y/podcaster/ui/presenter/opml/OpmlViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.presenter.opml
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import com.mr3y.podcaster.core.opml.OpmlManager
6 | import dagger.hilt.android.lifecycle.HiltViewModel
7 | import kotlinx.coroutines.launch
8 | import javax.inject.Inject
9 |
10 | @HiltViewModel
11 | class OpmlViewModel @Inject constructor(
12 | private val opmlManager: OpmlManager,
13 | ) : ViewModel() {
14 |
15 | val result = opmlManager.result
16 |
17 | fun import() {
18 | viewModelScope.launch {
19 | opmlManager.cancelCurrentRunningTask()
20 | opmlManager.import()
21 | }
22 | }
23 |
24 | fun export() {
25 | viewModelScope.launch {
26 | opmlManager.cancelCurrentRunningTask()
27 | opmlManager.export()
28 | }
29 | }
30 |
31 | fun consumeResult() {
32 | viewModelScope.launch { opmlManager.resetResultState() }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/mr3y/podcaster/ui/presenter/podcastdetails/PodcastDetailsUIState.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.presenter.podcastdetails
2 |
3 | import com.mr3y.podcaster.core.model.Episode
4 | import com.mr3y.podcaster.core.model.Podcast
5 | import com.mr3y.podcaster.ui.presenter.RefreshResult
6 |
7 | data class PodcastDetailsUIState(
8 | val isPodcastLoading: Boolean,
9 | val isEpisodesLoading: Boolean,
10 | val podcast: Podcast?,
11 | val subscriptionState: SubscriptionState,
12 | val isSubscriptionStateInEditMode: Boolean,
13 | val episodes: List?,
14 | val queueEpisodesIds: List,
15 | val isRefreshing: Boolean,
16 | val refreshResult: RefreshResult?,
17 | )
18 |
19 | enum class SubscriptionState {
20 | Subscribed,
21 | NotSubscribed,
22 | }
23 |
24 | sealed interface PodcastDetailsUIEvent {
25 |
26 | data object Subscribe : PodcastDetailsUIEvent
27 |
28 | data object UnSubscribe : PodcastDetailsUIEvent
29 |
30 | data object Refresh : PodcastDetailsUIEvent
31 |
32 | data object RefreshResultConsumed : PodcastDetailsUIEvent
33 |
34 | data object Retry : PodcastDetailsUIEvent
35 |
36 | data class PlayEpisode(val episode: Episode) : PodcastDetailsUIEvent
37 |
38 | data object Pause : PodcastDetailsUIEvent
39 |
40 | data class AddEpisodeToQueue(val episode: Episode) : PodcastDetailsUIEvent
41 |
42 | data class RemoveEpisodeFromQueue(val episodeId: Long) : PodcastDetailsUIEvent
43 |
44 | data object ErrorPlayingStatusConsumed : PodcastDetailsUIEvent
45 | }
46 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/mr3y/podcaster/ui/presenter/subscriptions/SubscriptionsUIState.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.presenter.subscriptions
2 |
3 | import com.mr3y.podcaster.core.model.Episode
4 | import com.mr3y.podcaster.core.model.EpisodeWithDownloadMetadata
5 | import com.mr3y.podcaster.core.model.Podcast
6 | import com.mr3y.podcaster.ui.presenter.RefreshResult
7 |
8 | data class SubscriptionsUIState(
9 | val isSubscriptionsLoading: Boolean,
10 | val isEpisodesLoading: Boolean,
11 | val isRefreshing: Boolean,
12 | val refreshResult: RefreshResult?,
13 | val subscriptions: List,
14 | val episodes: List,
15 | val queueEpisodesIds: List,
16 | )
17 |
18 | sealed interface SubscriptionsUIEvent {
19 |
20 | data object Refresh : SubscriptionsUIEvent
21 |
22 | data object RefreshResultConsumed : SubscriptionsUIEvent
23 |
24 | data class PlayEpisode(val episode: Episode) : SubscriptionsUIEvent
25 |
26 | data object Pause : SubscriptionsUIEvent
27 |
28 | data class AddEpisodeToQueue(val episode: Episode) : SubscriptionsUIEvent
29 |
30 | data class RemoveEpisodeFromQueue(val episodeId: Long) : SubscriptionsUIEvent
31 |
32 | data object ErrorPlayingStatusConsumed : SubscriptionsUIEvent
33 | }
34 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/mr3y/podcaster/ui/resources/Icons.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.resources
2 |
3 | import androidx.compose.material.icons.Icons
4 | import androidx.compose.ui.graphics.Color
5 | import androidx.compose.ui.graphics.PathFillType
6 | import androidx.compose.ui.graphics.SolidColor
7 | import androidx.compose.ui.graphics.StrokeCap
8 | import androidx.compose.ui.graphics.StrokeJoin
9 | import androidx.compose.ui.graphics.vector.ImageVector
10 | import androidx.compose.ui.graphics.vector.path
11 | import androidx.compose.ui.unit.dp
12 |
13 | private var _Subscriptions: ImageVector? = null
14 |
15 | public val Icons.Outlined.Subscriptions: ImageVector
16 | get() {
17 | if (_Subscriptions != null) {
18 | return _Subscriptions!!
19 | }
20 | _Subscriptions = ImageVector.Builder(
21 | name = "Subscriptions",
22 | defaultWidth = 24.dp,
23 | defaultHeight = 24.dp,
24 | viewportWidth = 24f,
25 | viewportHeight = 24f,
26 | ).apply {
27 | path(
28 | fill = SolidColor(Color(0xFF000000)),
29 | fillAlpha = 1.0f,
30 | stroke = null,
31 | strokeAlpha = 1.0f,
32 | strokeLineWidth = 1.0f,
33 | strokeLineCap = StrokeCap.Butt,
34 | strokeLineJoin = StrokeJoin.Miter,
35 | strokeLineMiter = 1.0f,
36 | pathFillType = PathFillType.NonZero,
37 | ) {
38 | moveTo(3f, 3f)
39 | verticalLineToRelative(8f)
40 | horizontalLineToRelative(8f)
41 | verticalLineTo(3f)
42 | horizontalLineTo(3f)
43 | close()
44 | moveTo(9f, 9f)
45 | horizontalLineTo(5f)
46 | verticalLineTo(5f)
47 | horizontalLineToRelative(4f)
48 | verticalLineTo(9f)
49 | close()
50 | moveTo(3f, 13f)
51 | verticalLineToRelative(8f)
52 | horizontalLineToRelative(8f)
53 | verticalLineToRelative(-8f)
54 | horizontalLineTo(3f)
55 | close()
56 | moveTo(9f, 19f)
57 | horizontalLineTo(5f)
58 | verticalLineToRelative(-4f)
59 | horizontalLineToRelative(4f)
60 | verticalLineTo(19f)
61 | close()
62 | moveTo(13f, 3f)
63 | verticalLineToRelative(8f)
64 | horizontalLineToRelative(8f)
65 | verticalLineTo(3f)
66 | horizontalLineTo(13f)
67 | close()
68 | moveTo(19f, 9f)
69 | horizontalLineToRelative(-4f)
70 | verticalLineTo(5f)
71 | horizontalLineToRelative(4f)
72 | verticalLineTo(9f)
73 | close()
74 | moveTo(13f, 13f)
75 | verticalLineToRelative(8f)
76 | horizontalLineToRelative(8f)
77 | verticalLineToRelative(-8f)
78 | horizontalLineTo(13f)
79 | close()
80 | moveTo(19f, 19f)
81 | horizontalLineToRelative(-4f)
82 | verticalLineToRelative(-4f)
83 | horizontalLineToRelative(4f)
84 | verticalLineTo(19f)
85 | close()
86 | }
87 | }.build()
88 | return _Subscriptions!!
89 | }
90 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/mr3y/podcaster/ui/screens/LicensesScreen.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.screens
2 |
3 | import androidx.compose.foundation.layout.PaddingValues
4 | import androidx.compose.foundation.layout.WindowInsets
5 | import androidx.compose.foundation.layout.exclude
6 | import androidx.compose.foundation.layout.fillMaxSize
7 | import androidx.compose.foundation.layout.fillMaxWidth
8 | import androidx.compose.foundation.layout.padding
9 | import androidx.compose.material3.MaterialTheme
10 | import androidx.compose.material3.Scaffold
11 | import androidx.compose.material3.ScaffoldDefaults
12 | import androidx.compose.material3.TopAppBarDefaults
13 | import androidx.compose.runtime.Composable
14 | import androidx.compose.runtime.LaunchedEffect
15 | import androidx.compose.ui.Modifier
16 | import androidx.compose.ui.graphics.Color
17 | import androidx.compose.ui.platform.LocalContext
18 | import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
19 | import com.mr3y.podcaster.ui.components.TopBar
20 | import com.mr3y.podcaster.ui.components.plus
21 | import com.mr3y.podcaster.ui.theme.isAppThemeDark
22 | import com.mr3y.podcaster.ui.theme.setStatusBarAppearanceLight
23 |
24 | @Composable
25 | fun LicensesScreen(
26 | onNavigateUp: () -> Unit,
27 | externalContentPadding: PaddingValues,
28 | excludedWindowInsets: WindowInsets?,
29 | modifier: Modifier = Modifier,
30 | ) {
31 | val isDarkTheme = isAppThemeDark()
32 | val context = LocalContext.current
33 | LaunchedEffect(key1 = isDarkTheme) {
34 | context.setStatusBarAppearanceLight(isAppearanceLight = !isDarkTheme)
35 | }
36 | Scaffold(
37 | topBar = {
38 | TopBar(
39 | onUpButtonClick = onNavigateUp,
40 | colors = TopAppBarDefaults.topAppBarColors(
41 | containerColor = Color.Transparent,
42 | navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
43 | ),
44 | modifier = Modifier.fillMaxWidth(),
45 | )
46 | },
47 | contentWindowInsets = if (excludedWindowInsets != null) ScaffoldDefaults.contentWindowInsets.exclude(excludedWindowInsets) else ScaffoldDefaults.contentWindowInsets,
48 | containerColor = MaterialTheme.colorScheme.surface,
49 | modifier = modifier,
50 | ) { contentPadding ->
51 | LibrariesContainer(
52 | modifier = Modifier
53 | .padding(contentPadding + externalContentPadding)
54 | .fillMaxSize(),
55 | )
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/mr3y/podcaster/ui/theme/StatusBarAppearance.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.theme
2 |
3 | import android.content.Context
4 | import android.os.Build
5 | import androidx.activity.ComponentActivity
6 | import androidx.core.view.WindowInsetsControllerCompat
7 |
8 | fun Context.setStatusBarAppearanceLight(
9 | isAppearanceLight: Boolean,
10 | ) {
11 | if (this !is ComponentActivity) return
12 |
13 | val window = this.window
14 | if (Build.VERSION.SDK_INT >= 29) {
15 | window.isStatusBarContrastEnforced = false
16 | }
17 |
18 | WindowInsetsControllerCompat(window, window.decorView).run {
19 | isAppearanceLightStatusBars = isAppearanceLight
20 | }
21 | }
22 |
23 | fun Context.isStatusBarAppearanceLight(): Boolean {
24 | this as ComponentActivity
25 |
26 | val window = this.window
27 | return WindowInsetsControllerCompat(window, window.decorView).run { isAppearanceLightStatusBars }
28 | }
29 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/mr3y/podcaster/ui/utils/DateTime.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.utils
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.remember
5 | import com.mr3y.podcaster.core.model.Episode
6 | import com.mr3y.podcaster.core.model.dateTimePublished
7 | import java.time.ZoneId
8 | import java.time.ZonedDateTime
9 | import java.time.format.DateTimeFormatter
10 |
11 | @Composable
12 | internal fun rememberFormattedEpisodeDate(episode: Episode): String {
13 | return remember(episode.datePublishedTimestamp) { format(episode.dateTimePublished) }
14 | }
15 |
16 | private fun format(dateTime: ZonedDateTime): String {
17 | val pattern = if (ZonedDateTime.now(ZoneId.systemDefault()).year != dateTime.year) "MMM d, yyyy" else "MMM d"
18 | return DateTimeFormatter.ofPattern(pattern).format(dateTime.toLocalDate())
19 | }
20 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/mr3y/podcaster/ui/utils/ShareSheet.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.utils
2 |
3 | import android.content.Context
4 | import android.content.Intent
5 | import androidx.compose.material.icons.Icons
6 | import androidx.compose.material.icons.filled.MoreVert
7 | import androidx.compose.material3.DropdownMenu
8 | import androidx.compose.material3.DropdownMenuItem
9 | import androidx.compose.material3.Icon
10 | import androidx.compose.material3.IconButton
11 | import androidx.compose.material3.IconButtonColors
12 | import androidx.compose.material3.IconButtonDefaults
13 | import androidx.compose.material3.Text
14 | import androidx.compose.runtime.Composable
15 | import androidx.compose.runtime.LaunchedEffect
16 | import androidx.compose.runtime.getValue
17 | import androidx.compose.runtime.mutableStateOf
18 | import androidx.compose.runtime.remember
19 | import androidx.compose.runtime.setValue
20 | import androidx.compose.ui.Modifier
21 | import androidx.compose.ui.platform.LocalContext
22 | import com.mr3y.podcaster.LocalStrings
23 |
24 | @Composable
25 | fun TopBarMoreOptionsButton(
26 | shareActionTitle: String,
27 | shareActionText: String,
28 | modifier: Modifier = Modifier,
29 | colors: IconButtonColors? = null,
30 | ) {
31 | var showOptions by remember { mutableStateOf(false) }
32 | var showShareSheet by remember { mutableStateOf(false) }
33 | val strings = LocalStrings.current
34 | val context = LocalContext.current
35 | IconButton(
36 | onClick = { showOptions = !showOptions },
37 | colors = colors ?: IconButtonDefaults.iconButtonColors(),
38 | modifier = modifier,
39 | ) {
40 | Icon(
41 | imageVector = Icons.Filled.MoreVert,
42 | contentDescription = strings.icon_more_options_content_description,
43 | )
44 | }
45 |
46 | DropdownMenu(
47 | expanded = showOptions,
48 | onDismissRequest = { showOptions = false },
49 | ) {
50 | DropdownMenuItem(
51 | text = { Text(strings.share_label) },
52 | onClick = { showShareSheet = true },
53 | )
54 | }
55 |
56 | LaunchedEffect(showShareSheet) {
57 | if (showShareSheet) {
58 | context.launchShareSheet(shareActionTitle, shareActionText)
59 | showShareSheet = false
60 | showOptions = false
61 | }
62 | }
63 | }
64 |
65 | internal fun Context.launchShareSheet(title: String, url: String) {
66 | val sendIntent: Intent = Intent().apply {
67 | action = Intent.ACTION_SEND
68 | putExtra(Intent.EXTRA_TITLE, title)
69 | putExtra(Intent.EXTRA_TEXT, url)
70 | type = "text/plain"
71 | }
72 |
73 | val shareIntent = Intent.createChooser(sendIntent, null)
74 | startActivity(shareIntent)
75 | }
76 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/mr3y/podcaster/ui/utils/SharedTransitionKeys.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.utils
2 |
3 | import com.mr3y.podcaster.core.model.Episode
4 |
5 | val Episode.dateSharedTransitionKey: String
6 | get() = "$id-$datePublishedTimestamp"
7 |
--------------------------------------------------------------------------------
/app/src/main/play/release-notes/en-US/production.txt:
--------------------------------------------------------------------------------
1 | - 🆕 Add the option to share podcast/episode url.
2 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable-hdpi/ic_notification.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/app/src/main/res/drawable-hdpi/ic_notification.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-mdpi/ic_notification.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/app/src/main/res/drawable-mdpi/ic_notification.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xhdpi/ic_notification.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/app/src/main/res/drawable-xhdpi/ic_notification.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxhdpi/ic_notification.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/app/src/main/res/drawable-xxhdpi/ic_notification.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxxhdpi/ic_notification.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/app/src/main/res/drawable-xxxhdpi/ic_notification.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/app/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/app/src/main/res/mipmap-hdpi/ic_launcher_background.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/app/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/app/src/main/res/mipmap-mdpi/ic_launcher_background.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/app/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FF003049
4 | #FF000000
5 | #FFFFFFFF
6 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Podcaster
3 | Receiving notifications about currently downloading episodes
4 | assertk.assertThat
5 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/app/src/test/kotlin/com/mr3y/podcaster/PodcasterApplication.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster
2 |
3 | import android.app.Application
4 |
5 | class PodcasterApplication : Application() {
6 |
7 | override fun onCreate() {
8 | super.onCreate()
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/app/src/test/kotlin/com/mr3y/podcaster/core/coroutines/MainDispatcherRule.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.coroutines
2 |
3 | import kotlinx.coroutines.Dispatchers
4 | import kotlinx.coroutines.test.TestDispatcher
5 | import kotlinx.coroutines.test.UnconfinedTestDispatcher
6 | import kotlinx.coroutines.test.resetMain
7 | import kotlinx.coroutines.test.setMain
8 | import org.junit.rules.TestWatcher
9 | import org.junit.runner.Description
10 |
11 | /**
12 | * A JUnit [TestRule] that sets the Main dispatcher to [testDispatcher]
13 | * for the duration of the test.
14 | */
15 |
16 | class MainDispatcherRule(
17 | val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
18 | ) : TestWatcher() {
19 | override fun starting(description: Description) {
20 | Dispatchers.setMain(testDispatcher)
21 | }
22 |
23 | override fun finished(description: Description) {
24 | Dispatchers.resetMain()
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/app/src/test/kotlin/com/mr3y/podcaster/ui/presenter/BasePresenterTest.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.presenter
2 |
3 | import com.mr3y.podcaster.core.data.internal.DefaultPodcastsRepository
4 | import com.mr3y.podcaster.core.local.dao.DefaultPodcastsDao
5 | import com.mr3y.podcaster.core.local.dao.DefaultRecentSearchesDao
6 | import com.mr3y.podcaster.core.local.di.FakeDatabaseModule
7 | import com.mr3y.podcaster.core.logger.TestLogger
8 | import com.mr3y.podcaster.core.network.di.FakeHttpClient
9 | import com.mr3y.podcaster.core.network.internal.DefaultPodcastIndexClient
10 | import kotlinx.coroutines.flow.MutableSharedFlow
11 | import kotlinx.coroutines.test.UnconfinedTestDispatcher
12 |
13 | open class BasePresenterTest {
14 |
15 | protected val testDispatcher = UnconfinedTestDispatcher()
16 | private val database = FakeDatabaseModule.provideInMemoryDatabaseInstance()
17 | private val httpClient = FakeHttpClient.getInstance()
18 | protected val repository = DefaultPodcastsRepository(
19 | podcastsDao = DefaultPodcastsDao(database, testDispatcher),
20 | recentSearchesDao = DefaultRecentSearchesDao(database, testDispatcher),
21 | networkClient = DefaultPodcastIndexClient(httpClient, TestLogger()),
22 | )
23 | protected val events = MutableSharedFlow(extraBufferCapacity = 20)
24 | }
25 |
--------------------------------------------------------------------------------
/app/src/test/kotlin/com/mr3y/podcaster/ui/presenter/downloads/DownloadsPresenterTest.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.presenter.downloads
2 |
3 | import app.cash.molecule.RecompositionMode
4 | import app.cash.molecule.moleculeFlow
5 | import app.cash.turbine.test
6 | import assertk.assertThat
7 | import assertk.assertions.hasSize
8 | import assertk.assertions.isEmpty
9 | import assertk.assertions.isEqualTo
10 | import assertk.assertions.isFalse
11 | import assertk.assertions.isTrue
12 | import com.mr3y.podcaster.core.model.EpisodeDownloadMetadata
13 | import com.mr3y.podcaster.core.model.EpisodeDownloadStatus
14 | import com.mr3y.podcaster.core.sampledata.Episodes
15 | import com.mr3y.podcaster.ui.presenter.BasePresenterTest
16 | import kotlinx.coroutines.test.runTest
17 | import org.junit.Test
18 |
19 | class DownloadsPresenterTest : BasePresenterTest() {
20 |
21 | @Test
22 | fun `test downloads presenter state`() = runTest(testDispatcher) {
23 | moleculeFlow(RecompositionMode.Immediate) {
24 | DownloadsPresenter(repository = repository)
25 | }.test {
26 | val initialState = awaitItem()
27 | assertThat(initialState.isLoading).isTrue()
28 | assertThat(initialState.downloads).isEmpty()
29 |
30 | val emptyState = awaitItem()
31 | assertThat(emptyState.isLoading).isFalse()
32 | assertThat(emptyState.downloads).isEmpty()
33 |
34 | repository.addEpisodeOnDeviceIfNotExist(Episodes[0])
35 | repository.updateEpisodeDownloadStatus(Episodes[0].id, EpisodeDownloadStatus.Queued)
36 |
37 | var currentState = awaitItem()
38 | assertThat(currentState.downloads).hasSize(1)
39 |
40 | repository.updateEpisodeDownloadStatus(Episodes[0].id, EpisodeDownloadStatus.Downloading)
41 |
42 | currentState = awaitItem()
43 | assertThat(currentState.downloads).hasSize(1)
44 | assertThat(currentState.downloads.first().downloadMetadata).isEqualTo(
45 | EpisodeDownloadMetadata(Episodes[0].id, EpisodeDownloadStatus.Downloading),
46 | )
47 |
48 | repository.updateEpisodeDownloadProgress(Episodes[0].id, 0.2f)
49 |
50 | currentState = awaitItem()
51 | assertThat(currentState.downloads.single().downloadMetadata).isEqualTo(
52 | EpisodeDownloadMetadata(Episodes[0].id, EpisodeDownloadStatus.Downloading, 0.2f),
53 | )
54 |
55 | expectNoEvents()
56 | }
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/app/src/test/kotlin/com/mr3y/podcaster/ui/presenter/podcastdetails/PodcastDetailsPresenterTest.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.presenter.podcastdetails
2 |
3 | import app.cash.molecule.RecompositionMode
4 | import app.cash.molecule.moleculeFlow
5 | import app.cash.turbine.test
6 | import assertk.assertThat
7 | import assertk.assertions.hasSize
8 | import assertk.assertions.isEqualTo
9 | import assertk.assertions.isFalse
10 | import assertk.assertions.isNotNull
11 | import assertk.assertions.isNull
12 | import assertk.assertions.isTrue
13 | import com.mr3y.podcaster.core.sampledata.Episodes
14 | import com.mr3y.podcaster.core.sampledata.Podcasts
15 | import com.mr3y.podcaster.ui.presenter.BasePresenterTest
16 | import kotlinx.coroutines.test.runTest
17 | import org.junit.Test
18 |
19 | class PodcastDetailsPresenterTest : BasePresenterTest() {
20 |
21 | @Test
22 | fun `test podcast details presenter state`() = runTest(testDispatcher) {
23 | // Setup, subscribe to random podcast with some random episodes
24 | val randomEpisodes = Episodes.slice(0..1).map { it.copy(podcastId = Podcasts[0].id) }
25 | repository.subscribeToPodcast(Podcasts[0], randomEpisodes)
26 |
27 | moleculeFlow(RecompositionMode.Immediate) {
28 | PodcastDetailsPresenter(podcastId = Podcasts[0].id, repository = repository, events = events)
29 | }.test {
30 | val initialState = awaitItem()
31 | assertThat(initialState.isPodcastLoading).isTrue()
32 | assertThat(initialState.isEpisodesLoading).isTrue()
33 | assertThat(initialState.isRefreshing).isFalse()
34 | assertThat(initialState.podcast).isNull()
35 | assertThat(initialState.episodes).isNull()
36 | assertThat(initialState.subscriptionState).isEqualTo(SubscriptionState.NotSubscribed)
37 | assertThat(initialState.isSubscriptionStateInEditMode).isTrue()
38 |
39 | // On the next frame
40 | var currentState = awaitItem()
41 | assertThat(currentState.isPodcastLoading).isFalse()
42 | assertThat(currentState.isEpisodesLoading).isFalse()
43 | assertThat(currentState.isRefreshing).isFalse()
44 | assertThat(currentState.podcast).isNotNull().isEqualTo(Podcasts[0])
45 | assertThat(currentState.episodes).isNotNull().hasSize(2)
46 | assertThat(currentState.subscriptionState).isEqualTo(SubscriptionState.Subscribed)
47 | assertThat(currentState.isSubscriptionStateInEditMode).isFalse()
48 |
49 | events.tryEmit(PodcastDetailsUIEvent.UnSubscribe)
50 | currentState = awaitItem()
51 | assertThat(currentState.subscriptionState).isEqualTo(SubscriptionState.Subscribed)
52 | assertThat(currentState.isSubscriptionStateInEditMode).isTrue()
53 |
54 | currentState = awaitItem()
55 | assertThat(currentState.subscriptionState).isEqualTo(SubscriptionState.NotSubscribed)
56 | assertThat(currentState.isSubscriptionStateInEditMode).isFalse()
57 |
58 | events.tryEmit(PodcastDetailsUIEvent.Subscribe)
59 | currentState = awaitItem()
60 | assertThat(currentState.subscriptionState).isEqualTo(SubscriptionState.NotSubscribed)
61 | assertThat(currentState.isSubscriptionStateInEditMode).isTrue()
62 |
63 | currentState = awaitItem()
64 | assertThat(currentState.subscriptionState).isEqualTo(SubscriptionState.Subscribed)
65 | assertThat(currentState.isSubscriptionStateInEditMode).isFalse()
66 |
67 | expectNoEvents()
68 | }
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/app/src/test/kotlin/com/mr3y/podcaster/ui/presenter/subscriptions/SubscriptionsPresenterTest.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.presenter.subscriptions
2 |
3 | import app.cash.molecule.RecompositionMode
4 | import app.cash.molecule.moleculeFlow
5 | import app.cash.turbine.test
6 | import assertk.assertThat
7 | import assertk.assertions.hasSize
8 | import assertk.assertions.isEmpty
9 | import assertk.assertions.isEqualTo
10 | import assertk.assertions.isFalse
11 | import assertk.assertions.isTrue
12 | import com.mr3y.podcaster.core.sampledata.Episodes
13 | import com.mr3y.podcaster.core.sampledata.Podcasts
14 | import com.mr3y.podcaster.ui.presenter.BasePresenterTest
15 | import kotlinx.coroutines.test.runTest
16 | import org.junit.Test
17 |
18 | class SubscriptionsPresenterTest : BasePresenterTest() {
19 |
20 | @Test
21 | fun `test subscriptions presenter state`() = runTest(testDispatcher) {
22 | moleculeFlow(RecompositionMode.Immediate) {
23 | SubscriptionsPresenter(repository = repository, events = events)
24 | }.test {
25 | // Initially, our presenter is loading & preparing the state of our UI
26 | val initialState = awaitItem()
27 | assertThat(initialState.isSubscriptionsLoading).isTrue()
28 | assertThat(initialState.isEpisodesLoading).isTrue()
29 | assertThat(initialState.isRefreshing).isFalse()
30 | assertThat(initialState.subscriptions).isEmpty()
31 | assertThat(initialState.episodes).isEmpty()
32 |
33 | // on the next frame, loading is finished but there are no subscriptions yet.
34 | val emptyState = awaitItem()
35 | assertThat(emptyState.isSubscriptionsLoading).isFalse()
36 | assertThat(emptyState.isEpisodesLoading).isFalse()
37 | assertThat(emptyState.isRefreshing).isFalse()
38 | assertThat(emptyState.subscriptions).isEmpty()
39 | assertThat(emptyState.episodes).isEmpty()
40 |
41 | // subscribe to random podcast with some random episodes
42 | val randomEpisodes = Episodes.slice(0..1).map { it.copy(podcastId = Podcasts[0].id) }
43 | repository.subscribeToPodcast(Podcasts[0], randomEpisodes)
44 |
45 | var currentState = awaitItem()
46 | assertThat(currentState.isSubscriptionsLoading).isFalse()
47 | assertThat(currentState.isEpisodesLoading).isFalse()
48 | assertThat(currentState.isRefreshing).isFalse()
49 | assertThat(currentState.subscriptions).isEqualTo(listOf(Podcasts[0]))
50 | assertThat(currentState.episodes).isEmpty()
51 | currentState = awaitItem()
52 | assertThat(currentState.subscriptions).isEqualTo(listOf(Podcasts[0]))
53 | assertThat(currentState.episodes).hasSize(2)
54 |
55 | expectNoEvents()
56 | }
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/app/src/test/kotlin/com/mr3y/podcaster/ui/screens/BaseScreenshotTest.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.screens
2 |
3 | import android.app.Application
4 | import android.graphics.BitmapFactory
5 | import android.graphics.drawable.BitmapDrawable
6 | import androidx.compose.ui.test.junit4.createComposeRule
7 | import androidx.compose.ui.test.onRoot
8 | import androidx.test.core.app.ApplicationProvider
9 | import coil3.ImageLoader
10 | import coil3.SingletonImageLoader
11 | import coil3.test.FakeImageLoaderEngine
12 | import coil3.test.intercept
13 | import com.github.takahirom.roborazzi.ExperimentalRoborazziApi
14 | import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
15 | import com.github.takahirom.roborazzi.RoborazziOptions
16 | import com.github.takahirom.roborazzi.ThresholdValidator
17 | import com.github.takahirom.roborazzi.captureRoboImage
18 | import okio.FileSystem
19 | import okio.Path
20 | import okio.Path.Companion.toPath
21 | import org.junit.Before
22 | import org.junit.Rule
23 | import org.robolectric.annotation.Config
24 | import org.robolectric.annotation.GraphicsMode
25 |
26 | @GraphicsMode(GraphicsMode.Mode.NATIVE)
27 | @Config(sdk = [34], manifest = Config.NONE, qualifiers = RobolectricDeviceQualifiers.Pixel7)
28 | open class BaseScreenshotTest {
29 |
30 | @get:Rule
31 | val composeRule = createComposeRule()
32 |
33 | var tolerance = 0.01f
34 |
35 | protected val context = ApplicationProvider.getApplicationContext()
36 |
37 | @Before
38 | fun setup() {
39 | SingletonImageLoader.setSafe(provideFakeImageLoader())
40 | }
41 |
42 | @OptIn(ExperimentalRoborazziApi::class)
43 | protected fun takeScreenshot() {
44 | composeRule.onRoot().captureRoboImage(
45 | roborazziOptions = RoborazziOptions(
46 | compareOptions = RoborazziOptions.CompareOptions(
47 | resultValidator = ThresholdValidator(tolerance),
48 | ),
49 | ),
50 | )
51 | }
52 |
53 | private fun provideFakeImageLoader(): SingletonImageLoader.Factory {
54 | return SingletonImageLoader.Factory {
55 | val engine = FakeImageLoaderEngine.Builder()
56 | .intercept({ it is String }, loadTestBitmap("adb_test_image.png".toPath()))
57 | .build()
58 | ImageLoader.Builder(context)
59 | .components { add(engine) }
60 | .build()
61 | }
62 | }
63 |
64 | private fun loadTestBitmap(path: Path): BitmapDrawable = FileSystem.RESOURCES.read(path) {
65 | BitmapDrawable(context.resources, BitmapFactory.decodeStream(this.inputStream()))
66 | }
67 | }
68 |
69 | /**
70 | * Used to filter ScreenshotTests using -Pscreenshot parameter
71 | */
72 | interface ScreenshotTests
73 |
--------------------------------------------------------------------------------
/app/src/test/kotlin/com/mr3y/podcaster/ui/screens/EpisodeDetailsScreenshotTest.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.screens
2 |
3 | import androidx.compose.foundation.layout.PaddingValues
4 | import androidx.compose.foundation.layout.fillMaxSize
5 | import androidx.compose.ui.Modifier
6 | import androidx.compose.ui.unit.dp
7 | import com.mr3y.podcaster.core.sampledata.DownloadMetadata
8 | import com.mr3y.podcaster.core.sampledata.EpisodeWithDetails
9 | import com.mr3y.podcaster.ui.presenter.episodedetails.EpisodeDetailsUIState
10 | import com.mr3y.podcaster.ui.theme.PodcasterTheme
11 | import org.junit.Test
12 | import org.junit.experimental.categories.Category
13 | import org.junit.runner.RunWith
14 | import org.robolectric.RobolectricTestRunner
15 |
16 | @RunWith(RobolectricTestRunner::class)
17 | @Category(ScreenshotTests::class)
18 | class EpisodeDetailsScreenshotTest : BaseScreenshotTest() {
19 |
20 | @Test
21 | fun compact_episodeDetailsScreen() {
22 | composeRule.setContent {
23 | PodcasterTheme(dynamicColor = false) {
24 | EpisodeDetailsScreen(
25 | state = EpisodeDetailsUIState(
26 | isLoading = false,
27 | episode = EpisodeWithDetails,
28 | queueEpisodesIds = emptyList(),
29 | isRefreshing = false,
30 | refreshResult = null,
31 | downloadMetadata = DownloadMetadata,
32 | ),
33 | onNavigateUp = {},
34 | isSelected = false,
35 | playingStatus = null,
36 | externalContentPadding = PaddingValues(0.dp),
37 | excludedWindowInsets = null,
38 | eventSink = {},
39 | modifier = Modifier.fillMaxSize(),
40 | )
41 | }
42 | }
43 |
44 | takeScreenshot()
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/app/src/test/kotlin/com/mr3y/podcaster/ui/screens/PodcastDetailsScreenshotTest.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.screens
2 |
3 | import androidx.compose.foundation.layout.PaddingValues
4 | import androidx.compose.foundation.layout.fillMaxSize
5 | import androidx.compose.ui.Modifier
6 | import androidx.compose.ui.unit.dp
7 | import com.mr3y.podcaster.core.sampledata.Episodes
8 | import com.mr3y.podcaster.core.sampledata.PodcastWithDetails
9 | import com.mr3y.podcaster.ui.presenter.podcastdetails.PodcastDetailsUIState
10 | import com.mr3y.podcaster.ui.presenter.podcastdetails.SubscriptionState
11 | import com.mr3y.podcaster.ui.theme.PodcasterTheme
12 | import org.junit.Test
13 | import org.junit.experimental.categories.Category
14 | import org.junit.runner.RunWith
15 | import org.robolectric.RobolectricTestRunner
16 |
17 | @RunWith(RobolectricTestRunner::class)
18 | @Category(ScreenshotTests::class)
19 | class PodcastDetailsScreenshotTest : BaseScreenshotTest() {
20 |
21 | @Test
22 | fun compact_notSubscribed_podcastDetailsScreen() {
23 | composeRule.setContent {
24 | PodcasterTheme(dynamicColor = false) {
25 | PodcastDetailsScreen(
26 | state = PodcastDetailsUIState(
27 | isPodcastLoading = false,
28 | isEpisodesLoading = false,
29 | podcast = PodcastWithDetails,
30 | subscriptionState = SubscriptionState.NotSubscribed,
31 | isSubscriptionStateInEditMode = false,
32 | episodes = Episodes.take(4),
33 | isRefreshing = false,
34 | refreshResult = null,
35 | queueEpisodesIds = Episodes.take(1).map { it.id },
36 | ),
37 | onNavigateUp = {},
38 | currentlyPlayingEpisode = null,
39 | externalContentPadding = PaddingValues(0.dp),
40 | excludedWindowInsets = null,
41 | onEpisodeClick = { _, _ -> },
42 | eventSink = {},
43 | modifier = Modifier.fillMaxSize(),
44 | )
45 | }
46 | }
47 |
48 | takeScreenshot()
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/app/src/test/kotlin/com/mr3y/podcaster/ui/screens/SubscriptionsScreenshotTest.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.screens
2 |
3 | import androidx.compose.foundation.layout.PaddingValues
4 | import androidx.compose.foundation.layout.fillMaxSize
5 | import androidx.compose.ui.Modifier
6 | import androidx.compose.ui.unit.dp
7 | import com.mr3y.podcaster.core.sampledata.Episodes
8 | import com.mr3y.podcaster.core.sampledata.EpisodesWithDownloadMetadata
9 | import com.mr3y.podcaster.core.sampledata.Podcasts
10 | import com.mr3y.podcaster.ui.presenter.subscriptions.SubscriptionsUIState
11 | import com.mr3y.podcaster.ui.theme.PodcasterTheme
12 | import org.junit.Test
13 | import org.junit.experimental.categories.Category
14 | import org.junit.runner.RunWith
15 | import org.robolectric.RobolectricTestRunner
16 |
17 | @RunWith(RobolectricTestRunner::class)
18 | @Category(ScreenshotTests::class)
19 | class SubscriptionsScreenshotTest : BaseScreenshotTest() {
20 |
21 | @Test
22 | fun compact_subscriptionsScreen() {
23 | composeRule.setContent {
24 | PodcasterTheme(dynamicColor = false) {
25 | SubscriptionsScreen(
26 | state = SubscriptionsUIState(
27 | isSubscriptionsLoading = false,
28 | isEpisodesLoading = false,
29 | isRefreshing = false,
30 | refreshResult = null,
31 | subscriptions = Podcasts,
32 | episodes = EpisodesWithDownloadMetadata,
33 | queueEpisodesIds = Episodes.take(2).map { it.id },
34 | ),
35 | onPodcastClick = {},
36 | onEpisodeClick = { _, _ -> },
37 | externalContentPadding = PaddingValues(0.dp),
38 | excludedWindowInsets = null,
39 | currentlyPlayingEpisode = null,
40 | eventSink = {},
41 | modifier = Modifier.fillMaxSize(),
42 | )
43 | }
44 | }
45 |
46 | takeScreenshot()
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/app/src/test/resources/adb_test_image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/app/src/test/resources/adb_test_image.png
--------------------------------------------------------------------------------
/baselineProfile/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import com.android.build.api.dsl.ManagedVirtualDevice
2 |
3 | plugins {
4 | alias(libs.plugins.podcaster.android.test.lib)
5 | alias(libs.plugins.baselineprofile)
6 | }
7 |
8 | android {
9 | namespace = "com.mr3y.podcaster.baselineprofile"
10 |
11 | targetProjectPath = ":app"
12 | experimentalProperties["android.experimental.self-instrumenting"] = true
13 |
14 | // This code creates the gradle managed device used to generate baseline profiles.
15 | // To use GMD please invoke generation through the command line:
16 | // ./gradlew :app:generateBaselineProfile
17 | testOptions.managedDevices.devices {
18 | create("pixel6Api34") {
19 | device = "Pixel 6"
20 | apiLevel = 34
21 | systemImageSource = "aosp"
22 | }
23 | }
24 | }
25 |
26 | // This is the configuration block for the Baseline Profile plugin.
27 | // You can specify to run the generators on a managed devices or connected devices.
28 | baselineProfile {
29 | managedDevices += "pixel6Api34"
30 | useConnectedDevices = false
31 | }
32 |
33 | dependencies {
34 | implementation(libs.androidx.test.ext.junit)
35 | implementation(libs.espresso.core)
36 | implementation(libs.androidx.uiautomator)
37 | implementation(libs.androidx.benchmark.macro.junit4)
38 | }
39 |
40 | androidComponents {
41 | onVariants { v ->
42 | val artifactsLoader = v.artifacts.getBuiltArtifactsLoader()
43 | v.instrumentationRunnerArguments.put(
44 | "targetAppId",
45 | v.testedApks.map { artifactsLoader.load(it)?.applicationId }
46 | )
47 | }
48 | }
--------------------------------------------------------------------------------
/baselineProfile/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/baselineProfile/src/main/kotlin/com/mr3y/podcaster/baselineprofile/Utils.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.baselineprofile
2 |
3 | import android.Manifest
4 | import android.os.Build.VERSION.SDK_INT
5 | import android.os.Build.VERSION_CODES.TIRAMISU
6 | import androidx.benchmark.macro.MacrobenchmarkScope
7 |
8 | internal fun MacrobenchmarkScope.allowNotifications() {
9 | if (SDK_INT >= TIRAMISU) {
10 | val command = "pm grant $packageName ${Manifest.permission.POST_NOTIFICATIONS}"
11 | device.executeShellCommand(command)
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/baselineProfile/src/main/kotlin/com/mr3y/podcaster/baselineprofile/startup/StartupBaselineProfile.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.baselineprofile.startup
2 |
3 | import androidx.benchmark.macro.junit4.BaselineProfileRule
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 | import androidx.test.filters.LargeTest
6 | import androidx.test.platform.app.InstrumentationRegistry
7 | import com.mr3y.podcaster.baselineprofile.allowNotifications
8 | import org.junit.Rule
9 | import org.junit.Test
10 | import org.junit.runner.RunWith
11 |
12 | /**
13 | * This test class generates a basic startup baseline profile for the target package.
14 | *
15 | * We recommend you start with this but add important user flows to the profile to improve their performance.
16 | * Refer to the [baseline profile documentation](https://d.android.com/topic/performance/baselineprofiles)
17 | * for more information.
18 | *
19 | * You can run the generator with the "Generate Baseline Profile" run configuration in Android Studio or
20 | * the equivalent `generateBaselineProfile` gradle task:
21 | * ```
22 | * ./gradlew :app:generateReleaseBaselineProfile
23 | * ```
24 | * The run configuration runs the Gradle task and applies filtering to run only the generators.
25 | *
26 | * Check [documentation](https://d.android.com/topic/performance/benchmarking/macrobenchmark-instrumentation-args)
27 | * for more information about available instrumentation arguments.
28 | *
29 | * After you run the generator, you can verify the improvements running the [StartupBenchmarks] benchmark.
30 | *
31 | * When using this class to generate a baseline profile, only API 33+ or rooted API 28+ are supported.
32 | *
33 | * The minimum required version of androidx.benchmark to generate a baseline profile is 1.2.0.
34 | **/
35 | @RunWith(AndroidJUnit4::class)
36 | @LargeTest
37 | class StartupBaselineProfile {
38 |
39 | @get:Rule
40 | val rule = BaselineProfileRule()
41 |
42 | @Test
43 | fun generate() {
44 | // The application id for the running build variant is read from the instrumentation arguments.
45 | rule.collect(
46 | packageName = InstrumentationRegistry.getArguments().getString("targetAppId")
47 | ?: throw Exception("targetAppId not passed as instrumentation runner arg"),
48 |
49 | // See: https://d.android.com/topic/performance/baselineprofiles/dex-layout-optimizations
50 | // Disable startup profiles for now as they increase the final app size by almost ~24%
51 | includeInStartupProfile = false
52 | ) {
53 | // This block defines the app's startup user journey. Here we are interested in
54 | // optimizing for app startup. But you can also navigate and scroll through your most important UI.
55 | pressHome()
56 | startActivityAndWait()
57 | allowNotifications()
58 | }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
2 | @Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
3 | plugins {
4 | alias(libs.plugins.com.android.application) apply false
5 | alias(libs.plugins.com.android.library) apply false
6 | alias(libs.plugins.compose.compiler) apply false
7 | alias(libs.plugins.kotlin.android) apply false
8 | alias(libs.plugins.kotlin.jvm) apply false
9 | alias(libs.plugins.kotlinx.serialization) apply false
10 | alias(libs.plugins.ktlint) apply false
11 | alias(libs.plugins.hilt) apply false
12 | alias(libs.plugins.ksp) apply false
13 | alias(libs.plugins.sqldelight) apply false
14 | alias(libs.plugins.google.services) apply false
15 | alias(libs.plugins.crashlytics) apply false
16 | alias(libs.plugins.aboutlibraries) apply false
17 | alias(libs.plugins.android.test) apply false
18 | alias(libs.plugins.baselineprofile) apply false
19 | }
20 | true // Needed to make the Suppress annotation work for the plugins block
--------------------------------------------------------------------------------
/changelog_config.json:
--------------------------------------------------------------------------------
1 | {
2 | "categories": [
3 | {
4 | "title": "## 🚀 Features",
5 | "labels": ["feature", "enhancement"]
6 | },
7 | {
8 | "title": "## 🐛 Bugs",
9 | "labels": ["bug"]
10 | },
11 | {
12 | "title": "## 🎇 Performance",
13 | "labels": ["Performance"]
14 | },
15 | {
16 | "title": "## 🔧 Chore",
17 | "labels": ["dependencies", "chore"]
18 | }
19 | ],
20 | "ignore_labels": [
21 | "duplicate", "good first issue", "help wanted", "invalid", "question", "wontfix", "skip release notes", "hold"
22 | ],
23 | "sort": "ASC",
24 | "template": "${{CHANGELOG}}",
25 | "pr_template": "- ${{TITLE}} (#${{NUMBER}})",
26 | "empty_template": "- no changes",
27 | "transformers": [
28 | {
29 | "pattern": "[\\-\\*] (\\[(...|TEST|CI|SKIP)\\])( )?(.+?)\n(.+?[\\-\\*] )(.+)",
30 | "target": "- $4\n - $6"
31 | }
32 | ],
33 | "max_tags_to_fetch": 200,
34 | "max_pull_requests": 200,
35 | "max_back_track_time_days": 365,
36 | "tag_resolver": {
37 | "method": "semver"
38 | },
39 | "base_branches": [
40 | "main"
41 | ]
42 | }
43 |
--------------------------------------------------------------------------------
/convention-plugins/gradle.properties:
--------------------------------------------------------------------------------
1 | # Gradle properties are not passed to included builds https://github.com/gradle/gradle/issues/2534
2 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
3 | android.useAndroidX=true
4 | kotlin.code.style=official
5 | android.nonTransitiveRClass=true
6 | org.gradle.caching=true
7 | org.gradle.parallel=true
8 | org.gradle.configuration-cache=true
9 | org.gradle.kotlin.dsl.skipMetadataVersionCheck=false
--------------------------------------------------------------------------------
/convention-plugins/plugins/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/convention-plugins/plugins/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
2 |
3 | plugins {
4 | `kotlin-dsl`
5 | }
6 |
7 | group = "com.mr3y.podcaster.conventionplugins"
8 |
9 | java {
10 | sourceCompatibility = JavaVersion.VERSION_17
11 | targetCompatibility = JavaVersion.VERSION_17
12 | }
13 |
14 | tasks.withType().configureEach {
15 | kotlinOptions {
16 | jvmTarget = JavaVersion.VERSION_17.toString()
17 | }
18 | }
19 |
20 | dependencies {
21 | compileOnly(libs.android.gradlePlugin)
22 | compileOnly(libs.kotlin.gradlePlugin)
23 | compileOnly(libs.compose.compiler.gradlePlugin)
24 | compileOnly(libs.ktlint.gradlePlugin)
25 | }
26 |
27 | gradlePlugin {
28 | plugins {
29 | register("androidApplication") {
30 | id = "podcaster.android.application"
31 | implementationClass = "com.mr3y.podcaster.gradle.AndroidApplicationConventionPlugin"
32 | }
33 | register("androidLibrary") {
34 | id = "podcaster.android.library"
35 | implementationClass = "com.mr3y.podcaster.gradle.AndroidLibraryConventionPlugin"
36 | }
37 | register("androidTestLibrary") {
38 | id = "podcaster.android.test.library"
39 | implementationClass = "com.mr3y.podcaster.gradle.AndroidTestConventionPlugin"
40 | }
41 | register("androidComposeLibrary") {
42 | id = "podcaster.android.compose.library"
43 | implementationClass = "com.mr3y.podcaster.gradle.AndroidComposeLibraryConventionPlugin"
44 | }
45 | register("jvmLibrary") {
46 | id = "podcaster.jvm.library"
47 | implementationClass = "com.mr3y.podcaster.gradle.JvmConventionPlugin"
48 | }
49 | }
50 | }
51 |
52 |
--------------------------------------------------------------------------------
/convention-plugins/plugins/src/main/kotlin/com/mr3y/podcaster/gradle/AndroidApplicationConventionPlugin.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.gradle
2 |
3 | import com.android.build.api.dsl.ApplicationExtension
4 | import org.gradle.api.JavaVersion
5 | import org.gradle.api.Plugin
6 | import org.gradle.api.Project
7 | import org.gradle.kotlin.dsl.getByType
8 | import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension
9 |
10 | class AndroidApplicationConventionPlugin : Plugin {
11 |
12 | override fun apply(target: Project) {
13 | with(target) {
14 | pluginManager.apply("com.android.application")
15 | pluginManager.apply("org.jetbrains.kotlin.android")
16 | pluginManager.apply("org.jlleitschuh.gradle.ktlint")
17 | pluginManager.apply("org.jetbrains.kotlin.plugin.compose")
18 |
19 | val applicationExtension = extensions.getByType()
20 | val composeExtension = extensions.getByType()
21 | configureAndroidApplicationExtension(applicationExtension, composeExtension)
22 | }
23 | }
24 |
25 |
26 | private fun Project.configureAndroidApplicationExtension(
27 | applicationExtension: ApplicationExtension,
28 | composeExtension: ComposeCompilerGradlePluginExtension,
29 | ) {
30 | applicationExtension.apply {
31 | compileSdk = libs.findVersion("compileSdk").get().toString().toInt()
32 |
33 | defaultConfig {
34 | minSdk = libs.findVersion("minSdk").get().toString().toInt()
35 | targetSdk = libs.findVersion("targetSdk").get().toString().toInt()
36 |
37 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
38 | vectorDrawables {
39 | useSupportLibrary = true
40 | }
41 | }
42 |
43 | compileOptions {
44 | sourceCompatibility = JavaVersion.VERSION_17
45 | targetCompatibility = JavaVersion.VERSION_17
46 | }
47 |
48 | buildFeatures {
49 | compose = true
50 | buildConfig = true
51 | }
52 |
53 | composeExtension.apply {
54 | reportsDestination.set(layout.buildDirectory.dir("compose_compiler"))
55 | }
56 |
57 | packaging {
58 | resources {
59 | excludes += "/META-INF/{AL2.0,LGPL2.1}"
60 | }
61 | jniLibs {
62 | excludes += "**/libdatastore_shared_counter.so"
63 | }
64 | }
65 | }
66 |
67 | configureKotlin()
68 | configureKtlint()
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/convention-plugins/plugins/src/main/kotlin/com/mr3y/podcaster/gradle/AndroidComposeLibraryConventionPlugin.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.gradle
2 |
3 | import com.android.build.api.dsl.LibraryExtension
4 | import org.gradle.api.JavaVersion
5 | import org.gradle.api.Plugin
6 | import org.gradle.api.Project
7 | import org.gradle.kotlin.dsl.getByType
8 | import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension
9 |
10 | class AndroidComposeLibraryConventionPlugin : Plugin {
11 |
12 | override fun apply(target: Project) {
13 | with(target) {
14 | pluginManager.apply("com.android.library")
15 | pluginManager.apply("org.jetbrains.kotlin.android")
16 | pluginManager.apply("org.jlleitschuh.gradle.ktlint")
17 | pluginManager.apply("org.jetbrains.kotlin.plugin.compose")
18 |
19 | val extension = extensions.getByType()
20 | val composeExtension = extensions.getByType()
21 | configureAndroidComposeLibraryExtension(extension, composeExtension)
22 | }
23 | }
24 |
25 | private fun Project.configureAndroidComposeLibraryExtension(
26 | libraryExtension: LibraryExtension,
27 | composeExtension: ComposeCompilerGradlePluginExtension,
28 | ) {
29 | libraryExtension.apply {
30 | compileSdk = libs.findVersion("compileSdk").get().toString().toInt()
31 |
32 | defaultConfig {
33 | minSdk = libs.findVersion("minSdk").get().toString().toInt()
34 |
35 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
36 | }
37 |
38 | buildFeatures {
39 | compose = true
40 | }
41 |
42 | composeExtension.apply {
43 | reportsDestination.set(layout.buildDirectory.dir("compose_compiler"))
44 | }
45 |
46 | compileOptions {
47 | sourceCompatibility = JavaVersion.VERSION_17
48 | targetCompatibility = JavaVersion.VERSION_17
49 | }
50 |
51 | packaging {
52 | resources {
53 | excludes += "/META-INF/{AL2.0,LGPL2.1}"
54 | }
55 | }
56 | }
57 |
58 | configureKotlin()
59 | configureKtlint()
60 | }
61 | }
--------------------------------------------------------------------------------
/convention-plugins/plugins/src/main/kotlin/com/mr3y/podcaster/gradle/AndroidLibraryConventionPlugin.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.gradle
2 |
3 | import com.android.build.api.dsl.LibraryExtension
4 | import org.gradle.api.JavaVersion
5 | import org.gradle.api.Plugin
6 | import org.gradle.api.Project
7 | import org.gradle.kotlin.dsl.getByType
8 |
9 | class AndroidLibraryConventionPlugin : Plugin {
10 |
11 | override fun apply(target: Project) {
12 | with(target) {
13 | pluginManager.apply("com.android.library")
14 | pluginManager.apply("org.jetbrains.kotlin.android")
15 | pluginManager.apply("org.jlleitschuh.gradle.ktlint")
16 |
17 | val extension = extensions.getByType()
18 | configureAndroidLibraryExtension(extension)
19 | }
20 | }
21 |
22 | private fun Project.configureAndroidLibraryExtension(
23 | libraryExtension: LibraryExtension
24 | ) {
25 | libraryExtension.apply {
26 | compileSdk = libs.findVersion("compileSdk").get().toString().toInt()
27 |
28 | defaultConfig {
29 | minSdk = libs.findVersion("minSdk").get().toString().toInt()
30 |
31 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
32 | }
33 |
34 | compileOptions {
35 | sourceCompatibility = JavaVersion.VERSION_17
36 | targetCompatibility = JavaVersion.VERSION_17
37 | }
38 |
39 | packaging {
40 | resources {
41 | excludes += "/META-INF/{AL2.0,LGPL2.1}"
42 | }
43 | }
44 | }
45 |
46 | configureKotlin()
47 | configureKtlint()
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/convention-plugins/plugins/src/main/kotlin/com/mr3y/podcaster/gradle/AndroidTestConventionPlugin.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.gradle
2 |
3 | import com.android.build.api.dsl.TestExtension
4 | import org.gradle.api.JavaVersion
5 | import org.gradle.api.Plugin
6 | import org.gradle.api.Project
7 | import org.gradle.kotlin.dsl.getByType
8 |
9 | class AndroidTestConventionPlugin : Plugin {
10 |
11 | override fun apply(target: Project) {
12 | with(target) {
13 | pluginManager.apply("com.android.test")
14 | pluginManager.apply("org.jetbrains.kotlin.android")
15 | pluginManager.apply("org.jlleitschuh.gradle.ktlint")
16 |
17 | val extension = extensions.getByType()
18 | configureAndroidTestExtension(extension)
19 | }
20 | }
21 |
22 | private fun Project.configureAndroidTestExtension(
23 | testExtension: TestExtension
24 | ) {
25 | testExtension.apply {
26 | compileSdk = libs.findVersion("compileSdk").get().toString().toInt()
27 |
28 | defaultConfig {
29 | minSdk = 28 // Generating baseline profiles isn't supported on devices running Android API 27 and lower.
30 | targetSdk = libs.findVersion("targetSdk").get().toString().toInt()
31 |
32 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
33 | }
34 |
35 | compileOptions {
36 | sourceCompatibility = JavaVersion.VERSION_17
37 | targetCompatibility = JavaVersion.VERSION_17
38 | }
39 | }
40 |
41 | configureKotlin()
42 | configureKtlint()
43 | }
44 | }
--------------------------------------------------------------------------------
/convention-plugins/plugins/src/main/kotlin/com/mr3y/podcaster/gradle/Java.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.gradle
2 |
3 | import org.gradle.api.JavaVersion
4 | import org.gradle.api.Project
5 | import org.gradle.api.plugins.JavaPluginExtension
6 | import org.gradle.kotlin.dsl.configure
7 |
8 | fun Project.configureJava() {
9 | java {
10 | sourceCompatibility = JavaVersion.VERSION_17
11 | targetCompatibility = JavaVersion.VERSION_17
12 | }
13 | }
14 |
15 | private fun Project.java(action: JavaPluginExtension.() -> Unit) = extensions.configure(action)
16 |
--------------------------------------------------------------------------------
/convention-plugins/plugins/src/main/kotlin/com/mr3y/podcaster/gradle/JvmConventionPlugin.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.gradle
2 |
3 | import org.gradle.api.Plugin
4 | import org.gradle.api.Project
5 |
6 | class JvmConventionPlugin : Plugin {
7 |
8 | override fun apply(target: Project) {
9 | with(target) {
10 | pluginManager.apply("org.jetbrains.kotlin.jvm")
11 | pluginManager.apply("org.jlleitschuh.gradle.ktlint")
12 |
13 | configureJava()
14 | configureKotlin()
15 | configureKtlint()
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/convention-plugins/plugins/src/main/kotlin/com/mr3y/podcaster/gradle/Kotlin.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.gradle
2 |
3 | import org.gradle.api.Project
4 | import org.gradle.kotlin.dsl.withType
5 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget
6 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
7 |
8 | fun Project.configureKotlin() {
9 | tasks.withType().configureEach {
10 | compilerOptions {
11 | jvmTarget.set(JvmTarget.JVM_17)
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/convention-plugins/plugins/src/main/kotlin/com/mr3y/podcaster/gradle/Ktlint.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.gradle
2 |
3 | import org.gradle.api.Project
4 | import org.gradle.kotlin.dsl.configure
5 | import org.jlleitschuh.gradle.ktlint.KtlintExtension
6 |
7 | fun Project.configureKtlint() {
8 | ktlint {
9 | filter {
10 | exclude("**/generated/**")
11 | exclude("**/build/**")
12 | }
13 | }
14 | }
15 |
16 | private fun Project.ktlint(action: KtlintExtension.() -> Unit) = extensions.configure(action)
17 |
--------------------------------------------------------------------------------
/convention-plugins/plugins/src/main/kotlin/com/mr3y/podcaster/gradle/ProjectExtensions.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.gradle
2 |
3 | import org.gradle.api.Project
4 | import org.gradle.api.artifacts.VersionCatalog
5 | import org.gradle.api.artifacts.VersionCatalogsExtension
6 | import org.gradle.kotlin.dsl.getByType
7 |
8 | val Project.libs
9 | get(): VersionCatalog = extensions.getByType().named("libs")
10 |
--------------------------------------------------------------------------------
/convention-plugins/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | dependencyResolutionManagement {
2 | repositories {
3 | google()
4 | mavenCentral()
5 | maven {
6 | url = uri("https://plugins.gradle.org/m2/")
7 | content {
8 | includeGroup("org.jlleitschuh.gradle")
9 | }
10 | }
11 | }
12 | versionCatalogs {
13 | create("libs") {
14 | from(files("../gradle/libs.versions.toml"))
15 | }
16 | }
17 | }
18 |
19 | rootProject.name = "convention-plugins"
20 | include(":plugins")
--------------------------------------------------------------------------------
/core/data/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.podcaster.android.lib)
3 | alias(libs.plugins.ksp)
4 | }
5 |
6 | android {
7 | namespace = "com.mr3y.podcaster.core.data"
8 | }
9 |
10 | kotlin {
11 | compilerOptions {
12 | freeCompilerArgs.addAll(
13 | listOf(
14 | "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
15 | ),
16 | )
17 | }
18 | }
19 |
20 | dependencies {
21 |
22 | ksp(libs.hilt.compiler)
23 | implementation(libs.hilt.runtime)
24 | implementation(projects.core.model)
25 | implementation(projects.core.network)
26 | implementation(projects.core.database)
27 | implementation(projects.core.logger)
28 | implementation(libs.result)
29 |
30 | testImplementation(projects.core.networkTestFixtures)
31 | testImplementation(projects.core.databaseTestFixtures)
32 | testImplementation(projects.core.loggerTestFixtures)
33 | testImplementation(libs.bundles.unit.testing)
34 | testImplementation(libs.ktor.client.mock)
35 | }
36 |
--------------------------------------------------------------------------------
/core/data/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/core/data/src/main/kotlin/com/mr3y/podcaster/core/data/di/RepositoriesModule.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.data.di
2 |
3 | import com.mr3y.podcaster.core.data.PodcastsRepository
4 | import com.mr3y.podcaster.core.data.internal.DefaultPodcastsRepository
5 | import dagger.Binds
6 | import dagger.Module
7 | import dagger.hilt.InstallIn
8 | import dagger.hilt.components.SingletonComponent
9 | import javax.inject.Singleton
10 |
11 | @Module
12 | @InstallIn(SingletonComponent::class)
13 | abstract class RepositoriesModule {
14 |
15 | @Binds
16 | @Singleton
17 | abstract fun providePodcastsRepositoryInstance(impl: DefaultPodcastsRepository): PodcastsRepository
18 | }
19 |
--------------------------------------------------------------------------------
/core/data/src/test/kotlin/com/mr3y/podcaster/core/data/SyncDataTest.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.data
2 |
3 | import assertk.assertThat
4 | import assertk.assertions.isEqualTo
5 | import assertk.assertions.isNotNull
6 | import assertk.assertions.isNull
7 | import assertk.assertions.isTrue
8 | import com.mr3y.podcaster.core.data.internal.DefaultPodcastsRepository
9 | import com.mr3y.podcaster.core.local.dao.DefaultPodcastsDao
10 | import com.mr3y.podcaster.core.local.dao.DefaultRecentSearchesDao
11 | import com.mr3y.podcaster.core.local.di.FakeDatabaseModule
12 | import com.mr3y.podcaster.core.logger.TestLogger
13 | import com.mr3y.podcaster.core.network.ModifiedPodcastFeed
14 | import com.mr3y.podcaster.core.network.di.FakeHttpClient
15 | import com.mr3y.podcaster.core.network.di.doCleanup
16 | import com.mr3y.podcaster.core.network.di.enqueueMockResponse
17 | import com.mr3y.podcaster.core.network.internal.DefaultPodcastIndexClient
18 | import com.mr3y.podcaster.core.sampledata.Podcasts
19 | import io.ktor.http.HttpStatusCode
20 | import kotlinx.coroutines.test.UnconfinedTestDispatcher
21 | import kotlinx.coroutines.test.runTest
22 | import org.junit.After
23 | import org.junit.Before
24 | import org.junit.Test
25 |
26 | class SyncDataTest {
27 |
28 | private val testDispatcher = UnconfinedTestDispatcher()
29 | private val database = FakeDatabaseModule.provideInMemoryDatabaseInstance()
30 | private val httpClient = FakeHttpClient.getInstance()
31 | private val podcastsDao = DefaultPodcastsDao(database, testDispatcher)
32 |
33 | private lateinit var sut: DefaultPodcastsRepository
34 |
35 | @Before
36 | fun setUp() {
37 | sut = DefaultPodcastsRepository(
38 | podcastsDao = podcastsDao,
39 | recentSearchesDao = DefaultRecentSearchesDao(database, testDispatcher),
40 | networkClient = DefaultPodcastIndexClient(httpClient, TestLogger()),
41 | )
42 | }
43 |
44 | @Test
45 | fun `test refreshing podcast info is working as expected`() = runTest(testDispatcher) {
46 | // expect subscriptions are refreshed.
47 | val podcast = Podcasts[0]
48 | podcastsDao.upsertPodcast(podcast)
49 | httpClient.enqueueMockResponse(ModifiedPodcastFeed, HttpStatusCode.OK)
50 |
51 | val syncResult = sut.syncRemotePodcastWithLocal(podcast.id)
52 | assertThat(syncResult).isTrue()
53 | assertThat(podcastsDao.getPodcast(podcast.id)).isNotNull().isEqualTo(podcast.copy(title = "Fragmented"))
54 |
55 | // Reset
56 | podcastsDao.deletePodcast(podcast.id)
57 |
58 | // but don't refresh podcast if it is not from subscriptions
59 | val newSyncResult = sut.syncRemotePodcastWithLocal(podcast.id)
60 | assertThat(newSyncResult).isTrue()
61 | assertThat(podcastsDao.getPodcast(podcast.id)).isNull()
62 | }
63 |
64 | @After
65 | fun cleanUp() {
66 | httpClient.doCleanup()
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/core/database-test-fixtures/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.podcaster.android.lib)
3 | alias(libs.plugins.sqldelight)
4 | }
5 |
6 | android {
7 | namespace = "com.mr3y.podcaster.core.local.di"
8 | }
9 |
10 | sqldelight {
11 | databases {
12 | create("PodcasterDatabase") {
13 | packageName.set("com.mr3y.podcaster.test") // This must be different from packageName set in database module
14 | dependency(projects.core.database)
15 | }
16 | }
17 | }
18 |
19 | dependencies {
20 |
21 | implementation(projects.core.model)
22 | implementation(projects.core.database)
23 | implementation(libs.sqldelight.sqlitedriver)
24 | implementation(libs.sqldelight.primitiveadapters)
25 | implementation(libs.sqlite.jdbc) {
26 | version { strictly(libs.versions.sqlite.jdbc.get()) }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/core/database-test-fixtures/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/core/database-test-fixtures/src/main/kotlin/com/mr3y/podcaster/core/local/di/FakeDatabaseModule.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.local.di
2 |
3 | import app.cash.sqldelight.EnumColumnAdapter
4 | import app.cash.sqldelight.adapter.primitive.FloatColumnAdapter
5 | import app.cash.sqldelight.adapter.primitive.IntColumnAdapter
6 | import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
7 | import com.mr3y.podcaster.CurrentlyPlayingEntity
8 | import com.mr3y.podcaster.DownloadableEpisodeEntity
9 | import com.mr3y.podcaster.EpisodeEntity
10 | import com.mr3y.podcaster.PodcastEntity
11 | import com.mr3y.podcaster.PodcasterDatabase
12 | import com.mr3y.podcaster.core.local.GenresColumnAdapter
13 |
14 | object FakeDatabaseModule {
15 |
16 | fun provideInMemoryDatabaseInstance(): PodcasterDatabase {
17 | // Some tests may fail complaining that sqlite-jdbc jar isn't on the classpath whilst it is already on the classpath.
18 | // so, this line fixes it until we find a better solution or better understand the root cause exactly.
19 | Class.forName("org.sqlite.JDBC")
20 |
21 | val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
22 | PodcasterDatabase.Schema.create(driver)
23 | return PodcasterDatabase(
24 | driver,
25 | currentlyPlayingEntityAdapter = CurrentlyPlayingEntity.Adapter(EnumColumnAdapter(), FloatColumnAdapter),
26 | downloadableEpisodeEntityAdapter = DownloadableEpisodeEntity.Adapter(EnumColumnAdapter(), FloatColumnAdapter),
27 | episodeEntityAdapter = EpisodeEntity.Adapter(
28 | durationInSecAdapter = IntColumnAdapter,
29 | episodeNumAdapter = IntColumnAdapter,
30 | progressInSecAdapter = IntColumnAdapter,
31 | ),
32 | podcastEntityAdapter = PodcastEntity.Adapter(
33 | episodeCountAdapter = IntColumnAdapter,
34 | genresAdapter = GenresColumnAdapter,
35 | ),
36 | )
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/core/database/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import app.cash.sqldelight.core.capitalize
2 | import app.cash.sqldelight.gradle.SqlDelightTask
3 |
4 | plugins {
5 | alias(libs.plugins.podcaster.android.lib)
6 | alias(libs.plugins.ksp)
7 | alias(libs.plugins.sqldelight)
8 | }
9 |
10 | android {
11 | namespace = "com.mr3y.podcaster.core.local"
12 | }
13 |
14 | androidComponents {
15 | onVariants(selector().all()) { variant ->
16 | // TODO: find a way to get rid of the obscure `afterEvaluate` here
17 | afterEvaluate {
18 | val sqlDelightTask = this.project.tasks.named("generate${variant.name.capitalize()}PodcasterDatabaseInterface").get() as SqlDelightTask
19 |
20 | project.tasks.getByName("ksp" + variant.name.capitalize() + "Kotlin") {
21 | (this as org.jetbrains.kotlin.gradle.tasks.AbstractKotlinCompileTool<*>).setSource(sqlDelightTask.outputDirectory)
22 | }
23 | }
24 | }
25 | }
26 |
27 | sqldelight {
28 | databases {
29 | create("PodcasterDatabase") {
30 | packageName.set("com.mr3y.podcaster")
31 | schemaOutputDirectory.set(file("src/main/sqldelight/databases"))
32 | verifyMigrations.set(true)
33 | }
34 | }
35 | }
36 |
37 | kotlin {
38 | compilerOptions {
39 | freeCompilerArgs.addAll(
40 | listOf(
41 | "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
42 | ),
43 | )
44 | }
45 | }
46 |
47 | dependencies {
48 |
49 | implementation(projects.core.model)
50 | ksp(libs.hilt.compiler)
51 | implementation(libs.hilt.runtime)
52 |
53 | implementation(libs.sqldelight.driver)
54 | implementation(libs.sqldelight.flowext)
55 | implementation(libs.sqldelight.primitiveadapters)
56 |
57 | kspTest(libs.hilt.compiler)
58 |
59 | testImplementation(libs.bundles.unit.testing)
60 | testImplementation(projects.core.databaseTestFixtures)
61 | }
62 |
--------------------------------------------------------------------------------
/core/database/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/core/database/src/main/kotlin/com/mr3y/podcaster/core/local/Adapters.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.local
2 |
3 | import app.cash.sqldelight.ColumnAdapter
4 | import com.mr3y.podcaster.core.model.Genre
5 |
6 | object GenresColumnAdapter : ColumnAdapter, String> {
7 |
8 | override fun decode(databaseValue: String): List {
9 | // deserializes the genres stored in the following format [(genre1Id, "label1"), (genre2Id, "label2")]
10 | return databaseValue
11 | .removePrefix("[")
12 | .removeSuffix("]")
13 | .replace("(", "")
14 | .replace(")", "")
15 | .split(", ")
16 | .windowed(2, step = 2)
17 | .map { (id, label) ->
18 | Genre(id.toInt(), label.replace("\"", ""))
19 | }
20 | }
21 |
22 | override fun encode(value: List): String {
23 | // Serializes the list of genres into the following text format/pattern: [(genre1Id, "label1"), (genre2Id, "label2")]
24 | val encodedValue = StringBuilder()
25 | encodedValue.append('[')
26 | value.forEachIndexed { index, genre ->
27 | encodedValue.append('(')
28 | encodedValue.append(genre.id)
29 | encodedValue.append(", ")
30 | encodedValue.append("\"${genre.label}\"")
31 | encodedValue.append(')')
32 | if (index != value.lastIndex) {
33 | encodedValue.append(", ")
34 | }
35 | }
36 | encodedValue.append(']')
37 | return encodedValue.toString()
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/core/database/src/main/kotlin/com/mr3y/podcaster/core/local/dao/RecentSearchesDao.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.local.dao
2 |
3 | import app.cash.sqldelight.coroutines.asFlow
4 | import app.cash.sqldelight.coroutines.mapToList
5 | import com.mr3y.podcaster.PodcasterDatabase
6 | import com.mr3y.podcaster.core.local.di.IODispatcher
7 | import kotlinx.coroutines.CoroutineDispatcher
8 | import kotlinx.coroutines.flow.Flow
9 | import javax.inject.Inject
10 |
11 | interface RecentSearchesDao {
12 |
13 | fun recentSearchQueries(): Flow>
14 |
15 | fun addNewSearchQuery(queryText: String)
16 |
17 | fun deleteSearchQuery(queryText: String)
18 | }
19 |
20 | class DefaultRecentSearchesDao @Inject constructor(
21 | private val database: PodcasterDatabase,
22 | @IODispatcher private val dispatcher: CoroutineDispatcher,
23 | ) : RecentSearchesDao {
24 |
25 | override fun recentSearchQueries(): Flow> {
26 | return database.recentSearchEntryQueries.getAllRecentSearchEntries { queryText, _ -> queryText }
27 | .asFlow()
28 | .mapToList(dispatcher)
29 | }
30 |
31 | override fun addNewSearchQuery(queryText: String) {
32 | database.recentSearchEntryQueries.insertNewRecentSearchEntry(queryText)
33 | }
34 |
35 | override fun deleteSearchQuery(queryText: String) {
36 | database.recentSearchEntryQueries.deleteRecentSearchEntry(queryText)
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/core/database/src/main/kotlin/com/mr3y/podcaster/core/local/di/DaosModule.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.local.di
2 |
3 | import com.mr3y.podcaster.core.local.dao.DefaultPodcastsDao
4 | import com.mr3y.podcaster.core.local.dao.DefaultRecentSearchesDao
5 | import com.mr3y.podcaster.core.local.dao.PodcastsDao
6 | import com.mr3y.podcaster.core.local.dao.RecentSearchesDao
7 | import dagger.Binds
8 | import dagger.Module
9 | import dagger.hilt.InstallIn
10 | import dagger.hilt.components.SingletonComponent
11 | import javax.inject.Singleton
12 |
13 | @Module
14 | @InstallIn(SingletonComponent::class)
15 | abstract class DaosModule {
16 |
17 | @Binds
18 | @Singleton
19 | abstract fun provideRecentSearchesDaoInstance(impl: DefaultRecentSearchesDao): RecentSearchesDao
20 |
21 | @Binds
22 | @Singleton
23 | abstract fun providePodcastsDaoInstance(impl: DefaultPodcastsDao): PodcastsDao
24 | }
25 |
--------------------------------------------------------------------------------
/core/database/src/main/kotlin/com/mr3y/podcaster/core/local/di/DatabaseModule.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.local.di
2 |
3 | import android.content.Context
4 | import app.cash.sqldelight.EnumColumnAdapter
5 | import app.cash.sqldelight.adapter.primitive.FloatColumnAdapter
6 | import app.cash.sqldelight.adapter.primitive.IntColumnAdapter
7 | import app.cash.sqldelight.driver.android.AndroidSqliteDriver
8 | import com.mr3y.podcaster.CurrentlyPlayingEntity
9 | import com.mr3y.podcaster.DownloadableEpisodeEntity
10 | import com.mr3y.podcaster.EpisodeEntity
11 | import com.mr3y.podcaster.PodcastEntity
12 | import com.mr3y.podcaster.PodcasterDatabase
13 | import com.mr3y.podcaster.core.local.GenresColumnAdapter
14 | import dagger.Module
15 | import dagger.Provides
16 | import dagger.hilt.InstallIn
17 | import dagger.hilt.android.qualifiers.ApplicationContext
18 | import dagger.hilt.components.SingletonComponent
19 | import kotlinx.coroutines.CoroutineDispatcher
20 | import kotlinx.coroutines.Dispatchers
21 | import javax.inject.Qualifier
22 | import javax.inject.Singleton
23 |
24 | @Qualifier
25 | @Retention(AnnotationRetention.BINARY)
26 | annotation class IODispatcher
27 |
28 | @Module
29 | @InstallIn(SingletonComponent::class)
30 | object DatabaseModule {
31 |
32 | @Provides
33 | @Singleton
34 | fun provideDatabaseInstance(@ApplicationContext context: Context): PodcasterDatabase {
35 | return PodcasterDatabase(
36 | driver = AndroidSqliteDriver(
37 | schema = PodcasterDatabase.Schema,
38 | context = context,
39 | name = "podcaster_database.db",
40 | ),
41 | podcastEntityAdapter = PodcastEntity.Adapter(
42 | episodeCountAdapter = IntColumnAdapter,
43 | genresAdapter = GenresColumnAdapter,
44 | ),
45 | episodeEntityAdapter = EpisodeEntity.Adapter(
46 | durationInSecAdapter = IntColumnAdapter,
47 | episodeNumAdapter = IntColumnAdapter,
48 | progressInSecAdapter = IntColumnAdapter,
49 | ),
50 | currentlyPlayingEntityAdapter = CurrentlyPlayingEntity.Adapter(EnumColumnAdapter(), FloatColumnAdapter),
51 | downloadableEpisodeEntityAdapter = DownloadableEpisodeEntity.Adapter(EnumColumnAdapter(), FloatColumnAdapter),
52 | )
53 | }
54 |
55 | @Provides
56 | @IODispatcher
57 | fun provideIOCoroutineDispatcher(): CoroutineDispatcher {
58 | return Dispatchers.IO
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/core/database/src/main/sqldelight/com/mr3y/podcaster/CurrentlyPlayingEntity.sq:
--------------------------------------------------------------------------------
1 | import com.mr3y.podcaster.core.model.PlayingStatus;
2 |
3 | CREATE TABLE IF NOT EXISTS currentlyPlayingEntity (
4 | episodeId INTEGER PRIMARY KEY NOT NULL,
5 | playingStatus TEXT AS PlayingStatus NOT NULL,
6 | playingSpeed REAL AS kotlin.Float DEFAULT 1.0 NOT NULL
7 | );
8 |
9 | CREATE TRIGGER IF NOT EXISTS pre_delete_active_episode
10 | BEFORE DELETE ON episodeEntity
11 | BEGIN DELETE FROM currentlyPlayingEntity WHERE episodeId = old.id;
12 | END;
13 |
14 | -- If the currently playing episode changed its favourite status, then notify observers instantly by triggering invalidation
15 | CREATE TRIGGER IF NOT EXISTS trigger_invalidate
16 | AFTER UPDATE OF isFavourite ON episodeEntity
17 | BEGIN UPDATE currentlyPlayingEntity SET playingStatus = playingStatus WHERE episodeId = new.id;
18 | END;
19 |
20 | getCurrentlyPlayingEpisode:
21 | SELECT * FROM currentlyPlayingEntity;
22 |
23 | hasCurrentlyPlayingEpisode:
24 | SELECT 1 FROM currentlyPlayingEntity;
25 |
26 | updateCurrentlyPlayingEpisode:
27 | INSERT OR REPLACE INTO currentlyPlayingEntity(episodeId, playingStatus, playingSpeed) VALUES ?;
28 |
29 | updateCurrentlyPlayingEpisodeStatus:
30 | UPDATE currentlyPlayingEntity SET playingStatus = :playingStatus;
31 |
32 | updateCurrentlyPlayingEpisodeSpeed:
33 | UPDATE currentlyPlayingEntity SET playingSpeed = :playingSpeed;
34 |
35 | deleteCurrentlyPlayingEpisode:
36 | DELETE FROM currentlyPlayingEntity;
--------------------------------------------------------------------------------
/core/database/src/main/sqldelight/com/mr3y/podcaster/DownloadableEpisodeEntity.sq:
--------------------------------------------------------------------------------
1 | import com.mr3y.podcaster.core.model.EpisodeDownloadStatus;
2 |
3 | CREATE TABLE IF NOT EXISTS downloadableEpisodeEntity (
4 | episodeId INTEGER PRIMARY KEY NOT NULL,
5 | downloadStatus TEXT AS EpisodeDownloadStatus DEFAULT "NotDownloaded" NOT NULL,
6 | downloadProgress REAL AS kotlin.Float DEFAULT 0.0 NOT NULL
7 | );
8 |
9 | CREATE TRIGGER IF NOT EXISTS post_insert_new_episode
10 | AFTER INSERT ON episodeEntity
11 | BEGIN INSERT OR REPLACE INTO downloadableEpisodeEntity(episodeId) VALUES (new.id);
12 | END;
13 |
14 | CREATE TRIGGER IF NOT EXISTS pre_delete_episode
15 | BEFORE DELETE ON episodeEntity
16 | BEGIN DELETE FROM downloadableEpisodeEntity WHERE episodeId = old.id;
17 | END;
18 |
19 | getDownloadableEpisodeById:
20 | SELECT * FROM downloadableEpisodeEntity WHERE episodeId = :id;
21 |
22 | getDownloadableEpisodesByIds:
23 | SELECT * FROM downloadableEpisodeEntity WHERE episodeId IN :ids;
24 |
25 | getDownloadingEpisodesWithDownloadMetadata:
26 | SELECT ee.*,dee.downloadStatus,dee.downloadProgress FROM episodeEntity AS ee
27 | INNER JOIN downloadableEpisodeEntity AS dee ON ee.id == dee.episodeId AND dee.downloadStatus != "NotDownloaded";
28 |
29 | updateEpisodeDownloadStatus:
30 | UPDATE downloadableEpisodeEntity SET downloadStatus = :downloadStatus WHERE episodeId = :id;
31 |
32 | updateEpisodeDownloadProgress:
33 | UPDATE downloadableEpisodeEntity SET downloadProgress = :downloadProgress WHERE episodeId = :id;
34 |
35 | getEpisodesWithDownloadMetadataForPodcast:
36 | SELECT ee.*,dee.downloadStatus,dee.downloadProgress FROM episodeEntity AS ee
37 | INNER JOIN downloadableEpisodeEntity AS dee ON ee.id == dee.episodeId AND ee.podcastId IN :podcastsIds
38 | ORDER BY ee.datePublishedTimestamp DESC LIMIT :limit;
39 |
40 | getUntouchedEpisodesIdsForPodcast:
41 | SELECT ee.id FROM episodeEntity AS ee
42 | INNER JOIN downloadableEpisodeEntity AS dee ON ee.id == dee.episodeId AND ee.podcastId = :podcastId
43 | WHERE ee.isCompleted = 0 AND ee.isFavourite = 0 AND (ee.progressInSec IS NULL OR ee.progressInSec < 1) AND dee.downloadStatus = "NotDownloaded";
44 |
45 | countDownloads:
46 | SELECT count(*) FROM downloadableEpisodeEntity WHERE downloadStatus != "NotDownloaded";
47 |
--------------------------------------------------------------------------------
/core/database/src/main/sqldelight/com/mr3y/podcaster/EpisodeEntity.sq:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS episodeEntity (
2 | id INTEGER PRIMARY KEY NOT NULL,
3 | podcastId INTEGER NOT NULL,
4 | guid TEXT NOT NULL,
5 | title TEXT NOT NULL,
6 | description TEXT NOT NULL,
7 | episodeUrl TEXT NOT NULL,
8 | datePublishedTimestamp INTEGER NOT NULL,
9 | datePublishedFormatted TEXT NOT NULL,
10 | durationInSec INTEGER AS kotlin.Int,
11 | episodeNum INTEGER AS kotlin.Int,
12 | artworkUrl TEXT NOT NULL,
13 | enclosureUrl TEXT NOT NULL,
14 | enclosureSizeInBytes INTEGER NOT NULL,
15 | podcastTitle TEXT,
16 | isCompleted INTEGER AS kotlin.Boolean DEFAULT 0 NOT NULL,
17 | progressInSec INTEGER AS kotlin.Int,
18 | isFavourite INTEGER AS kotlin.Boolean DEFAULT 0 NOT NULL
19 | );
20 |
21 | getEpisodesForPodcasts:
22 | SELECT * FROM episodeEntity WHERE podcastId IN :podcastsIds ORDER BY datePublishedTimestamp DESC LIMIT :limit;
23 |
24 | getEpisodesForPodcast:
25 | SELECT * FROM episodeEntity WHERE podcastId == :podcastId ORDER BY datePublishedTimestamp DESC;
26 |
27 | getCompletedEpisodes:
28 | SELECT * FROM episodeEntity WHERE isCompleted == 1;
29 |
30 | getEpisode:
31 | SELECT * FROM episodeEntity WHERE id == :id;
32 |
33 | getFavouriteEpisodes:
34 | SELECT * FROM episodeEntity WHERE isFavourite == 1;
35 |
36 | countFavouriteEpisodes:
37 | SELECT count(*) FROM episodeEntity WHERE isFavourite == 1;
38 |
39 | hasEpisode:
40 | SELECT 1 FROM episodeEntity WHERE id = :id;
41 |
42 | insertEpisode:
43 | INSERT OR REPLACE INTO episodeEntity(id, podcastId, guid, title, description, episodeUrl, datePublishedTimestamp, datePublishedFormatted, durationInSec, episodeNum, artworkUrl, enclosureUrl, enclosureSizeInBytes, podcastTitle) VALUES ?;
44 |
45 | updateEpisodeInfo:
46 | UPDATE episodeEntity SET
47 | guid = :guid,
48 | title = :title,
49 | description = :description,
50 | episodeUrl = :episodeUrl,
51 | datePublishedTimestamp = :datePublishedTimestamp,
52 | datePublishedFormatted = :datePublishedFormatted,
53 | episodeNum = :episodeNum,
54 | artworkUrl = :artworkUrl,
55 | enclosureUrl = :enclosureUrl,
56 | enclosureSizeInBytes = :enclosureSizeInBytes,
57 | podcastTitle = :podcastTitle
58 | WHERE id = :id;
59 |
60 | setEpisodeCompleted:
61 | UPDATE episodeEntity SET isCompleted = 1 WHERE id = :id;
62 |
63 | toggleEpisodeFavouriteStatus:
64 | UPDATE episodeEntity SET isFavourite = :isFavourite WHERE id = :id;
65 |
66 | updateEpisodeProgress:
67 | UPDATE episodeEntity SET progressInSec = :progressInSec WHERE id = :id;
68 |
69 | updateEpisodeDuration:
70 | UPDATE episodeEntity SET durationInSec = :durationInSec WHERE id = :id;
71 |
72 | updateEpisodePodcastTitleById:
73 | UPDATE episodeEntity SET podcastTitle = :newPodcastTitle WHERE id = :id;
74 |
75 | updateEpisodePodcastTitleByPodcastId:
76 | UPDATE episodeEntity SET podcastTitle = :newPodcastTitle WHERE podcastId = :podcastId;
77 |
78 | deleteEpisodesByIds:
79 | DELETE FROM episodeEntity WHERE id IN :ids;
80 |
--------------------------------------------------------------------------------
/core/database/src/main/sqldelight/com/mr3y/podcaster/PodcastEntity.sq:
--------------------------------------------------------------------------------
1 | import com.mr3y.podcaster.core.model.Genre;
2 | import kotlin.collections.List;
3 |
4 | CREATE TABLE IF NOT EXISTS podcastEntity (
5 | id INTEGER PRIMARY KEY NOT NULL,
6 | guid TEXT NOT NULL,
7 | title TEXT NOT NULL,
8 | description TEXT NOT NULL,
9 | podcastUrl TEXT NOT NULL,
10 | website TEXT NOT NULL,
11 | artworkUrl TEXT NOT NULL,
12 | author TEXT NOT NULL,
13 | owner TEXT NOT NULL,
14 | languageCode TEXT NOT NULL,
15 | episodeCount INTEGER AS kotlin.Int NOT NULL,
16 | genres TEXT AS List NOT NULL
17 | );
18 |
19 | getAllPodcasts:
20 | SELECT * FROM podcastEntity;
21 |
22 | getPodcast:
23 | SELECT * FROM podcastEntity WHERE id == :id;
24 |
25 | insertPodcast:
26 | INSERT OR REPLACE INTO podcastEntity(id, guid, title, description, podcastUrl, website, artworkUrl, author, owner, languageCode, episodeCount, genres) VALUES ?;
27 |
28 | countPodcasts:
29 | SELECT count(*) FROM podcastEntity;
30 |
31 | hasPodcast:
32 | SELECT 1 FROM podcastEntity WHERE id = :id;
33 |
34 | deletePodcast:
35 | DELETE FROM podcastEntity WHERE id = :id;
--------------------------------------------------------------------------------
/core/database/src/main/sqldelight/com/mr3y/podcaster/QueueEntity.sq:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS queueEntity (
2 | episodeId INTEGER PRIMARY KEY NOT NULL,
3 | added_at TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL
4 | );
5 |
6 | CREATE TRIGGER IF NOT EXISTS pre_delete_queue_episode
7 | BEFORE DELETE ON episodeEntity
8 | BEGIN DELETE FROM queueEntity WHERE episodeId = old.id;
9 | END;
10 |
11 | getQueueEpisodesIds:
12 | SELECT episodeId FROM queueEntity ORDER BY datetime(added_at) ASC;
13 |
14 | getQueueEpisodes:
15 | SELECT ee.* FROM episodeEntity AS ee INNER JOIN queueEntity AS qe ON ee.id == qe.episodeId
16 | ORDER BY datetime(qe.added_at) ASC;
17 |
18 | insertNewQueueEpisode:
19 | INSERT OR REPLACE INTO queueEntity (episodeId) VALUES (:episodeId);
20 |
21 | replaceQueueEpisode:
22 | UPDATE queueEntity SET episodeId = :newEpisodeId WHERE episodeId = :oldEpisodeId;
23 |
24 | deleteEpisodeFromQueue:
25 | DELETE FROM queueEntity WHERE episodeId = :episodeId;
26 |
27 | isEpisodeInQueue:
28 | SELECT 1 FROM queueEntity WHERE episodeId = :episodeId;
29 |
30 | clearQueueExceptEpisodes:
31 | DELETE FROM queueEntity WHERE episodeId NOT IN :episodeIds;
--------------------------------------------------------------------------------
/core/database/src/main/sqldelight/com/mr3y/podcaster/RecentSearchEntry.sq:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS recentSearchEntry (
2 | queryText TEXT PRIMARY KEY NOT NULL,
3 | added_at TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL
4 | );
5 |
6 | getAllRecentSearchEntries:
7 | SELECT * FROM recentSearchEntry ORDER BY datetime(added_at) DESC;
8 |
9 | insertNewRecentSearchEntry:
10 | INSERT OR REPLACE INTO recentSearchEntry (queryText) VALUES (:queryText);
11 |
12 | deleteRecentSearchEntry:
13 | DELETE FROM recentSearchEntry WHERE queryText = :queryText;
--------------------------------------------------------------------------------
/core/database/src/main/sqldelight/databases/1.db:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/core/database/src/main/sqldelight/databases/1.db
--------------------------------------------------------------------------------
/core/database/src/main/sqldelight/databases/2.db:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/core/database/src/main/sqldelight/databases/2.db
--------------------------------------------------------------------------------
/core/database/src/main/sqldelight/databases/3.db:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/core/database/src/main/sqldelight/databases/3.db
--------------------------------------------------------------------------------
/core/database/src/main/sqldelight/databases/4.db:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/core/database/src/main/sqldelight/databases/4.db
--------------------------------------------------------------------------------
/core/database/src/main/sqldelight/databases/5.db:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/core/database/src/main/sqldelight/databases/5.db
--------------------------------------------------------------------------------
/core/database/src/main/sqldelight/databases/6.db:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/core/database/src/main/sqldelight/databases/6.db
--------------------------------------------------------------------------------
/core/database/src/main/sqldelight/migrations/1.sqm:
--------------------------------------------------------------------------------
1 | import com.mr3y.podcaster.core.model.PlayingStatus;
2 |
3 | CREATE TABLE IF NOT EXISTS currentlyPlayingEntity (
4 | episodeId INTEGER PRIMARY KEY NOT NULL,
5 | playingStatus TEXT AS PlayingStatus NOT NULL
6 | );
--------------------------------------------------------------------------------
/core/database/src/main/sqldelight/migrations/2.sqm:
--------------------------------------------------------------------------------
1 |
2 | ALTER TABLE currentlyPlayingEntity ADD COLUMN playingSpeed REAL AS kotlin.Float DEFAULT 1.0 NOT NULL;
--------------------------------------------------------------------------------
/core/database/src/main/sqldelight/migrations/3.sqm:
--------------------------------------------------------------------------------
1 | import com.mr3y.podcaster.core.model.EpisodeDownloadStatus;
2 |
3 | CREATE TABLE IF NOT EXISTS temp (
4 | id INTEGER PRIMARY KEY NOT NULL,
5 | podcastId INTEGER NOT NULL,
6 | guid TEXT NOT NULL,
7 | title TEXT NOT NULL,
8 | description TEXT NOT NULL,
9 | episodeUrl TEXT NOT NULL,
10 | datePublishedTimestamp INTEGER NOT NULL,
11 | datePublishedFormatted TEXT NOT NULL,
12 | durationInSec INTEGER AS kotlin.Int,
13 | episodeNum INTEGER AS kotlin.Int,
14 | artworkUrl TEXT NOT NULL,
15 | enclosureUrl TEXT NOT NULL,
16 | enclosureSizeInBytes INTEGER NOT NULL,
17 | podcastTitle TEXT,
18 | isCompleted INTEGER AS kotlin.Boolean DEFAULT 0 NOT NULL,
19 | progressInSec INTEGER AS kotlin.Int
20 | );
21 |
22 | CREATE TABLE IF NOT EXISTS downloadableEpisodeEntity (
23 | episodeId INTEGER PRIMARY KEY NOT NULL,
24 | downloadStatus TEXT AS EpisodeDownloadStatus DEFAULT "NotDownloaded" NOT NULL,
25 | downloadProgress REAL AS kotlin.Float DEFAULT 0.0 NOT NULL
26 | );
27 |
28 | INSERT INTO temp (id, podcastId, guid, title, description, episodeUrl, datePublishedTimestamp, datePublishedFormatted, durationInSec, episodeNum, artworkUrl, enclosureUrl, enclosureSizeInBytes, podcastTitle, isCompleted, progressInSec)
29 | SELECT id, podcastId, guid, title, description, episodeUrl, datePublishedTimestamp, datePublishedFormatted, durationInSec, episodeNum, artworkUrl, enclosureUrl, enclosureSizeInBytes, podcastTitle, isCompleted, progressInSec
30 | FROM episodeEntity;
31 |
32 | DROP TABLE episodeEntity;
33 |
34 | ALTER TABLE temp RENAME TO episodeEntity;
35 |
36 | CREATE TRIGGER IF NOT EXISTS post_insert_new_episode
37 | AFTER INSERT ON episodeEntity
38 | BEGIN INSERT OR REPLACE INTO downloadableEpisodeEntity(episodeId) VALUES (new.id);
39 | END;
40 |
41 | CREATE TRIGGER IF NOT EXISTS pre_delete_episode
42 | BEFORE DELETE ON episodeEntity
43 | BEGIN DELETE FROM downloadableEpisodeEntity WHERE episodeId = old.id;
44 | END;
45 |
46 | INSERT INTO downloadableEpisodeEntity (episodeId) SELECT id FROM episodeEntity;
47 |
--------------------------------------------------------------------------------
/core/database/src/main/sqldelight/migrations/4.sqm:
--------------------------------------------------------------------------------
1 | CREATE TRIGGER IF NOT EXISTS pre_delete_active_episode
2 | BEFORE DELETE ON episodeEntity
3 | BEGIN DELETE FROM currentlyPlayingEntity WHERE episodeId = old.id;
4 | END;
--------------------------------------------------------------------------------
/core/database/src/main/sqldelight/migrations/5.sqm:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS queueEntity (
2 | episodeId INTEGER PRIMARY KEY NOT NULL,
3 | added_at TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL
4 | );
5 |
6 | CREATE TRIGGER IF NOT EXISTS pre_delete_queue_episode
7 | BEFORE DELETE ON episodeEntity
8 | BEGIN DELETE FROM queueEntity WHERE episodeId = old.id;
9 | END;
10 |
11 | INSERT OR REPLACE INTO queueEntity(episodeId) SELECT episodeId FROM currentlyPlayingEntity;
12 |
--------------------------------------------------------------------------------
/core/database/src/main/sqldelight/migrations/6.sqm:
--------------------------------------------------------------------------------
1 | ALTER TABLE episodeEntity ADD COLUMN isFavourite INTEGER AS kotlin.Boolean DEFAULT 0 NOT NULL;
2 |
3 | CREATE TRIGGER IF NOT EXISTS trigger_invalidate
4 | AFTER UPDATE OF isFavourite ON episodeEntity
5 | BEGIN UPDATE currentlyPlayingEntity SET playingStatus = playingStatus WHERE episodeId = new.id;
6 | END;
7 |
8 |
--------------------------------------------------------------------------------
/core/database/src/test/kotlin/com/mr3y/podcaster/core/local/TestAdapters.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.local
2 |
3 | import assertk.assertThat
4 | import assertk.assertions.isEqualTo
5 | import com.mr3y.podcaster.core.model.Genre
6 | import org.junit.Test
7 |
8 | class TestAdapters {
9 |
10 | @Test
11 | fun `test encoding genres list`() {
12 | // Zero
13 | val encodedEmptyValue = GenresColumnAdapter.encode(emptyList())
14 | assertThat(encodedEmptyValue).isEqualTo("[]")
15 |
16 | // One
17 | val encodedOneValue = GenresColumnAdapter.encode(listOf(Genre(id = 42, label = "Leisure")))
18 | assertThat(encodedOneValue).isEqualTo("[(42, \"Leisure\")]")
19 |
20 | // Many
21 | val encodedValue = GenresColumnAdapter.encode(listOf(Genre(id = 102, label = "Technology"), Genre(id = 55, label = "News")))
22 | assertThat(encodedValue).isEqualTo("[(102, \"Technology\"), (55, \"News\")]")
23 | }
24 |
25 | @Test
26 | fun `test decoding genres list`() {
27 | // Zero
28 | val decodedEmptyValue = GenresColumnAdapter.decode("[]")
29 | assertThat(decodedEmptyValue).isEqualTo(emptyList())
30 |
31 | // One
32 | val decodedOneValue = GenresColumnAdapter.decode("[(42, \"Leisure\")]")
33 | assertThat(decodedOneValue).isEqualTo(listOf(Genre(id = 42, label = "Leisure")))
34 |
35 | // Many
36 | val decodedValue = GenresColumnAdapter.decode("[(102, \"Technology\"), (55, \"News\")]")
37 | assertThat(decodedValue).isEqualTo(listOf(Genre(id = 102, label = "Technology"), Genre(id = 55, label = "News")))
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/core/logger-test-fixtures/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.podcaster.android.lib)
3 | }
4 |
5 | android {
6 | namespace = "com.mr3y.podcaster.core.logger"
7 | }
8 |
9 | dependencies {
10 |
11 | implementation(projects.core.logger)
12 | }
13 |
--------------------------------------------------------------------------------
/core/logger-test-fixtures/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/core/logger-test-fixtures/src/main/kotlin/com/mr3y/podcaster/core/logger/TestLogger.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.logger
2 |
3 | class TestLogger : Logger {
4 |
5 | private val _logs = mutableListOf()
6 | val logs: List
7 | get() = _logs
8 |
9 | override fun d(throwable: Throwable?, tag: String, message: () -> String) {
10 | val evaluatedMessage = message()
11 | _logs += evaluatedMessage
12 | println("$tag: $evaluatedMessage")
13 | }
14 |
15 | override fun i(throwable: Throwable?, tag: String, message: () -> String) {
16 | val evaluatedMessage = message()
17 | _logs += evaluatedMessage
18 | println("$tag: $evaluatedMessage")
19 | }
20 |
21 | override fun e(throwable: Throwable?, tag: String, message: () -> String) {
22 | val evaluatedMessage = message()
23 | _logs += evaluatedMessage
24 | println("$tag: $evaluatedMessage - $throwable")
25 | }
26 |
27 | override fun v(throwable: Throwable?, tag: String, message: () -> String) {
28 | val evaluatedMessage = message()
29 | _logs += evaluatedMessage
30 | println("$tag: $evaluatedMessage")
31 | }
32 |
33 | override fun w(throwable: Throwable?, tag: String, message: () -> String) {
34 | val evaluatedMessage = message()
35 | _logs += evaluatedMessage
36 | println("$tag: $evaluatedMessage - $throwable")
37 | }
38 |
39 | fun reset() {
40 | _logs.clear()
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/core/logger/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.podcaster.android.lib)
3 | alias(libs.plugins.ksp)
4 | }
5 |
6 | android {
7 | namespace = "com.mr3y.podcaster.core.logger"
8 | }
9 |
10 | dependencies {
11 | ksp(libs.hilt.compiler)
12 | implementation(libs.hilt.runtime)
13 |
14 | implementation(libs.kermit)
15 | implementation(libs.kermit.crashlytics)
16 | }
17 |
--------------------------------------------------------------------------------
/core/logger/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/core/logger/src/main/kotlin/com/mr3y/podcaster/core/logger/Logger.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.logger
2 |
3 | interface Logger {
4 |
5 | fun d(throwable: Throwable? = null, tag: String = "", message: () -> String)
6 |
7 | fun i(throwable: Throwable? = null, tag: String = "", message: () -> String)
8 |
9 | fun e(throwable: Throwable? = null, tag: String = "", message: () -> String)
10 |
11 | fun v(throwable: Throwable? = null, tag: String = "", message: () -> String)
12 |
13 | fun w(throwable: Throwable? = null, tag: String = "", message: () -> String)
14 | }
15 |
--------------------------------------------------------------------------------
/core/logger/src/main/kotlin/com/mr3y/podcaster/core/logger/di/LoggingModule.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.logger.di
2 |
3 | import co.touchlab.kermit.ExperimentalKermitApi
4 | import co.touchlab.kermit.crashlytics.CrashlyticsLogWriter
5 | import co.touchlab.kermit.loggerConfigInit
6 | import co.touchlab.kermit.platformLogWriter
7 | import com.mr3y.podcaster.core.logger.Logger
8 | import com.mr3y.podcaster.core.logger.internal.DefaultLogger
9 | import dagger.Module
10 | import dagger.Provides
11 | import dagger.hilt.InstallIn
12 | import dagger.hilt.components.SingletonComponent
13 | import javax.inject.Singleton
14 | import co.touchlab.kermit.Logger as KermitLogger
15 |
16 | @Module
17 | @InstallIn(SingletonComponent::class)
18 | object LoggingModule {
19 |
20 | @Provides
21 | @OptIn(ExperimentalKermitApi::class)
22 | @Singleton
23 | fun provideLoggerInstance(): Logger {
24 | return DefaultLogger(
25 | KermitLogger(config = loggerConfigInit(platformLogWriter(), CrashlyticsLogWriter())),
26 | )
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/core/logger/src/main/kotlin/com/mr3y/podcaster/core/logger/internal/DefaultLogger.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.logger.internal
2 |
3 | import com.mr3y.podcaster.core.logger.Logger
4 | import javax.inject.Inject
5 | import co.touchlab.kermit.Logger as KermitLogger
6 |
7 | class DefaultLogger @Inject constructor(
8 | private val kermitLogger: KermitLogger,
9 | ) : Logger {
10 | override fun d(throwable: Throwable?, tag: String, message: () -> String) {
11 | kermitLogger.d(throwable, tag, message)
12 | }
13 |
14 | override fun i(throwable: Throwable?, tag: String, message: () -> String) {
15 | kermitLogger.i(throwable, tag, message)
16 | }
17 |
18 | override fun e(throwable: Throwable?, tag: String, message: () -> String) {
19 | kermitLogger.e(throwable, tag, message)
20 | }
21 |
22 | override fun v(throwable: Throwable?, tag: String, message: () -> String) {
23 | kermitLogger.v(throwable, tag, message)
24 | }
25 |
26 | override fun w(throwable: Throwable?, tag: String, message: () -> String) {
27 | kermitLogger.w(throwable, tag, message)
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/core/model/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.podcaster.jvm.lib)
3 | }
4 |
5 | dependencies {
6 | api(libs.androidx.annotations)
7 | }
8 |
--------------------------------------------------------------------------------
/core/model/src/main/kotlin/com/mr3y/podcaster/core/model/CurrentlyPlayingEpisode.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.model
2 |
3 | data class CurrentlyPlayingEpisode(
4 | val episode: Episode,
5 | val playingStatus: PlayingStatus,
6 | val playingSpeed: Float,
7 | )
8 |
9 | enum class PlayingStatus {
10 | Loading,
11 | Playing,
12 | Paused,
13 | Error,
14 | }
15 |
--------------------------------------------------------------------------------
/core/model/src/main/kotlin/com/mr3y/podcaster/core/model/Episode.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.model
2 |
3 | data class Episode(
4 | val id: Long,
5 | val podcastId: Long,
6 | val guid: String,
7 | val title: String,
8 | val description: String,
9 | val episodeUrl: String,
10 | val datePublishedTimestamp: Long,
11 | @get:Deprecated(
12 | message = "This property is discouraged to access, as it may be inaccurate in most cases",
13 | replaceWith = ReplaceWith(expression = "this.dateTimePublished", "com.mr3y.podcaster.core.model.dateTimePublished"),
14 | )
15 | val datePublishedFormatted: String,
16 | val durationInSec: Int? = null,
17 | val episodeNum: Int? = null,
18 | val artworkUrl: String,
19 | val enclosureUrl: String,
20 | val enclosureSizeInBytes: Long,
21 | val podcastTitle: String? = null,
22 | val isCompleted: Boolean = false,
23 | val progressInSec: Int? = null,
24 | val isFavourite: Boolean = false,
25 | ) {
26 | @Suppress("DEPRECATION")
27 | override fun toString(): String {
28 | return "\nEpisode(\n" +
29 | "id = ${id}L,\n" +
30 | "podcastId = ${podcastId}L,\n" +
31 | "guid = \"$guid\",\n" +
32 | "title = \"$title\",\n" +
33 | "description = \"$description\",\n" +
34 | "episodeUrl = \"$episodeUrl\",\n" +
35 | "datePublishedTimestamp = ${datePublishedTimestamp}L,\n" +
36 | "datePublishedFormatted = \"$datePublishedFormatted\",\n" +
37 | "durationInSec = $durationInSec,\n" +
38 | "episodeNum = $episodeNum,\n" +
39 | "artworkUrl = \"$artworkUrl\",\n" +
40 | "enclosureUrl = \"$enclosureUrl\",\n" +
41 | "enclosureSizeInBytes = ${enclosureSizeInBytes}L,\n" +
42 | "podcastTitle = \"$podcastTitle\",\n" +
43 | "isCompleted = $isCompleted,\n" +
44 | "progressInSec = $progressInSec,\n" +
45 | "isFavourite = $isFavourite\n" +
46 | ")"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/core/model/src/main/kotlin/com/mr3y/podcaster/core/model/EpisodeDownloadMetadata.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.model
2 |
3 | import androidx.annotation.FloatRange
4 |
5 | data class EpisodeDownloadMetadata(
6 | val episodeId: Long,
7 | val downloadStatus: EpisodeDownloadStatus = EpisodeDownloadStatus.NotDownloaded,
8 | @FloatRange(from = 0.0, to = 1.0) val downloadProgress: Float = 0f,
9 | )
10 |
11 | enum class EpisodeDownloadStatus {
12 | NotDownloaded,
13 | Queued,
14 | Downloading,
15 | Paused,
16 | Downloaded,
17 | }
18 |
--------------------------------------------------------------------------------
/core/model/src/main/kotlin/com/mr3y/podcaster/core/model/EpisodeExt.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.model
2 |
3 | import java.time.Instant
4 | import java.time.ZoneId
5 | import java.time.ZonedDateTime
6 |
7 | val Episode.dateTimePublished: ZonedDateTime
8 | get() = ZonedDateTime.ofInstant(Instant.ofEpochSecond(datePublishedTimestamp), ZoneId.systemDefault())
9 |
--------------------------------------------------------------------------------
/core/model/src/main/kotlin/com/mr3y/podcaster/core/model/EpisodeWithDownloadMetadata.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.model
2 |
3 | data class EpisodeWithDownloadMetadata(
4 | val episode: Episode,
5 | val downloadMetadata: EpisodeDownloadMetadata,
6 | )
7 |
--------------------------------------------------------------------------------
/core/model/src/main/kotlin/com/mr3y/podcaster/core/model/Podcast.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.model
2 |
3 | data class Podcast(
4 | val id: Long,
5 | val guid: String,
6 | val title: String,
7 | val description: String,
8 | val podcastUrl: String,
9 | val website: String,
10 | val artworkUrl: String,
11 | val author: String,
12 | val owner: String,
13 | val languageCode: String,
14 | val episodeCount: Int,
15 | val genres: List,
16 | ) {
17 | override fun toString(): String {
18 | return "\nPodcast(\n" +
19 | "id = ${id}L,\n" +
20 | "guid = \"$guid\",\n" +
21 | "title = \"$title\",\n" +
22 | "description = \"$description\",\n" +
23 | "podcastUrl = \"$podcastUrl\",\n" +
24 | "website = \"$website\",\n" +
25 | "artworkUrl = \"$artworkUrl\",\n" +
26 | "author = \"$author\",\n" +
27 | "owner = \"$owner\",\n" +
28 | "languageCode = \"$languageCode\",\n" +
29 | "episodeCount = $episodeCount,\n" +
30 | "genres = $genres\n" +
31 | ")"
32 | }
33 | }
34 |
35 | data class Genre(
36 | val id: Int,
37 | val label: String,
38 | )
39 |
--------------------------------------------------------------------------------
/core/network-test-fixtures/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.podcaster.android.lib)
3 | }
4 |
5 | android {
6 | namespace = "com.mr3y.podcaster.core.network"
7 | }
8 |
9 | dependencies {
10 |
11 | implementation(libs.bundles.ktor)
12 | implementation(libs.kotlinx.serialization)
13 | implementation(libs.ktor.client.mock)
14 | }
15 |
--------------------------------------------------------------------------------
/core/network-test-fixtures/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/core/network-test-fixtures/src/main/kotlin/com/mr3y/podcaster/core/network/di/FakeHttpClient.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.network.di
2 |
3 | import io.ktor.client.HttpClient
4 | import io.ktor.client.engine.mock.MockEngine
5 | import io.ktor.client.engine.mock.MockEngineConfig
6 | import io.ktor.client.engine.mock.respond
7 | import io.ktor.client.engine.mock.respondOk
8 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
9 | import io.ktor.http.HttpHeaders
10 | import io.ktor.http.HttpStatusCode
11 | import io.ktor.http.headers
12 | import io.ktor.serialization.kotlinx.json.json
13 | import kotlinx.serialization.json.Json
14 |
15 | object FakeHttpClient {
16 |
17 | private val jsonInstance = Json {
18 | ignoreUnknownKeys = true
19 | isLenient = true
20 | }
21 |
22 | fun getInstance(): HttpClient {
23 | val engine = MockEngine.create {
24 | requestHandlers.add {
25 | respondOk()
26 | }
27 | }
28 | return HttpClient(engine) {
29 | install(ContentNegotiation) {
30 | json(jsonInstance)
31 | }
32 | }
33 | }
34 | }
35 |
36 | fun HttpClient.enqueueMockResponse(response: String, status: HttpStatusCode, headers: Set> = emptySet()) {
37 | (this.engineConfig as? MockEngineConfig)?.apply {
38 | requestHandlers.clear()
39 | addHandler { _ ->
40 | respond(
41 | content = response,
42 | status = status,
43 | headers = headers {
44 | append(HttpHeaders.ContentType, "application/json")
45 | headers.forEach { (name, value) ->
46 | append(name, value)
47 | }
48 | },
49 | )
50 | }
51 | }
52 | }
53 |
54 | fun HttpClient.doCleanup() {
55 | (this.engineConfig as? MockEngineConfig)?.requestHandlers?.clear()
56 | }
57 |
--------------------------------------------------------------------------------
/core/network/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import java.io.FileInputStream
2 | import java.util.Properties
3 |
4 | plugins {
5 | alias(libs.plugins.podcaster.android.lib)
6 | alias(libs.plugins.kotlinx.serialization)
7 | alias(libs.plugins.ksp)
8 | }
9 |
10 | android {
11 | namespace = "com.mr3y.podcaster.core.network"
12 |
13 | defaultConfig {
14 |
15 | buildConfigField("String", "API_KEY", "\"${getValueOfKey("API_KEY")}\"")
16 | buildConfigField("String", "API_SECRET", "\"${getValueOfKey("API_SECRET")}\"")
17 | }
18 |
19 | buildFeatures {
20 | buildConfig = true
21 | }
22 | }
23 |
24 | kotlin {
25 | compilerOptions {
26 | freeCompilerArgs.addAll(
27 | listOf(
28 | "-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
29 | "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
30 | ),
31 | )
32 | }
33 | }
34 |
35 | fun getValueOfKey(key: String): String {
36 | return if (System.getenv("CI").toBoolean()) {
37 | System.getenv(key)
38 | } else {
39 | val properties = Properties()
40 | properties.load(FileInputStream(rootProject.file("local.properties")))
41 | properties.getProperty(key)
42 | }
43 | }
44 |
45 | dependencies {
46 |
47 | ksp(libs.hilt.compiler)
48 | implementation(libs.hilt.runtime)
49 | implementation(projects.core.model)
50 | implementation(projects.core.logger)
51 | implementation(libs.bundles.ktor)
52 | implementation(libs.result)
53 | implementation(libs.kotlinx.serialization)
54 |
55 | testImplementation(projects.core.networkTestFixtures)
56 | testImplementation(projects.core.loggerTestFixtures)
57 | testImplementation(libs.bundles.unit.testing)
58 | }
59 |
--------------------------------------------------------------------------------
/core/network/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/core/network/src/main/kotlin/com/mr3y/podcaster/core/network/PodcastIndexClient.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.network
2 |
3 | import com.github.michaelbull.result.Result
4 | import com.mr3y.podcaster.core.network.model.NetworkEpisode
5 | import com.mr3y.podcaster.core.network.model.NetworkEpisodes
6 | import com.mr3y.podcaster.core.network.model.NetworkPodcast
7 | import com.mr3y.podcaster.core.network.model.NetworkPodcasts
8 |
9 | typealias ApiResponse = Result
10 |
11 | interface PodcastIndexClient {
12 |
13 | suspend fun searchForPodcastsByTerm(term: String): ApiResponse
14 |
15 | suspend fun getPodcastByFeedUrl(feedUrl: String): ApiResponse
16 |
17 | suspend fun getPodcastById(podcastId: Long): ApiResponse
18 |
19 | suspend fun getEpisodesByPodcastId(podcastId: Long): ApiResponse
20 |
21 | suspend fun getEpisodeById(episodeId: Long): ApiResponse
22 | }
23 |
--------------------------------------------------------------------------------
/core/network/src/main/kotlin/com/mr3y/podcaster/core/network/di/PodcastIndexClientModule.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.network.di
2 |
3 | import com.mr3y.podcaster.core.network.PodcastIndexClient
4 | import com.mr3y.podcaster.core.network.internal.DefaultPodcastIndexClient
5 | import dagger.Binds
6 | import dagger.Module
7 | import dagger.hilt.InstallIn
8 | import dagger.hilt.components.SingletonComponent
9 | import javax.inject.Singleton
10 |
11 | @Module
12 | @InstallIn(SingletonComponent::class)
13 | abstract class PodcastIndexClientModule {
14 |
15 | @Binds
16 | @Singleton
17 | abstract fun providePodcastIndexClientInstance(impl: DefaultPodcastIndexClient): PodcastIndexClient
18 | }
19 |
--------------------------------------------------------------------------------
/core/network/src/main/kotlin/com/mr3y/podcaster/core/network/internal/DefaultPodcastIndexClient.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.network.internal
2 |
3 | import com.mr3y.podcaster.core.logger.Logger
4 | import com.mr3y.podcaster.core.network.ApiResponse
5 | import com.mr3y.podcaster.core.network.PodcastIndexClient
6 | import com.mr3y.podcaster.core.network.model.NetworkEpisode
7 | import com.mr3y.podcaster.core.network.model.NetworkEpisodes
8 | import com.mr3y.podcaster.core.network.model.NetworkPodcast
9 | import com.mr3y.podcaster.core.network.model.NetworkPodcasts
10 | import com.mr3y.podcaster.core.network.utils.getApiResponse
11 | import io.ktor.client.HttpClient
12 | import io.ktor.client.request.parameter
13 | import javax.inject.Inject
14 |
15 | class DefaultPodcastIndexClient @Inject constructor(
16 | private val httpClient: HttpClient,
17 | private val logger: Logger,
18 | ) : PodcastIndexClient {
19 |
20 | override suspend fun searchForPodcastsByTerm(term: String): ApiResponse {
21 | return httpClient.getApiResponse("$BaseUrl/search/byterm", logger) {
22 | parameter("q", term)
23 | }
24 | }
25 |
26 | override suspend fun getPodcastByFeedUrl(feedUrl: String): ApiResponse {
27 | return httpClient.getApiResponse("$BaseUrl/podcasts/byfeedurl", logger) {
28 | parameter("url", feedUrl)
29 | }
30 | }
31 |
32 | override suspend fun getPodcastById(podcastId: Long): ApiResponse {
33 | return httpClient.getApiResponse("$BaseUrl/podcasts/byfeedid", logger) {
34 | parameter("id", podcastId)
35 | }
36 | }
37 |
38 | override suspend fun getEpisodesByPodcastId(podcastId: Long): ApiResponse {
39 | return httpClient.getApiResponse("$BaseUrl/episodes/byfeedid?id=$podcastId&fulltext", logger)
40 | }
41 |
42 | override suspend fun getEpisodeById(episodeId: Long): ApiResponse {
43 | return httpClient.getApiResponse("$BaseUrl/episodes/byid?id=$episodeId&fulltext", logger)
44 | }
45 |
46 | companion object {
47 | private const val BaseUrl = "https://api.podcastindex.org/api/1.0"
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/core/network/src/main/kotlin/com/mr3y/podcaster/core/network/model/PodcastEpisodeFeed.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.network.model
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | data class PodcastEpisodeFeed(
8 | val id: Long,
9 | val guid: String,
10 | val title: String,
11 | val description: String,
12 | @SerialName("link")
13 | val episodeUrl: String,
14 | val datePublished: Long,
15 | @SerialName("datePublishedPretty")
16 | val datePublishedFormatted: String,
17 | @SerialName("duration")
18 | val durationInSec: Int? = null,
19 | @SerialName("episode")
20 | val episodeNum: Int? = null,
21 | @SerialName("image")
22 | val artworkUrl: String,
23 | val enclosureUrl: String,
24 | @SerialName("enclosureLength")
25 | val enclosureSizeInBytes: Long,
26 | @SerialName("feedId")
27 | val podcastId: Long,
28 | @SerialName("feedTitle")
29 | val podcastTitle: String? = null, // will exist if we are fetching the feed for a single network episode
30 | )
31 |
32 | @Serializable
33 | data class NetworkEpisode(
34 | val status: Boolean,
35 | @SerialName("episode")
36 | val episodeFeed: PodcastEpisodeFeed,
37 | )
38 |
39 | @Serializable
40 | data class NetworkEpisodes(
41 | val status: Boolean,
42 | @SerialName("items")
43 | val episodesFeed: List,
44 | val count: Long,
45 | )
46 |
--------------------------------------------------------------------------------
/core/network/src/main/kotlin/com/mr3y/podcaster/core/network/model/PodcastFeed.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.network.model
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | data class PodcastFeed(
8 | val id: Long,
9 | @SerialName("podcastGuid")
10 | val guid: String?,
11 | val title: String,
12 | val description: String,
13 | @SerialName("url")
14 | val podcastUrl: String,
15 | @SerialName("link")
16 | val website: String,
17 | @SerialName("artwork")
18 | val artworkUrl: String,
19 | val author: String,
20 | @SerialName("ownerName")
21 | val owner: String,
22 | @SerialName("language")
23 | val languageCode: String,
24 | val episodeCount: Int,
25 | @SerialName("categories")
26 | val genres: Map?,
27 | )
28 |
29 | @Serializable
30 | data class NetworkPodcast(
31 | val status: Boolean,
32 | val feed: PodcastFeed,
33 | )
34 |
35 | @Serializable
36 | data class NetworkPodcasts(
37 | val status: Boolean,
38 | val feeds: List,
39 | val count: Long,
40 | )
41 |
--------------------------------------------------------------------------------
/core/network/src/main/kotlin/com/mr3y/podcaster/core/network/utils/KtorExtensions.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.network.utils
2 |
3 | import com.github.michaelbull.result.Err
4 | import com.github.michaelbull.result.Ok
5 | import com.mr3y.podcaster.core.logger.Logger
6 | import com.mr3y.podcaster.core.network.ApiResponse
7 | import io.ktor.client.HttpClient
8 | import io.ktor.client.call.body
9 | import io.ktor.client.request.HttpRequestBuilder
10 | import io.ktor.client.request.get
11 | import io.ktor.client.request.url
12 | import io.ktor.http.isSuccess
13 |
14 | /**
15 | * Executes an [HttpClient]'s GET request with the specified [url] and
16 | * an optional [block] receiving an [HttpRequestBuilder] for configuring the request.
17 | *
18 | * @return [ApiResponse]
19 | */
20 | suspend inline fun HttpClient.getApiResponse(
21 | urlString: String,
22 | logger: Logger,
23 | block: HttpRequestBuilder.() -> Unit = {},
24 | ): ApiResponse {
25 | return try {
26 | val response = get(urlString, block = block)
27 | if (response.status.isSuccess()) {
28 | Ok(response.body())
29 | } else {
30 | logger.w(tag = "DefaultPodcastIndexClient") {
31 | "Request failed! endpoint: $urlString, http status code: ${response.status}"
32 | }
33 | Err(response)
34 | }
35 | } catch (ex: Exception) {
36 | logger.e(ex, tag = "DefaultPodcastIndexClient") {
37 | "Exception occurred on requesting data from endpoint $urlString"
38 | }
39 | Err(ex)
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/core/network/src/main/kotlin/com/mr3y/podcaster/core/network/utils/Mappers.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.network.utils
2 |
3 | import com.mr3y.podcaster.core.model.Episode
4 | import com.mr3y.podcaster.core.model.Genre
5 | import com.mr3y.podcaster.core.model.Podcast
6 | import com.mr3y.podcaster.core.network.model.NetworkEpisode
7 | import com.mr3y.podcaster.core.network.model.NetworkEpisodes
8 | import com.mr3y.podcaster.core.network.model.NetworkPodcast
9 | import com.mr3y.podcaster.core.network.model.NetworkPodcasts
10 | import com.mr3y.podcaster.core.network.model.PodcastEpisodeFeed
11 | import com.mr3y.podcaster.core.network.model.PodcastFeed
12 |
13 | fun NetworkPodcast.mapToPodcast(): Podcast = this.feed.mapToPodcast()
14 |
15 | fun NetworkPodcasts.mapToPodcasts(): List = this.feeds.map(PodcastFeed::mapToPodcast)
16 |
17 | fun NetworkEpisode.mapToEpisode(podcastTitle: String?, podcastArtworkUrl: String?): Episode = this.episodeFeed.mapToEpisode(podcastTitle, podcastArtworkUrl)
18 |
19 | fun NetworkEpisodes.mapToEpisodes(podcastTitle: String?, podcastArtworkUrl: String?): List = this.episodesFeed.map { it.mapToEpisode(podcastTitle, podcastArtworkUrl) }
20 |
21 | fun PodcastFeed.mapToPodcast() = Podcast(
22 | id = id,
23 | guid = guid ?: "",
24 | title = title,
25 | description = description,
26 | podcastUrl = podcastUrl,
27 | website = website,
28 | artworkUrl = artworkUrl,
29 | author = author,
30 | owner = owner,
31 | languageCode = languageCode,
32 | episodeCount = episodeCount,
33 | genres = genres?.map { (id, label) -> Genre(id, label) } ?: emptyList(),
34 | )
35 |
36 | fun PodcastEpisodeFeed.mapToEpisode(podcastTitle: String?, podcastArtworkUrl: String?) = Episode(
37 | id = id,
38 | podcastId = podcastId,
39 | guid = guid,
40 | title = title,
41 | description = description,
42 | episodeUrl = episodeUrl,
43 | datePublishedTimestamp = datePublished,
44 | datePublishedFormatted = datePublishedFormatted,
45 | durationInSec = durationInSec,
46 | episodeNum = episodeNum,
47 | artworkUrl = artworkUrl.takeIf { it.isNotEmpty() } ?: podcastArtworkUrl ?: "",
48 | enclosureUrl = enclosureUrl,
49 | enclosureSizeInBytes = enclosureSizeInBytes,
50 | podcastTitle = this.podcastTitle ?: podcastTitle,
51 | isCompleted = false,
52 | progressInSec = null,
53 | )
54 |
--------------------------------------------------------------------------------
/core/network/src/test/kotlin/com/mr3y/podcaster/core/network/internal/DefaultPodcastIndexClientSerializationTest.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.network.internal
2 |
3 | import assertk.all
4 | import assertk.assertThat
5 | import assertk.assertions.isEqualTo
6 | import assertk.assertions.isTrue
7 | import com.mr3y.podcaster.core.logger.TestLogger
8 | import com.mr3y.podcaster.core.network.AndroidSearchQueryResponse
9 | import com.mr3y.podcaster.core.network.PodcastSearchQueryResponse
10 | import com.mr3y.podcaster.core.network.TechnologySearchQueryResponse
11 | import com.mr3y.podcaster.core.network.di.FakeHttpClient
12 | import com.mr3y.podcaster.core.network.di.doCleanup
13 | import com.mr3y.podcaster.core.network.di.enqueueMockResponse
14 | import io.ktor.http.HttpStatusCode
15 | import kotlinx.coroutines.test.runTest
16 | import org.junit.After
17 | import org.junit.Before
18 | import org.junit.Test
19 |
20 | class DefaultPodcastIndexClientSerializationTest {
21 |
22 | private val httpClient = FakeHttpClient.getInstance()
23 |
24 | private lateinit var sut: DefaultPodcastIndexClient
25 |
26 | @Before
27 | fun setUp() {
28 | sut = DefaultPodcastIndexClient(httpClient, TestLogger())
29 | }
30 |
31 | @Test
32 | fun `test deserializing search for podcasts response is working as expected`() = runTest {
33 | // Given a 200 successful response with some android-related podcasts info in the json response.
34 | httpClient.enqueueMockResponse(AndroidSearchQueryResponse, HttpStatusCode.OK)
35 | var searchResult = sut.searchForPodcastsByTerm("android")
36 |
37 | // then the response should be deserialized successfully.
38 | assertThat(searchResult).all {
39 | assertThat(searchResult.isOk).isTrue()
40 | val responseSize = searchResult.value.count
41 | assertThat(responseSize).isEqualTo(60)
42 | }
43 | // Reset
44 | httpClient.doCleanup()
45 |
46 | // Repeat the same steps but on a different search query.
47 | httpClient.enqueueMockResponse(TechnologySearchQueryResponse, HttpStatusCode.OK)
48 |
49 | searchResult = sut.searchForPodcastsByTerm("technology")
50 | assertThat(searchResult).all {
51 | assertThat(searchResult.isOk).isTrue()
52 | val responseSize = searchResult.value.count
53 | assertThat(responseSize).isEqualTo(60)
54 | }
55 |
56 | // Reset
57 | httpClient.doCleanup()
58 |
59 | // Repeat the same steps but on a different search query.
60 | httpClient.enqueueMockResponse(PodcastSearchQueryResponse, HttpStatusCode.OK)
61 |
62 | searchResult = sut.searchForPodcastsByTerm("podcast")
63 | assertThat(searchResult).all {
64 | assertThat(searchResult.isOk).isTrue()
65 | val responseSize = searchResult.value.count
66 | assertThat(responseSize).isEqualTo(60)
67 | }
68 | }
69 |
70 | @After
71 | fun cleanUp() {
72 | httpClient.doCleanup()
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/core/opml/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.podcaster.android.lib)
3 | alias(libs.plugins.kotlinx.serialization)
4 | alias(libs.plugins.ksp)
5 | }
6 |
7 | android {
8 | namespace = "com.mr3y.podcaster.core.opml"
9 | }
10 |
11 | kotlin {
12 | compilerOptions {
13 | freeCompilerArgs.addAll(
14 | listOf(
15 | "-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
16 | "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
17 | ),
18 | )
19 | }
20 | }
21 |
22 | dependencies {
23 |
24 | implementation(projects.core.model)
25 | implementation(projects.core.data)
26 | implementation(projects.core.logger)
27 | ksp(libs.hilt.compiler)
28 | implementation(libs.hilt.runtime)
29 | implementation(libs.bundles.serialization)
30 | implementation(libs.result)
31 |
32 | testImplementation(projects.core.loggerTestFixtures)
33 | testImplementation(libs.bundles.unit.testing)
34 | }
35 |
--------------------------------------------------------------------------------
/core/opml/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/core/opml/src/main/kotlin/com/mr3y/podcaster/core/opml/OpmlAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.opml
2 |
3 | import com.github.michaelbull.result.Err
4 | import com.github.michaelbull.result.Ok
5 | import com.github.michaelbull.result.Result
6 | import com.mr3y.podcaster.core.logger.Logger
7 | import com.mr3y.podcaster.core.model.Podcast
8 | import com.mr3y.podcaster.core.opml.model.Body
9 | import com.mr3y.podcaster.core.opml.model.Head
10 | import com.mr3y.podcaster.core.opml.model.Opml
11 | import com.mr3y.podcaster.core.opml.model.OpmlPodcast
12 | import com.mr3y.podcaster.core.opml.model.Outline
13 | import kotlinx.serialization.serializer
14 | import nl.adaptivity.xmlutil.serialization.XML
15 | import javax.inject.Inject
16 | import javax.inject.Singleton
17 |
18 | @Singleton
19 | class OpmlAdapter @Inject constructor(
20 | private val xmlInstance: XML,
21 | private val logger: Logger,
22 | ) {
23 |
24 | fun decode(content: String): Result, Any> {
25 | return try {
26 | val opml = xmlInstance.decodeFromString(serializer(), content)
27 | val opmlFeeds = mutableListOf()
28 |
29 | fun flatten(outline: Outline) {
30 | if (outline.outlines.isNullOrEmpty() && !outline.xmlUrl.isNullOrBlank()) {
31 | opmlFeeds.add(mapOutlineToOpmlPodcast(outline))
32 | }
33 |
34 | outline.outlines?.forEach { nestedOutline -> flatten(nestedOutline) }
35 | }
36 |
37 | opml.body.outlines.forEach { outline -> flatten(outline) }
38 |
39 | Ok(opmlFeeds.distinctBy { it.link })
40 | } catch (ex: Exception) {
41 | logger.e(ex, tag = "OpmlAdapter") {
42 | "Exception occurred on decoding Opml podcasts from content $content"
43 | }
44 | Err(ex)
45 | }
46 | }
47 |
48 | fun encode(podcasts: List): Result {
49 | return try {
50 | val opml = Opml(
51 | version = "2.0",
52 | head = Head("Podcaster Subscriptions", dateCreated = null),
53 | body = Body(outlines = podcasts.map(::mapPodcastToOutline)),
54 | )
55 |
56 | val xmlString = xmlInstance.encodeToString(serializer(), opml)
57 |
58 | StringBuilder(xmlString)
59 | .insert(0, "\n")
60 | .appendLine()
61 | .toString()
62 | .let {
63 | Ok(it)
64 | }
65 | } catch (ex: Exception) {
66 | logger.e(ex, tag = "OpmlAdapter") {
67 | "Exception occurred on encoding Opml podcasts $podcasts"
68 | }
69 | Err(ex)
70 | }
71 | }
72 |
73 | private fun mapPodcastToOutline(podcast: Podcast) =
74 | Outline(text = podcast.title, title = podcast.title, type = "rss", xmlUrl = podcast.podcastUrl, htmlUrl = podcast.website, outlines = null)
75 |
76 | private fun mapOutlineToOpmlPodcast(outline: Outline): OpmlPodcast {
77 | return OpmlPodcast(title = outline.title ?: outline.text, link = outline.xmlUrl!!)
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/core/opml/src/main/kotlin/com/mr3y/podcaster/core/opml/di/XMLSerializerModule.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.opml.di
2 |
3 | import dagger.Module
4 | import dagger.Provides
5 | import dagger.hilt.InstallIn
6 | import dagger.hilt.components.SingletonComponent
7 | import kotlinx.coroutines.CoroutineDispatcher
8 | import kotlinx.coroutines.Dispatchers
9 | import nl.adaptivity.xmlutil.serialization.XML
10 | import javax.inject.Qualifier
11 | import javax.inject.Singleton
12 |
13 | @Qualifier
14 | @Retention(AnnotationRetention.BINARY)
15 | annotation class IODispatcher
16 |
17 | @Module
18 | @InstallIn(SingletonComponent::class)
19 | object XMLSerializerModule {
20 |
21 | @Singleton
22 | @Provides
23 | fun provideXMLInstance(): XML {
24 | return XML {
25 | autoPolymorphic = true
26 | indentString = " "
27 | defaultPolicy {
28 | pedantic = false
29 | ignoreUnknownChildren()
30 | }
31 | }
32 | }
33 |
34 | @Provides
35 | @IODispatcher
36 | fun provideIOCoroutineDispatcher(): CoroutineDispatcher {
37 | return Dispatchers.IO
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/core/opml/src/main/kotlin/com/mr3y/podcaster/core/opml/model/Opml.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.opml.model
2 |
3 | import kotlinx.serialization.Serializable
4 | import nl.adaptivity.xmlutil.serialization.XmlElement
5 | import nl.adaptivity.xmlutil.serialization.XmlSerialName
6 |
7 | @Serializable
8 | @XmlSerialName("opml")
9 | internal data class Opml(
10 | @XmlElement(value = false) val version: String?,
11 | @XmlElement(value = false) val head: Head?,
12 | val body: Body,
13 | )
14 |
15 | @Serializable
16 | @XmlSerialName("head")
17 | internal data class Head(@XmlElement val title: String, @XmlElement val dateCreated: String?)
18 |
19 | @Serializable
20 | @XmlSerialName("body")
21 | internal data class Body(@XmlSerialName("outline") val outlines: List)
22 |
23 | @Serializable
24 | @XmlSerialName("outline")
25 | internal data class Outline(
26 | @XmlElement(value = false) val title: String?,
27 | @XmlElement(value = false) val text: String?,
28 | @XmlElement(value = false) val type: String?,
29 | @XmlElement(value = false) val xmlUrl: String?,
30 | @XmlElement(value = false) val htmlUrl: String?,
31 | @XmlSerialName("outline") val outlines: List?,
32 | )
33 |
--------------------------------------------------------------------------------
/core/opml/src/main/kotlin/com/mr3y/podcaster/core/opml/model/OpmlPodcast.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.opml.model
2 |
3 | data class OpmlPodcast(val title: String?, val link: String)
4 |
--------------------------------------------------------------------------------
/core/opml/src/main/kotlin/com/mr3y/podcaster/core/opml/model/OpmlResult.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.opml.model
2 |
3 | sealed interface OpmlResult {
4 | data object Idle : OpmlResult
5 |
6 | data object Loading : OpmlResult
7 |
8 | data object Success : OpmlResult
9 |
10 | sealed interface Error : OpmlResult {
11 | data object NoContentInOpmlFile : Error
12 |
13 | data object EncodingError : Error
14 |
15 | data object DecodingError : Error
16 |
17 | data object NetworkError : Error
18 |
19 | data class UnknownFailure(val error: Exception) : Error
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/core/sync/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.podcaster.android.lib)
3 | alias(libs.plugins.ksp)
4 | }
5 |
6 | android {
7 | namespace = "com.mr3y.podcaster.core.sync"
8 | }
9 |
10 | kotlin {
11 | compilerOptions {
12 | freeCompilerArgs.addAll(
13 | listOf(
14 | "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
15 | ),
16 | )
17 | }
18 | }
19 |
20 | dependencies {
21 | implementation(projects.core.model)
22 | implementation(projects.core.data)
23 | ksp(libs.hilt.compiler)
24 | ksp(libs.hilt.androidx.compiler)
25 | implementation(libs.bundles.workmanager)
26 | implementation(libs.hilt.runtime)
27 | }
28 |
--------------------------------------------------------------------------------
/core/sync/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/core/sync/src/main/kotlin/com/mr3y/podcaster/core/sync/Initializer.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.sync
2 |
3 | import android.content.Context
4 | import androidx.work.ExistingPeriodicWorkPolicy
5 | import androidx.work.WorkManager
6 |
7 | fun initializeWorkManagerInstance(appContext: Context): WorkManager {
8 | return WorkManager.getInstance(appContext).apply {
9 | enqueueUniquePeriodicWork(
10 | SubscriptionsSyncWorker.PeriodicWorkRequestID,
11 | ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE,
12 | SubscriptionsSyncWorker.subscriptionsPeriodicSyncWorker(),
13 | )
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/core/sync/src/main/kotlin/com/mr3y/podcaster/core/sync/SubscriptionsSyncWorker.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.sync
2 |
3 | import android.content.Context
4 | import androidx.hilt.work.HiltWorker
5 | import androidx.work.Constraints
6 | import androidx.work.CoroutineWorker
7 | import androidx.work.ForegroundInfo
8 | import androidx.work.NetworkType
9 | import androidx.work.PeriodicWorkRequestBuilder
10 | import androidx.work.WorkerParameters
11 | import com.mr3y.podcaster.core.data.PodcastsRepository
12 | import dagger.assisted.Assisted
13 | import dagger.assisted.AssistedInject
14 | import kotlinx.coroutines.async
15 | import kotlinx.coroutines.awaitAll
16 | import kotlinx.coroutines.coroutineScope
17 | import java.util.concurrent.TimeUnit
18 |
19 | @HiltWorker
20 | class SubscriptionsSyncWorker @AssistedInject constructor(
21 | @Assisted private val context: Context,
22 | @Assisted params: WorkerParameters,
23 | private val podcastsRepository: PodcastsRepository,
24 | ) : CoroutineWorker(context, params) {
25 |
26 | override suspend fun doWork(): Result {
27 | setSafeForeground(getForegroundInfo())
28 | return coroutineScope {
29 | val aggregatedSyncResults = podcastsRepository.getSubscriptionsNonObservable().map { podcast ->
30 | async {
31 | val result1 = podcastsRepository.syncRemotePodcastWithLocal(podcast.id)
32 | val result2 = podcastsRepository.syncRemoteEpisodesForPodcastWithLocal(
33 | podcast.id,
34 | podcast.title,
35 | podcast.artworkUrl,
36 | )
37 | result1 && result2
38 | }
39 | }.awaitAll()
40 |
41 | when {
42 | aggregatedSyncResults.all { isSuccessful -> isSuccessful } -> Result.success()
43 | else -> {
44 | // TODO: Log more info for better investigation.
45 | Result.failure()
46 | }
47 | }
48 | }
49 | }
50 |
51 | override suspend fun getForegroundInfo(): ForegroundInfo {
52 | return context.syncForegroundInfo()
53 | }
54 |
55 | private suspend fun setSafeForeground(foregroundInfo: ForegroundInfo) {
56 | try {
57 | setForeground(foregroundInfo)
58 | } catch (exception: IllegalStateException) {
59 | // TODO: Log more info for better investigation.
60 | }
61 | }
62 |
63 | companion object {
64 | const val PeriodicWorkRequestID = "subscriptionsPeriodicSyncWorker"
65 |
66 | fun subscriptionsPeriodicSyncWorker() = PeriodicWorkRequestBuilder(8, TimeUnit.HOURS)
67 | .setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
68 | .build()
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/core/sync/src/main/kotlin/com/mr3y/podcaster/core/sync/SyncNotification.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.core.sync
2 |
3 | import android.app.Notification
4 | import android.app.NotificationChannel
5 | import android.app.NotificationManager
6 | import android.content.Context
7 | import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE
8 | import android.os.Build
9 | import androidx.core.app.NotificationCompat
10 | import androidx.work.ForegroundInfo
11 |
12 | private const val SYNC_NOTIFICATION_ID = 10
13 | private const val SYNC_NOTIFICATION_CHANNEL_ID = "SyncNotificationChannel"
14 |
15 | /**
16 | * Foreground information when sync workers are being run with a foreground service
17 | */
18 | fun Context.syncForegroundInfo(): ForegroundInfo {
19 | return if (Build.VERSION.SDK_INT >= 34) {
20 | ForegroundInfo(SYNC_NOTIFICATION_ID, syncWorkNotification(), FOREGROUND_SERVICE_TYPE_SHORT_SERVICE)
21 | } else {
22 | ForegroundInfo(SYNC_NOTIFICATION_ID, syncWorkNotification())
23 | }
24 | }
25 |
26 | /**
27 | * Notification displayed when sync workers are being run with a foreground service
28 | */
29 | private fun Context.syncWorkNotification(): Notification {
30 | val channel = NotificationChannel(
31 | SYNC_NOTIFICATION_CHANNEL_ID,
32 | getString(R.string.sync_work_notification_channel_name),
33 | NotificationManager.IMPORTANCE_DEFAULT,
34 | ).apply {
35 | description = getString(R.string.sync_work_notification_channel_description)
36 | }
37 | // Register the channel with the system
38 | val notificationManager: NotificationManager? =
39 | getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
40 |
41 | notificationManager?.createNotificationChannel(channel)
42 |
43 | return NotificationCompat.Builder(this, SYNC_NOTIFICATION_CHANNEL_ID)
44 | .setSmallIcon(R.drawable.ic_notification)
45 | .setContentTitle(getString(R.string.sync_work_notification_title))
46 | .setContentText(getString(R.string.sync_work_notification_body))
47 | .setPriority(NotificationCompat.PRIORITY_DEFAULT)
48 | .build()
49 | }
50 |
--------------------------------------------------------------------------------
/core/sync/src/main/res/drawable-hdpi/ic_notification.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/core/sync/src/main/res/drawable-hdpi/ic_notification.png
--------------------------------------------------------------------------------
/core/sync/src/main/res/drawable-mdpi/ic_notification.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/core/sync/src/main/res/drawable-mdpi/ic_notification.png
--------------------------------------------------------------------------------
/core/sync/src/main/res/drawable-xhdpi/ic_notification.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/core/sync/src/main/res/drawable-xhdpi/ic_notification.png
--------------------------------------------------------------------------------
/core/sync/src/main/res/drawable-xxhdpi/ic_notification.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/core/sync/src/main/res/drawable-xxhdpi/ic_notification.png
--------------------------------------------------------------------------------
/core/sync/src/main/res/drawable-xxxhdpi/ic_notification.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/core/sync/src/main/res/drawable-xxxhdpi/ic_notification.png
--------------------------------------------------------------------------------
/core/sync/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Sync
3 | Background Refreshing tasks for Podcaster
4 | Podcaster is Syncing your subscriptions
5 | Refreshing podcasts.
6 |
--------------------------------------------------------------------------------
/docs/PrivacyPolicy.md:
--------------------------------------------------------------------------------
1 | # Podcaster Privacy Policy - Developer: Abdelrahman Khairy
2 | ## Permissions Podcaster has:
3 | Podcaster has the following permissions in order to function correctly
4 | - android.permission.INTERNET
5 | - android.permission.POST_NOTIFICATIONS
6 | - android.permission.FOREGROUND_SERVICE
7 | - android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK
8 | - android.permission.FOREGROUND_SERVICE_DATA_SYNC
9 | - android.permission.ACCESS_NETWORK_STATE
10 |
11 | ## Third-party SDKs Podcaster uses:
12 | Podcaster uses Google Firebase Crashlytics and Analytics to gather data about crashes happening while the app is being used by users,
13 | this data is collected to help me fix Crashes & bugs, and hence Improve the end user experience, All data is anonymous.
14 |
15 | If you think the app is accessing or collecting data that is not supposed to be accessed or collected,
16 | then reach out at 26522145+mr3y-the-programmer@users.noreply.github.com.
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | [PrivacyPolicy](https://github.com/mr3y-the-programmer/Podcaster/blob/main/docs/PrivacyPolicy.md)
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | # Setting new jvm arguments clear the default gradle values
10 | # (even those that aren't overriden) see https://github.com/gradle/gradle/issues/19750
11 | org.gradle.jvmargs=-Xmx6g -Xms256m -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -XX:+UseParallelGC -Dfile.encoding=UTF-8
12 | # When configured, Gradle will run in incubating parallel mode.
13 | # This option should only be used with decoupled projects. More details, visit
14 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
15 | # org.gradle.parallel=true
16 | # AndroidX package structure to make it clearer which packages are bundled with the
17 | # Android operating system, and which are packaged with your app's APK
18 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
19 | android.useAndroidX=true
20 | # Kotlin code style for this project: "official" or "obsolete":
21 | kotlin.code.style=official
22 | # Enables namespacing of each library's R class so that its R class includes only the
23 | # resources declared in the library itself and none from the library's dependencies,
24 | # thereby reducing the size of the R class for that library
25 | android.nonTransitiveRClass=true
26 | org.gradle.caching=true
27 | org.gradle.parallel=true
28 | org.gradle.configuration-cache=true
29 | org.gradle.kotlin.dsl.skipMetadataVersionCheck=false
30 |
31 | # Roborazzi
32 | roborazzi.record.namingStrategy=testClassAndMethod
33 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mr3y-the-programmer/Podcaster/371f66d898304681acd82d018511d9cd557ca8c8/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionSha256Sum=61ad310d3c7d3e5da131b76bbf22b5a4c0786e9d892dae8c1658d4b484de3caa
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
5 | networkTimeout=10000
6 | validateDistributionUrl=true
7 | zipStoreBase=GRADLE_USER_HOME
8 | zipStorePath=wrapper/dists
9 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 | @rem SPDX-License-Identifier: Apache-2.0
17 | @rem
18 |
19 | @if "%DEBUG%"=="" @echo off
20 | @rem ##########################################################################
21 | @rem
22 | @rem Gradle startup script for Windows
23 | @rem
24 | @rem ##########################################################################
25 |
26 | @rem Set local scope for the variables with windows NT shell
27 | if "%OS%"=="Windows_NT" setlocal
28 |
29 | set DIRNAME=%~dp0
30 | if "%DIRNAME%"=="" set DIRNAME=.
31 | @rem This is normally unused
32 | set APP_BASE_NAME=%~n0
33 | set APP_HOME=%DIRNAME%
34 |
35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
37 |
38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
40 |
41 | @rem Find java.exe
42 | if defined JAVA_HOME goto findJavaFromJavaHome
43 |
44 | set JAVA_EXE=java.exe
45 | %JAVA_EXE% -version >NUL 2>&1
46 | if %ERRORLEVEL% equ 0 goto execute
47 |
48 | echo. 1>&2
49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
50 | echo. 1>&2
51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
52 | echo location of your Java installation. 1>&2
53 |
54 | goto fail
55 |
56 | :findJavaFromJavaHome
57 | set JAVA_HOME=%JAVA_HOME:"=%
58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
59 |
60 | if exist "%JAVA_EXE%" goto execute
61 |
62 | echo. 1>&2
63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
64 | echo. 1>&2
65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
66 | echo location of your Java installation. 1>&2
67 |
68 | goto fail
69 |
70 | :execute
71 | @rem Setup the command line
72 |
73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
74 |
75 |
76 | @rem Execute Gradle
77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
78 |
79 | :end
80 | @rem End local scope for the variables with windows NT shell
81 | if %ERRORLEVEL% equ 0 goto mainEnd
82 |
83 | :fail
84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
85 | rem the _cmd.exe /c_ return code!
86 | set EXIT_CODE=%ERRORLEVEL%
87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
89 | exit /b %EXIT_CODE%
90 |
91 | :mainEnd
92 | if "%OS%"=="Windows_NT" endlocal
93 |
94 | :omega
95 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Podcaster
5 |
6 |
7 | Privacy Policy
8 |
9 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": [
4 | "config:base",
5 | "group:all",
6 | ":dependencyDashboard",
7 | "schedule:weekly"
8 | ],
9 | "baseBranches": [
10 | "main"
11 | ],
12 | "labels": [
13 | "dependencies"
14 | ],
15 | "packageRules": [
16 | {
17 | "matchPackagePrefixes": [
18 | "com.google.devtools.ksp",
19 | "org.jetbrains.kotlin"
20 | ],
21 | "groupName": "kotlin"
22 | },
23 | {
24 | "matchPackageNames": ["org.xerial:sqlite-jdbc"],
25 | "allowedVersions": "<=3.18.0"
26 | },
27 | {
28 | "matchPackagePrefixes": ["cafe.adriel.lyricist"],
29 | "allowedVersions": "/^[0-9].[0-9].[0-9]$/"
30 | }
31 | ]
32 | }
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | includeBuild("convention-plugins")
3 | repositories {
4 | google()
5 | mavenCentral()
6 | gradlePluginPortal()
7 | }
8 | }
9 | dependencyResolutionManagement {
10 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
11 | repositories {
12 | google()
13 | mavenCentral()
14 | }
15 | }
16 |
17 | rootProject.name = "Podcaster"
18 |
19 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
20 | include(":app")
21 | include(":core:model")
22 | include(":core:database")
23 | include(":core:database-test-fixtures")
24 | include(":core:logger")
25 | include(":core:logger-test-fixtures")
26 | include(":core:network")
27 | include(":core:network-test-fixtures")
28 | include(":core:data")
29 | include(":core:sync")
30 | include(":core:opml")
31 | include(":ui:resources")
32 | include(":ui:preview")
33 | include(":ui:design-system")
34 | include(":baselineprofile")
35 |
--------------------------------------------------------------------------------
/ui/design-system/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.podcaster.compose.android.lib)
3 | }
4 |
5 | android {
6 | namespace = "com.mr3y.podcaster.ui"
7 | }
8 |
9 | kotlin {
10 | compilerOptions {
11 | freeCompilerArgs.addAll(
12 | listOf(
13 | "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
14 | "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
15 | "-opt-in=androidx.compose.animation.ExperimentalSharedTransitionApi",
16 | "-opt-in=coil3.annotation.ExperimentalCoilApi",
17 | ),
18 | )
19 | }
20 | }
21 |
22 | dependencies {
23 | implementation(platform(libs.compose.bom))
24 | implementation(libs.bundles.compose)
25 | implementation(libs.core.ktx)
26 |
27 | implementation(projects.core.model)
28 | implementation(projects.ui.preview)
29 | implementation(projects.ui.resources)
30 |
31 | // For previews
32 | debugImplementation(libs.ui.tooling)
33 | }
34 |
--------------------------------------------------------------------------------
/ui/design-system/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/ui/design-system/src/main/kotlin/com/mr3y/podcaster/ui/components/Error.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.components
2 |
3 | import androidx.compose.foundation.layout.Arrangement
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.Spacer
6 | import androidx.compose.foundation.layout.height
7 | import androidx.compose.foundation.shape.RoundedCornerShape
8 | import androidx.compose.material3.ButtonDefaults
9 | import androidx.compose.material3.ElevatedButton
10 | import androidx.compose.material3.MaterialTheme
11 | import androidx.compose.material3.Text
12 | import androidx.compose.runtime.Composable
13 | import androidx.compose.ui.Alignment
14 | import androidx.compose.ui.Modifier
15 | import androidx.compose.ui.text.style.TextAlign
16 | import androidx.compose.ui.unit.dp
17 | import com.mr3y.podcaster.LocalStrings
18 | import com.mr3y.podcaster.ui.theme.onPrimaryTertiary
19 | import com.mr3y.podcaster.ui.theme.primaryTertiary
20 |
21 | @Composable
22 | fun Error(
23 | onRetry: () -> Unit,
24 | modifier: Modifier = Modifier,
25 | ) {
26 | val strings = LocalStrings.current
27 | Column(
28 | verticalArrangement = Arrangement.Center,
29 | horizontalAlignment = Alignment.CenterHorizontally,
30 | modifier = modifier,
31 | ) {
32 | Text(
33 | text = strings.generic_error_message,
34 | color = MaterialTheme.colorScheme.onSurfaceVariant,
35 | style = MaterialTheme.typography.titleMedium,
36 | textAlign = TextAlign.Center,
37 | )
38 | Spacer(modifier = Modifier.height(24.dp))
39 | ElevatedButton(
40 | onClick = onRetry,
41 | shape = RoundedCornerShape(16.dp),
42 | colors = ButtonDefaults.elevatedButtonColors(
43 | containerColor = MaterialTheme.colorScheme.primaryTertiary,
44 | contentColor = MaterialTheme.colorScheme.onPrimaryTertiary,
45 | ),
46 | ) {
47 | Text(
48 | text = strings.retry_label,
49 | style = MaterialTheme.typography.labelLarge,
50 | )
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/ui/design-system/src/main/kotlin/com/mr3y/podcaster/ui/components/HtmlConverter.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.components
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.remember
5 | import androidx.compose.ui.text.AnnotatedString
6 | import androidx.compose.ui.unit.TextUnit
7 | import be.digitalia.compose.htmlconverter.HtmlStyle
8 | import be.digitalia.compose.htmlconverter.htmlToAnnotatedString
9 |
10 | @Composable
11 | fun rememberHtmlToAnnotatedString(
12 | text: String,
13 | indentUnit: TextUnit? = null,
14 | ): AnnotatedString {
15 | return remember(text) {
16 | htmlToAnnotatedString(
17 | text,
18 | style = if (indentUnit != null) HtmlStyle(indentUnit = indentUnit) else HtmlStyle.DEFAULT,
19 | )
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/ui/design-system/src/main/kotlin/com/mr3y/podcaster/ui/components/LoadingIndicator.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.components
2 |
3 | import androidx.compose.foundation.layout.Box
4 | import androidx.compose.material3.CircularProgressIndicator
5 | import androidx.compose.material3.MaterialTheme
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.ui.Alignment
8 | import androidx.compose.ui.Modifier
9 | import androidx.compose.ui.graphics.Color
10 | import androidx.compose.ui.graphics.StrokeCap
11 | import com.mr3y.podcaster.ui.theme.primaryTertiary
12 |
13 | @Composable
14 | fun LoadingIndicator(
15 | modifier: Modifier = Modifier,
16 | color: Color = MaterialTheme.colorScheme.primaryTertiary,
17 | ) {
18 | Box(
19 | contentAlignment = Alignment.Center,
20 | modifier = modifier,
21 | ) {
22 | CircularProgressIndicator(
23 | color = color,
24 | strokeCap = StrokeCap.Round,
25 | )
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/ui/design-system/src/main/kotlin/com/mr3y/podcaster/ui/components/PaddingValues.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.components
2 |
3 | import androidx.compose.foundation.layout.PaddingValues
4 | import androidx.compose.ui.unit.Dp
5 | import androidx.compose.ui.unit.LayoutDirection
6 |
7 | operator fun PaddingValues.plus(other: PaddingValues): PaddingValues = object : PaddingValues {
8 | override fun calculateBottomPadding(): Dp =
9 | this@plus.calculateBottomPadding() + other.calculateBottomPadding()
10 |
11 | override fun calculateLeftPadding(layoutDirection: LayoutDirection): Dp =
12 | this@plus.calculateLeftPadding(layoutDirection) + other.calculateLeftPadding(layoutDirection)
13 |
14 | override fun calculateRightPadding(layoutDirection: LayoutDirection): Dp =
15 | this@plus.calculateRightPadding(layoutDirection) + other.calculateRightPadding(layoutDirection)
16 |
17 | override fun calculateTopPadding(): Dp =
18 | this@plus.calculateTopPadding() + other.calculateTopPadding()
19 | }
20 |
--------------------------------------------------------------------------------
/ui/design-system/src/main/kotlin/com/mr3y/podcaster/ui/components/PullToRefresh.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.components
2 |
3 | import androidx.compose.material3.MaterialTheme
4 | import androidx.compose.material3.pulltorefresh.PullToRefreshBox
5 | import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
6 | import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.Alignment
9 | import androidx.compose.ui.Modifier
10 | import androidx.compose.ui.graphics.Color
11 | import com.mr3y.podcaster.ui.theme.primaryTertiary
12 |
13 | @Composable
14 | fun PullToRefresh(
15 | isRefreshing: Boolean,
16 | onRefresh: () -> Unit,
17 | modifier: Modifier = Modifier,
18 | contentColor: Color = MaterialTheme.colorScheme.primaryTertiary,
19 | content: @Composable () -> Unit,
20 | ) {
21 | val state = rememberPullToRefreshState()
22 |
23 | PullToRefreshBox(
24 | state = state,
25 | isRefreshing = isRefreshing,
26 | onRefresh = onRefresh,
27 | indicator = {
28 | PullToRefreshDefaults.Indicator(
29 | modifier = Modifier.align(Alignment.TopCenter),
30 | isRefreshing = isRefreshing,
31 | state = state,
32 | color = contentColor,
33 | )
34 | },
35 | modifier = modifier,
36 | ) {
37 | content()
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/ui/design-system/src/main/kotlin/com/mr3y/podcaster/ui/components/TopAppBar.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.components
2 |
3 | import androidx.compose.foundation.layout.RowScope
4 | import androidx.compose.foundation.layout.fillMaxWidth
5 | import androidx.compose.material.icons.Icons
6 | import androidx.compose.material.icons.automirrored.filled.ArrowBack
7 | import androidx.compose.material3.Icon
8 | import androidx.compose.material3.IconButton
9 | import androidx.compose.material3.IconButtonDefaults
10 | import androidx.compose.material3.MaterialTheme
11 | import androidx.compose.material3.TopAppBar
12 | import androidx.compose.material3.TopAppBarColors
13 | import androidx.compose.material3.TopAppBarDefaults
14 | import androidx.compose.material3.TopAppBarScrollBehavior
15 | import androidx.compose.runtime.Composable
16 | import androidx.compose.ui.Modifier
17 | import androidx.compose.ui.graphics.Color
18 | import com.mr3y.podcaster.LocalStrings
19 |
20 | @Composable
21 | fun TopBar(
22 | onUpButtonClick: (() -> Unit)?,
23 | modifier: Modifier = Modifier,
24 | title: @Composable () -> Unit = {},
25 | actions: @Composable RowScope.() -> Unit = {},
26 | colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors(),
27 | scrollBehavior: TopAppBarScrollBehavior? = null,
28 | ) {
29 | val strings = LocalStrings.current
30 | val navIconContentColor = if (colors.navigationIconContentColor == Color.Unspecified) MaterialTheme.colorScheme.onSurface else colors.navigationIconContentColor
31 | TopAppBar(
32 | navigationIcon = {
33 | if (onUpButtonClick != null) {
34 | IconButton(
35 | onClick = onUpButtonClick,
36 | colors = IconButtonDefaults.filledIconButtonColors(containerColor = Color.Transparent, contentColor = navIconContentColor),
37 | ) {
38 | Icon(
39 | imageVector = Icons.AutoMirrored.Filled.ArrowBack,
40 | contentDescription = strings.icon_navigate_up_content_description,
41 | )
42 | }
43 | }
44 | },
45 | title = title,
46 | actions = actions,
47 | colors = colors,
48 | modifier = modifier.fillMaxWidth(),
49 | scrollBehavior = scrollBehavior,
50 | )
51 | }
52 |
--------------------------------------------------------------------------------
/ui/design-system/src/main/kotlin/com/mr3y/podcaster/ui/theme/Color.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.theme
2 |
3 | import androidx.compose.ui.graphics.Color
4 |
5 | val md_theme_light_primary = Color(0xFFAB3600)
6 | val md_theme_light_onPrimary = Color(0xFFFFFFFF)
7 | val md_theme_light_primaryContainer = Color(0xFFFFDBCF)
8 | val md_theme_light_onPrimaryContainer = Color(0xFF390C00)
9 | val md_theme_light_secondary = Color(0xFFAB3600)
10 | val md_theme_light_onSecondary = Color(0xFFFFFFFF)
11 | val md_theme_light_secondaryContainer = Color(0xFFFFDBCF)
12 | val md_theme_light_onSecondaryContainer = Color(0xFF390C00)
13 | val md_theme_light_tertiary = Color(0xFF00658A)
14 | val md_theme_light_onTertiary = Color(0xFFFFFFFF)
15 | val md_theme_light_tertiaryContainer = Color(0xFFC4E7FF)
16 | val md_theme_light_onTertiaryContainer = Color(0xFF001E2C)
17 | val md_theme_light_error = Color(0xFFBA1A1A)
18 | val md_theme_light_errorContainer = Color(0xFFFFDAD6)
19 | val md_theme_light_onError = Color(0xFFFFFFFF)
20 | val md_theme_light_onErrorContainer = Color(0xFF410002)
21 | val md_theme_light_background = Color(0xFFFFFFFF)
22 | val md_theme_light_onBackground = Color(0xFF1A1C1E)
23 | val md_theme_light_surface = Color(0xFFFFFFFF)
24 | val md_theme_light_onSurface = Color(0xFF1A1C1E)
25 | val md_theme_light_surfaceVariant = Color(0xFFF3F3F3)
26 | val md_theme_light_onSurfaceVariant = Color(0xFF43474E)
27 | val md_theme_light_outline = Color(0xFF85736E)
28 | val md_theme_light_inverseOnSurface = Color(0xFFF1F0F4)
29 | val md_theme_light_inverseSurface = Color(0xFF2F3033)
30 | val md_theme_light_inversePrimary = Color(0xFFFFB59C)
31 | val md_theme_light_shadow = Color(0xFF000000)
32 | val md_theme_light_surfaceTint = Color(0xFFAB3600)
33 | val md_theme_light_outlineVariant = Color(0xFFD8C2BB)
34 | val md_theme_light_scrim = Color(0xFF000000)
35 |
36 | val md_theme_dark_primary = Color(0xFFFFB59C)
37 | val md_theme_dark_onPrimary = Color(0xFF5C1900)
38 | val md_theme_dark_primaryContainer = Color(0xFF832700)
39 | val md_theme_dark_onPrimaryContainer = Color(0xFFFFDBCF)
40 | val md_theme_dark_secondary = Color(0xFFFFB59C)
41 | val md_theme_dark_onSecondary = Color(0xFF5C1900)
42 | val md_theme_dark_secondaryContainer = Color(0xFF832700)
43 | val md_theme_dark_onSecondaryContainer = Color(0xFFFFDBCF)
44 | val md_theme_dark_tertiary = Color(0xFF7CD0FF)
45 | val md_theme_dark_onTertiary = Color(0xFF00344A)
46 | val md_theme_dark_tertiaryContainer = Color(0xFF004C69)
47 | val md_theme_dark_onTertiaryContainer = Color(0xFFC4E7FF)
48 | val md_theme_dark_error = Color(0xFFFFB4AB)
49 | val md_theme_dark_errorContainer = Color(0xFF93000A)
50 | val md_theme_dark_onError = Color(0xFF690005)
51 | val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
52 | val md_theme_dark_background = Color(0xFF1A1C1E)
53 | val md_theme_dark_onBackground = Color(0xFFE3E2E6)
54 | val md_theme_dark_surface = Color(0xFF1A1C1E)
55 | val md_theme_dark_onSurface = Color(0xFFE3E2E6)
56 | val md_theme_dark_surfaceVariant = Color(0xFF43474E)
57 | val md_theme_dark_onSurfaceVariant = Color(0xFFC3C6CF)
58 | val md_theme_dark_outline = Color(0xFFA08D87)
59 | val md_theme_dark_inverseOnSurface = Color(0xFF1A1C1E)
60 | val md_theme_dark_inverseSurface = Color(0xFFE3E2E6)
61 | val md_theme_dark_inversePrimary = Color(0xFFAB3600)
62 | val md_theme_dark_shadow = Color(0xFF000000)
63 | val md_theme_dark_surfaceTint = Color(0xFFFFB59C)
64 | val md_theme_dark_outlineVariant = Color(0xFF53433F)
65 | val md_theme_dark_scrim = Color(0xFF000000)
66 |
67 | // Source color (Neon orange)
68 | val seed = Color(0xFFFF5F1F)
69 |
--------------------------------------------------------------------------------
/ui/design-system/src/main/kotlin/com/mr3y/podcaster/ui/theme/ColorUtils.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.theme
2 |
3 | import androidx.compose.ui.graphics.Color
4 | import androidx.compose.ui.graphics.compositeOver
5 | import androidx.compose.ui.graphics.luminance
6 |
7 | const val MinContrastRatio = 3f
8 |
9 | fun Color.contrastAgainst(background: Color): Float {
10 | val fg = if (alpha < 1f) compositeOver(background) else this
11 |
12 | val fgLuminance = fg.luminance() + 0.05f
13 | val bgLuminance = background.luminance() + 0.05f
14 |
15 | return maxOf(fgLuminance, bgLuminance) / minOf(fgLuminance, bgLuminance)
16 | }
17 |
--------------------------------------------------------------------------------
/ui/design-system/src/main/kotlin/com/mr3y/podcaster/ui/theme/Shape.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.theme
2 |
3 | import androidx.compose.foundation.shape.RoundedCornerShape
4 | import androidx.compose.material3.Shapes
5 | import androidx.compose.ui.unit.dp
6 |
7 | val Shapes = Shapes(
8 | small = RoundedCornerShape(4.dp),
9 | medium = RoundedCornerShape(8.dp),
10 | large = RoundedCornerShape(0.dp),
11 | )
12 |
--------------------------------------------------------------------------------
/ui/design-system/src/main/kotlin/com/mr3y/podcaster/ui/theme/Type.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.theme
2 |
3 | import androidx.compose.material3.Typography
4 | import androidx.compose.ui.text.TextStyle
5 | import androidx.compose.ui.text.font.FontFamily
6 | import androidx.compose.ui.text.font.FontWeight
7 | import androidx.compose.ui.unit.sp
8 |
9 | // Set of Material typography styles to start with
10 | val Typography = Typography(
11 | bodyLarge = TextStyle(
12 | fontFamily = FontFamily.Default,
13 | fontWeight = FontWeight.Normal,
14 | fontSize = 16.sp,
15 | lineHeight = 24.sp,
16 | letterSpacing = 0.5.sp,
17 | ),
18 | /* Other default text styles to override
19 | titleLarge = TextStyle(
20 | fontFamily = FontFamily.Default,
21 | fontWeight = FontWeight.Normal,
22 | fontSize = 22.sp,
23 | lineHeight = 28.sp,
24 | letterSpacing = 0.sp
25 | ),
26 | labelSmall = TextStyle(
27 | fontFamily = FontFamily.Default,
28 | fontWeight = FontWeight.Medium,
29 | fontSize = 11.sp,
30 | lineHeight = 16.sp,
31 | letterSpacing = 0.5.sp
32 | )
33 | */
34 | )
35 |
--------------------------------------------------------------------------------
/ui/preview/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.podcaster.compose.android.lib)
3 | }
4 |
5 | android {
6 | namespace = "com.mr3y.podcaster.ui.preview"
7 | }
8 |
9 | dependencies {
10 | implementation(platform(libs.compose.bom))
11 | implementation(libs.ui.tooling.preview)
12 | }
13 |
--------------------------------------------------------------------------------
/ui/preview/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/ui/preview/src/main/kotlin/com/mr3y/podcaster/ui/preview/PodcasterPreview.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.preview
2 |
3 | import android.content.res.Configuration
4 | import android.content.res.Configuration.UI_MODE_TYPE_NORMAL
5 | import androidx.compose.ui.tooling.preview.Preview
6 | import androidx.compose.ui.tooling.preview.PreviewParameterProvider
7 | import androidx.compose.ui.tooling.preview.Wallpapers.GREEN_DOMINATED_EXAMPLE
8 |
9 | // TODO: use the predefined PreviewX that ships
10 | // with ui-tooling-preview dependency when you update to 1.6.0 stable release.
11 |
12 | @Retention(AnnotationRetention.BINARY)
13 | @Target(
14 | AnnotationTarget.ANNOTATION_CLASS,
15 | AnnotationTarget.FUNCTION,
16 | )
17 | @Preview(device = "spec:width=411dp,height=891dp", uiMode = Configuration.UI_MODE_NIGHT_NO, wallpaper = GREEN_DOMINATED_EXAMPLE)
18 | @Preview(device = "spec:width=411dp,height=891dp", uiMode = Configuration.UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL, wallpaper = GREEN_DOMINATED_EXAMPLE)
19 | annotation class PodcasterPreview
20 |
21 | class DynamicColorsParameterProvider : PreviewParameterProvider {
22 | override val values: Sequence
23 | get() = sequenceOf(false, true)
24 | }
25 |
--------------------------------------------------------------------------------
/ui/resources/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.podcaster.compose.android.lib)
3 | alias(libs.plugins.ksp)
4 | }
5 |
6 | android {
7 | namespace = "com.mr3y.podcaster.ui.resources"
8 | }
9 |
10 | ksp {
11 | arg("lyricist.packageName", "com.mr3y.podcaster")
12 | }
13 |
14 | dependencies {
15 | implementation(platform(libs.compose.bom))
16 | implementation(libs.ui)
17 | ksp(libs.lyricist.processor)
18 | implementation(libs.lyricist)
19 | }
20 |
--------------------------------------------------------------------------------
/ui/resources/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/ui/resources/src/main/kotlin/com/mr3y/podcaster/ui/resources/PodcasterStrings.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.resources
2 |
3 | import androidx.compose.ui.text.AnnotatedString
4 |
5 | data class PodcasterStrings(
6 | val tab_subscriptions_label: String,
7 | val tab_explore_label: String,
8 | val tab_library_label: String,
9 | val tab_settings_label: String,
10 | val subscriptions_refresh_result_error: String,
11 | val subscriptions_refresh_result_mixed: String,
12 | val icon_menu_content_description: String,
13 | val icon_settings_content_description: String,
14 | val icon_navigate_up_content_description: String,
15 | val icon_theme_content_description: String,
16 | val subscriptions_label: String,
17 | val subscriptions_empty_list: String,
18 | val subscriptions_episodes_empty_list: String,
19 | val generic_error_message: String,
20 | val retry_label: String,
21 | val currently_playing: String,
22 | val buffering_playback: String,
23 | val navigate_to_episode_a11y_label: (String) -> String,
24 | val search_for_podcast_placeholder: String,
25 | val recent_searches_label: String,
26 | val close_label: String,
27 | val feed_url_incorrect_message: String,
28 | val search_podcasts_empty_list: String,
29 | val podcast_details_refresh_result_error: String,
30 | val podcast_details_refresh_result_mixed: String,
31 | val subscribe_label: String,
32 | val unsubscribe_label: String,
33 | val about_label: String,
34 | val episodes_label: String,
35 | val episode_details_refresh_result_error: String,
36 | val episode_details_refresh_result_mixed: String,
37 | val sync_work_notification_title: String,
38 | val sync_work_notification_body: String,
39 | val sync_work_notification_channel_name: String,
40 | val sync_work_notification_channel_description: String,
41 | val download_work_notification_message: String,
42 | val downloads_label: String,
43 | val downloads_empty_list: String,
44 | val library_label: String,
45 | val settings_label: String,
46 | val appearance_label: String,
47 | val theme_heading: String,
48 | val theme_light_label: String,
49 | val theme_dark_label: String,
50 | val theme_system_default_label: String,
51 | val dynamic_colors_label: String,
52 | val dynamic_colors_on_label: String,
53 | val dynamic_colors_off_label: String,
54 | val open_source_licenses_label: String,
55 | val version_label: String,
56 | val feedback_and_issues_label: String,
57 | val privacy_policy_label: String,
58 | val powered_by_label: AnnotatedString,
59 | val import_export_label: String,
60 | val import_label: String,
61 | val export_label: String,
62 | val import_notice: String,
63 | val import_succeeded: String,
64 | val import_network_error: String,
65 | val import_empty_file_error: String,
66 | val import_corrupted_file_error: String,
67 | val import_unknown_error: String,
68 | val favorites_label: String,
69 | val favorites_empty_list: String,
70 | val share_label: String,
71 | val icon_more_options_content_description: String,
72 | )
73 |
--------------------------------------------------------------------------------
/ui/resources/src/main/kotlin/com/mr3y/podcaster/ui/resources/ProvideStrings.kt:
--------------------------------------------------------------------------------
1 | package com.mr3y.podcaster.ui.resources
2 |
3 | import androidx.compose.runtime.Composable
4 | import com.mr3y.podcaster.ProvideStrings
5 |
6 | @Composable
7 | fun ProvideAppStrings(
8 | content: @Composable () -> Unit,
9 | ) {
10 | ProvideStrings(content = content)
11 | }
12 |
--------------------------------------------------------------------------------