├── .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 | 11 | 12 | 14 | 15 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 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 | 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 | --------------------------------------------------------------------------------