├── .github └── workflows │ └── build-release.yml ├── .gitignore ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro ├── src │ ├── androidTest │ │ └── java │ │ │ └── ua │ │ │ └── leonidius │ │ │ └── beatinspector │ │ │ ├── ExampleInstrumentedTest.kt │ │ │ └── data │ │ │ ├── playlists │ │ │ └── repository │ │ │ │ └── MyPlaylistsRepositoryTest.kt │ │ │ └── tracks │ │ │ └── lists │ │ │ └── liked │ │ │ └── repository │ │ │ └── LikedTracksRepositoryTest.kt │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── ic_launcher-playstore.png │ │ ├── java │ │ │ └── ua │ │ │ │ └── leonidius │ │ │ │ └── beatinspector │ │ │ │ ├── BeatInspectorApp.kt │ │ │ │ ├── MainActivity.kt │ │ │ │ ├── MainModule.kt │ │ │ │ ├── MainViewModel.kt │ │ │ │ ├── data │ │ │ │ ├── account │ │ │ │ │ ├── cache │ │ │ │ │ │ └── AccountDataCache.kt │ │ │ │ │ ├── domain │ │ │ │ │ │ └── AccountDetails.kt │ │ │ │ │ ├── network │ │ │ │ │ │ ├── AccountNetworkDataSource.kt │ │ │ │ │ │ ├── api │ │ │ │ │ │ │ └── AccountApi.kt │ │ │ │ │ │ └── dto │ │ │ │ │ │ │ └── AccountInfoResponse.kt │ │ │ │ │ └── repository │ │ │ │ │ │ ├── AccountRepository.kt │ │ │ │ │ │ └── AccountRepositoryImpl.kt │ │ │ │ ├── auth │ │ │ │ │ ├── logic │ │ │ │ │ │ ├── AuthException.kt │ │ │ │ │ │ ├── AuthTokenProvider.kt │ │ │ │ │ │ └── Authenticator.kt │ │ │ │ │ └── storage │ │ │ │ │ │ ├── AuthStateSharedPrefStorage.kt │ │ │ │ │ │ └── AuthStateStorage.kt │ │ │ │ ├── playlists │ │ │ │ │ ├── MyPlaylistsPagingDataSource.kt │ │ │ │ │ ├── PlaylistInfoRepository.kt │ │ │ │ │ ├── PlaylistTitlesCache.kt │ │ │ │ │ ├── db │ │ │ │ │ │ ├── PlaylistDao.kt │ │ │ │ │ │ ├── PlaylistPageKeys.kt │ │ │ │ │ │ └── PlaylistPageKeysDao.kt │ │ │ │ │ ├── domain │ │ │ │ │ │ └── PlaylistSearchResult.kt │ │ │ │ │ ├── network │ │ │ │ │ │ ├── api │ │ │ │ │ │ │ └── MyPlaylistsService.kt │ │ │ │ │ │ └── dto │ │ │ │ │ │ │ ├── MyPlaylistsResponse.kt │ │ │ │ │ │ │ └── SimplifiedPlaylistDto.kt │ │ │ │ │ └── repository │ │ │ │ │ │ ├── MyPlaylistsMediator.kt │ │ │ │ │ │ └── MyPlaylistsRepository.kt │ │ │ │ ├── settings │ │ │ │ │ └── SettingsRepository.kt │ │ │ │ ├── shared │ │ │ │ │ ├── BasePagingDataSourceWithTitleCache.kt │ │ │ │ │ ├── ListMapper.kt │ │ │ │ │ ├── Mapper.kt │ │ │ │ │ ├── PagingDataSource.kt │ │ │ │ │ ├── Resource.kt │ │ │ │ │ ├── cache │ │ │ │ │ │ └── InMemCache.kt │ │ │ │ │ ├── db │ │ │ │ │ │ └── TracksDatabase.kt │ │ │ │ │ ├── domain │ │ │ │ │ │ └── SearchResult.kt │ │ │ │ │ ├── exception │ │ │ │ │ │ └── SongDataIOException.kt │ │ │ │ │ ├── network │ │ │ │ │ │ ├── BaseNetworkDataSource.kt │ │ │ │ │ │ ├── NetworkDataSource.kt │ │ │ │ │ │ ├── NetworkResponseExt.kt │ │ │ │ │ │ └── dto │ │ │ │ │ │ │ ├── ErrorResponse.kt │ │ │ │ │ │ │ └── ImageDto.kt │ │ │ │ │ └── repository │ │ │ │ │ │ ├── BaseBasicRepository.kt │ │ │ │ │ │ ├── BaseTrackListPagingRepository.kt │ │ │ │ │ │ └── BasicRepository.kt │ │ │ │ └── tracks │ │ │ │ │ ├── details │ │ │ │ │ ├── cache │ │ │ │ │ │ └── FullTrackDetailsCacheDataSource.kt │ │ │ │ │ ├── domain │ │ │ │ │ │ └── Song.kt │ │ │ │ │ ├── network │ │ │ │ │ │ ├── api │ │ │ │ │ │ │ ├── ArtistsApi.kt │ │ │ │ │ │ │ └── TrackAudioAnalysisApi.kt │ │ │ │ │ │ └── dto │ │ │ │ │ │ │ ├── FullArtistDto.kt │ │ │ │ │ │ │ ├── MultipleArtistsResponse.kt │ │ │ │ │ │ │ ├── TrackAudioAnalysisDto.kt │ │ │ │ │ │ │ └── TrackAudioAnalysisResponse.kt │ │ │ │ │ └── repository │ │ │ │ │ │ ├── TrackDetailsRepository.kt │ │ │ │ │ │ └── TrackDetailsRepositoryImpl.kt │ │ │ │ │ ├── lists │ │ │ │ │ ├── BaseTrackPagingDataSource.kt │ │ │ │ │ ├── liked │ │ │ │ │ │ ├── SavedTracksNetworkPagingSource.kt │ │ │ │ │ │ ├── network │ │ │ │ │ │ │ ├── api │ │ │ │ │ │ │ │ └── LikedTracksApi.kt │ │ │ │ │ │ │ └── dto │ │ │ │ │ │ │ │ ├── LikedTrackDto.kt │ │ │ │ │ │ │ │ └── LikedTracksResponse.kt │ │ │ │ │ │ └── repository │ │ │ │ │ │ │ └── LikedTracksRepository.kt │ │ │ │ │ ├── playlist │ │ │ │ │ │ ├── PlaylistPagingDataSource.kt │ │ │ │ │ │ └── network │ │ │ │ │ │ │ ├── api │ │ │ │ │ │ │ └── PlaylistApi.kt │ │ │ │ │ │ │ └── dto │ │ │ │ │ │ │ └── PlaylistResponse.kt │ │ │ │ │ ├── recent │ │ │ │ │ │ ├── RecentlyPlayedDataSource.kt │ │ │ │ │ │ └── network │ │ │ │ │ │ │ ├── api │ │ │ │ │ │ │ └── RecentlyPlayedApi.kt │ │ │ │ │ │ │ └── dto │ │ │ │ │ │ │ └── RecentlyPlayedResponse.kt │ │ │ │ │ ├── shared │ │ │ │ │ │ └── db │ │ │ │ │ │ │ ├── SongInPlaylist.kt │ │ │ │ │ │ │ └── SongInPlaylistPagingKeys.kt │ │ │ │ │ └── top │ │ │ │ │ │ ├── TopTracksPagingDataSource.kt │ │ │ │ │ │ └── network │ │ │ │ │ │ ├── api │ │ │ │ │ │ └── TopTracksApi.kt │ │ │ │ │ │ └── dto │ │ │ │ │ │ └── TopTracksResponse.kt │ │ │ │ │ ├── search │ │ │ │ │ ├── db │ │ │ │ │ │ └── SearchResult.kt │ │ │ │ │ ├── network │ │ │ │ │ │ ├── SearchNetworkDataSource.kt │ │ │ │ │ │ ├── api │ │ │ │ │ │ │ └── SearchApi.kt │ │ │ │ │ │ └── dto │ │ │ │ │ │ │ └── SearchResultsResponse.kt │ │ │ │ │ └── repository │ │ │ │ │ │ ├── SearchRepository.kt │ │ │ │ │ │ └── SearchRepositoryImpl.kt │ │ │ │ │ └── shared │ │ │ │ │ ├── cache │ │ │ │ │ └── SongTitlesInMemCache.kt │ │ │ │ │ ├── db │ │ │ │ │ ├── ArtistGenre.kt │ │ │ │ │ ├── TrackArtist.kt │ │ │ │ │ ├── TrackDao.kt │ │ │ │ │ ├── TrackShelfInfo.kt │ │ │ │ │ └── TrackShelfInfoWithArtists.kt │ │ │ │ │ ├── domain │ │ │ │ │ ├── Artist.kt │ │ │ │ │ └── SongSearchResult.kt │ │ │ │ │ └── network │ │ │ │ │ └── dto │ │ │ │ │ ├── AlbumDto.kt │ │ │ │ │ ├── ArtistDto.kt │ │ │ │ │ └── TrackDto.kt │ │ │ │ ├── features │ │ │ │ ├── crash │ │ │ │ │ └── ui │ │ │ │ │ │ └── OnCrashActivity.kt │ │ │ │ ├── details │ │ │ │ │ ├── ui │ │ │ │ │ │ └── SongDetailsScreen.kt │ │ │ │ │ └── viewmodels │ │ │ │ │ │ └── SongDetailsViewModel.kt │ │ │ │ ├── home │ │ │ │ │ ├── ui │ │ │ │ │ │ └── HomeScreen.kt │ │ │ │ │ └── viewmodels │ │ │ │ │ │ └── HomeScreenViewModel.kt │ │ │ │ ├── legal │ │ │ │ │ ├── ui │ │ │ │ │ │ └── LongTextScreen.kt │ │ │ │ │ └── viewmodels │ │ │ │ │ │ └── LongTextViewModel.kt │ │ │ │ ├── login │ │ │ │ │ ├── ui │ │ │ │ │ │ ├── LoginScreen.kt │ │ │ │ │ │ ├── LoginScreenError.kt │ │ │ │ │ │ ├── LoginScreenInProgress.kt │ │ │ │ │ │ └── LoginScreenOffer.kt │ │ │ │ │ └── viewmodels │ │ │ │ │ │ └── LoginViewModel.kt │ │ │ │ ├── search │ │ │ │ │ ├── ui │ │ │ │ │ │ └── SearchScreen.kt │ │ │ │ │ └── viewmodels │ │ │ │ │ │ └── SearchViewModel.kt │ │ │ │ ├── settings │ │ │ │ │ ├── ui │ │ │ │ │ │ └── SettingsScreen.kt │ │ │ │ │ └── viewmodels │ │ │ │ │ │ └── SettingsViewModel.kt │ │ │ │ ├── shared │ │ │ │ │ ├── model │ │ │ │ │ │ └── ExceptionTextExt.kt │ │ │ │ │ └── ui │ │ │ │ │ │ ├── CenteredScrollableTextScreen.kt │ │ │ │ │ │ ├── LoadingScreen.kt │ │ │ │ │ │ └── SearchBox.kt │ │ │ │ └── tracklist │ │ │ │ │ ├── liked │ │ │ │ │ └── viewmodels │ │ │ │ │ │ └── LikedTracksViewModel.kt │ │ │ │ │ ├── playlist │ │ │ │ │ ├── di │ │ │ │ │ │ └── PlaylistTracksModule.kt │ │ │ │ │ ├── ui │ │ │ │ │ │ └── PlaylistContentScreen.kt │ │ │ │ │ └── viewmodels │ │ │ │ │ │ └── PlaylistContentViewModel.kt │ │ │ │ │ ├── recent │ │ │ │ │ └── viewmodels │ │ │ │ │ │ └── RecentlyPlayedViewModel.kt │ │ │ │ │ ├── shared │ │ │ │ │ ├── ui │ │ │ │ │ │ └── TrackListScreen.kt │ │ │ │ │ └── viewmodels │ │ │ │ │ │ └── TrackListViewModel.kt │ │ │ │ │ └── top │ │ │ │ │ └── viewmodels │ │ │ │ │ └── TopTracksViewModel.kt │ │ │ │ ├── infrastructure │ │ │ │ ├── ApiErrorInterceptor.kt │ │ │ │ ├── AuthInterceptor.kt │ │ │ │ └── ContextPackageInstallCheckExt.kt │ │ │ │ ├── shared │ │ │ │ ├── domain │ │ │ │ │ └── SettingsState.kt │ │ │ │ ├── logic │ │ │ │ │ └── eventbus │ │ │ │ │ │ ├── Event.kt │ │ │ │ │ │ ├── EventBus.kt │ │ │ │ │ │ ├── EventBusImpl.kt │ │ │ │ │ │ ├── UserLogoutRequestEvent.kt │ │ │ │ │ │ └── UserSettingsChangeEvent.kt │ │ │ │ └── viewmodels │ │ │ │ │ └── AccountImageViewModel.kt │ │ │ │ └── ui │ │ │ │ └── theme │ │ │ │ ├── Color.kt │ │ │ │ ├── Dimens.kt │ │ │ │ ├── Theme.kt │ │ │ │ └── Type.kt │ │ └── res │ │ │ ├── drawable │ │ │ ├── ic_launcher_background.xml │ │ │ ├── ic_launcher_foreground.xml │ │ │ ├── spotify_full_logo_black.xml │ │ │ ├── spotify_full_logo_white.xml │ │ │ ├── spotify_icon_black.xml │ │ │ └── spotify_icon_white.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ │ ├── mipmap-hdpi │ │ │ ├── ic_launcher.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-mdpi │ │ │ ├── ic_launcher.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xhdpi │ │ │ ├── ic_launcher.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxhdpi │ │ │ ├── ic_launcher.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxxhdpi │ │ │ ├── ic_launcher.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_round.png │ │ │ ├── values │ │ │ ├── colors.xml │ │ │ ├── dimens.xml │ │ │ ├── ic_launcher_background.xml │ │ │ ├── strings.xml │ │ │ └── themes.xml │ │ │ └── xml │ │ │ ├── backup_rules.xml │ │ │ └── data_extraction_rules.xml │ └── test │ │ └── java │ │ └── ua │ │ └── leonidius │ │ └── beatinspector │ │ ├── ExampleUnitTest.kt │ │ ├── data │ │ └── settings │ │ │ └── SettingsRepositoryTest.kt │ │ └── infrastructure │ │ └── AuthInterceptorTest.kt └── version ├── build.gradle.kts ├── docs ├── icon.png └── screenshots │ ├── all-old.png │ ├── all.png │ ├── details-screen-landscape-old.png │ ├── details-screen-landscape.png │ ├── details-screen-portrait-old.png │ ├── details-screen-portrait.png │ ├── main-screen-portrait.png │ ├── search-screen-landscape-old.png │ ├── search-screen-landscape.png │ ├── search-screen-portrait-old.png │ └── search-screen-portrait.png ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts /.github/workflows/build-release.yml: -------------------------------------------------------------------------------- 1 | # This action is manually triggered. It will bump the version according to argument (major, minor, patch), 2 | # commit the change to the development branch, get these values from version file, merge --no-ff the development branch into the master branch, 3 | # build a release APK, sing it, and create a draft release on GitHub named after the new version name 4 | # with the description having all commit messages since last tag. 5 | 6 | # but that's in the future, for now we just want to build a release APK and create a draft release on GitHub. 7 | name: Build Release APK 8 | 9 | on: 10 | workflow_dispatch: 11 | #inputs: 12 | # version: 13 | # description: 'Version to bump to' 14 | # required: true 15 | # default: 'patch' 16 | 17 | permissions: 18 | contents: write 19 | 20 | 21 | jobs: 22 | build: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v4 26 | - name: Set up JDK 17 27 | uses: actions/setup-java@v4 28 | with: 29 | java-version: 17 30 | distribution: 'zulu' 31 | - name: Setup Gradle 32 | uses: gradle/actions/setup-gradle@v3 33 | - name: Grant execute permission for gradlew 34 | run: chmod +x gradlew 35 | - name: Create local.properties 36 | # run: echo "sdk.dir=$ANDROID_SDK_ROOT" > local.properties 37 | # run: echo "sdk.dir=/opt/android/sdk" > local.properties 38 | # run: echo "sdk.dir=$ANDROID_HOME" > local.properties 39 | # run: echo "sdk.dir=/home/runner/work/_temp/_github_home/Android/sdk" > local.properties 40 | run: touch local.properties 41 | - name: Decode keystore 42 | run: echo ${{ secrets.SIGNATURE_KEYSTORE_BASE64 }} | base64 --decode > android-keystore.jks 43 | - name: Build with Gradle 44 | env: 45 | SPOTIFY_CLIENT_ID: ${{ secrets.SPOTIFY_CLIENT_ID }} 46 | SIGNATURE_KEYSTORE_PASSWORD: ${{ secrets.SIGNATURE_KEYSTORE_PASSWORD }} 47 | SIGNATURE_KEY_PASSWORD: ${{ secrets.SIGNATURE_KEY_PASSWORD }} 48 | SIGNATURE_KEY_ALIAS: ${{ vars.SIGNATURE_KEY_ALIAS }} 49 | run: ./gradlew assembleRelease 50 | - name: Upload APK # can be removed after we upload to the release 51 | uses: actions/upload-artifact@v2 52 | with: 53 | name: app-release.apk 54 | path: app/build/outputs/apk/release/app-release.apk 55 | - name: Read version name 56 | id: read_version_name 57 | uses: ActionsTools/read-json-action@main 58 | with: 59 | file_path: "./app/version" 60 | - name: Create Release 61 | id: create_release 62 | uses: actions/create-release@v1 63 | env: 64 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 65 | with: 66 | tag_name: ${{ steps.read_version_name.outputs.major }}.${{ steps.read_version_name.outputs.minor }}.${{ steps.read_version_name.outputs.patch }} 67 | release_name: ${{ steps.read_version_name.outputs.major }}.${{ steps.read_version_name.outputs.minor }}.${{ steps.read_version_name.outputs.patch }} 68 | body: | 69 | Changes in this Release 70 | - 71 | draft: true 72 | prerelease: false 73 | - name: Attach APK to Release 74 | uses: actions/upload-release-asset@v1 75 | env: 76 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 77 | with: 78 | upload_url: ${{ steps.create_release.outputs.upload_url }} 79 | asset_path: ./app/build/outputs/apk/release/app-release.apk 80 | asset_name: app-release.apk 81 | asset_content_type: application/vnd.android.package-archive 82 | - name: Attach deobfuscation file to Release 83 | uses: actions/upload-release-asset@v1 84 | env: 85 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 86 | with: 87 | upload_url: ${{ steps.create_release.outputs.upload_url }} 88 | asset_path: ./app/build/app-r8-mapping.txt 89 | asset_name: app-r8-mapping.txt 90 | asset_content_type: text/plain 91 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | .idea 5 | .DS_Store 6 | /build 7 | /captures 8 | .externalNativeBuild 9 | .cxx 10 | local.properties 11 | secrets.properties 12 | *.jks 13 | android-keystore.jks 14 | /android-keystore.jks 15 | /*.jks -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BeatInspector 2 | An Android app to view the BPM, key, genre, time signature, and average loudness of a track from Spotify API. 3 | 4 | --- 5 | [](https://apt.izzysoft.de/fdroid/index/apk/io.github.leonidius20.beatinspector/) 6 | 7 | --- 8 | 9 | ## Purpose 10 | The data provided by the app is meant to help music producers remix other tracks or use them as references for thier own work. 11 | ## Technology 12 | Built with [Jetpack Compose](https://developer.android.com/jetpack/compose), Single-Activity Architecture, Android Architecture Components ([ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel), [Navigation Component](https://developer.android.com/jetpack/androidx/releases/navigation)), [Retrofit](https://square.github.io/retrofit/), [Dagger/Hilt](https://dagger.dev/hilt/), [Coil](https://coil-kt.github.io/coil/), [Room](https://developer.android.com/training/data-storage/room), Kotlin [Coroutines](https://kotlinlang.org/docs/coroutines-overview.html) and [Flows](https://kotlinlang.org/docs/flow.html), [AppAuth](https://github.com/openid/AppAuth-Android) to implement the Authorization code with PKCE flow, [Jetpack Palette library](https://developer.android.com/jetpack/androidx/releases/palette), and the [Paging 3](https://developer.android.com/topic/libraries/architecture/paging/v3-overview) library. 13 | ## Screenshots 14 | ![Screenshots collage](/docs/screenshots/all.png) 15 | ## Building from sources 16 | ### Prerequisites 17 | - In order to build the app yourself, you need to register the app with Spotify API according to [the guide](https://developer.spotify.com/documentation/web-api/concepts/apps). When registering, use a name other than BeatInspector. Mind that the API access will be granted in "Development mode" by default, which means that only you and up to 25 manually added users will be able to use the app (read more [here](https://developer.spotify.com/documentation/web-api/concepts/quota-modes)). 18 | - You will need a keystore to sign the app. Name the keystore file `android-keystore.jks` and place it in the project's root folder. Make sure not to commit this file to a public repository. By default both the debug and the release builds are signed with this keystore. However, if you only want to build a debug version with a debug signature, you can remove the `buildTypes.debug.signingConfig` property from `app/build.gradle.kts`. In this case the keystore file would not be needed. 19 | ### Build locally 20 | - Clone the repository. 21 | - Create a file called `secrets.properties` in the project's root folder with the following content (`SIGNATURE_` values are not needed if you chose to use the debug key): 22 | ``` 23 | SPOTIFY_CLIENT_ID= 24 | 25 | SIGNATURE_KEYSTORE_PASSWORD= 26 | SIGNATURE_KEY_ALIAS= 27 | SIGNATURE_KEY_PASSWORD= 28 | ``` 29 | Make sure that you do not commit this file to a public repository. 30 | - Build the project using Android Studio's UI or run the commands in the project's root folder: 31 | ``` 32 | ./gradlew assembleDebug 33 | ``` 34 | to create a debug build of the app at `app/build/outputs/apk/debug/app-debug.apk`, or 35 | ``` 36 | ./gradlew assembleRelease 37 | ``` 38 | for the release version at `/app/build/outputs/apk/release/app-release.apk`. 39 | ### Build using Github Actions 40 | You will only be able to make a release build with this method. 41 | - Create a fork of this repository. 42 | - Add the following Repository secrets: 43 | - `SPOTIFY_CLIENT_ID` - your app's client id from Spotify developer dashboard 44 | - `SIGNATURE_KEYSTORE_BASE64` - your keystore file encoded in base64 45 | - `SIGNATURE_KEYSTORE_PASSWORD` - the password to the key in the keystore that you want to use for signing the app. 46 | - Add a Repository variable with the following name and value: 47 | - `SIGNATURE_KEY_ALIAS` - the alias of the key that you want to use. 48 | - Run the Build Release APK workflow. 49 | - When the workflow job is finished, the apk will be available as its artifact. A draft release will also be created and the apk will be attached to it. 50 | ## Credits 51 | The app's icon is from Freepik. 52 | ## License 53 | This project is licensed under the [GNU General Public License v3](LICENSE). 54 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /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 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | -keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | -renamesourcefileattribute SourceFile 22 | # apparentry this makes the app crash for some reason 23 | 24 | # Save mapping between original and obfuscated class names 25 | # for stack traces decoding in the future 26 | -printmapping ./build/app-r8-mapping.txt 27 | 28 | # Print the resulting configuration 29 | # -printconfiguration ./build/app-full-r8-config.txt 30 | # -printusage ./build/app-full-r8-usage.txt 31 | # -printseeds ./build/app-full-r8-seeds.txt 32 | 33 | # maybe this will keep the NetworkResponseAdapter library working 34 | #-keep class com.haroldadmin.cnradapter.** { *; } 35 | 36 | -verbose 37 | #generated 38 | -dontwarn com.google.errorprone.annotations.CanIgnoreReturnValue 39 | -dontwarn com.google.errorprone.annotations.CheckReturnValue 40 | -dontwarn com.google.errorprone.annotations.Immutable 41 | -dontwarn com.google.errorprone.annotations.RestrictedApi 42 | -------------------------------------------------------------------------------- /app/src/androidTest/java/ua/leonidius/beatinspector/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("ua.leonidius.beatinspector", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/ua/leonidius/beatinspector/data/playlists/repository/MyPlaylistsRepositoryTest.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.playlists.repository 2 | 3 | import com.haroldadmin.cnradapter.NetworkResponse 4 | import org.junit.Assert.* 5 | import org.junit.Test 6 | import ua.leonidius.beatinspector.data.playlists.network.api.MyPlaylistsService 7 | import ua.leonidius.beatinspector.data.playlists.network.dto.MyPlaylistsResponse 8 | import ua.leonidius.beatinspector.data.shared.network.dto.ErrorResponse 9 | 10 | class MyPlaylistsRepositoryTest { 11 | 12 | object FakePlaylistsService: MyPlaylistsService { 13 | override suspend fun getMyPlaylists( 14 | limit: Int, 15 | offset: Int 16 | ): NetworkResponse { 17 | TODO("Not yet implemented") 18 | } 19 | } 20 | 21 | object FakePlaylists 22 | 23 | @Test 24 | fun uiTest() { 25 | // val repository = MyPlaylistsRepository() 26 | } 27 | 28 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/ua/leonidius/beatinspector/data/tracks/lists/liked/repository/LikedTracksRepositoryTest.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.lists.liked.repository 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | class LikedTracksRepositoryTest { 8 | 9 | //@Test 10 | //fun 11 | 12 | 13 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 21 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Leonidius20/BeatInspector/0d85e1b63fcaea4951842c49dd5819a9691914dc/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/BeatInspectorApp.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector 2 | 3 | import android.app.Application 4 | import cat.ereza.customactivityoncrash.config.CaocConfig 5 | import dagger.hilt.android.HiltAndroidApp 6 | import ua.leonidius.beatinspector.features.crash.ui.OnCrashActivity 7 | 8 | @HiltAndroidApp 9 | class BeatInspectorApp: Application() { 10 | 11 | override fun onCreate() { 12 | super.onCreate() 13 | 14 | CaocConfig.Builder 15 | .create() 16 | .errorActivity(OnCrashActivity::class.java) 17 | .restartActivity(MainActivity::class.java) 18 | .apply() 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/MainViewModel.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector 2 | 3 | import androidx.lifecycle.ViewModel 4 | import dagger.hilt.android.lifecycle.HiltViewModel 5 | import ua.leonidius.beatinspector.data.auth.logic.AuthTokenProvider 6 | import javax.inject.Inject 7 | 8 | /** 9 | * This viewmodel is designed to load the auth state on the launch 10 | * of the app to decide if login screen should be shown or not. 11 | * This is its only purpose. AuthStateViewModel will be renambed into 12 | * LoginViewModel and will only care about the ui state of the login 13 | * screen and submitting the results to the data layer. It will be in 14 | * features/login/viewmodels. 15 | */ 16 | @HiltViewModel 17 | class MainViewModel @Inject constructor( 18 | private val authenticator: AuthTokenProvider 19 | ): ViewModel() { 20 | 21 | fun isLoggedIn(): Boolean { 22 | return authenticator.isAuthorized() 23 | } 24 | 25 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/account/cache/AccountDataCache.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.account.cache 2 | 3 | import android.content.SharedPreferences 4 | import ua.leonidius.beatinspector.data.account.domain.AccountDetails 5 | import ua.leonidius.beatinspector.data.shared.cache.InMemCache 6 | import ua.leonidius.beatinspector.shared.logic.eventbus.EventBus 7 | import ua.leonidius.beatinspector.shared.logic.eventbus.UserLogoutRequestEvent 8 | import javax.inject.Inject 9 | import javax.inject.Named 10 | 11 | class AccountDataCache @Inject constructor( 12 | @Named("account_cache") private val prefs: SharedPreferences, 13 | eventBus: EventBus, 14 | ): InMemCache { 15 | 16 | override val cache: MutableMap = mutableMapOf() // todo: remove this 17 | 18 | private val prefUsernameKey = "username" 19 | private val prefIdKey = "id" 20 | private val prefSmallImageUrl = "imageUrl" 21 | private val prefBigImageUrl = "bigImageUrl" 22 | 23 | init { 24 | eventBus.subscribe(UserLogoutRequestEvent::class) { 25 | clear() 26 | } 27 | } 28 | 29 | // todo: it may be a good idea have the data as datastore flow, so that any viewmodel can observe it and update ui accordingly 30 | 31 | override operator fun get(id: Unit): AccountDetails { 32 | val username = prefs.getString(prefUsernameKey, null)!! // should throw exception if null, should check isDataAvailable first 33 | val id = prefs.getString(prefIdKey, null)!! 34 | val smallImageUrl = prefs.getString(prefSmallImageUrl, null) 35 | val bigImageUrl = prefs.getString(prefBigImageUrl, null) 36 | 37 | return AccountDetails(id, username, smallImageUrl, bigImageUrl) 38 | } 39 | 40 | override operator fun set(id: Unit, data: AccountDetails) { 41 | with(prefs.edit()) { 42 | putString(prefUsernameKey, data.username) 43 | putString(prefIdKey, data.id) 44 | putString(prefSmallImageUrl, data.smallImageUrl) 45 | putString(prefBigImageUrl, data.bigImageUrl) 46 | apply() 47 | } 48 | } 49 | 50 | override fun has(id: Unit): Boolean { 51 | return prefs.contains(prefUsernameKey) && prefs.contains(prefIdKey) 52 | } 53 | 54 | fun clear() { 55 | with(prefs.edit()) { 56 | remove(prefUsernameKey) 57 | remove(prefIdKey) 58 | remove(prefSmallImageUrl) 59 | remove(prefBigImageUrl) 60 | apply() 61 | } 62 | } 63 | 64 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/account/domain/AccountDetails.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.account.domain 2 | 3 | data class AccountDetails( 4 | val id: String, 5 | val username: String, 6 | val smallImageUrl: String?, 7 | val bigImageUrl: String?, 8 | ) -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/account/network/AccountNetworkDataSource.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.account.network 2 | 3 | import ua.leonidius.beatinspector.data.account.network.dto.AccountInfoResponse 4 | import ua.leonidius.beatinspector.data.account.network.api.AccountApi 5 | import ua.leonidius.beatinspector.data.account.domain.AccountDetails 6 | import ua.leonidius.beatinspector.data.shared.network.BaseNetworkDataSource 7 | import javax.inject.Inject 8 | 9 | class AccountNetworkDataSource @Inject constructor( 10 | private val accountApi: AccountApi, 11 | ): BaseNetworkDataSource( 12 | 13 | service = { _ -> accountApi.getAccountInfo() } 14 | 15 | ) -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/account/network/api/AccountApi.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.account.network.api 2 | 3 | import com.haroldadmin.cnradapter.NetworkResponse 4 | import retrofit2.http.GET 5 | import ua.leonidius.beatinspector.data.account.network.dto.AccountInfoResponse 6 | import ua.leonidius.beatinspector.data.shared.network.dto.ErrorResponse 7 | 8 | interface AccountApi { 9 | 10 | @GET("me") 11 | suspend fun getAccountInfo(): NetworkResponse 12 | 13 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/account/network/dto/AccountInfoResponse.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.account.network.dto 2 | 3 | import androidx.annotation.Keep 4 | import com.google.gson.annotations.SerializedName 5 | import ua.leonidius.beatinspector.data.account.domain.AccountDetails 6 | import ua.leonidius.beatinspector.data.shared.Mapper 7 | import ua.leonidius.beatinspector.data.shared.network.dto.ImageDto 8 | 9 | @Keep 10 | data class AccountInfoResponse( 11 | val id: String, 12 | 13 | @SerializedName("display_name") 14 | val displayName: String, 15 | 16 | val images: List 17 | ): Mapper { 18 | 19 | val smallestImage: ImageDto? 20 | get() = images.minByOrNull { it.height * it.width } 21 | 22 | val biggestImage : ImageDto? 23 | get() = images.maxByOrNull { it.height * it.width } 24 | 25 | override fun toDomainObject(): AccountDetails { 26 | return AccountDetails( 27 | id, displayName, 28 | smallestImage?.url, biggestImage?.url 29 | ) 30 | } 31 | 32 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/account/repository/AccountRepository.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.account.repository 2 | 3 | import ua.leonidius.beatinspector.data.account.domain.AccountDetails 4 | import ua.leonidius.beatinspector.data.shared.repository.BasicRepository 5 | 6 | interface AccountRepository: BasicRepository -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/account/repository/AccountRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.account.repository 2 | 3 | import kotlinx.coroutines.CoroutineDispatcher 4 | import ua.leonidius.beatinspector.data.shared.cache.InMemCache 5 | import ua.leonidius.beatinspector.data.account.network.AccountNetworkDataSource 6 | import ua.leonidius.beatinspector.data.account.network.dto.AccountInfoResponse 7 | import ua.leonidius.beatinspector.data.account.domain.AccountDetails 8 | import ua.leonidius.beatinspector.data.shared.repository.BaseBasicRepository 9 | import javax.inject.Inject 10 | import javax.inject.Named 11 | 12 | class AccountRepositoryImpl @Inject constructor( 13 | dataSource: AccountNetworkDataSource, 14 | cache: InMemCache, 15 | @Named("io") ioDispatcher: CoroutineDispatcher, 16 | ): BaseBasicRepository( 17 | cache, dataSource, ioDispatcher), AccountRepository -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/auth/logic/AuthException.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.auth.logic 2 | 3 | import java.io.IOException 4 | 5 | class TokenRefreshException( 6 | oathProtocolErrorString: String, 7 | errorDescription: String, 8 | cause: Throwable 9 | ): IOException( 10 | """ 11 | Token refresh exception. 12 | Oath error string: $oathProtocolErrorString 13 | Description provided by library: $errorDescription 14 | """.trimIndent(), 15 | cause 16 | ) -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/auth/logic/AuthTokenProvider.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.auth.logic 2 | 3 | import android.content.Intent 4 | 5 | /** 6 | * Provides a fresh auth token to be added to the request headers. 7 | * If the token is expired, it will be refreshed before being returned. 8 | */ 9 | interface AuthTokenProvider { 10 | 11 | // val loginState: StateFlow 12 | 13 | //fun isTokenRefreshNeeded(): Boolean 14 | 15 | //suspend fun refreshTokens() 16 | 17 | suspend fun getAccessToken(): String 18 | 19 | fun isAuthorized(): Boolean 20 | 21 | 22 | } 23 | 24 | /** 25 | * Provides methods to perform the two steps of the PKCE authentication flow: 26 | * 1. Getting the code 27 | * 2. Exchanging the code for tokens 28 | * It is expected that this class will also handle storing auth state after performing 29 | * these steps. 30 | */ 31 | interface PKCEAuthenticationInitiator { 32 | 33 | fun prepareStepOneIntent(): Intent 34 | 35 | suspend fun exchangeCodeForTokens(prevStepIntent: Intent?) 36 | 37 | } 38 | 39 | sealed class LoginState { 40 | object NotLoggedIn : LoginState() 41 | object LoggedIn : LoginState() 42 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/auth/storage/AuthStateSharedPrefStorage.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.auth.storage 2 | 3 | import android.content.SharedPreferences 4 | import javax.inject.Inject 5 | import javax.inject.Named 6 | 7 | class AuthStateSharedPrefStorage @Inject constructor( 8 | @Named("tokens_cache") private val prefs: SharedPreferences 9 | ) { 10 | 11 | // todo maybe put the flow here, and make wrapper authState.update functions 12 | // that also update the json in shareprefs and make a flow emit updated AuthState 13 | 14 | private val PREF_KEY_AUTH_STATE = "auth_state" 15 | 16 | fun storeJson(jsonString: String) { 17 | with(prefs.edit()) { 18 | putString(PREF_KEY_AUTH_STATE, jsonString) 19 | apply() 20 | } 21 | } 22 | 23 | fun getJson() = prefs.getString(PREF_KEY_AUTH_STATE, null)!! // should throw if not found, bc you should check isAuthStateStored() first 24 | 25 | fun isAuthStateStored(): Boolean { 26 | return prefs.contains(PREF_KEY_AUTH_STATE) 27 | } 28 | 29 | fun clear() { 30 | with(prefs.edit()) { 31 | remove(PREF_KEY_AUTH_STATE) 32 | apply() 33 | } 34 | } 35 | 36 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/auth/storage/AuthStateStorage.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.auth.storage 2 | 3 | interface AuthStateStorage { 4 | 5 | fun getJson(): String 6 | 7 | fun storeJson(jsonString: String) 8 | 9 | fun isAuthStateStored(): Boolean 10 | 11 | fun clear() 12 | 13 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/playlists/MyPlaylistsPagingDataSource.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.playlists 2 | 3 | import androidx.paging.PagingSource 4 | import ua.leonidius.beatinspector.data.playlists.network.dto.MyPlaylistsResponse 5 | import ua.leonidius.beatinspector.data.playlists.network.api.MyPlaylistsService 6 | import ua.leonidius.beatinspector.data.playlists.domain.PlaylistSearchResult 7 | import ua.leonidius.beatinspector.data.shared.BasePagingDataSourceWithTitleCache 8 | import javax.inject.Inject 9 | 10 | class MyPlaylistsPagingDataSource @Inject constructor( 11 | service: MyPlaylistsService, 12 | cache: PlaylistTitlesInMemCache, 13 | ): BasePagingDataSourceWithTitleCache( 14 | service::getMyPlaylists, 15 | cache 16 | ) 17 | 18 | /*class MyPlaylistsPagingDataSource @Inject constructor( 19 | 20 | ): PagingSource() { 21 | 22 | // here we will get shit from the DB 23 | // idk what should be the first type parameter, if its just a string then 24 | // i don't think we can establish the ordering of values 25 | // so we will probably need a synthetic int id 26 | }*/ -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/playlists/PlaylistInfoRepository.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.playlists 2 | 3 | import ua.leonidius.beatinspector.data.playlists.db.PlaylistDao 4 | import ua.leonidius.beatinspector.data.playlists.domain.PlaylistSearchResult 5 | import ua.leonidius.beatinspector.data.shared.repository.BasicRepository 6 | import javax.inject.Inject 7 | import javax.inject.Singleton 8 | 9 | @Singleton 10 | class PlaylistInfoRepository @Inject constructor( 11 | private val playlistDao: PlaylistDao, 12 | ): BasicRepository { 13 | 14 | override suspend fun get(id: String) = playlistDao.get(id) ?: 15 | throw IllegalArgumentException("Playlist with id $id not found in db cache") 16 | 17 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/playlists/PlaylistTitlesCache.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.playlists 2 | 3 | import ua.leonidius.beatinspector.data.shared.cache.InMemCache 4 | import ua.leonidius.beatinspector.data.playlists.domain.PlaylistSearchResult 5 | import javax.inject.Inject 6 | import javax.inject.Singleton 7 | 8 | @Singleton 9 | class PlaylistTitlesInMemCache @Inject constructor(): InMemCache { 10 | 11 | override val cache = mutableMapOf() 12 | 13 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/playlists/db/PlaylistDao.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.playlists.db 2 | 3 | import androidx.paging.PagingSource 4 | import androidx.room.Dao 5 | import androidx.room.Delete 6 | import androidx.room.Insert 7 | import androidx.room.OnConflictStrategy 8 | import androidx.room.Query 9 | import ua.leonidius.beatinspector.data.playlists.domain.PlaylistSearchResult 10 | 11 | @Dao 12 | interface PlaylistDao { 13 | 14 | @Insert(onConflict = OnConflictStrategy.REPLACE) 15 | suspend fun insertAll(playlists: List) 16 | 17 | @Query("DELETE FROM playlists") 18 | suspend fun clearAll() 19 | 20 | @Query("SELECT * FROM playlists") 21 | fun getAll(): PagingSource 22 | 23 | @Query("SELECT * FROM playlists WHERE id = :id") 24 | suspend fun get(id: String): PlaylistSearchResult? 25 | 26 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/playlists/db/PlaylistPageKeys.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.playlists.db 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.PrimaryKey 6 | 7 | @Entity(tableName = "playlist_page_keys") 8 | data class PlaylistPageKeys( 9 | @PrimaryKey @ColumnInfo(name = "playlist_id") val playlistId: String, 10 | @ColumnInfo(name = "prev_key") val prevKey: Int?, 11 | @ColumnInfo(name = "next_key") val nextKey: Int?, 12 | 13 | @ColumnInfo(name = "cached_at") 14 | val cachedAt: Long = System.currentTimeMillis() 15 | ) -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/playlists/db/PlaylistPageKeysDao.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.playlists.db 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Insert 5 | import androidx.room.OnConflictStrategy 6 | import androidx.room.Query 7 | 8 | @Dao 9 | interface PlaylistPageKeysDao { 10 | 11 | @Insert(onConflict = OnConflictStrategy.REPLACE) 12 | suspend fun insertAll(list: List) 13 | 14 | @Query("SELECT * FROM playlist_page_keys WHERE playlist_id = :playlistId") 15 | suspend fun keysByPlaylistId(playlistId: String): PlaylistPageKeys? 16 | 17 | @Query("DELETE FROM playlist_page_keys") 18 | suspend fun clearKeys() 19 | 20 | @Query("SELECT cached_at FROM playlist_page_keys ORDER BY cached_at DESC LIMIT 1") 21 | suspend fun getCachingTimestamp(): Long? 22 | 23 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/playlists/domain/PlaylistSearchResult.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.playlists.domain 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.PrimaryKey 6 | import ua.leonidius.beatinspector.data.shared.domain.SearchResult 7 | 8 | @Entity(tableName = "playlists") 9 | data class PlaylistSearchResult( 10 | @PrimaryKey override val id: String, 11 | 12 | val name: String, 13 | 14 | @ColumnInfo(name = "small_image_url") val smallImageUrl: String?, 15 | 16 | @ColumnInfo(name = "big_image_url") val bigImageUrl: String?, 17 | 18 | val uri: String, 19 | ): SearchResult -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/playlists/network/api/MyPlaylistsService.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.playlists.network.api 2 | 3 | import com.haroldadmin.cnradapter.NetworkResponse 4 | import retrofit2.http.GET 5 | import retrofit2.http.Query 6 | import ua.leonidius.beatinspector.data.shared.network.dto.ErrorResponse 7 | import ua.leonidius.beatinspector.data.playlists.network.dto.MyPlaylistsResponse 8 | 9 | interface MyPlaylistsService { 10 | 11 | @GET("me/playlists") 12 | suspend fun getMyPlaylists( 13 | @Query("limit") limit: Int, 14 | @Query("offset") offset: Int 15 | ): NetworkResponse 16 | 17 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/playlists/network/dto/MyPlaylistsResponse.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.playlists.network.dto 2 | 3 | import androidx.annotation.Keep 4 | import ua.leonidius.beatinspector.data.playlists.domain.PlaylistSearchResult 5 | import ua.leonidius.beatinspector.data.shared.ListMapper 6 | import ua.leonidius.beatinspector.data.shared.Mapper 7 | 8 | @Keep 9 | data class MyPlaylistsResponse( 10 | val items: List 11 | ): Mapper>, 12 | ListMapper { 13 | 14 | override fun toDomainObject(): List { 15 | return items.map { it.toDomainObject() } 16 | } 17 | 18 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/playlists/network/dto/SimplifiedPlaylistDto.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.playlists.network.dto 2 | 3 | import androidx.annotation.Keep 4 | import ua.leonidius.beatinspector.data.playlists.domain.PlaylistSearchResult 5 | import ua.leonidius.beatinspector.data.shared.Mapper 6 | import ua.leonidius.beatinspector.data.shared.network.dto.ImageDto 7 | 8 | @Keep 9 | data class SimplifiedPlaylistDto( 10 | val id: String, 11 | val name: String, 12 | val images: List?, 13 | val uri: String, 14 | ): Mapper { 15 | 16 | private val smallestImageOrNull: ImageDto? 17 | get() = images?.lastOrNull() // according to the API, the smallest image is the last one 18 | 19 | private val biggestImageOrNull: ImageDto? 20 | get() = images?.firstOrNull() // according to the API, images returned in descending order 21 | 22 | override fun toDomainObject(): PlaylistSearchResult { 23 | return PlaylistSearchResult( 24 | id = id, 25 | name = name, 26 | smallImageUrl = smallestImageOrNull?.url, 27 | bigImageUrl = biggestImageOrNull?.url, 28 | uri = uri, 29 | ) 30 | } 31 | 32 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/playlists/repository/MyPlaylistsMediator.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.playlists.repository 2 | 3 | import androidx.paging.ExperimentalPagingApi 4 | import androidx.paging.LoadType 5 | import androidx.paging.PagingState 6 | import androidx.paging.RemoteMediator 7 | import androidx.room.withTransaction 8 | import com.haroldadmin.cnradapter.NetworkResponse 9 | import retrofit2.HttpException 10 | import ua.leonidius.beatinspector.data.playlists.db.PlaylistPageKeys 11 | import ua.leonidius.beatinspector.data.playlists.domain.PlaylistSearchResult 12 | import ua.leonidius.beatinspector.data.playlists.network.api.MyPlaylistsService 13 | import ua.leonidius.beatinspector.data.shared.db.TracksDatabase 14 | import java.io.IOException 15 | import java.util.concurrent.TimeUnit 16 | 17 | /** 18 | * by "synthetic" i mean that the spotify api doesn't take page numbers, 19 | * instead it takes offsets and limits, but we pretend to work with pages 20 | */ 21 | private const val START_SYNTHETIC_PAGE_INDEX = 0 22 | 23 | @OptIn(ExperimentalPagingApi::class) 24 | internal class MyPlaylistsMediator( 25 | private val api: MyPlaylistsService, 26 | private val db: TracksDatabase, 27 | ): RemoteMediator() { 28 | 29 | override suspend fun initialize(): InitializeAction { 30 | val cacheTimeout = TimeUnit.MILLISECONDS.convert(1, TimeUnit.DAYS) 31 | 32 | return if (System.currentTimeMillis() - (db.playlistPageKeysDao().getCachingTimestamp() ?: 0) < cacheTimeout) { 33 | InitializeAction.SKIP_INITIAL_REFRESH 34 | } else { 35 | InitializeAction.LAUNCH_INITIAL_REFRESH 36 | } 37 | } 38 | 39 | override suspend fun load( 40 | loadType: LoadType, 41 | state: PagingState 42 | ): MediatorResult { 43 | val page = when (loadType) { 44 | LoadType.REFRESH -> { 45 | /* 46 | If remoteKey is not null, then we can get the nextKey from it. In the Github API the page keys are incremented sequentially. So to get the page that contains the current item, we just subtract 1 from remoteKey.nextKey. 47 | If RemoteKey is null (because the anchorPosition was null), then the page we need to load is the initial one: GITHUB_STARTING_PAGE_INDEX 48 | */ 49 | val remoteKeys = getKeysClosestToCurrentPosition(state) 50 | remoteKeys?.nextKey?.minus(1) ?: START_SYNTHETIC_PAGE_INDEX 51 | } 52 | LoadType.PREPEND -> { 53 | val remoteKeys = getKeysForFirstItem(state) 54 | // If remoteKeys is null, that means the refresh result is not in the database yet. 55 | val prevKey = remoteKeys?.prevKey 56 | ?: return MediatorResult.Success(endOfPaginationReached = remoteKeys != null) 57 | prevKey 58 | } 59 | LoadType.APPEND -> { 60 | val remoteKeys = getKeysForLastItem(state) 61 | // If remoteKeys is null, that means the refresh result is not in the database yet. 62 | // We can return Success with endOfPaginationReached = false because Paging 63 | // will call this method again if RemoteKeys becomes non-null. 64 | // If remoteKeys is NOT NULL but its nextKey is null, that means we've reached 65 | // the end of pagination for append. 66 | val nextKey = remoteKeys?.nextKey 67 | ?: return MediatorResult.Success(endOfPaginationReached = remoteKeys != null) 68 | 69 | nextKey 70 | } 71 | } 72 | 73 | val limit = state.config.pageSize 74 | val offset = page * limit 75 | 76 | try { 77 | val apiResponse = api.getMyPlaylists(limit, offset) 78 | if (apiResponse is NetworkResponse.Error) { 79 | return MediatorResult.Error(apiResponse.error ?: IOException("Unknown error MyPlaylistsMediator.load():68")) 80 | } 81 | 82 | val successfulResponse = apiResponse as NetworkResponse.Success 83 | val playlists = successfulResponse.body.items 84 | val endOfPaginationReached = playlists.isEmpty() 85 | 86 | db.withTransaction { 87 | 88 | if (loadType == LoadType.REFRESH) { 89 | // if reloading all, clear all from db 90 | db.playlistPageKeysDao().clearKeys() 91 | db.playlistDao().clearAll() 92 | } 93 | 94 | val prevKey = if (page == START_SYNTHETIC_PAGE_INDEX) null else page - 1 95 | val nextKey = if (endOfPaginationReached) null else page + 1 96 | 97 | val keys = playlists.map { 98 | PlaylistPageKeys(playlistId = it.id, prevKey = prevKey, nextKey = nextKey) 99 | } 100 | 101 | db.playlistPageKeysDao().insertAll(keys) 102 | db.playlistDao().insertAll(playlists.map { it.toDomainObject() }) 103 | 104 | } 105 | 106 | return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached) 107 | } catch (e: IOException) { 108 | return MediatorResult.Error(e) 109 | } catch (e: HttpException) { 110 | return MediatorResult.Error(e) 111 | } 112 | } 113 | 114 | private suspend fun getKeysForLastItem(state: PagingState): PlaylistPageKeys? { 115 | // Get the last page that was retrieved, that contained items. 116 | // From that last page, get the last item 117 | return state.pages.lastOrNull { it.data.isNotEmpty() }?.data?.lastOrNull() 118 | ?.let { repo -> 119 | // Get the remote keys of the last item retrieved 120 | db.playlistPageKeysDao().keysByPlaylistId(repo.id) 121 | } 122 | } 123 | 124 | private suspend fun getKeysForFirstItem(state: PagingState): PlaylistPageKeys? { 125 | // Get the first page that was retrieved, that contained items. 126 | // From that first page, get the first item 127 | return state.pages.firstOrNull { it.data.isNotEmpty() }?.data?.firstOrNull() 128 | ?.let { repo -> 129 | // Get the remote keys of the first item retrieved 130 | db.playlistPageKeysDao().keysByPlaylistId(repo.id) 131 | } 132 | } 133 | 134 | private suspend fun getKeysClosestToCurrentPosition(state: PagingState): PlaylistPageKeys? { 135 | // The paging library is trying to load data after the anchor position 136 | // Get the item closest to the anchor position 137 | return state.anchorPosition?.let { position -> 138 | state.closestItemToPosition(position)?.id?.let { repoId -> 139 | db.playlistPageKeysDao().keysByPlaylistId(repoId) 140 | } 141 | } 142 | } 143 | 144 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/playlists/repository/MyPlaylistsRepository.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.playlists.repository 2 | 3 | import androidx.paging.ExperimentalPagingApi 4 | import androidx.paging.Pager 5 | import androidx.paging.PagingConfig 6 | import androidx.paging.PagingData 7 | import kotlinx.coroutines.flow.Flow 8 | import ua.leonidius.beatinspector.data.playlists.domain.PlaylistSearchResult 9 | import ua.leonidius.beatinspector.data.playlists.network.api.MyPlaylistsService 10 | import ua.leonidius.beatinspector.data.shared.db.TracksDatabase 11 | import ua.leonidius.beatinspector.shared.logic.eventbus.EventBus 12 | import ua.leonidius.beatinspector.shared.logic.eventbus.UserLogoutRequestEvent 13 | import javax.inject.Inject 14 | 15 | private const val PAGE_SIZE = 30 16 | 17 | class MyPlaylistsRepository @Inject constructor( 18 | private val api: MyPlaylistsService, 19 | private val db: TracksDatabase, 20 | eventBus: EventBus, 21 | ) { 22 | 23 | // here we will have method to load new data which will empty the db table 24 | // and load new data there. The shit should be 25 | 26 | 27 | init { 28 | // make sure playlists cache is cleared after logging out, 29 | // bc these playlists belong to the user that is logging out 30 | eventBus.subscribe(UserLogoutRequestEvent::class) { 31 | db.playlistPageKeysDao().clearKeys() 32 | db.playlistDao().clearAll() 33 | } 34 | } 35 | 36 | @OptIn(ExperimentalPagingApi::class) 37 | fun getMyPlaylistsFlow(): Flow> { 38 | return Pager( 39 | config = PagingConfig( 40 | pageSize = PAGE_SIZE, 41 | enablePlaceholders = false 42 | ), 43 | remoteMediator = MyPlaylistsMediator(api, db), 44 | pagingSourceFactory = { db.playlistDao().getAll() } 45 | ).flow 46 | } 47 | 48 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/settings/SettingsRepository.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.settings 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 kotlinx.coroutines.flow.map 8 | import ua.leonidius.beatinspector.shared.logic.eventbus.EventBus 9 | import ua.leonidius.beatinspector.shared.logic.eventbus.UserHideExplicitSettingChangeEvent 10 | import ua.leonidius.beatinspector.shared.domain.SettingsState 11 | import javax.inject.Inject 12 | import javax.inject.Named 13 | import javax.inject.Singleton 14 | 15 | @Singleton 16 | class SettingsRepository @Inject constructor( 17 | @Named("general") prefs: DataStore, 18 | eventBus: EventBus, 19 | ) { 20 | 21 | private val hideExplicitKey = booleanPreferencesKey("hide_explicit") 22 | 23 | val settingsFlow = prefs.data.map { 24 | SettingsState( 25 | hideExplicit = it[hideExplicitKey] ?: false 26 | ) 27 | } 28 | 29 | init { 30 | 31 | eventBus.subscribe(UserHideExplicitSettingChangeEvent::class) { event -> 32 | prefs.edit { 33 | it[hideExplicitKey] = event.newValue 34 | } 35 | } 36 | 37 | } 38 | 39 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/shared/BasePagingDataSourceWithTitleCache.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.shared 2 | 3 | import androidx.paging.Pager 4 | import androidx.paging.PagingConfig 5 | import androidx.paging.PagingData 6 | import androidx.paging.PagingSource 7 | import androidx.paging.PagingState 8 | import androidx.paging.cachedIn 9 | import com.haroldadmin.cnradapter.NetworkResponse 10 | import kotlinx.coroutines.CoroutineScope 11 | import kotlinx.coroutines.flow.Flow 12 | import ua.leonidius.beatinspector.data.shared.cache.InMemCache 13 | import ua.leonidius.beatinspector.data.shared.network.dto.ErrorResponse 14 | import ua.leonidius.beatinspector.data.shared.domain.SearchResult 15 | import ua.leonidius.beatinspector.data.shared.network.toUIException 16 | 17 | /** 18 | * @param T - title type (SongSearchResult, PlaylistSearchResult, etc) 19 | * @param D - dto type (...Response) that can be mapped to a list of T 20 | */ 21 | abstract class BasePagingDataSourceWithTitleCache>( 22 | private val service: suspend (limit: Int, offset: Int) -> NetworkResponse, 23 | private val cache: InMemCache, 24 | // private val hideExplicit: () -> Boolean, 25 | private val filter: (suspend (T) -> Boolean)? = null, 26 | ): PagingSource(), PagingDataSource { 27 | 28 | private val itemsPerPage = 50 29 | 30 | override fun getRefreshKey(state: PagingState): Int? { 31 | return state.anchorPosition?.let { anchorPosition -> 32 | val anchorPage = state.closestPageToPosition(anchorPosition) 33 | anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1) 34 | } 35 | } 36 | 37 | override suspend fun load(params: LoadParams): LoadResult { 38 | val page = params.key ?: 1 39 | 40 | val offset = (page - 1) * itemsPerPage 41 | 42 | when (val resp = service(itemsPerPage, offset)) { 43 | is NetworkResponse.Success -> { 44 | 45 | var trackList = resp.body.toDomainObject() 46 | 47 | filter?.let { filter -> 48 | trackList = trackList.filter { filter(it) } 49 | } 50 | 51 | cache.batchAdd(trackList.associateBy { it.id }) 52 | 53 | return LoadResult.Page( 54 | data = trackList, 55 | // prevKey = if (page == 1) null else page - 1, 56 | prevKey = null, 57 | nextKey = if (trackList.isEmpty()) null else page + 1 58 | ) 59 | 60 | } 61 | is NetworkResponse.Error -> { 62 | return LoadResult.Error(resp.toUIException()) 63 | } 64 | } 65 | } 66 | 67 | override fun getFlow(scope: CoroutineScope): Flow> { 68 | return Pager(PagingConfig(pageSize = itemsPerPage)) { 69 | this // todo i think when cache is exhausted and after a network load it should be able to create a new instance 70 | }.flow.cachedIn(scope) 71 | } 72 | 73 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/shared/ListMapper.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.shared 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | interface ListMapper { 7 | 8 | fun toDomainObject(): List 9 | 10 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/shared/Mapper.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.shared 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | interface Mapper { 7 | 8 | fun toDomainObject(): T 9 | 10 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/shared/PagingDataSource.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.shared 2 | 3 | import androidx.paging.PagingData 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.flow.Flow 6 | 7 | interface PagingDataSource { 8 | 9 | fun getFlow(scope: CoroutineScope): Flow> 10 | 11 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/shared/Resource.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.shared 2 | 3 | /** 4 | * A wrapper for a cacheable resource 5 | */ 6 | sealed class Resource { 7 | 8 | data class Error ( 9 | val error: Throwable 10 | ): Resource() 11 | 12 | sealed class Value( 13 | val value: T 14 | ): Resource() 15 | 16 | class Success( 17 | value: T 18 | ): Value(value) 19 | 20 | /** 21 | * Maybe something was loaded only partially, or only 22 | * the cached version is available 23 | */ 24 | class ValueWithError( 25 | value: T, 26 | val error: Throwable 27 | ): Value(value) 28 | 29 | class Loading: Resource() 30 | 31 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/shared/cache/InMemCache.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.shared.cache 2 | 3 | /** 4 | * @param T - type of the data to be cached 5 | * @param I - type of the ID of the data, can be Unit if there's only 1 object to store 6 | */ 7 | interface InMemCache { 8 | 9 | val cache: MutableMap 10 | 11 | operator fun get(id: I): T { 12 | return cache[id] ?: throw Exception("No data in ${this::class.simpleName} for id $id") 13 | } 14 | 15 | operator fun set(id: I, data: T) { 16 | cache[id] = data 17 | } 18 | 19 | fun batchAdd(data: Map) { 20 | cache.putAll(data) 21 | } 22 | 23 | fun has(id: I): Boolean { 24 | return cache.containsKey(id) 25 | } 26 | 27 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/shared/db/TracksDatabase.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.shared.db 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | import ua.leonidius.beatinspector.data.playlists.db.PlaylistDao 6 | import ua.leonidius.beatinspector.data.playlists.db.PlaylistPageKeys 7 | import ua.leonidius.beatinspector.data.playlists.db.PlaylistPageKeysDao 8 | import ua.leonidius.beatinspector.data.playlists.domain.PlaylistSearchResult 9 | import javax.inject.Singleton 10 | 11 | @Database( 12 | entities = [PlaylistSearchResult::class, PlaylistPageKeys::class], 13 | version = 2, 14 | exportSchema = false, 15 | ) 16 | @Singleton 17 | abstract class TracksDatabase : RoomDatabase() { 18 | 19 | abstract fun playlistDao(): PlaylistDao 20 | 21 | abstract fun playlistPageKeysDao(): PlaylistPageKeysDao 22 | 23 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/shared/domain/SearchResult.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.shared.domain 2 | 3 | interface SearchResult { 4 | 5 | val id: String 6 | 7 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/shared/exception/SongDataIOException.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.shared.exception 2 | 3 | import java.io.IOException 4 | 5 | sealed class SongDataIOException( 6 | cause: Throwable? = null 7 | ): IOException(cause) { 8 | 9 | abstract fun toTextDescription(): String 10 | 11 | data class Server( 12 | val code: Int?, 13 | val messageFromApi: String, 14 | val rawResponse: String? = null 15 | ) : SongDataIOException() { 16 | 17 | override fun toTextDescription() = """ 18 | Code: $code 19 | Message from API: $messageFromApi 20 | Raw response: $rawResponse 21 | """.trimIndent() 22 | 23 | } 24 | 25 | data class Network( 26 | val e: Throwable 27 | ): SongDataIOException(e) { 28 | 29 | override fun toTextDescription() = """ 30 | Exception type: ${e::class.java.name} 31 | Exception message: ${e.message} 32 | ${if (e is TokenRefresh) """ 33 | Token refresh exception data: 34 | ${e.toTextDescription()} 35 | """.trimIndent() else ""} 36 | """.trimIndent() 37 | 38 | } 39 | 40 | data class Unknown( 41 | val e: Throwable, 42 | val rawResponse: String? = null 43 | ): SongDataIOException(e) { 44 | 45 | override fun toTextDescription() = """ 46 | Exception type: ${e::class.java.name} 47 | Exception message: ${e.message} 48 | Raw API response: $rawResponse 49 | """.trimIndent() 50 | 51 | } 52 | 53 | /*data class Other( 54 | val messageFromLibrary: String, 55 | val e: Throwable 56 | ): SongDataIOException(e) { 57 | 58 | override fun toTextDescription() = """ 59 | Message from library: $messageFromLibrary 60 | Exception message: ${e.message} 61 | """.trimIndent() 62 | 63 | }*/ 64 | 65 | data class TokenRefresh( 66 | val e: Throwable // (TokenRefreshException) 67 | ): SongDataIOException() { 68 | 69 | override fun toTextDescription() = e.message!! 70 | 71 | } 72 | 73 | object NotLoggedIn: SongDataIOException() { 74 | 75 | override fun toTextDescription() = "User is not logged in. Please relaunch the app to trigger the login sequence. If that doesn't work, clear app data and try again." 76 | } 77 | 78 | object ApiAccessDenied: SongDataIOException() { 79 | 80 | override fun toTextDescription() = "api access denied" 81 | 82 | } 83 | 84 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/shared/network/BaseNetworkDataSource.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.shared.network 2 | 3 | import com.haroldadmin.cnradapter.NetworkResponse 4 | import ua.leonidius.beatinspector.data.shared.network.dto.ErrorResponse 5 | 6 | /** 7 | * The goal of this class is to load data from service and convert it to domain object 8 | * 9 | * @param I - type of the ID of the data to be retrieved 10 | * @param D - type of the DTO that can be converted to the domain object 11 | * @param T - type of the domain object to be returned 12 | */ 13 | open class BaseNetworkDataSource, T>( 14 | private val service: suspend (I) -> NetworkResponse, 15 | ): NetworkDataSource { 16 | 17 | override suspend fun load(id: I): T { 18 | return when (val result = service(id)) { 19 | is NetworkResponse.Success -> { 20 | result.body.toDomainObject() 21 | } 22 | 23 | is NetworkResponse.Error -> { 24 | throw result.toUIException() 25 | } 26 | } 27 | } 28 | 29 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/shared/network/NetworkDataSource.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.shared.network 2 | 3 | /** 4 | * @param I - type of the ID of the data to be retrieved 5 | * @param D - type of the DTO that can be converted to the domain object 6 | * @param T - type of the domain object to be returned 7 | */ 8 | interface NetworkDataSource, T> { 9 | 10 | suspend fun load(id: I): T 11 | 12 | 13 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/shared/network/NetworkResponseExt.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.shared.network 2 | 3 | import com.haroldadmin.cnradapter.NetworkResponse 4 | import ua.leonidius.beatinspector.data.shared.exception.SongDataIOException 5 | 6 | fun NetworkResponse.Error.toUIException(): SongDataIOException { 7 | if (this.error is SongDataIOException) { 8 | return this.error as SongDataIOException // todo: we should clearly separate infrastructure layer exceptions, like api access blocked, from the rest 9 | } 10 | 11 | 12 | return when (this) { 13 | is NetworkResponse.ServerError -> { 14 | SongDataIOException.Server( 15 | this.code, this.body?.message ?: "< No response body >", 16 | /*this.response?.raw()?.body?.string()*/"extraction not implemented" 17 | ) 18 | } 19 | is NetworkResponse.NetworkError -> { 20 | SongDataIOException.Network(this.error) 21 | } 22 | is NetworkResponse.UnknownError -> { 23 | SongDataIOException.Unknown( 24 | this.error, /*this.response?.raw()?.body?.string()*/"extraction not implemented" 25 | ) 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/shared/network/dto/ErrorResponse.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.shared.network.dto 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class ErrorResponse( 7 | val status: Int, 8 | val message: String, 9 | ) 10 | -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/shared/network/dto/ImageDto.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.shared.network.dto 2 | 3 | import androidx.annotation.Keep 4 | 5 | /** 6 | * Spotify "ImageObject" 7 | */ 8 | @Keep 9 | data class ImageDto( 10 | val url: String, 11 | val height: Int, 12 | val width: Int, 13 | ) -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/shared/repository/BaseBasicRepository.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.shared.repository 2 | 3 | import kotlinx.coroutines.CoroutineDispatcher 4 | import kotlinx.coroutines.launch 5 | import kotlinx.coroutines.withContext 6 | import ua.leonidius.beatinspector.data.shared.cache.InMemCache 7 | import ua.leonidius.beatinspector.data.shared.network.NetworkDataSource 8 | import ua.leonidius.beatinspector.data.shared.Mapper 9 | 10 | /** 11 | * @param T - type of the data to be returned (a single object, not a list, i think) 12 | * @param D - type of the DTO that can be converted to the domain object 13 | * @param I - type of the ID of the data, can be Unit if there's only 1 object, like account details 14 | */ 15 | abstract class BaseBasicRepository, T>( 16 | private val cache: InMemCache, 17 | private val networkDataSource: NetworkDataSource, 18 | private val ioDispatcher: CoroutineDispatcher, 19 | ): BasicRepository { 20 | 21 | override suspend fun get(id: I): T = withContext(ioDispatcher) { 22 | if (cache.has(id)) { 23 | return@withContext cache[id] 24 | } 25 | 26 | val data = networkDataSource.load(id) 27 | 28 | launch { 29 | cache[id] = data 30 | } 31 | 32 | return@withContext data 33 | } 34 | 35 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/shared/repository/BaseTrackListPagingRepository.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.shared.repository 2 | 3 | import androidx.paging.PagingData 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.flow.Flow 6 | import kotlinx.coroutines.flow.map 7 | import ua.leonidius.beatinspector.data.shared.BasePagingDataSourceWithTitleCache 8 | import ua.leonidius.beatinspector.data.shared.ListMapper 9 | import ua.leonidius.beatinspector.data.tracks.lists.BaseTrackPagingDataSource 10 | import ua.leonidius.beatinspector.data.tracks.lists.liked.network.dto.LikedTracksResponse 11 | import ua.leonidius.beatinspector.data.tracks.shared.domain.SongSearchResult 12 | import ua.leonidius.beatinspector.shared.domain.SettingsState 13 | 14 | /** 15 | * @param P - PagingSource type 16 | */ 17 | abstract class BaseTrackListPagingRepository { 18 | 19 | abstract val items: Flow> 20 | 21 | protected abstract fun createPagingSource(): 22 | BaseTrackPagingDataSource 23 | 24 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/shared/repository/BasicRepository.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.shared.repository 2 | 3 | interface BasicRepository { 4 | 5 | suspend fun get(id: I): T 6 | 7 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/details/cache/FullTrackDetailsCacheDataSource.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.details.cache 2 | 3 | import ua.leonidius.beatinspector.data.tracks.details.domain.Song 4 | import javax.inject.Inject 5 | import javax.inject.Singleton 6 | 7 | @Singleton 8 | class FullTrackDetailsCacheDataSource @Inject constructor() { 9 | 10 | // todo: proper disk cache 11 | 12 | private val cache = mutableMapOf() 13 | 14 | fun updateCache(song: Song) { 15 | cache[song.id] = song 16 | } 17 | 18 | fun getFromCache(id: String): Song? { 19 | return cache[id] 20 | } 21 | 22 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/details/domain/Song.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.details.domain 2 | 3 | data class Song( 4 | val id: String, 5 | val name: String, 6 | val artist: String, 7 | 8 | val duration: Double, 9 | val loudness: Double, 10 | val bpm: Double, 11 | val bpmConfidence: Double, 12 | val timeSignature: Int, // over 4 13 | val timeSignatureConfidence: Double, 14 | val key: String, // todo: enum 15 | val keyConfidence: Double, 16 | val modeConfidence: Double, 17 | val genres: List = listOf(), 18 | val albumArtUrl: String?, 19 | ) 20 | -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/details/network/api/ArtistsApi.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.details.network.api 2 | 3 | import com.haroldadmin.cnradapter.NetworkResponse 4 | import retrofit2.http.GET 5 | import retrofit2.http.Query 6 | import ua.leonidius.beatinspector.data.shared.network.dto.ErrorResponse 7 | import ua.leonidius.beatinspector.data.tracks.details.network.dto.MultipleArtistsResponse 8 | 9 | interface ArtistsApi { 10 | 11 | @GET("artists") 12 | suspend fun getArtists( 13 | @Query("ids") ids: String 14 | ): NetworkResponse 15 | 16 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/details/network/api/TrackAudioAnalysisApi.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.details.network.api 2 | 3 | import com.haroldadmin.cnradapter.NetworkResponse 4 | import retrofit2.http.GET 5 | import retrofit2.http.Path 6 | import ua.leonidius.beatinspector.data.shared.network.dto.ErrorResponse 7 | import ua.leonidius.beatinspector.data.tracks.details.network.dto.TrackAudioAnalysisResponse 8 | 9 | interface TrackAudioAnalysisApi { 10 | 11 | @GET("audio-analysis/{id}") 12 | suspend fun getTrackAudioAnalysis( 13 | @Path("id") trackId: String 14 | ): NetworkResponse 15 | 16 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/details/network/dto/FullArtistDto.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.details.network.dto 2 | 3 | import androidx.annotation.Keep 4 | 5 | /** 6 | * An artist DTO that includes all info, including genres. Spotify search API does 7 | * not return genres in the artist objects, this is way there are two Artist DTOs 8 | */ 9 | @Keep 10 | data class FullArtistDto( 11 | val id: String, 12 | val name: String, 13 | val genres: List 14 | ) -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/details/network/dto/MultipleArtistsResponse.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.details.network.dto 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class MultipleArtistsResponse( 7 | val artists: List 8 | ) -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/details/network/dto/TrackAudioAnalysisDto.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.details.network.dto 2 | 3 | import androidx.annotation.Keep 4 | import com.google.gson.annotations.SerializedName 5 | 6 | @Keep 7 | data class TrackAudioAnalysisDto( 8 | val duration: Double, 9 | // todo: display loudness in UI? is it LUFS? 10 | val loudness: Double, // db but which kind of db? between -60 and 0 11 | val tempo: Double, 12 | @SerializedName("tempo_confidence") 13 | val tempoConfidence: Double, // from 0.0 to 1.0 14 | 15 | 16 | @SerializedName("time_signature") 17 | val timeSignature: Int, /* An estimated time signature. The time signature (meter) is a notational convention to specify how many beats are in each bar (or measure). The time signature ranges from 3 to 7 indicating time signatures of "3/4", to "7/4". 18 | Range: 3 - 7 19 | Example: 4 */ 20 | 21 | @SerializedName("time_signature_confidence") 22 | val timeSignatureConfidence: Double, 23 | 24 | val key: Int, /* The key the track is in. Integers map to pitches using standard Pitch Class notation. E.g. 0 = C, 1 = C♯/D♭, 2 = D, and so on. If no key was detected, the value is -1. 25 | Range: -1 - 11 26 | Example: 9 27 | https://en.wikipedia.org/wiki/Pitch_class 28 | */ 29 | 30 | @SerializedName("key_confidence") 31 | val keyConfidence: Double, 32 | 33 | val mode: Int, // major =1, minor = 0 34 | 35 | @SerializedName("mode_confidence") 36 | val modeConfidence: Double, 37 | 38 | 39 | @SerializedName("rhythmstring") 40 | val rhythmString: String, // todo: learn what this is and if it is useful to analyse percussion patterns 41 | 42 | @SerializedName("rhythm_version") 43 | val rhythmVersion: Double, 44 | 45 | 46 | // todo: display by sections ("sections") 47 | // val sections: List
, 48 | ) -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/details/network/dto/TrackAudioAnalysisResponse.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.details.network.dto 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class TrackAudioAnalysisResponse( 7 | val track: TrackAudioAnalysisDto, 8 | ) 9 | -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/details/repository/TrackDetailsRepository.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.details.repository 2 | 3 | import ua.leonidius.beatinspector.data.tracks.details.domain.Song 4 | 5 | interface TrackDetailsRepository { 6 | 7 | suspend fun getFullDetails(id: String): Song 8 | 9 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/details/repository/TrackDetailsRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.details.repository 2 | 3 | import com.haroldadmin.cnradapter.NetworkResponse 4 | import kotlinx.coroutines.CoroutineDispatcher 5 | import kotlinx.coroutines.async 6 | import kotlinx.coroutines.withContext 7 | import ua.leonidius.beatinspector.data.tracks.details.cache.FullTrackDetailsCacheDataSource 8 | import ua.leonidius.beatinspector.data.tracks.details.domain.Song 9 | import ua.leonidius.beatinspector.data.tracks.details.network.api.ArtistsApi 10 | import ua.leonidius.beatinspector.data.tracks.details.network.api.TrackAudioAnalysisApi 11 | import ua.leonidius.beatinspector.data.tracks.search.repository.SearchRepository 12 | import ua.leonidius.beatinspector.data.shared.network.toUIException 13 | import javax.inject.Inject 14 | import javax.inject.Named 15 | 16 | class TrackDetailsRepositoryImpl @Inject constructor( 17 | private val trackDetailsCacheDataSource: FullTrackDetailsCacheDataSource, 18 | private val searchRepository: SearchRepository, // for title and artists 19 | private val artistsApi: ArtistsApi, 20 | private val audioAnalysisService: TrackAudioAnalysisApi, 21 | @Named("io") private val ioDispatcher: CoroutineDispatcher, 22 | ): TrackDetailsRepository { 23 | 24 | override suspend fun getFullDetails(id: String): Song = withContext(ioDispatcher) { 25 | trackDetailsCacheDataSource.getFromCache(id) 26 | ?.let { return@withContext it } 27 | 28 | val baseInfo = searchRepository.getById(id) 29 | 30 | val trackAnalysisDeferredResponse = async { 31 | when (val response = audioAnalysisService.getTrackAudioAnalysis(baseInfo.id)) { 32 | is NetworkResponse.Success -> Result.success(response.body.track) 33 | 34 | is NetworkResponse.Error -> Result.failure(response.toUIException()) 35 | } 36 | } 37 | 38 | val genresDeferredResponse = async { 39 | when (val response = artistsApi.getArtists(baseInfo.artists.joinToString(",") { it.id })) { 40 | is NetworkResponse.Success -> Result.success( 41 | response.body.artists.map { it.genres }.flatten().distinct()) 42 | 43 | is NetworkResponse.Error -> Result.failure(response.toUIException()) 44 | // ??? throw response.toUIException() 45 | } 46 | } 47 | 48 | val trackAnalysis = trackAnalysisDeferredResponse.await().getOrThrow() 49 | 50 | // if genres request fails and we returned an empty list instead, 51 | // if would be cached and we will never get those genres at all, 52 | // unless we clear the cache, which is not great, so we throw here 53 | val genres = genresDeferredResponse.await().getOrThrow() 54 | 55 | 56 | return@withContext assembleTrackDomainObject( 57 | baseInfo, 58 | trackAnalysis, 59 | genres 60 | ).also { trackDetailsCacheDataSource.updateCache(it) } 61 | } 62 | 63 | private fun assembleTrackDomainObject( 64 | baseInfo: ua.leonidius.beatinspector.data.tracks.shared.domain.SongSearchResult, 65 | details: ua.leonidius.beatinspector.data.tracks.details.network.dto.TrackAudioAnalysisDto, 66 | genres: List, 67 | ): Song { 68 | 69 | return Song( 70 | id = baseInfo.id, 71 | name = baseInfo.name, 72 | artist = baseInfo.artists.joinToString(", ") { it.name }, // todo: don't, just return as is and let ui layer handle it 73 | duration = details.duration, 74 | loudness = details.loudness, 75 | bpm = details.tempo, 76 | bpmConfidence = details.tempoConfidence, 77 | timeSignature = details.timeSignature, 78 | timeSignatureConfidence = details.timeSignatureConfidence, 79 | key = getKeyStringFromSpotifyValue(details.key, details.mode), 80 | keyConfidence = details.keyConfidence, 81 | modeConfidence = details.modeConfidence, 82 | genres = genres, 83 | albumArtUrl = baseInfo.imageUrl, 84 | ) 85 | } 86 | 87 | private fun getKeyStringFromSpotifyValue(keyInt: Int, modeInt: Int): String { 88 | val key = when (keyInt) { 89 | 0 -> "C" 90 | 1 -> "C♯/D♭" 91 | 2 -> "D" 92 | 3 -> "D♯/E♭" 93 | 4 -> "E" 94 | 5 -> "F" 95 | 6 -> "F♯/G♭" 96 | 7 -> "G" 97 | 8 -> "G♯/A♭" 98 | 9 -> "A" 99 | 10 -> "A♯/B♭" 100 | 11 -> "B" 101 | else -> "?" 102 | } 103 | 104 | val mode = when (modeInt) { 105 | 1 -> "Maj" 106 | 0 -> "Min" 107 | else -> "?" 108 | 109 | } 110 | 111 | return "$key $mode" 112 | } 113 | 114 | 115 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/lists/BaseTrackPagingDataSource.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.lists 2 | 3 | import com.haroldadmin.cnradapter.NetworkResponse 4 | import kotlinx.coroutines.flow.Flow 5 | import kotlinx.coroutines.flow.first 6 | import ua.leonidius.beatinspector.data.shared.cache.InMemCache 7 | import ua.leonidius.beatinspector.data.shared.network.dto.ErrorResponse 8 | import ua.leonidius.beatinspector.data.shared.ListMapper 9 | import ua.leonidius.beatinspector.data.tracks.shared.domain.SongSearchResult 10 | import ua.leonidius.beatinspector.data.shared.BasePagingDataSourceWithTitleCache 11 | 12 | open class BaseTrackPagingDataSource>( 13 | api: suspend (limit: Int, offset: Int) -> NetworkResponse, 14 | cache: InMemCache, 15 | hideExplicit: Flow, 16 | ): BasePagingDataSourceWithTitleCache( 17 | api, cache, 18 | filter = { song -> !hideExplicit.first() || !song.isExplicit } 19 | ) -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/lists/liked/SavedTracksNetworkPagingSource.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.lists.liked 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.coroutines.flow.map 5 | import ua.leonidius.beatinspector.data.tracks.lists.liked.network.dto.LikedTracksResponse 6 | import ua.leonidius.beatinspector.data.tracks.lists.liked.network.api.LikedTracksApi 7 | import ua.leonidius.beatinspector.data.tracks.lists.BaseTrackPagingDataSource 8 | import ua.leonidius.beatinspector.data.tracks.shared.cache.SongTitlesInMemCache 9 | import ua.leonidius.beatinspector.shared.domain.SettingsState 10 | import javax.inject.Inject 11 | 12 | class SavedTracksNetworkPagingSource @Inject constructor( 13 | service: LikedTracksApi, 14 | searchCache: SongTitlesInMemCache, 15 | settingsFlow: Flow, 16 | ): BaseTrackPagingDataSource( 17 | service::getSavedTracks, 18 | searchCache, 19 | settingsFlow.map { it.hideExplicit }, 20 | ) -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/lists/liked/network/api/LikedTracksApi.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.lists.liked.network.api 2 | 3 | import com.haroldadmin.cnradapter.NetworkResponse 4 | import retrofit2.http.GET 5 | import retrofit2.http.Query 6 | import ua.leonidius.beatinspector.data.shared.network.dto.ErrorResponse 7 | import ua.leonidius.beatinspector.data.tracks.lists.liked.network.dto.LikedTracksResponse 8 | 9 | interface LikedTracksApi { 10 | 11 | @GET("me/tracks") 12 | suspend fun getSavedTracks( 13 | @Query("limit") limit: Int, 14 | @Query("offset") offset: Int 15 | ): NetworkResponse 16 | 17 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/lists/liked/network/dto/LikedTrackDto.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.lists.liked.network.dto 2 | 3 | import androidx.annotation.Keep 4 | import com.google.gson.annotations.SerializedName 5 | import ua.leonidius.beatinspector.data.shared.Mapper 6 | import ua.leonidius.beatinspector.data.tracks.shared.domain.SongSearchResult 7 | import ua.leonidius.beatinspector.data.tracks.shared.network.dto.TrackDto 8 | 9 | @Keep 10 | data class LikedTrackDto( 11 | 12 | @SerializedName("added_at") 13 | val addedAtDateString: String, // we can sort these strings and that would be equivalent to sorting by date 14 | 15 | val track: TrackDto, 16 | ): Mapper { 17 | 18 | override fun toDomainObject(): ua.leonidius.beatinspector.data.tracks.shared.domain.SongSearchResult { 19 | return track.toDomainObject() 20 | } 21 | 22 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/lists/liked/network/dto/LikedTracksResponse.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.lists.liked.network.dto 2 | 3 | import androidx.annotation.Keep 4 | import com.google.gson.annotations.SerializedName 5 | 6 | @Keep 7 | data class LikedTracksResponse( 8 | 9 | @SerializedName("previous") 10 | val previousUrl: String?, 11 | 12 | @SerializedName("next") 13 | val nextUrl: String?, 14 | 15 | @SerializedName("total") 16 | val numberOfItems: Int, 17 | 18 | val offset: Int, // same as in request 19 | val limit: Int, 20 | 21 | val items: List 22 | ): ua.leonidius.beatinspector.data.shared.ListMapper { 23 | 24 | override fun toDomainObject(): List { 25 | return items.map { it.toDomainObject() } 26 | } 27 | 28 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/lists/liked/repository/LikedTracksRepository.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.lists.liked.repository 2 | 3 | import androidx.paging.Pager 4 | import androidx.paging.PagingConfig 5 | import androidx.paging.filter 6 | import kotlinx.coroutines.flow.Flow 7 | import kotlinx.coroutines.flow.first 8 | import kotlinx.coroutines.flow.map 9 | import ua.leonidius.beatinspector.data.shared.repository.BaseTrackListPagingRepository 10 | import ua.leonidius.beatinspector.data.tracks.lists.liked.SavedTracksNetworkPagingSource 11 | import ua.leonidius.beatinspector.data.tracks.lists.liked.network.api.LikedTracksApi 12 | import ua.leonidius.beatinspector.data.tracks.shared.cache.SongTitlesInMemCache 13 | import ua.leonidius.beatinspector.shared.domain.SettingsState 14 | import javax.inject.Inject 15 | 16 | private const val PAGE_SIZE = 50 17 | 18 | class LikedTracksRepository @Inject constructor( 19 | private val api: LikedTracksApi, 20 | private val cache: SongTitlesInMemCache, 21 | private val settingsFlow: Flow, 22 | ): BaseTrackListPagingRepository() { 23 | override protected fun createPagingSource() = SavedTracksNetworkPagingSource( 24 | api, cache, settingsFlow // , PAGE_SIZE 25 | ) 26 | 27 | override val items = Pager( 28 | config = PagingConfig(pageSize = PAGE_SIZE), 29 | pagingSourceFactory = { createPagingSource() } 30 | ).flow.map { pagingData -> 31 | if (settingsFlow.first().hideExplicit) { 32 | pagingData.filter { !it.isExplicit } 33 | } else { 34 | pagingData 35 | } 36 | } 37 | 38 | /* 39 | * the "map" call here is business logic (filtering out explicit material) 40 | */ 41 | 42 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/lists/playlist/PlaylistPagingDataSource.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.lists.playlist 2 | 3 | import androidx.lifecycle.SavedStateHandle 4 | import kotlinx.coroutines.flow.Flow 5 | import kotlinx.coroutines.flow.map 6 | import ua.leonidius.beatinspector.data.tracks.lists.BaseTrackPagingDataSource 7 | import ua.leonidius.beatinspector.data.tracks.lists.playlist.network.api.PlaylistApi 8 | import ua.leonidius.beatinspector.data.tracks.lists.playlist.network.dto.PlaylistResponse 9 | import ua.leonidius.beatinspector.data.tracks.shared.cache.SongTitlesInMemCache 10 | import ua.leonidius.beatinspector.shared.domain.SettingsState 11 | import javax.inject.Inject 12 | 13 | /** 14 | * Contents of a playlist 15 | */ 16 | class PlaylistPagingDataSource @Inject constructor( 17 | savedStateHandle: SavedStateHandle, 18 | api: PlaylistApi, 19 | searchCache: SongTitlesInMemCache, 20 | settingsFlow: Flow, 21 | ): BaseTrackPagingDataSource( 22 | { limit, offset -> api.getTracks(savedStateHandle.get("playlistId")!!, limit, offset) }, 23 | searchCache, 24 | settingsFlow.map { it.hideExplicit }, 25 | ) -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/lists/playlist/network/api/PlaylistApi.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.lists.playlist.network.api 2 | 3 | import com.haroldadmin.cnradapter.NetworkResponse 4 | import retrofit2.http.GET 5 | import retrofit2.http.Path 6 | import retrofit2.http.Query 7 | import ua.leonidius.beatinspector.data.shared.network.dto.ErrorResponse 8 | import ua.leonidius.beatinspector.data.tracks.lists.playlist.network.dto.PlaylistResponse 9 | 10 | interface PlaylistApi { 11 | 12 | @GET("playlists/{id}/tracks") 13 | suspend fun getTracks( 14 | @Path("id") playlistId: String, 15 | @Query("limit") limit: Int, 16 | @Query("offset") offset: Int, 17 | @Query("additional_types") additionalTypes: String = "track", 18 | @Query("fields") fields: String = "items(track(id,name,type,artists(id,name),album(images)", 19 | ): NetworkResponse 20 | 21 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/lists/playlist/network/dto/PlaylistResponse.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.lists.playlist.network.dto 2 | 3 | import androidx.annotation.Keep 4 | import ua.leonidius.beatinspector.data.shared.ListMapper 5 | import ua.leonidius.beatinspector.data.tracks.shared.domain.SongSearchResult 6 | import ua.leonidius.beatinspector.data.tracks.shared.network.dto.AlbumDto 7 | import ua.leonidius.beatinspector.data.tracks.shared.network.dto.ArtistDto 8 | import ua.leonidius.beatinspector.data.tracks.shared.network.dto.TrackDto 9 | 10 | @Keep 11 | data class PlaylistResponse( 12 | val items: List 13 | ): ListMapper { 14 | 15 | @Keep 16 | data class PlaylistTrackDto( 17 | val track: PlaylistTrackOrPodcastEpDto 18 | ) { 19 | 20 | @Keep 21 | data class PlaylistTrackOrPodcastEpDto( 22 | val id: String, 23 | val name: String, 24 | val type: String, // "track" or "episode" 25 | val artists: List?, // null if this is a podcast episode 26 | val album: AlbumDto?, // null if this is a podcast episode 27 | val explicit: Boolean, 28 | ) { 29 | fun isTrack() = type == "track" 30 | } 31 | 32 | fun isTrack() = track.isTrack() 33 | 34 | } 35 | 36 | private fun onlyTracks() = items.filter { it.isTrack() }.map { 37 | TrackDto( 38 | id = it.track.id, 39 | name = it.track.name, 40 | artists = it.track.artists!!, 41 | album = it.track.album!!, 42 | explicit = it.track.explicit, 43 | ) 44 | } 45 | 46 | override fun toDomainObject(): List { 47 | return onlyTracks().map { 48 | it.toDomainObject() 49 | } 50 | } 51 | 52 | } 53 | 54 | 55 | -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/lists/recent/RecentlyPlayedDataSource.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.lists.recent 2 | 3 | import androidx.paging.Pager 4 | import androidx.paging.PagingConfig 5 | import androidx.paging.PagingData 6 | import androidx.paging.PagingSource 7 | import androidx.paging.PagingState 8 | import androidx.paging.cachedIn 9 | import com.haroldadmin.cnradapter.NetworkResponse 10 | import kotlinx.coroutines.CoroutineScope 11 | import kotlinx.coroutines.flow.Flow 12 | import kotlinx.coroutines.flow.first 13 | import ua.leonidius.beatinspector.data.shared.PagingDataSource 14 | import ua.leonidius.beatinspector.data.tracks.shared.cache.SongTitlesInMemCache 15 | import ua.leonidius.beatinspector.data.tracks.lists.recent.network.api.RecentlyPlayedApi 16 | import ua.leonidius.beatinspector.data.tracks.shared.domain.SongSearchResult 17 | import ua.leonidius.beatinspector.data.shared.network.toUIException 18 | import ua.leonidius.beatinspector.shared.domain.SettingsState 19 | import javax.inject.Inject 20 | 21 | class RecentlyPlayedDataSource @Inject constructor( 22 | private val service: RecentlyPlayedApi, 23 | private val searchCache: SongTitlesInMemCache, 24 | private val settingsFlow: Flow, 25 | ): PagingSource(), PagingDataSource { 26 | 27 | private val itemsPerPage = 50 28 | override fun getRefreshKey(state: PagingState): String? { 29 | /*return state.anchorPosition?.let { anchorPosition -> 30 | val anchorPage = state.closestPageToPosition(anchorPosition) 31 | anchorPage?.prevKey ?: anchorPage?.nextKey // or null 32 | }*/ 33 | return null 34 | } 35 | 36 | override suspend fun load(params: LoadParams): LoadResult { 37 | val page = params.key // could be null 38 | 39 | when (val resp = service.getRecentlyPlayed(itemsPerPage, before = page)) { 40 | is NetworkResponse.Success -> { 41 | 42 | val dto = resp.body 43 | var trackList = dto.toDomainObject() 44 | 45 | 46 | if (settingsFlow.first().hideExplicit) { 47 | trackList = trackList.filter { !it.isExplicit } 48 | } 49 | 50 | searchCache.batchAdd(trackList.associateBy { it.id }) 51 | return LoadResult.Page( 52 | data = trackList, 53 | prevKey = /*dto.cursors?.before*/ null, // todo fix paging 54 | nextKey = /*dto.cursors?.after*/ null, 55 | ) 56 | 57 | } 58 | is NetworkResponse.Error -> { 59 | return LoadResult.Error(resp.toUIException()) 60 | } 61 | } 62 | } 63 | 64 | override fun getFlow(scope: CoroutineScope): Flow> { 65 | return Pager(PagingConfig(pageSize = itemsPerPage)) { 66 | this 67 | }.flow.cachedIn(scope) 68 | } 69 | 70 | 71 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/lists/recent/network/api/RecentlyPlayedApi.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.lists.recent.network.api 2 | 3 | import com.haroldadmin.cnradapter.NetworkResponse 4 | import retrofit2.http.GET 5 | import retrofit2.http.Query 6 | import ua.leonidius.beatinspector.data.shared.network.dto.ErrorResponse 7 | import ua.leonidius.beatinspector.data.tracks.lists.recent.network.dto.RecentlyPlayedResponse 8 | 9 | interface RecentlyPlayedApi { 10 | 11 | @GET("me/player/recently-played") 12 | suspend fun getRecentlyPlayed( 13 | @Query("limit") limit: Int = 50, 14 | @Query("after") after: String? = null, 15 | @Query("before") before: String? = null, 16 | ): NetworkResponse 17 | 18 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/lists/recent/network/dto/RecentlyPlayedResponse.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.lists.recent.network.dto 2 | 3 | import androidx.annotation.Keep 4 | import ua.leonidius.beatinspector.data.shared.ListMapper 5 | import ua.leonidius.beatinspector.data.tracks.shared.domain.SongSearchResult 6 | import ua.leonidius.beatinspector.data.tracks.shared.network.dto.TrackDto 7 | 8 | @Keep 9 | data class RecentlyPlayedResponse( 10 | val cursors: Cursors?, 11 | val items: List, 12 | ): ListMapper { 13 | 14 | data class Cursors( 15 | val after: String, 16 | val before: String, 17 | ) 18 | 19 | data class PlayHistoryDto( 20 | val track: TrackDto, 21 | ): ua.leonidius.beatinspector.data.shared.Mapper { 22 | 23 | override fun toDomainObject() = track.toDomainObject() 24 | 25 | } 26 | 27 | override fun toDomainObject() = items.map { it.track.toDomainObject() } 28 | 29 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/lists/shared/db/SongInPlaylist.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.lists.shared.db 2 | 3 | import androidx.room.Entity 4 | 5 | /** 6 | * represents a many-to-many relationship between songs and playlists. 7 | * Playlists also include top tracks (id 'top'), liked (id 'liked'), 8 | * recent (id 'recent'). The records from this table can be deleted and 9 | * re-fetched, because playlists can change. However, the songs themselves 10 | */ 11 | @Entity 12 | class SongInPlaylist { 13 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/lists/shared/db/SongInPlaylistPagingKeys.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.lists.shared.db 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | 6 | /** 7 | * this represents paging keys for a list of tracks that belong to 8 | * a playlist (or liked tracks, or top tracks, etc). All of playlists 9 | * are using this one table, differentiated by playlist_id. DAOs should 10 | * filter all actions by playlistId. 11 | */ 12 | @Entity( 13 | primaryKeys = ["playlist_id", "song_id"] 14 | ) 15 | data class SongInPlaylistPagingKeys( 16 | @ColumnInfo(name = "playlist_id") val playlistId: String, 17 | @ColumnInfo(name = "song_id") val songId: String, 18 | 19 | @ColumnInfo(name = "prev_key") val prevKey: Int?, 20 | @ColumnInfo(name = "next_key") val nextKey: Int?, 21 | 22 | @ColumnInfo(name = "cached_at") 23 | val cachedAt: Long = System.currentTimeMillis() 24 | ) 25 | -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/lists/top/TopTracksPagingDataSource.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.lists.top 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.coroutines.flow.map 5 | import ua.leonidius.beatinspector.data.tracks.shared.cache.SongTitlesInMemCache 6 | import ua.leonidius.beatinspector.data.tracks.lists.top.network.dto.TopTracksResponse 7 | import ua.leonidius.beatinspector.data.tracks.lists.top.network.api.TopTracksApi 8 | import ua.leonidius.beatinspector.data.tracks.lists.BaseTrackPagingDataSource 9 | import ua.leonidius.beatinspector.shared.domain.SettingsState 10 | import javax.inject.Inject 11 | 12 | class TopTracksPagingDataSource @Inject constructor( 13 | topTracksApi: TopTracksApi, 14 | cache: SongTitlesInMemCache, 15 | settingsFlow: Flow, 16 | ): BaseTrackPagingDataSource( 17 | topTracksApi::getTopTracks, 18 | cache, 19 | settingsFlow.map { it.hideExplicit }, 20 | ) -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/lists/top/network/api/TopTracksApi.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.lists.top.network.api 2 | 3 | import com.haroldadmin.cnradapter.NetworkResponse 4 | import retrofit2.http.GET 5 | import retrofit2.http.Query 6 | import ua.leonidius.beatinspector.data.shared.network.dto.ErrorResponse 7 | import ua.leonidius.beatinspector.data.tracks.lists.top.network.dto.TopTracksResponse 8 | 9 | interface TopTracksApi { 10 | 11 | @GET("me/top/tracks") 12 | suspend fun getTopTracks( 13 | @Query("limit") limit: Int, 14 | @Query("offset") offset: Int 15 | ): NetworkResponse 16 | 17 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/lists/top/network/dto/TopTracksResponse.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.lists.top.network.dto 2 | 3 | import androidx.annotation.Keep 4 | import ua.leonidius.beatinspector.data.shared.ListMapper 5 | import ua.leonidius.beatinspector.data.tracks.shared.domain.SongSearchResult 6 | import ua.leonidius.beatinspector.data.tracks.shared.network.dto.TrackDto 7 | 8 | @Keep 9 | data class TopTracksResponse( 10 | val items: List 11 | ): ListMapper { 12 | 13 | override fun toDomainObject(): List { 14 | return items.map { it.toDomainObject() } 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/search/db/SearchResult.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.search.db 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.PrimaryKey 6 | 7 | /** 8 | * this table backs a list of search results. 9 | * although it's possible that is doesn't make any fucking sense to store 10 | * Tracks (track shelf data) in their own table, because tracks in this app 11 | * don't exist all by themselves, actually they can only exist as a search 12 | * result or a member of a playlist (although theoretically we can add the 13 | * ability to open spotify links in this app, such as track links, and taking 14 | * ppl straight to the track details screen, which would justify caching 15 | * tracks as a separate table, i suppose (but even then the same info 16 | * could be embedded into track audio analysis)). 17 | * 18 | * the downsides of having this separate table are: 19 | * 1) the fact that we will store titles and cover urls for all tracks that 20 | * the user has ever seen in the app (incl. in search results), even though 21 | * for a lot of tracks the user will never see them again. They will become 22 | * dangling records without ownership (neither any playlist nor the search 23 | * owns them). This will be a waste of memory and hard to deal with 24 | * 25 | * 2) there was some other downside but i don't remember what is was 26 | * 27 | * Overall, we should delete the TrackShelfInfo table and store the song 28 | * titles/cover urls embedded right into the search results and playlist 29 | * contents tables. 30 | * 31 | * However, the Artists and their genres should be stored in a separate table, because they 32 | * can be shared between tracks, and old artist's data can be reused for 33 | * new tracks. So we will have to join the artists table to Searchresult 34 | * 35 | * 36 | * 37 | * 38 | * Alternative: we could try using ON DELETE RESTRICT and CASCADE combinator 39 | * that tries to delete associated TrackShelfInfo when you delete a 40 | * SearchResult or a PlaylistContentItem, but fails to do so if there are other 41 | * cached items referencing it. todo: research if this is possible 42 | * 43 | * the upside of this is that it will be possible to imeplement a feature 44 | * that takes the user to the track details screen directly by link, should 45 | * i choose to implement it/ 46 | * 47 | * However, first we should research if it is possible in room to insert 48 | * new data together with JOINED tables (e.g. SearchResult + TrackShelfInfo + Artist + ArtistGenre) 49 | */ 50 | @Entity(tableName = "search_results") 51 | data class SearchResult( 52 | 53 | @PrimaryKey @ColumnInfo(name = "track_id") val trackId: String 54 | 55 | ) 56 | -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/search/network/SearchNetworkDataSource.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.search.network 2 | 3 | import ua.leonidius.beatinspector.data.shared.network.BaseNetworkDataSource 4 | import ua.leonidius.beatinspector.data.tracks.search.network.dto.SearchResultsResponse 5 | import ua.leonidius.beatinspector.data.tracks.search.network.api.SearchApi 6 | import ua.leonidius.beatinspector.data.tracks.shared.domain.SongSearchResult 7 | import javax.inject.Inject 8 | 9 | class SearchNetworkDataSource @Inject constructor( 10 | private val searchService: SearchApi 11 | ): BaseNetworkDataSource>( 12 | 13 | service = { query -> searchService.search(query) } 14 | 15 | ) -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/search/network/api/SearchApi.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.search.network.api 2 | 3 | import com.haroldadmin.cnradapter.NetworkResponse 4 | import retrofit2.http.GET 5 | import retrofit2.http.Query 6 | import ua.leonidius.beatinspector.data.shared.network.dto.ErrorResponse 7 | import ua.leonidius.beatinspector.data.tracks.search.network.dto.SearchResultsResponse 8 | 9 | interface SearchApi { 10 | 11 | @GET("search?type=track") 12 | suspend fun search( 13 | @Query("q") q: String 14 | ): NetworkResponse 15 | 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/search/network/dto/SearchResultsResponse.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.search.network.dto 2 | 3 | import androidx.annotation.Keep 4 | import ua.leonidius.beatinspector.data.shared.ListMapper 5 | import ua.leonidius.beatinspector.data.shared.Mapper 6 | import ua.leonidius.beatinspector.data.tracks.shared.domain.SongSearchResult 7 | import ua.leonidius.beatinspector.data.tracks.shared.network.dto.TrackDto 8 | 9 | @Keep 10 | data class SearchResultsResponse( 11 | val tracks: Tracks 12 | ): Mapper>, ListMapper { 13 | 14 | @Keep 15 | data class Tracks( 16 | val total: Int, 17 | val items: List 18 | ) 19 | 20 | override fun toDomainObject(): List { 21 | return tracks.items.map { it.toDomainObject() } 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/search/repository/SearchRepository.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.search.repository 2 | 3 | import ua.leonidius.beatinspector.data.tracks.shared.domain.SongSearchResult 4 | import ua.leonidius.beatinspector.data.shared.repository.BasicRepository 5 | 6 | interface SearchRepository: BasicRepository> { 7 | 8 | fun getById(id: String): SongSearchResult 9 | 10 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/search/repository/SearchRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.search.repository 2 | 3 | import kotlinx.coroutines.CoroutineDispatcher 4 | import kotlinx.coroutines.flow.Flow 5 | import kotlinx.coroutines.flow.first 6 | import kotlinx.coroutines.launch 7 | import kotlinx.coroutines.withContext 8 | import ua.leonidius.beatinspector.data.tracks.search.network.SearchNetworkDataSource 9 | import ua.leonidius.beatinspector.data.tracks.shared.cache.SongTitlesInMemCache 10 | import ua.leonidius.beatinspector.data.tracks.shared.domain.SongSearchResult 11 | import ua.leonidius.beatinspector.shared.domain.SettingsState 12 | import javax.inject.Inject 13 | import javax.inject.Named 14 | import javax.inject.Singleton 15 | 16 | @Singleton 17 | class SearchRepositoryImpl @Inject constructor( 18 | 19 | @Named("io") private val ioDispatcher: CoroutineDispatcher, 20 | private val properNetworkDataSource: SearchNetworkDataSource, 21 | private val searchCacheDataSource: SongTitlesInMemCache, 22 | private val settingsFlow: Flow, 23 | ) : SearchRepository { 24 | 25 | // we don't save query results in cache, because they are cached by okhttp 26 | 27 | override suspend fun get(q: String): List = withContext(ioDispatcher) { 28 | var results = properNetworkDataSource.load(q) 29 | 30 | if (settingsFlow.first().hideExplicit) { 31 | results = results.filter { !it.isExplicit } 32 | } 33 | 34 | launch { 35 | searchCacheDataSource.batchAdd(results.associateBy { it.id }) 36 | } 37 | results 38 | } 39 | 40 | override fun getById(id: String): SongSearchResult { 41 | return searchCacheDataSource[id] 42 | ?: throw Error("no base info found in cache for song id $id") 43 | // todo maybe add network call here if not found in cache 44 | } 45 | 46 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/shared/cache/SongTitlesInMemCache.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.shared.cache 2 | 3 | import ua.leonidius.beatinspector.data.shared.cache.InMemCache 4 | import ua.leonidius.beatinspector.data.tracks.shared.domain.SongSearchResult 5 | import javax.inject.Inject 6 | import javax.inject.Singleton 7 | 8 | /** 9 | * 10 | */ 11 | @Singleton 12 | class SongTitlesInMemCache @Inject constructor(): InMemCache { 13 | 14 | override val cache = mutableMapOf() 15 | 16 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/shared/db/ArtistGenre.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.shared.db 2 | 3 | import androidx.room.Entity 4 | 5 | /** 6 | * represents that an artist is associated with (0 or more) genres. 7 | * it is possible that artist (his id and name) are cached, but the genres where 8 | * not. This is why this is a separate table. 9 | */ 10 | @Entity(tableName = "artist_genres", primaryKeys = ["artistId", "genre"]) 11 | data class ArtistGenre( 12 | val artistId: String, 13 | val genre: String 14 | ) -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/shared/db/TrackArtist.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.shared.db 2 | 3 | import androidx.room.Entity 4 | 5 | /** 6 | * represent the relationship between a track and 1 or more 7 | * artists who made it. 8 | */ 9 | @Entity(tableName = "track_artists", primaryKeys = ["trackId", "artistId"]) 10 | data class TrackArtist( 11 | val trackId: String, 12 | val artistId: String, 13 | ) 14 | -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/shared/db/TrackDao.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.shared.db 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Query 5 | import androidx.room.Transaction 6 | 7 | @Dao 8 | interface TrackDao { 9 | 10 | @Transaction 11 | @Query("SELECT * FROM tracks WHERE id = :trackId") 12 | fun getTrackShelfInfoWithArtists(trackId: String): List 13 | 14 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/shared/db/TrackShelfInfo.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.shared.db 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.PrimaryKey 6 | 7 | /** 8 | * This is the "shelf" data of the track i.e. the data that the user 9 | * sees first, including the name and the cover art. The list of artists 10 | * is JOIN-ed in SQL. 11 | */ 12 | @Entity(tableName = "tracks", primaryKeys = ["id"]) 13 | data class TrackShelfInfo( 14 | 15 | @PrimaryKey val id: String, 16 | 17 | val name: String, 18 | 19 | @ColumnInfo(name = "is_explicit") val isExplicit: Boolean, 20 | 21 | @ColumnInfo(name = "big_image_url") val imageUrl: String, 22 | 23 | @ColumnInfo(name = "small_image_url") val smallestImageUrl: String? = null, 24 | ) -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/shared/db/TrackShelfInfoWithArtists.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.shared.db 2 | 3 | import androidx.room.Embedded 4 | import androidx.room.Junction 5 | import androidx.room.Relation 6 | import ua.leonidius.beatinspector.data.tracks.shared.domain.Artist 7 | 8 | data class TrackShelfInfoWithArtists( 9 | @Embedded val trackShelfInfo: TrackShelfInfo, 10 | 11 | @Relation( 12 | associateBy = Junction(TrackArtist::class), 13 | parentColumn = "trackId", 14 | entityColumn = "artistId", 15 | ) 16 | val artists: List 17 | ) -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/shared/domain/Artist.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.shared.domain 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | 6 | @Entity(tableName = "artists") 7 | data class Artist( 8 | 9 | @PrimaryKey val id: String, 10 | 11 | val name: String, 12 | ) -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/shared/domain/SongSearchResult.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.shared.domain 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.PrimaryKey 6 | 7 | /** 8 | * this represents the title and the artists and the small & big cover 9 | * images of a song. This data will be updated every time someone fetches 10 | * a list of items (search, playlist content etc) and a certain song happens 11 | * to be there (bc if fresh data is received this way every time, might as well 12 | * update the data). However, this data will never be invalidated on its own 13 | * (bc song data doesn't change usually) 14 | * and as such does not have a timestamp. 15 | * 16 | * the playlist contents, search results will be stored in other tables 17 | * with reference to this one. 18 | * 19 | * the playlist membership data will be timestamped and invalidated 20 | * 21 | * the search results will not be timestamped, instead it will be invalidated every time 22 | */ 23 | 24 | data class SongSearchResult( 25 | @PrimaryKey override val id: String, 26 | val name: String, 27 | 28 | val artists: List, // todo: how do we represent the artists? 29 | @ColumnInfo(name = "is_explicit") val isExplicit: Boolean, 30 | @ColumnInfo(name = "big_image_url") val imageUrl: String?, 31 | @ColumnInfo(name = "small_image_url") val smallestImageUrl: String? = null, 32 | 33 | 34 | ): ua.leonidius.beatinspector.data.shared.domain.SearchResult 35 | -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/shared/network/dto/AlbumDto.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.shared.network.dto 2 | 3 | import androidx.annotation.Keep 4 | import ua.leonidius.beatinspector.data.shared.network.dto.ImageDto 5 | 6 | @Keep 7 | data class AlbumDto( 8 | val id: String, 9 | val images: List? // first image is the biggest one 10 | ) { 11 | 12 | fun smallestImageUrlOrNull() = images?.minByOrNull { it.width * it.height }?.url 13 | 14 | fun biggestImageUrlOrNull() = images?.firstOrNull()?.url 15 | 16 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/shared/network/dto/ArtistDto.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.shared.network.dto 2 | 3 | import androidx.annotation.Keep 4 | import ua.leonidius.beatinspector.data.shared.Mapper 5 | import ua.leonidius.beatinspector.data.tracks.shared.domain.Artist 6 | 7 | /** 8 | * This does not include genres and is returned in search results and whatnot. 9 | */ 10 | @Keep 11 | data class ArtistDto( 12 | val id: String, 13 | val name: String, 14 | ): Mapper { 15 | 16 | override fun toDomainObject(): Artist { 17 | return Artist( 18 | id = id, 19 | name = name, 20 | ) 21 | } 22 | 23 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/data/tracks/shared/network/dto/TrackDto.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.tracks.shared.network.dto 2 | 3 | import androidx.annotation.Keep 4 | import ua.leonidius.beatinspector.data.shared.Mapper 5 | import ua.leonidius.beatinspector.data.tracks.shared.domain.SongSearchResult 6 | 7 | @Keep 8 | data class TrackDto( 9 | val id: String, 10 | val name: String, 11 | val album: AlbumDto, 12 | val artists: List, 13 | val explicit: Boolean, 14 | ): Mapper { 15 | 16 | override fun toDomainObject(): SongSearchResult { 17 | val name = if (this.explicit) "$name \uD83C\uDD74" else name // "E" emoji 18 | return SongSearchResult( 19 | id = id, 20 | name = name, 21 | artists = artists.map { it.toDomainObject() }, 22 | isExplicit = explicit, 23 | imageUrl = album.biggestImageUrlOrNull(), 24 | smallestImageUrl = album.smallestImageUrlOrNull(), 25 | ) 26 | } 27 | 28 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/features/details/viewmodels/SongDetailsViewModel.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.features.details.viewmodels 2 | 3 | import android.util.Log 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.mutableStateOf 6 | import androidx.compose.runtime.setValue 7 | import androidx.lifecycle.SavedStateHandle 8 | import androidx.lifecycle.ViewModel 9 | import androidx.lifecycle.viewModelScope 10 | import dagger.hilt.android.lifecycle.HiltViewModel 11 | import kotlinx.coroutines.Dispatchers 12 | import kotlinx.coroutines.launch 13 | import kotlinx.coroutines.withContext 14 | import ua.leonidius.beatinspector.R 15 | import ua.leonidius.beatinspector.data.shared.exception.SongDataIOException 16 | import ua.leonidius.beatinspector.data.tracks.details.repository.TrackDetailsRepository 17 | import ua.leonidius.beatinspector.features.shared.model.toUiMessage 18 | import java.text.DecimalFormat 19 | import javax.inject.Inject 20 | import javax.inject.Named 21 | 22 | @HiltViewModel 23 | class SongDetailsViewModel @Inject constructor( 24 | private val savedStateHandle: SavedStateHandle, 25 | //private val songsRepository: SongsRepository, 26 | private val trackDetailsRepository: TrackDetailsRepository, 27 | private val decimalFormat: DecimalFormat, 28 | @Named("spotify_installed") private val isSpotifyInstalled: Boolean, 29 | ): ViewModel() { 30 | 31 | private val songId = savedStateHandle.get("songId")!! 32 | 33 | sealed class UiState { 34 | object Loading: UiState() 35 | data class Loaded( 36 | val songId: String, 37 | val title: String, 38 | val artists: String, 39 | val bpm: String, 40 | val key: String, 41 | val timeSignatureOver4: Int, 42 | val loudness: String, 43 | val genres: String, 44 | val albumArtUrl: String?, 45 | val isSpotifyInstalled: Boolean, 46 | ): UiState() 47 | 48 | data class Error( 49 | val errorMsgId: Int, 50 | val errorAdditionalInfo: String, 51 | ): UiState() 52 | } 53 | 54 | var uiState by mutableStateOf(UiState.Loading) 55 | private set 56 | 57 | init { 58 | loadSongDetails(songId) 59 | } 60 | 61 | private fun loadSongDetails(id: String) { 62 | viewModelScope.launch { 63 | val _songDetails = try { 64 | val song = trackDetailsRepository.getFullDetails(id) 65 | UiState.Loaded( 66 | songId = song.id, 67 | title = song.name, 68 | artists = song.artist, 69 | bpm = decimalFormat.format(song.bpm), 70 | key = song.key, 71 | timeSignatureOver4 = song.timeSignature, 72 | loudness = decimalFormat.format(song.loudness) + " db", 73 | genres = song.genres.joinToString(", "), 74 | albumArtUrl = song.albumArtUrl, 75 | isSpotifyInstalled = isSpotifyInstalled, 76 | ) 77 | } catch (e: SongDataIOException) { 78 | UiState.Error( 79 | errorMsgId = e.toUiMessage(), 80 | errorAdditionalInfo = e.toTextDescription() 81 | ) 82 | } catch (e: Exception) { 83 | Log.e("SongDetailsViewModel", "Unknown error", e) 84 | UiState.Error( 85 | errorMsgId = R.string.unknown_error, 86 | errorAdditionalInfo = """ 87 | Non-SongDataIOException exception thrown: 88 | Type: ${e.javaClass.name} 89 | Message: ${e.message} 90 | """.trimIndent() 91 | ) 92 | } 93 | 94 | withContext(Dispatchers.Main) { 95 | uiState = _songDetails 96 | } 97 | } 98 | } 99 | 100 | /*companion object { 101 | 102 | val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { 103 | 104 | @Suppress("UNCHECKED_CAST") 105 | override fun create(modelClass: Class, extras: CreationExtras): T { 106 | val app = checkNotNull( 107 | extras[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] 108 | ) as BeatInspectorApp 109 | 110 | return SongDetailsViewModel( 111 | extras.createSavedStateHandle(), 112 | app.trackDetailsRepository, 113 | app.decimalFormat, 114 | app.isSpotifyInstalled 115 | ) as T 116 | } 117 | 118 | } 119 | 120 | }*/ 121 | 122 | 123 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/features/home/viewmodels/HomeScreenViewModel.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.features.home.viewmodels 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import androidx.paging.cachedIn 6 | import dagger.hilt.android.lifecycle.HiltViewModel 7 | import ua.leonidius.beatinspector.data.account.repository.AccountRepository 8 | import ua.leonidius.beatinspector.data.playlists.repository.MyPlaylistsRepository 9 | import ua.leonidius.beatinspector.shared.viewmodels.AccountImageViewModel 10 | import ua.leonidius.beatinspector.shared.viewmodels.AccountImageViewModelImpl 11 | import javax.inject.Inject 12 | 13 | /** 14 | * ViewModel for the main screen, the one with user's playlists. 15 | */ 16 | @HiltViewModel 17 | class HomeScreenViewModel @Inject constructor( 18 | accountRepository: AccountRepository, 19 | myPlaylistsRepository: MyPlaylistsRepository, 20 | ): ViewModel(), AccountImageViewModel by AccountImageViewModelImpl(accountRepository) { 21 | 22 | val playlistsPagingFlow = myPlaylistsRepository.getMyPlaylistsFlow() 23 | .cachedIn(viewModelScope) 24 | 25 | init { 26 | loadAccountImage(viewModelScope) 27 | } 28 | 29 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/features/legal/ui/LongTextScreen.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.features.legal.ui 2 | 3 | import androidx.annotation.StringRes 4 | import androidx.compose.foundation.text.selection.SelectionContainer 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.res.stringResource 9 | import androidx.compose.ui.text.style.TextAlign 10 | import androidx.hilt.navigation.compose.hiltViewModel 11 | import androidx.lifecycle.viewmodel.compose.viewModel 12 | import com.ireward.htmlcompose.HtmlText 13 | import ua.leonidius.beatinspector.features.shared.ui.CenteredScrollableTextScreen 14 | import ua.leonidius.beatinspector.features.legal.viewmodels.LongTextViewModel 15 | 16 | @Composable 17 | fun LongTextScreen( 18 | modifier: Modifier = Modifier, 19 | viewModel: LongTextViewModel = hiltViewModel(), 20 | ) { 21 | LongTextScreen( 22 | modifier = modifier, 23 | textId = viewModel.textId 24 | ) 25 | } 26 | 27 | @Composable 28 | fun LongTextScreen( 29 | modifier: Modifier = Modifier, 30 | @StringRes textId: Int 31 | ) { 32 | LongTextScreen(modifier, text = stringResource(textId)) 33 | } 34 | 35 | @Composable 36 | fun LongTextScreen( 37 | modifier: Modifier = Modifier, 38 | text: String, 39 | ) { 40 | CenteredScrollableTextScreen(modifier = modifier) { 41 | SelectionContainer { 42 | HtmlText( 43 | text = text, 44 | style = MaterialTheme.typography.bodyLarge.copy(textAlign = TextAlign.Justify), 45 | ) 46 | } 47 | } 48 | 49 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/features/legal/viewmodels/LongTextViewModel.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.features.legal.viewmodels 2 | 3 | import androidx.lifecycle.SavedStateHandle 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.ViewModelProvider 6 | import androidx.lifecycle.createSavedStateHandle 7 | import androidx.lifecycle.viewmodel.CreationExtras 8 | import dagger.hilt.android.lifecycle.HiltViewModel 9 | import javax.inject.Inject 10 | 11 | @HiltViewModel 12 | class LongTextViewModel @Inject constructor( 13 | savedStateHandle: SavedStateHandle, 14 | ): ViewModel() { 15 | 16 | val textId = savedStateHandle.get("textId")!!.toInt() 17 | 18 | /*companion object { 19 | 20 | val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { 21 | 22 | @Suppress("UNCHECKED_CAST") 23 | override fun create(modelClass: Class, extras: CreationExtras): T { 24 | return LongTextViewModel( 25 | extras.createSavedStateHandle(), 26 | ) as T 27 | } 28 | 29 | } 30 | 31 | }*/ 32 | 33 | 34 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/features/login/ui/LoginScreen.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.features.login.ui 2 | 3 | import androidx.compose.foundation.layout.ColumnScope 4 | import androidx.compose.foundation.layout.RowScope 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.material3.Button 7 | import androidx.compose.material3.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.text.style.TextAlign 12 | import androidx.compose.ui.unit.dp 13 | import androidx.hilt.navigation.compose.hiltViewModel 14 | import androidx.lifecycle.viewmodel.compose.viewModel 15 | import ua.leonidius.beatinspector.features.login.viewmodels.LoginViewModel 16 | 17 | @Composable 18 | fun LoginScreen( 19 | onLoginButtonPressed: () -> Unit, 20 | onNavigateToLegalText: (Int) -> Unit, 21 | viewModel: LoginViewModel = hiltViewModel(), 22 | ) { 23 | LoginComposable( 24 | onLoginButtonPressed = onLoginButtonPressed, 25 | onNavigateToLegalText = onNavigateToLegalText, 26 | uiState = viewModel.uiState, 27 | isUserAMinor = viewModel.iAmAMinorOptionSelected, 28 | onUserAMinorCheckboxChange = { viewModel.iAmAMinorOptionSelected = it }, 29 | ) 30 | 31 | } 32 | 33 | @Composable 34 | private fun LoginComposable( 35 | onLoginButtonPressed: () -> Unit, 36 | onNavigateToLegalText: (Int) -> Unit, 37 | uiState: LoginViewModel.UiState, 38 | isUserAMinor: Boolean, 39 | onUserAMinorCheckboxChange: (Boolean) -> Unit, 40 | ) { 41 | when(uiState) { 42 | is LoginViewModel.UiState.LoginOffered -> LoginOfferScreen( 43 | onLoginButtonPressed = onLoginButtonPressed, 44 | onNavigateToLegalText = onNavigateToLegalText, 45 | isUserAMinor = isUserAMinor, 46 | onUserAMinorCheckboxChange = onUserAMinorCheckboxChange, 47 | ) 48 | 49 | is LoginViewModel.UiState.LoginInProgress -> 50 | LoginInProgressScreen() 51 | 52 | is LoginViewModel.UiState.LoginError -> 53 | LoginErrorScreen(onLoginButtonPressed) 54 | 55 | is LoginViewModel.UiState.SuccessfulLogin -> 56 | { /* this screen will not be shown */ } 57 | 58 | } 59 | } 60 | 61 | 62 | 63 | @Composable 64 | fun TextBlock( 65 | modifier: Modifier = Modifier, 66 | text: String, 67 | ) { 68 | Text( 69 | text, 70 | modifier = modifier.padding(bottom = 16.dp), 71 | textAlign = TextAlign.Justify, 72 | ) 73 | } 74 | 75 | @Composable 76 | fun ColumnScope.CenteredButton( 77 | modifier: Modifier = Modifier, 78 | onClick: () -> Unit, 79 | content: @Composable RowScope.() -> Unit, 80 | ) { 81 | Button( 82 | modifier = modifier.align(Alignment.CenterHorizontally), 83 | onClick = onClick, content = content, 84 | ) 85 | } 86 | 87 | 88 | -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/features/login/ui/LoginScreenError.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.features.login.ui 2 | 3 | import androidx.compose.material3.Text 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.res.stringResource 6 | import ua.leonidius.beatinspector.R 7 | import ua.leonidius.beatinspector.features.shared.ui.CenteredScrollableTextScreen 8 | 9 | @Composable 10 | fun LoginErrorScreen( 11 | onLoginButtonPressed: () -> Unit, 12 | ) { 13 | CenteredScrollableTextScreen { 14 | // todo: add actual error description based on the error type (network, user cancelled, etc) 15 | // todo: maybe replace with universal error screen, although this case is different, bc here login can be cancelled by user, which is unique 16 | TextBlock( 17 | text = stringResource(R.string.log_in_error), 18 | ) 19 | CenteredButton( 20 | onClick = onLoginButtonPressed 21 | ) { 22 | Text(text = stringResource(R.string.login_screen_try_again_button)) 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/features/login/ui/LoginScreenInProgress.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.features.login.ui 2 | 3 | import androidx.compose.material3.CircularProgressIndicator 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.Alignment 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.res.stringResource 8 | import ua.leonidius.beatinspector.R 9 | import ua.leonidius.beatinspector.features.shared.ui.CenteredScrollableTextScreen 10 | 11 | @Composable 12 | fun LoginInProgressScreen() { 13 | CenteredScrollableTextScreen { 14 | TextBlock( 15 | text = stringResource(R.string.login_in_progress_tip), 16 | ) 17 | CircularProgressIndicator(Modifier.align(Alignment.CenterHorizontally)) 18 | } 19 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/features/login/ui/LoginScreenOffer.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.features.login.ui 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.interaction.MutableInteractionSource 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.Spacer 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.layout.width 9 | import androidx.compose.foundation.text.ClickableText 10 | import androidx.compose.material.icons.Icons 11 | import androidx.compose.material.icons.filled.ArrowForward 12 | import androidx.compose.material3.Checkbox 13 | import androidx.compose.material3.Icon 14 | import androidx.compose.material3.MaterialTheme 15 | import androidx.compose.material3.Text 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.runtime.remember 18 | import androidx.compose.ui.Alignment 19 | import androidx.compose.ui.Modifier 20 | import androidx.compose.ui.graphics.Color 21 | import androidx.compose.ui.res.stringResource 22 | import androidx.compose.ui.text.SpanStyle 23 | import androidx.compose.ui.text.buildAnnotatedString 24 | import androidx.compose.ui.text.style.TextAlign 25 | import androidx.compose.ui.text.style.TextDecoration 26 | import androidx.compose.ui.text.withStyle 27 | import androidx.compose.ui.unit.dp 28 | import ua.leonidius.beatinspector.R 29 | import ua.leonidius.beatinspector.features.shared.ui.CenteredScrollableTextScreen 30 | 31 | @Composable 32 | fun LoginOfferScreen( 33 | onLoginButtonPressed: () -> Unit, 34 | onNavigateToLegalText: (Int) -> Unit, 35 | isUserAMinor: Boolean, 36 | onUserAMinorCheckboxChange: (Boolean) -> Unit, 37 | ) { 38 | CenteredScrollableTextScreen { 39 | 40 | val loginScopesTip = stringResource(id = R.string.login_scopes_tip) 41 | 42 | val annotatedString = remember { 43 | val linkSpanStyle = SpanStyle(color = Color.Blue, textDecoration = TextDecoration.Underline) 44 | 45 | buildAnnotatedString { 46 | append("In order to use the app, you need to log in with your Spotify account. Press \"Log in\" and follow the instructions in the browser window. By continuing, you agree to the app's ") 47 | withStyle(style = linkSpanStyle) { 48 | pushStringAnnotation("link", "privacy_policy") 49 | append("privacy policy") 50 | pop() 51 | } 52 | 53 | append(" and ") 54 | 55 | withStyle(style = linkSpanStyle) { 56 | pushStringAnnotation("link", "terms_of_service") 57 | append("terms of service") 58 | pop() 59 | } 60 | 61 | append(".\n\n") 62 | 63 | append(loginScopesTip) 64 | } 65 | } 66 | 67 | ClickableText( 68 | annotatedString, 69 | style = MaterialTheme.typography.bodyLarge.copy(textAlign = TextAlign.Justify), 70 | onClick = { offset -> 71 | annotatedString.getStringAnnotations("link", offset, offset) 72 | .firstOrNull()?.let { annotation -> 73 | onNavigateToLegalText( 74 | when (annotation.item) { 75 | // todo: this logic should be moved to the viewmodel or elsewhere 76 | "privacy_policy" -> R.string.privacy_policy 77 | "terms_of_service" -> R.string.terms_and_conditions 78 | else -> throw IllegalArgumentException("Unknown link annotation") 79 | } 80 | ) 81 | } 82 | }, 83 | ) 84 | 85 | LabelledCheckbox( 86 | modifier = Modifier 87 | .padding(bottom = 16.dp), 88 | label = stringResource(R.string.login_i_am_a_minor), 89 | checked = isUserAMinor, 90 | onCheckedChange = onUserAMinorCheckboxChange, 91 | ) 92 | 93 | CenteredButton( 94 | onClick = onLoginButtonPressed 95 | ) { 96 | Text(text = "Log in") 97 | Icon( 98 | modifier = Modifier.padding(start = 8.dp), 99 | imageVector = Icons.Default.ArrowForward, 100 | contentDescription = null) 101 | } 102 | } 103 | } 104 | 105 | @Composable 106 | fun LabelledCheckbox( 107 | modifier: Modifier = Modifier, 108 | label: String, 109 | checked: Boolean, 110 | onCheckedChange: (Boolean) -> Unit, 111 | ) { 112 | Row( 113 | modifier = modifier, 114 | verticalAlignment = Alignment.CenterVertically, 115 | ) { 116 | //CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) { 117 | Checkbox( 118 | checked = checked, 119 | onCheckedChange = onCheckedChange 120 | ) 121 | //} 122 | 123 | Spacer(modifier = Modifier.width(8.dp)) 124 | Text( 125 | text = label, 126 | modifier = Modifier.clickable( 127 | interactionSource = remember { MutableInteractionSource() }, 128 | indication = null, 129 | ) { 130 | onCheckedChange(!checked) 131 | } 132 | ) 133 | } 134 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/features/login/viewmodels/LoginViewModel.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.features.login.viewmodels 2 | 3 | import android.content.Intent 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.mutableStateOf 6 | import androidx.compose.runtime.setValue 7 | import androidx.lifecycle.ViewModel 8 | import androidx.lifecycle.viewModelScope 9 | import dagger.hilt.android.lifecycle.HiltViewModel 10 | import kotlinx.coroutines.Dispatchers 11 | import kotlinx.coroutines.launch 12 | import ua.leonidius.beatinspector.data.auth.logic.PKCEAuthenticationInitiator 13 | import ua.leonidius.beatinspector.shared.logic.eventbus.EventBus 14 | import ua.leonidius.beatinspector.shared.logic.eventbus.UserHideExplicitSettingChangeEvent 15 | import ua.leonidius.beatinspector.shared.logic.eventbus.UserLogoutRequestEvent 16 | import javax.inject.Inject 17 | 18 | @HiltViewModel 19 | class LoginViewModel @Inject constructor( 20 | private val authenticator: PKCEAuthenticationInitiator, 21 | private val eventBus: EventBus, 22 | ): ViewModel() { 23 | 24 | sealed class UiState { 25 | object LoginInProgress: UiState() 26 | 27 | object LoginOffered: UiState() 28 | 29 | data class LoginError( 30 | val errorDescription: String 31 | ): UiState() 32 | 33 | object SuccessfulLogin: UiState() // todo: find a way to navigate to the main screen after login without having to use this state, and remove it 34 | } 35 | 36 | var uiState by mutableStateOf(UiState.LoginOffered) 37 | private set 38 | 39 | // todo: single source of truth for auth status - authenticator class. maybe make it a stateflow? 40 | //var isLoggedIn by mutableStateOf(authenticator.isAuthorized()) 41 | // private set 42 | 43 | /*fun checkAuthStatus(launchLoginActivityWithIntent: (Intent) -> Unit) { 44 | if (authenticator.isAuthorized()) { // todo: remove this shit? 45 | // logged in, do nothing 46 | } else { 47 | // todo: find a proper place for this code, maybe in some lifecycle observer 48 | val intent = authenticator.prepareStepOneIntent() 49 | launchLoginActivityWithIntent(intent) 50 | } 51 | }*/ 52 | 53 | var iAmAMinorOptionSelected by mutableStateOf(false) 54 | 55 | fun launchLoginSequence(launchLoginActivityWithIntent: (Intent) -> Unit) { 56 | uiState = UiState.LoginInProgress 57 | 58 | eventBus.post( 59 | UserHideExplicitSettingChangeEvent(iAmAMinorOptionSelected), 60 | viewModelScope 61 | ) 62 | 63 | val intent = authenticator.prepareStepOneIntent() 64 | launchLoginActivityWithIntent(intent) 65 | } 66 | 67 | fun onLoginActivityResult(activitySuccess: Boolean, data: Intent?) { 68 | if (!activitySuccess) { 69 | uiState = UiState.LoginError( 70 | errorDescription = "Error while logging in. Please try again." 71 | ) 72 | return 73 | } 74 | 75 | // the code is obtained and now we need to exchange it for tokens to complete auth. 76 | // call the token-getting method 77 | viewModelScope.launch(Dispatchers.IO) { // todo: withContext() in authenticator.authSecondStep(), not here 78 | /*authenticator.authSecondStep(data) { isSuccessful -> 79 | // todo: remove when auth becomes the SSOT? 80 | 81 | if (isSuccessful) { 82 | uiState = UiState.SuccessfulLoginAccountDataLoading 83 | 84 | // todo: start loading account data 85 | //val accountData = accountDataCache.retrieve() 86 | // accountDataCache.store() 87 | 88 | } else { 89 | uiState = UiState.LoginError( 90 | errorDescription = "Error while logging in. Please try again." 91 | ) 92 | } 93 | }*/ 94 | 95 | try { 96 | authenticator.exchangeCodeForTokens(data) 97 | // todo: remove when the auth state is converted to flow 98 | uiState = UiState.SuccessfulLogin 99 | } catch (e: Exception) { 100 | uiState = UiState.LoginError( 101 | errorDescription = "Error while logging in. Please try again. (${e.message})" 102 | ) 103 | return@launch 104 | } 105 | 106 | 107 | //uiState = UiState.SuccessfulLogin 108 | } 109 | } 110 | 111 | fun logout() { 112 | eventBus.post(UserLogoutRequestEvent, viewModelScope) 113 | 114 | uiState = UiState.LoginOffered 115 | } 116 | 117 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/features/search/viewmodels/SearchViewModel.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.features.search.viewmodels 2 | 3 | import android.util.Log 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.mutableStateOf 6 | import androidx.compose.runtime.setValue 7 | import androidx.lifecycle.SavedStateHandle 8 | import androidx.lifecycle.ViewModel 9 | import androidx.lifecycle.viewModelScope 10 | import dagger.hilt.android.lifecycle.HiltViewModel 11 | import kotlinx.coroutines.launch 12 | import ua.leonidius.beatinspector.R 13 | import ua.leonidius.beatinspector.data.account.repository.AccountRepository 14 | import ua.leonidius.beatinspector.data.shared.exception.SongDataIOException 15 | import ua.leonidius.beatinspector.data.tracks.search.repository.SearchRepository 16 | import ua.leonidius.beatinspector.data.tracks.search.repository.SearchRepositoryImpl 17 | import ua.leonidius.beatinspector.data.tracks.shared.domain.SongSearchResult 18 | import ua.leonidius.beatinspector.features.shared.model.toUiMessage 19 | import ua.leonidius.beatinspector.shared.viewmodels.AccountImageViewModel 20 | import ua.leonidius.beatinspector.shared.viewmodels.AccountImageViewModelImpl 21 | import javax.inject.Inject 22 | 23 | @HiltViewModel 24 | class SearchViewModel @Inject constructor( 25 | savedStateHandle: SavedStateHandle, 26 | private val searchRepository: SearchRepository, 27 | accountRepository: AccountRepository, 28 | ) : ViewModel(), AccountImageViewModel by AccountImageViewModelImpl(accountRepository) { 29 | 30 | sealed class UiState { 31 | 32 | object Uninitialized : UiState() 33 | 34 | object Loading : UiState() 35 | 36 | data class Loaded( 37 | val searchResults: List, 38 | ) : UiState() 39 | 40 | data class Error( 41 | val errorMessageId: Int, 42 | val errorAdditionalInfo: String, 43 | ) : UiState() 44 | 45 | } 46 | 47 | 48 | var query by mutableStateOf(savedStateHandle["query"] ?: "") 49 | 50 | var uiState by mutableStateOf(UiState.Uninitialized) 51 | private set 52 | 53 | init { 54 | if (query.isNotEmpty()) { 55 | performSearch() 56 | } 57 | loadAccountImage(viewModelScope) 58 | } 59 | 60 | 61 | 62 | fun performSearch() { 63 | //searchQueriesFlow.emit(query) 64 | 65 | viewModelScope.launch { 66 | try { 67 | uiState = UiState.Loading 68 | val results = searchRepository.get(query) 69 | uiState = UiState.Loaded(results) 70 | } catch (e: SongDataIOException) { 71 | uiState = UiState.Error( 72 | e.toUiMessage(), 73 | e.toTextDescription() 74 | ) 75 | } catch (e: Error) { 76 | uiState = UiState.Error( 77 | R.string.unknown_error, 78 | """ 79 | Non-SongDataIOException exception thrown: 80 | Type: ${e.javaClass.name} 81 | ${e.message} 82 | """.trimIndent() 83 | ) 84 | Log.e("SearchViewModel", "Unknown error", e) 85 | } 86 | 87 | } 88 | } 89 | 90 | /** 91 | * Return to the list of user's playlists, hide the search results 92 | */ 93 | fun returnToUninitialized() { 94 | uiState = UiState.Uninitialized 95 | } 96 | 97 | /*companion object { 98 | 99 | val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { 100 | 101 | @Suppress("UNCHECKED_CAST") 102 | override fun create(modelClass: Class, extras: CreationExtras): T { 103 | val app = checkNotNull(extras[APPLICATION_KEY]) as BeatInspectorApp 104 | 105 | return SearchViewModel( 106 | extras.createSavedStateHandle(), 107 | app.searchRepository, 108 | app.accountRepository, 109 | ) as T 110 | } 111 | 112 | } 113 | 114 | }*/ 115 | 116 | 117 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/features/settings/viewmodels/SettingsViewModel.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.features.settings.viewmodels 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.compose.runtime.setValue 6 | import androidx.lifecycle.ViewModel 7 | import androidx.lifecycle.viewModelScope 8 | import com.mikepenz.aboutlibraries.entity.Library 9 | import dagger.hilt.android.lifecycle.HiltViewModel 10 | import kotlinx.coroutines.flow.first 11 | import kotlinx.coroutines.flow.map 12 | import kotlinx.coroutines.flow.stateIn 13 | import kotlinx.coroutines.launch 14 | import ua.leonidius.beatinspector.data.account.repository.AccountRepository 15 | import ua.leonidius.beatinspector.data.settings.SettingsRepository 16 | import ua.leonidius.beatinspector.shared.logic.eventbus.EventBus 17 | import ua.leonidius.beatinspector.shared.logic.eventbus.UserHideExplicitSettingChangeEvent 18 | import javax.inject.Inject 19 | 20 | @HiltViewModel 21 | class SettingsViewModel @Inject constructor( 22 | private val accountRepository: AccountRepository, 23 | private val libraries: List, 24 | private val settingsStore: SettingsRepository, 25 | private val eventBus: EventBus, 26 | ): ViewModel() { 27 | 28 | sealed class AccountDetailsState { 29 | 30 | object Loading: AccountDetailsState() 31 | 32 | data class Loaded( 33 | val username: String, 34 | val bigImageUrl: String?, 35 | ) : AccountDetailsState() 36 | 37 | object Error : AccountDetailsState() 38 | 39 | } 40 | 41 | var accountDetailsState by mutableStateOf(AccountDetailsState.Loading) 42 | private set 43 | 44 | val libraryNameAndLicenseHash = libraries.map { 45 | Pair(it.name, it.licenses.firstOrNull()?.hash) 46 | }.toTypedArray() 47 | 48 | val hideExplicit = settingsStore.settingsFlow 49 | .map { it.hideExplicit } 50 | 51 | init { 52 | loadAccountDetails() 53 | } 54 | 55 | private fun loadAccountDetails() { 56 | accountDetailsState = AccountDetailsState.Loading 57 | viewModelScope.launch { 58 | accountDetailsState = try { 59 | val details = accountRepository.get(Unit) 60 | AccountDetailsState.Loaded( 61 | details.username, details.bigImageUrl, 62 | ) 63 | 64 | } catch (e: Exception) { 65 | // todo: better error handling 66 | AccountDetailsState.Error 67 | } 68 | 69 | } 70 | } 71 | 72 | fun toggleHideExplicit(value: Boolean) { 73 | // todo: replace with factory to remove dependency on event constructor 74 | eventBus.post(UserHideExplicitSettingChangeEvent(value), viewModelScope) 75 | } 76 | 77 | 78 | /*companion object { 79 | 80 | val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { 81 | 82 | @Suppress("UNCHECKED_CAST") 83 | override fun create(modelClass: Class, extras: CreationExtras): T { 84 | val app = checkNotNull(extras[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]) as BeatInspectorApp 85 | 86 | return SettingsViewModel( 87 | app.accountRepository, 88 | app.libraries, 89 | app.settingsStore, 90 | app.eventBusO, 91 | ) as T 92 | } 93 | 94 | } 95 | 96 | }*/ 97 | 98 | 99 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/features/shared/model/ExceptionTextExt.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.features.shared.model 2 | 3 | import ua.leonidius.beatinspector.data.shared.exception.SongDataIOException 4 | import ua.leonidius.beatinspector.R 5 | 6 | fun SongDataIOException.toUiMessage(): Int { 7 | return when (this) { 8 | is SongDataIOException.Network -> R.string.network_error 9 | is SongDataIOException.Server -> R.string.server_error 10 | is SongDataIOException.Unknown -> R.string.unknown_error 11 | is SongDataIOException.TokenRefresh -> R.string.token_refresh_error 12 | is SongDataIOException.NotLoggedIn -> R.string.not_logged_in_error 13 | is SongDataIOException.ApiAccessDenied -> R.string.api_access_denied_error 14 | } 15 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/features/shared/ui/CenteredScrollableTextScreen.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.features.shared.ui 2 | 3 | import androidx.compose.foundation.layout.BoxWithConstraints 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.ColumnScope 6 | import androidx.compose.foundation.layout.fillMaxHeight 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.layout.width 9 | import androidx.compose.foundation.rememberScrollState 10 | import androidx.compose.foundation.verticalScroll 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.unit.dp 15 | import ua.leonidius.beatinspector.ui.theme.Dimens 16 | 17 | /** 18 | * A screen that centers its content, limits its width and makes it scrollable. 19 | * Perfect for making sure that a long text is comfortable to read on a wide screen 20 | * @param content The content of the screen 21 | * @param modifier The modifier for the vertical-scrollable column 22 | */ 23 | @Composable 24 | fun CenteredScrollableTextScreen( 25 | modifier: Modifier = Modifier, 26 | content: @Composable ColumnScope.() -> Unit, 27 | ) { 28 | Column( 29 | modifier 30 | .verticalScroll(rememberScrollState()) 31 | .fillMaxHeight() 32 | .padding(Dimens.paddingNormal) 33 | //.widthIn(min = 0.dp, max = 250.dp), 34 | ) { 35 | BoxWithConstraints( 36 | Modifier.align(Alignment.CenterHorizontally) 37 | ) { 38 | val boxScope = this 39 | 40 | val width = if (boxScope.maxWidth < 500.dp) boxScope.maxWidth else 500.dp 41 | 42 | Column(Modifier.width(width)) { 43 | content() 44 | } 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/features/shared/ui/LoadingScreen.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.features.shared.ui 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxHeight 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.material3.CircularProgressIndicator 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Alignment 9 | import androidx.compose.ui.Modifier 10 | 11 | @Composable 12 | fun LoadingScreen() { 13 | Box( 14 | modifier = Modifier 15 | .fillMaxWidth() 16 | .fillMaxHeight() 17 | ) { 18 | CircularProgressIndicator( 19 | modifier = Modifier.align(alignment = Alignment.Center) 20 | ) 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/features/tracklist/liked/viewmodels/LikedTracksViewModel.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.features.tracklist.liked.viewmodels 2 | 3 | import dagger.hilt.android.lifecycle.HiltViewModel 4 | import ua.leonidius.beatinspector.data.shared.PagingDataSource 5 | import ua.leonidius.beatinspector.data.tracks.shared.domain.SongSearchResult 6 | import ua.leonidius.beatinspector.features.tracklist.shared.viewmodels.TrackListViewModel 7 | import javax.inject.Inject 8 | import javax.inject.Named 9 | 10 | @HiltViewModel 11 | class LikedTracksViewModel @Inject constructor( 12 | @Named("liked") pagingSource: PagingDataSource, 13 | ): TrackListViewModel(pagingSource) -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/features/tracklist/playlist/di/PlaylistTracksModule.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.features.tracklist.playlist.di 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.android.components.ViewModelComponent 7 | import dagger.hilt.android.scopes.ViewModelScoped 8 | import ua.leonidius.beatinspector.data.shared.PagingDataSource 9 | import ua.leonidius.beatinspector.data.tracks.lists.playlist.PlaylistPagingDataSource 10 | import ua.leonidius.beatinspector.data.tracks.shared.domain.SongSearchResult 11 | import javax.inject.Named 12 | 13 | @Module 14 | @InstallIn(ViewModelComponent::class) 15 | abstract class PlaylistTracksModule { 16 | 17 | @Binds 18 | @ViewModelScoped 19 | @Named("playlist_content") 20 | abstract fun bindsPlaylistTracksDataSource( 21 | ds: PlaylistPagingDataSource 22 | ): PagingDataSource 23 | 24 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/features/tracklist/playlist/ui/PlaylistContentScreen.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.features.tracklist.playlist.ui 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.material3.ButtonDefaults 8 | import androidx.compose.material3.MaterialTheme 9 | import androidx.compose.material3.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.runtime.collectAsState 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.unit.dp 15 | import androidx.hilt.navigation.compose.hiltViewModel 16 | import kotlinx.coroutines.flow.Flow 17 | import ua.leonidius.beatinspector.features.details.ui.OpenInSpotifyButton 18 | import ua.leonidius.beatinspector.features.tracklist.playlist.viewmodels.PlaylistContentViewModel 19 | import ua.leonidius.beatinspector.features.tracklist.shared.ui.TrackListActions 20 | import ua.leonidius.beatinspector.features.tracklist.shared.ui.TrackListScreen 21 | import ua.leonidius.beatinspector.ui.theme.Dimens 22 | 23 | data class PlaylistContentActions( 24 | override val goToSongDetails: (String) -> Unit, 25 | override val openSongInSpotify: (String) -> Unit, 26 | val openPlaylistInSpotify: (String) -> Unit, 27 | ): TrackListActions 28 | 29 | @Composable 30 | fun PlaylistContentScreen( 31 | actions: PlaylistContentActions, 32 | isSpotifyInstalledFlow: Flow, 33 | ) { 34 | val model = hiltViewModel() 35 | 36 | TrackListScreen( 37 | model, 38 | actions, 39 | headerContent = { 40 | when (val state = model.uiState) { 41 | is PlaylistContentViewModel.UiState.Loading -> { 42 | // nothing 43 | } 44 | is PlaylistContentViewModel.UiState.Loaded -> { 45 | PlaylistHeaderPortrait( 46 | actions = actions, 47 | isSpotifyInstalledFlow = isSpotifyInstalledFlow, 48 | uiState = state, 49 | ) 50 | 51 | } 52 | } 53 | 54 | } 55 | ) 56 | } 57 | 58 | @Composable 59 | private fun PlaylistHeaderPortrait( 60 | actions: PlaylistContentActions, 61 | isSpotifyInstalledFlow: Flow, 62 | uiState: PlaylistContentViewModel.UiState.Loaded, 63 | ) { 64 | Column { 65 | // title 66 | // todo: extract it into a universal title component (for all track lists, incl. liked, top tracks, etc.) 67 | Text( 68 | modifier = Modifier 69 | .padding(start = 10.dp, end = 10.dp, top = 25.dp, bottom = 10.dp), 70 | text = uiState.playlistName, 71 | style = MaterialTheme.typography.headlineLarge, 72 | ) 73 | 74 | // centered open in spotify button 75 | Box(Modifier.fillMaxWidth()) { 76 | OpenInSpotifyButton( 77 | modifier = Modifier 78 | .padding(Dimens.paddingNormal) 79 | .align(Alignment.Center), 80 | onClick = { actions.openPlaylistInSpotify(uiState.uri) }, 81 | isSpotifyInstalled = isSpotifyInstalledFlow.collectAsState(false).value, 82 | colors = ButtonDefaults.buttonColors().copy( 83 | containerColor = MaterialTheme.colorScheme.surfaceTint.copy(alpha = 0.5f), 84 | contentColor = MaterialTheme.colorScheme.onSurface, 85 | ) //todo temorary until can extract color from playlist img 86 | ) 87 | } 88 | } 89 | 90 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/features/tracklist/playlist/viewmodels/PlaylistContentViewModel.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.features.tracklist.playlist.viewmodels 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.compose.runtime.setValue 6 | import androidx.lifecycle.SavedStateHandle 7 | import androidx.lifecycle.viewModelScope 8 | import dagger.hilt.android.lifecycle.HiltViewModel 9 | import kotlinx.coroutines.launch 10 | import ua.leonidius.beatinspector.data.shared.PagingDataSource 11 | import ua.leonidius.beatinspector.data.tracks.shared.domain.SongSearchResult 12 | import ua.leonidius.beatinspector.data.playlists.PlaylistInfoRepository 13 | import ua.leonidius.beatinspector.features.tracklist.shared.viewmodels.TrackListViewModel 14 | import javax.inject.Inject 15 | import javax.inject.Named 16 | 17 | @HiltViewModel 18 | class PlaylistContentViewModel @Inject constructor( 19 | savedStateHandle: SavedStateHandle, 20 | private val playlistInfoRepository: PlaylistInfoRepository, 21 | @Named("playlist_content") pagingSource: PagingDataSource 22 | ): TrackListViewModel(pagingSource) { 23 | 24 | private val playlistId = savedStateHandle.get("playlistId")!! 25 | 26 | var uiState by mutableStateOf(UiState.Loading) 27 | private set 28 | 29 | init { 30 | viewModelScope.launch { 31 | val result = playlistInfoRepository.get(playlistId) 32 | uiState = UiState.Loaded( 33 | playlistName = result.name, 34 | playlistCoverUrl = result.bigImageUrl, 35 | uri = result.uri, 36 | ) 37 | } 38 | } 39 | 40 | sealed class UiState { 41 | object Loading : UiState() 42 | data class Loaded( 43 | val playlistName: String, 44 | val playlistCoverUrl: String?, 45 | val uri: String, 46 | ) : UiState() 47 | } 48 | 49 | /*companion object { 50 | 51 | val Factory = object : ViewModelProvider.Factory { 52 | 53 | @Suppress("UNCHECKED_CAST") 54 | override fun create(modelClass: Class, extras: CreationExtras): T { 55 | val app = checkNotNull(extras[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]) as BeatInspectorApp 56 | 57 | val savedStateHandle = extras.createSavedStateHandle() 58 | 59 | return PlaylistContentViewModel( 60 | app.playlistInfoRepository, 61 | savedStateHandle.get("playlistId")!!, 62 | app.playlistDataSourceFactory(savedStateHandle.get("playlistId")!!), 63 | ) as T 64 | } 65 | 66 | } 67 | }*/ 68 | 69 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/features/tracklist/recent/viewmodels/RecentlyPlayedViewModel.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.features.tracklist.recent.viewmodels 2 | 3 | import dagger.hilt.android.lifecycle.HiltViewModel 4 | import ua.leonidius.beatinspector.data.shared.PagingDataSource 5 | import ua.leonidius.beatinspector.data.tracks.shared.domain.SongSearchResult 6 | import ua.leonidius.beatinspector.features.tracklist.shared.viewmodels.TrackListViewModel 7 | import javax.inject.Inject 8 | import javax.inject.Named 9 | 10 | @HiltViewModel 11 | class RecentlyPlayedViewModel @Inject constructor( 12 | @Named("recent") pagingSource: PagingDataSource, 13 | ): TrackListViewModel(pagingSource) -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/features/tracklist/shared/viewmodels/TrackListViewModel.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.features.tracklist.shared.viewmodels 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import ua.leonidius.beatinspector.data.shared.PagingDataSource 6 | import ua.leonidius.beatinspector.data.tracks.shared.domain.SongSearchResult 7 | 8 | abstract class TrackListViewModel( 9 | pagingSource: PagingDataSource, 10 | ): ViewModel() { 11 | 12 | val flow = pagingSource.getFlow(viewModelScope) 13 | 14 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/features/tracklist/top/viewmodels/TopTracksViewModel.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.features.tracklist.top.viewmodels 2 | 3 | import dagger.hilt.android.lifecycle.HiltViewModel 4 | import ua.leonidius.beatinspector.data.shared.PagingDataSource 5 | import ua.leonidius.beatinspector.data.tracks.shared.domain.SongSearchResult 6 | import ua.leonidius.beatinspector.features.tracklist.shared.viewmodels.TrackListViewModel 7 | import javax.inject.Inject 8 | import javax.inject.Named 9 | 10 | @HiltViewModel 11 | class TopTracksViewModel @Inject constructor( 12 | @Named("top") pagingSource: PagingDataSource, 13 | ): TrackListViewModel(pagingSource) -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/infrastructure/ApiErrorInterceptor.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.infrastructure 2 | 3 | import okhttp3.Interceptor 4 | import okhttp3.Response 5 | import ua.leonidius.beatinspector.data.shared.exception.SongDataIOException 6 | import javax.inject.Inject 7 | import javax.inject.Singleton 8 | 9 | /** 10 | * When the API usage is blocked, it does not return a JSON result, 11 | * instead it returns an empty ? or html? response. This makes 12 | * GsonConverter crash. This interceptor should catch such cases 13 | * and throw a custom io exception. 14 | */ 15 | @Singleton 16 | class ApiErrorInterceptor @Inject constructor() : Interceptor { 17 | 18 | override fun intercept(chain: Interceptor.Chain): Response { 19 | val request = chain.request() 20 | val response = chain.proceed(request) 21 | 22 | if (response.code == 403) { 23 | throw SongDataIOException.ApiAccessDenied 24 | } 25 | 26 | return response 27 | } 28 | 29 | 30 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/infrastructure/AuthInterceptor.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.infrastructure 2 | 3 | import kotlinx.coroutines.runBlocking 4 | import okhttp3.Interceptor 5 | import okhttp3.Response 6 | import ua.leonidius.beatinspector.data.auth.logic.AuthTokenProvider 7 | import ua.leonidius.beatinspector.data.auth.logic.TokenRefreshException 8 | import ua.leonidius.beatinspector.data.shared.exception.SongDataIOException 9 | import javax.inject.Inject 10 | import javax.inject.Singleton 11 | 12 | @Singleton 13 | class AuthInterceptor @Inject constructor( 14 | private val authenticator: AuthTokenProvider 15 | ): Interceptor { 16 | 17 | override fun intercept(chain: Interceptor.Chain): Response { 18 | val original = chain.request() 19 | 20 | val authed = authenticator.isAuthorized() 21 | 22 | if (!authed) { 23 | throw SongDataIOException.NotLoggedIn 24 | } else { 25 | 26 | val accessToken = try { 27 | runBlocking { 28 | authenticator.getAccessToken() 29 | } 30 | } catch (e: TokenRefreshException) { 31 | throw SongDataIOException.TokenRefresh(e) 32 | } 33 | 34 | val request = original.newBuilder() 35 | .header("Authorization", "Bearer $accessToken") 36 | .method(original.method, original.body) 37 | .build() 38 | 39 | // this is a workaround for a bug in AppAuth library 40 | // which forces this callback to be executed on the 41 | // main thread after a token refresh. this causes 42 | // okhttp3 to throw an exception 43 | 44 | return chain.proceed(request) 45 | 46 | 47 | } 48 | 49 | } 50 | 51 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/infrastructure/ContextPackageInstallCheckExt.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.infrastructure 2 | 3 | import android.content.Context 4 | import android.content.pm.PackageManager 5 | 6 | // todo: maybe have it return flow if there's a lib for that? 7 | fun Context.isPackageInstalled(packageName: String): Boolean { 8 | return try { 9 | packageManager.getPackageInfo(packageName, 0) 10 | true 11 | } catch (e: PackageManager.NameNotFoundException) { 12 | false 13 | } 14 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/shared/domain/SettingsState.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.shared.domain 2 | 3 | data class SettingsState( 4 | val hideExplicit: Boolean, 5 | // todo: dark mode, language... 6 | ) 7 | -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/shared/logic/eventbus/Event.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.shared.logic.eventbus 2 | 3 | interface Event -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/shared/logic/eventbus/EventBus.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.shared.logic.eventbus 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.MainScope 5 | import kotlin.reflect.KClass 6 | 7 | interface EventBus { 8 | 9 | fun post(event: Event, scope: CoroutineScope = MainScope()) 10 | 11 | fun subscribe(eventClass: KClass, 12 | scope: CoroutineScope = MainScope(), 13 | subscriber: suspend (E) -> Unit) 14 | 15 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/shared/logic/eventbus/EventBusImpl.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.shared.logic.eventbus 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.flow.MutableSharedFlow 5 | import kotlinx.coroutines.flow.filterIsInstance 6 | import kotlinx.coroutines.launch 7 | import javax.inject.Inject 8 | import kotlin.reflect.KClass 9 | 10 | class EventBusImpl @Inject constructor(): EventBus { 11 | 12 | private val flow = MutableSharedFlow() 13 | 14 | override fun post(event: Event, scope: CoroutineScope) { 15 | scope.launch { 16 | flow.emit(event) 17 | } 18 | } 19 | 20 | override fun subscribe( 21 | eventClass: KClass, 22 | scope: CoroutineScope, 23 | subscriber: suspend (E) -> Unit 24 | ) { 25 | scope.launch { 26 | flow 27 | .filterIsInstance(eventClass) 28 | .collect(subscriber) 29 | } 30 | } 31 | 32 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/shared/logic/eventbus/UserLogoutRequestEvent.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.shared.logic.eventbus 2 | 3 | object UserLogoutRequestEvent: Event -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/shared/logic/eventbus/UserSettingsChangeEvent.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.shared.logic.eventbus 2 | 3 | /** 4 | * Fired when the user changes a setting, or a user's action 5 | * somehow should result in a settings change (e.g. if when 6 | * logging in the user indicated that they are under 19, an 7 | * event should be fired to update explicit content hiding setting. 8 | */ 9 | open class UserSettingsChangeEvent( 10 | 11 | ): Event 12 | 13 | class UserHideExplicitSettingChangeEvent( 14 | val newValue: Boolean 15 | ): UserSettingsChangeEvent() -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/shared/viewmodels/AccountImageViewModel.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.shared.viewmodels 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.compose.runtime.setValue 6 | import kotlinx.coroutines.CoroutineScope 7 | import kotlinx.coroutines.launch 8 | import ua.leonidius.beatinspector.data.account.repository.AccountRepository 9 | 10 | interface AccountImageViewModel { 11 | fun loadAccountImage(scope: CoroutineScope) 12 | 13 | var pfpState: PfpState 14 | } 15 | 16 | /** 17 | * profile picture state 18 | */ 19 | sealed class PfpState { 20 | 21 | data class Loaded( 22 | val imageUrl: String 23 | ) : PfpState() 24 | 25 | sealed class NotLoaded : PfpState() 26 | 27 | object ErrorLoading : NotLoaded() 28 | 29 | object Loading : NotLoaded() 30 | 31 | object NoImageOnAccount : NotLoaded() 32 | } 33 | 34 | class AccountImageViewModelImpl( 35 | private val accountRepository: AccountRepository 36 | ) : AccountImageViewModel { 37 | 38 | override var pfpState by mutableStateOf(PfpState.Loading) 39 | 40 | override fun loadAccountImage(scope: CoroutineScope) { 41 | scope.launch { 42 | pfpState = PfpState.Loading 43 | 44 | pfpState = try { 45 | val accountImageUrl = accountRepository.get(Unit).smallImageUrl 46 | if (accountImageUrl != null) { 47 | PfpState.Loaded(accountImageUrl) 48 | } else { 49 | PfpState.NoImageOnAccount 50 | } 51 | } catch (e: Exception) { 52 | PfpState.ErrorLoading 53 | } 54 | } 55 | } 56 | 57 | } 58 | 59 | -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple80 = Color(0xFFD0BCFF) 6 | val PurpleGrey80 = Color(0xFFCCC2DC) 7 | val Pink80 = Color(0xFFEFB8C8) 8 | 9 | val Purple40 = Color(0xFF6650a4) 10 | val PurpleGrey40 = Color(0xFF625b71) 11 | val Pink40 = Color(0xFF7D5260) -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/ui/theme/Dimens.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.ui.theme 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.res.dimensionResource 5 | import androidx.compose.ui.unit.Dp 6 | import ua.leonidius.beatinspector.R 7 | 8 | object Dimens { 9 | 10 | val paddingNormal: Dp 11 | @Composable get() = dimensionResource(id = R.dimen.padding_normal) 12 | 13 | val paddingSmall: Dp 14 | @Composable get() = dimensionResource(id = R.dimen.padding_small) 15 | 16 | val paddingLarge: Dp 17 | @Composable get() = dimensionResource(id = R.dimen.padding_large) 18 | 19 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.ui.theme 2 | 3 | import android.app.Activity 4 | import android.graphics.Color 5 | import android.os.Build 6 | import android.util.Log 7 | import android.view.WindowInsetsController 8 | import androidx.compose.foundation.isSystemInDarkTheme 9 | import androidx.compose.material3.MaterialTheme 10 | import androidx.compose.material3.darkColorScheme 11 | import androidx.compose.material3.dynamicDarkColorScheme 12 | import androidx.compose.material3.dynamicLightColorScheme 13 | import androidx.compose.material3.lightColorScheme 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.SideEffect 16 | import androidx.compose.ui.graphics.luminance 17 | import androidx.compose.ui.graphics.toArgb 18 | import androidx.compose.ui.platform.LocalContext 19 | import androidx.compose.ui.platform.LocalView 20 | import androidx.compose.ui.unit.em 21 | import androidx.core.graphics.ColorUtils 22 | import androidx.core.graphics.luminance 23 | import androidx.core.view.WindowCompat 24 | 25 | private val DarkColorScheme = darkColorScheme( 26 | primary = Purple80, 27 | secondary = PurpleGrey80, 28 | tertiary = Pink80 29 | ) 30 | 31 | private val LightColorScheme = lightColorScheme( 32 | primary = Purple40, 33 | secondary = PurpleGrey40, 34 | tertiary = Pink40 35 | 36 | /* Other default colors to override 37 | background = Color(0xFFFFFBFE), 38 | surface = Color(0xFFFFFBFE), 39 | onPrimary = Color.White, 40 | onSecondary = Color.White, 41 | onTertiary = Color.White, 42 | onBackground = Color(0xFF1C1B1F), 43 | onSurface = Color(0xFF1C1B1F), 44 | */ 45 | ) 46 | 47 | @Composable 48 | fun BeatInspectorTheme( 49 | darkTheme: Boolean = isSystemInDarkTheme(), 50 | // Dynamic color is available on Android 12+ 51 | dynamicColor: Boolean = true, 52 | content: @Composable () -> Unit 53 | ) { 54 | val colorScheme = when { 55 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 56 | val context = LocalContext.current 57 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 58 | } 59 | 60 | darkTheme -> DarkColorScheme 61 | else -> LightColorScheme 62 | } 63 | val view = LocalView.current 64 | if (!view.isInEditMode) { 65 | SideEffect { 66 | val window = (view.context as Activity).window 67 | window.statusBarColor = colorScheme.primary.toArgb() 68 | WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme 69 | } 70 | } 71 | 72 | MaterialTheme( 73 | colorScheme = colorScheme, 74 | typography = Typography, 75 | content = content, 76 | // isLandscape = //todo pass with compositionLocal 77 | // view.context.resources.configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE 78 | ) 79 | } 80 | 81 | @Composable 82 | fun ChangeStatusBarColor(colorArgb: Int) { 83 | val view = LocalView.current 84 | if (!view.isInEditMode) { 85 | SideEffect { 86 | val window = (view.context as Activity).window 87 | window.statusBarColor = colorArgb 88 | window.navigationBarColor = colorArgb 89 | 90 | 91 | 92 | // if color is dark, make status bar icons light, otherwise make them dark 93 | val isColorDark = //if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { 94 | if (colorArgb == Color.TRANSPARENT) false else ColorUtils.calculateLuminance(colorArgb) < 0.6f 95 | //} else { 96 | // false 97 | //} 98 | 99 | Log.d("Theme", "isColorDark: $isColorDark, color: $colorArgb") 100 | 101 | WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = 102 | !isColorDark 103 | WindowCompat.getInsetsController(window, view).isAppearanceLightNavigationBars = 104 | !isColorDark 105 | 106 | } 107 | } 108 | } -------------------------------------------------------------------------------- /app/src/main/java/ua/leonidius/beatinspector/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.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 | ) -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 | 13 | 14 | 15 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 11 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/spotify_full_logo_black.xml: -------------------------------------------------------------------------------- 1 | 6 | 11 | 16 | 21 | 26 | 31 | 36 | 41 | 46 | 51 | 52 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/spotify_full_logo_white.xml: -------------------------------------------------------------------------------- 1 | 6 | 11 | 16 | 21 | 26 | 31 | 36 | 41 | 46 | 51 | 52 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/spotify_icon_black.xml: -------------------------------------------------------------------------------- 1 | 6 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/spotify_icon_white.xml: -------------------------------------------------------------------------------- 1 | 6 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Leonidius20/BeatInspector/0d85e1b63fcaea4951842c49dd5819a9691914dc/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Leonidius20/BeatInspector/0d85e1b63fcaea4951842c49dd5819a9691914dc/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Leonidius20/BeatInspector/0d85e1b63fcaea4951842c49dd5819a9691914dc/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Leonidius20/BeatInspector/0d85e1b63fcaea4951842c49dd5819a9691914dc/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Leonidius20/BeatInspector/0d85e1b63fcaea4951842c49dd5819a9691914dc/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Leonidius20/BeatInspector/0d85e1b63fcaea4951842c49dd5819a9691914dc/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Leonidius20/BeatInspector/0d85e1b63fcaea4951842c49dd5819a9691914dc/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Leonidius20/BeatInspector/0d85e1b63fcaea4951842c49dd5819a9691914dc/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Leonidius20/BeatInspector/0d85e1b63fcaea4951842c49dd5819a9691914dc/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Leonidius20/BeatInspector/0d85e1b63fcaea4951842c49dd5819a9691914dc/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Leonidius20/BeatInspector/0d85e1b63fcaea4951842c49dd5819a9691914dc/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Leonidius20/BeatInspector/0d85e1b63fcaea4951842c49dd5819a9691914dc/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Leonidius20/BeatInspector/0d85e1b63fcaea4951842c49dd5819a9691914dc/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Leonidius20/BeatInspector/0d85e1b63fcaea4951842c49dd5819a9691914dc/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Leonidius20/BeatInspector/0d85e1b63fcaea4951842c49dd5819a9691914dc/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16dp 4 | 8dp 5 | 24dp 6 | -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFFFFF 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/xml/backup_rules.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/xml/data_extraction_rules.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 12 | 13 | 14 | 20 | 21 | -------------------------------------------------------------------------------- /app/src/test/java/ua/leonidius/beatinspector/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } -------------------------------------------------------------------------------- /app/src/test/java/ua/leonidius/beatinspector/data/settings/SettingsRepositoryTest.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.data.settings 2 | 3 | class SettingsRepositoryTest { 4 | 5 | fun testDefaultSettings() { 6 | 7 | } 8 | 9 | } -------------------------------------------------------------------------------- /app/src/test/java/ua/leonidius/beatinspector/infrastructure/AuthInterceptorTest.kt: -------------------------------------------------------------------------------- 1 | package ua.leonidius.beatinspector.infrastructure 2 | 3 | import io.mockk.every 4 | import io.mockk.mockk 5 | import junit.framework.Assert.assertEquals 6 | import okhttp3.Call 7 | import okhttp3.Interceptor 8 | import okhttp3.Request 9 | import org.junit.Assert.assertThrows 10 | import org.junit.Test 11 | import ua.leonidius.beatinspector.data.auth.logic.AuthTokenProvider 12 | import ua.leonidius.beatinspector.data.auth.logic.TokenRefreshException 13 | import ua.leonidius.beatinspector.data.shared.exception.SongDataIOException 14 | 15 | class AuthInterceptorTest { 16 | 17 | abstract class YesFakeAuthTokenProvider: AuthTokenProvider { 18 | override fun isAuthorized() = true 19 | } 20 | 21 | object TrowingFakeAuthTokenProvider: YesFakeAuthTokenProvider() { 22 | 23 | val error = Error("test") 24 | override suspend fun getAccessToken() = throw TokenRefreshException("", "", error) 25 | } 26 | 27 | @Test 28 | fun testAddingTokenHeaderIfLoggedIn() { 29 | // todo: replace with Hilt injection so that constructor changes don't affect the test 30 | // val interceptor = AuthInterceptor(FakeAuthTokenProvider) 31 | 32 | } 33 | 34 | @Test 35 | fun testThrowingExceptionIfTokenRefreshFails() { 36 | val interceptor = AuthInterceptor(TrowingFakeAuthTokenProvider) 37 | 38 | val fakeInterceptorChain = mockk { 39 | every { request() } returns mockk() 40 | } 41 | 42 | assertThrows(SongDataIOException.TokenRefresh::class.java) { 43 | interceptor.intercept(fakeInterceptorChain) 44 | } 45 | } 46 | 47 | } -------------------------------------------------------------------------------- /app/version: -------------------------------------------------------------------------------- 1 | {"buildNumber":9,"patch":9,"minor":0,"major":0} -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | plugins { 3 | id("com.android.application") version "8.1.1" apply false 4 | id("org.jetbrains.kotlin.android") version "1.8.10" apply false 5 | id("org.jetbrains.kotlin.jvm") version "1.8.10" apply false 6 | id("com.android.library") version "8.1.1" apply false 7 | id("com.mikepenz.aboutlibraries.plugin") version "10.10.0" apply false 8 | id("com.google.dagger.hilt.android") version "2.44" apply false 9 | } 10 | 11 | buildscript { 12 | repositories { 13 | maven("https://jitpack.io") 14 | } 15 | 16 | dependencies { 17 | classpath("com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin:2.0.1") 18 | classpath("com.github.alexfu:androidautoversion:3.3.0") 19 | } 20 | } -------------------------------------------------------------------------------- /docs/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Leonidius20/BeatInspector/0d85e1b63fcaea4951842c49dd5819a9691914dc/docs/icon.png -------------------------------------------------------------------------------- /docs/screenshots/all-old.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Leonidius20/BeatInspector/0d85e1b63fcaea4951842c49dd5819a9691914dc/docs/screenshots/all-old.png -------------------------------------------------------------------------------- /docs/screenshots/all.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Leonidius20/BeatInspector/0d85e1b63fcaea4951842c49dd5819a9691914dc/docs/screenshots/all.png -------------------------------------------------------------------------------- /docs/screenshots/details-screen-landscape-old.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Leonidius20/BeatInspector/0d85e1b63fcaea4951842c49dd5819a9691914dc/docs/screenshots/details-screen-landscape-old.png -------------------------------------------------------------------------------- /docs/screenshots/details-screen-landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Leonidius20/BeatInspector/0d85e1b63fcaea4951842c49dd5819a9691914dc/docs/screenshots/details-screen-landscape.png -------------------------------------------------------------------------------- /docs/screenshots/details-screen-portrait-old.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Leonidius20/BeatInspector/0d85e1b63fcaea4951842c49dd5819a9691914dc/docs/screenshots/details-screen-portrait-old.png -------------------------------------------------------------------------------- /docs/screenshots/details-screen-portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Leonidius20/BeatInspector/0d85e1b63fcaea4951842c49dd5819a9691914dc/docs/screenshots/details-screen-portrait.png -------------------------------------------------------------------------------- /docs/screenshots/main-screen-portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Leonidius20/BeatInspector/0d85e1b63fcaea4951842c49dd5819a9691914dc/docs/screenshots/main-screen-portrait.png -------------------------------------------------------------------------------- /docs/screenshots/search-screen-landscape-old.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Leonidius20/BeatInspector/0d85e1b63fcaea4951842c49dd5819a9691914dc/docs/screenshots/search-screen-landscape-old.png -------------------------------------------------------------------------------- /docs/screenshots/search-screen-landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Leonidius20/BeatInspector/0d85e1b63fcaea4951842c49dd5819a9691914dc/docs/screenshots/search-screen-landscape.png -------------------------------------------------------------------------------- /docs/screenshots/search-screen-portrait-old.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Leonidius20/BeatInspector/0d85e1b63fcaea4951842c49dd5819a9691914dc/docs/screenshots/search-screen-portrait-old.png -------------------------------------------------------------------------------- /docs/screenshots/search-screen-portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Leonidius20/BeatInspector/0d85e1b63fcaea4951842c49dd5819a9691914dc/docs/screenshots/search-screen-portrait.png -------------------------------------------------------------------------------- /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 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app's APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Kotlin code style for this project: "official" or "obsolete": 19 | kotlin.code.style=official 20 | # Enables namespacing of each library's R class so that its R class includes only the 21 | # resources declared in the library itself and none from the library's dependencies, 22 | # thereby reducing the size of the R class for that library 23 | android.nonTransitiveRClass=true 24 | org.gradle.unsafe.configuration-cache=true 25 | 26 | android.enableR8.fullMode=false -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Leonidius20/BeatInspector/0d85e1b63fcaea4951842c49dd5819a9691914dc/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Thu Oct 12 21:50:43 EEST 2023 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /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 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | gradlePluginPortal() 6 | } 7 | } 8 | dependencyResolutionManagement { 9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 10 | repositories { 11 | google() 12 | mavenCentral() 13 | maven("https://jitpack.io") 14 | maven { 15 | url = uri("https://oss.sonatype.org/content/repositories/snapshots/") 16 | } 17 | } 18 | } 19 | 20 | rootProject.name = "BeatInspector" 21 | include(":app") 22 | // include(":data") 23 | --------------------------------------------------------------------------------